From 76841af7d3d5b89afc5b52d5bf5bfd1fe2fe419d Mon Sep 17 00:00:00 2001 From: altair823 Date: Mon, 1 Jun 2026 14:23:51 +0000 Subject: [PATCH 1/6] =?UTF-8?q?spike(embed-candle):=20candle=20e5-large=20?= =?UTF-8?q?=ED=83=80=EB=8B=B9=EC=84=B1=20=EA=B2=80=EC=A6=9D=20=E2=80=94=20?= =?UTF-8?q?VERDICT=20PASS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Track 1 / Phase 0 격리 스파이크. candle(순수 Rust)로 intfloat/multilingual-e5-large 를 돌려 기존 onnxruntime FastembedEmbedder 와 비교. 결과: - 패리티: 한/영 10문장 cosine min=mean=1.000000 (완전 일치) - padding_idx: XLM-R 규약 정상 (소스 + 패리티 이중 확인) - 스레드 제어: RAYON_NUM_THREADS=4 로 컴퓨트 스레드 12→4 캡 확인 (fastembed 4.9.1 의 48-하드코딩+override불가 문제 구조적 부재) - latency: batch=32 candle 2.161s vs fastembed 0.536s (~4×, 4 vs 12 스레드) → candle 본 구현 진행 권고 (GREEN). 상세 SPIKE_REPORT.md. candle 의존성은 crates/spike-embed-candle 에만 격리. 프로덕션 crate 동작 변경 없음. 결정적 NUMA 검증은 그 듀얼소켓 서버에서 사용자 실행 필요 (meta-spec §4.3). Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 401 +++++++++++++++++++++++++- Cargo.toml | 2 + SPIKE_BRIEF.md | 57 ++++ SPIKE_REPORT.md | 108 +++++++ crates/spike-embed-candle/Cargo.toml | 32 ++ crates/spike-embed-candle/src/main.rs | 251 ++++++++++++++++ 6 files changed, 849 insertions(+), 2 deletions(-) create mode 100644 SPIKE_BRIEF.md create mode 100644 SPIKE_REPORT.md create mode 100644 crates/spike-embed-candle/Cargo.toml create mode 100644 crates/spike-embed-candle/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index 7fcf6ca..94edd70 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -827,6 +827,20 @@ name = "bytemuck" version = "1.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] [[package]] name = "byteorder" @@ -874,6 +888,65 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "candle-core" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bd9895436c1ba5dc1037a19935d084b838db066ff4e15ef7dded020b7c12a4a" +dependencies = [ + "byteorder", + "float8", + "gemm", + "half", + "libm", + "memmap2", + "num-traits", + "num_cpus", + "rand 0.9.4", + "rand_distr 0.5.1", + "rayon", + "safetensors", + "thiserror 2.0.18", + "tokenizers 0.22.2", + "yoke", + "zip", +] + +[[package]] +name = "candle-nn" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9317a09d6530b758990ed7f625ac69ff43653bc9ee28b0464644ad1169ada87" +dependencies = [ + "candle-core", + "half", + "libc", + "num-traits", + "rayon", + "safetensors", + "serde", + "thiserror 2.0.18", +] + +[[package]] +name = "candle-transformers" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f59d08c89e9f4af9c464e2f3a8e16199e7cc601e6f34538c2cfbb42b623b1783" +dependencies = [ + "byteorder", + "candle-core", + "candle-nn", + "fancy-regex", + "num-traits", + "rand 0.9.4", + "rayon", + "serde", + "serde_json", + "serde_plain", + "tracing", +] + [[package]] name = "cassowary" version = "0.3.0" @@ -2238,6 +2311,22 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "dyn-stack" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c4713e43e2886ba72b8271aa66c93d722116acf7a75555cce11dcde84388fe8" +dependencies = [ + "bytemuck", + "dyn-stack-macros", +] + +[[package]] +name = "dyn-stack-macros" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d926b4d407d372f141f93bb444696142c29d32962ccbd3531117cf3aa0bfa9" + [[package]] name = "earcutr" version = "0.4.3" @@ -2278,6 +2367,18 @@ dependencies = [ "encoding_rs", ] +[[package]] +name = "enum-as-inner" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "equator" version = "0.4.2" @@ -2319,6 +2420,9 @@ name = "esaxx-rs" version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d817e038c30374a4bcb22f94d0a8a0e216958d4c3dcde369b1439fec4bdda6e6" +dependencies = [ + "cc", +] [[package]] name = "ethnum" @@ -2374,6 +2478,17 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" +[[package]] +name = "fancy-regex" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72cf461f865c862bb7dc573f643dd6a2b6842f7c30b07882b56bd148cc2761b8" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + [[package]] name = "fast-float2" version = "0.2.3" @@ -2400,7 +2515,7 @@ dependencies = [ "ort-sys", "rayon", "serde_json", - "tokenizers", + "tokenizers 0.21.4", ] [[package]] @@ -2480,6 +2595,18 @@ dependencies = [ "zlib-rs", ] +[[package]] +name = "float8" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d1f04709a8ac06e8e8042875a3c466cc4832d3c1a18dbcb9dba3c6e83046bc" +dependencies = [ + "half", + "num-traits", + "rand 0.9.4", + "rand_distr 0.5.1", +] + [[package]] name = "float_next_after" version = "1.0.0" @@ -2657,6 +2784,125 @@ dependencies = [ "slab", ] +[[package]] +name = "gemm" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa0673db364b12263d103b68337a68fbecc541d6f6b61ba72fe438654709eacb" +dependencies = [ + "dyn-stack", + "gemm-c32", + "gemm-c64", + "gemm-common", + "gemm-f16", + "gemm-f32", + "gemm-f64", + "num-complex", + "num-traits", + "paste", + "raw-cpuid", + "seq-macro", +] + +[[package]] +name = "gemm-c32" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "086936dbdcb99e37aad81d320f98f670e53c1e55a98bee70573e83f95beb128c" +dependencies = [ + "dyn-stack", + "gemm-common", + "num-complex", + "num-traits", + "paste", + "raw-cpuid", + "seq-macro", +] + +[[package]] +name = "gemm-c64" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20c8aeeeec425959bda4d9827664029ba1501a90a0d1e6228e48bef741db3a3f" +dependencies = [ + "dyn-stack", + "gemm-common", + "num-complex", + "num-traits", + "paste", + "raw-cpuid", + "seq-macro", +] + +[[package]] +name = "gemm-common" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88027625910cc9b1085aaaa1c4bc46bb3a36aad323452b33c25b5e4e7c8e2a3e" +dependencies = [ + "bytemuck", + "dyn-stack", + "half", + "libm", + "num-complex", + "num-traits", + "once_cell", + "paste", + "pulp", + "raw-cpuid", + "rayon", + "seq-macro", + "sysctl", +] + +[[package]] +name = "gemm-f16" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3df7a55202e6cd6739d82ae3399c8e0c7e1402859b30e4cb780e61525d9486e" +dependencies = [ + "dyn-stack", + "gemm-common", + "gemm-f32", + "half", + "num-complex", + "num-traits", + "paste", + "raw-cpuid", + "rayon", + "seq-macro", +] + +[[package]] +name = "gemm-f32" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02e0b8c9da1fbec6e3e3ab2ce6bc259ef18eb5f6f0d3e4edf54b75f9fd41a81c" +dependencies = [ + "dyn-stack", + "gemm-common", + "num-complex", + "num-traits", + "paste", + "raw-cpuid", + "seq-macro", +] + +[[package]] +name = "gemm-f64" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "056131e8f2a521bfab322f804ccd652520c79700d81209e9d9275bbdecaadc6a" +dependencies = [ + "dyn-stack", + "gemm-common", + "num-complex", + "num-traits", + "paste", + "raw-cpuid", + "seq-macro", +] + [[package]] name = "generator" version = "0.8.8" @@ -3475,9 +3721,12 @@ version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" dependencies = [ + "bytemuck", "cfg-if", "crunchy", "num-traits", + "rand 0.9.4", + "rand_distr 0.5.1", "zerocopy", ] @@ -3526,6 +3775,8 @@ dependencies = [ "allocator-api2", "equivalent", "foldhash 0.2.0", + "serde", + "serde_core", ] [[package]] @@ -3578,16 +3829,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "629d8f3bbeda9d148036d6b0de0a3ab947abd08ce90626327fc3547a49d59d97" dependencies = [ "dirs 6.0.0", + "futures", "http", "indicatif", "libc", "log", "native-tls", + "num_cpus", "rand 0.9.4", "reqwest 0.12.28", "serde", "serde_json", "thiserror 2.0.18", + "tokio", "ureq", "windows-sys 0.60.2", ] @@ -4490,7 +4744,7 @@ dependencies = [ "ort", "serde", "tempfile", - "tokenizers", + "tokenizers 0.21.4", "tracing", ] @@ -5761,6 +6015,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" dependencies = [ "libc", + "stable_deref_trait", ] [[package]] @@ -6026,6 +6281,7 @@ version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" dependencies = [ + "bytemuck", "num-traits", ] @@ -6730,6 +6986,29 @@ dependencies = [ "unicase", ] +[[package]] +name = "pulp" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e205bb30d5b916c55e584c22201771bcf2bad9aabd5d4127f38387140c38632" +dependencies = [ + "bytemuck", + "cfg-if", + "libm", + "num-complex", + "paste", + "pulp-wasm-simd-flag", + "raw-cpuid", + "reborrow", + "version_check", +] + +[[package]] +name = "pulp-wasm-simd-flag" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40e24eee682d89fb193496edf918a7f407d30175b2e785fe057e4392dfd182e0" + [[package]] name = "pxfm" version = "0.1.29" @@ -7051,6 +7330,15 @@ dependencies = [ "rgb", ] +[[package]] +name = "raw-cpuid" +version = "11.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" +dependencies = [ + "bitflags 2.11.1", +] + [[package]] name = "rawpointer" version = "0.2.1" @@ -7088,6 +7376,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "reborrow" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03251193000f4bd3b042892be858ee50e8b3719f2b08e5833ac4353724632430" + [[package]] name = "recursive" version = "0.1.1" @@ -7618,6 +7912,17 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd29631678d6fb0903b69223673e122c32e9ae559d0960a38d574695ebc0ea15" +[[package]] +name = "safetensors" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "675656c1eabb620b921efea4f9199f97fc86e36dd6ffd1fbbe48d0f59a4987f5" +dependencies = [ + "hashbrown 0.16.1", + "serde", + "serde_json", +] + [[package]] name = "same-file" version = "1.0.6" @@ -7798,6 +8103,15 @@ dependencies = [ "serde_json", ] +[[package]] +name = "serde_plain" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce1fc6db65a611022b23a0dec6975d63fb80a302cb3388835ff02c097258d50" +dependencies = [ + "serde", +] + [[package]] name = "serde_repr" version = "0.1.20" @@ -8083,6 +8397,23 @@ dependencies = [ "smallvec", ] +[[package]] +name = "spike-embed-candle" +version = "0.0.0" +dependencies = [ + "anyhow", + "candle-core", + "candle-nn", + "candle-transformers", + "hf-hub", + "kebab-config", + "kebab-embed", + "kebab-embed-local", + "rayon", + "serde_json", + "tokenizers 0.21.4", +] + [[package]] name = "spm_precompiled" version = "0.1.4" @@ -8281,6 +8612,20 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "sysctl" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01198a2debb237c62b6826ec7081082d951f46dbb64b0e8c7649a452230d1dfc" +dependencies = [ + "bitflags 2.11.1", + "byteorder", + "enum-as-inner", + "libc", + "thiserror 1.0.69", + "walkdir", +] + [[package]] name = "system-configuration" version = "0.7.0" @@ -8637,6 +8982,40 @@ name = "tokenizers" version = "0.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a620b996116a59e184c2fa2dfd8251ea34a36d0a514758c6f966386bd2e03476" +dependencies = [ + "ahash", + "aho-corasick", + "compact_str 0.9.0", + "dary_heap", + "derive_builder", + "esaxx-rs", + "getrandom 0.3.4", + "indicatif", + "itertools 0.14.0", + "log", + "macro_rules_attribute", + "monostate", + "onig", + "paste", + "rand 0.9.4", + "rayon", + "rayon-cond", + "regex", + "regex-syntax", + "serde", + "serde_json", + "spm_precompiled", + "thiserror 2.0.18", + "unicode-normalization-alignments", + "unicode-segmentation", + "unicode_categories", +] + +[[package]] +name = "tokenizers" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b238e22d44a15349529690fb07bd645cf58149a1b1e44d6cb5bd1641ff1a6223" dependencies = [ "ahash", "aho-corasick", @@ -9076,6 +9455,12 @@ dependencies = [ "rand 0.9.4", ] +[[package]] +name = "typed-path" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" + [[package]] name = "typenum" version = "1.20.0" @@ -10131,6 +10516,18 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "zip" +version = "7.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c42e33efc22a0650c311c2ef19115ce232583abbe80850bc8b66509ebef02de0" +dependencies = [ + "crc32fast", + "indexmap 2.14.0", + "memchr", + "typed-path", +] + [[package]] name = "zlib-rs" version = "0.6.3" diff --git a/Cargo.toml b/Cargo.toml index f8d3c23..4789a65 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,8 @@ members = [ "crates/kebab-mcp", "crates/kebab-parse-code", "crates/kebab-nli", + # Track 1 / Phase 0 feasibility spike (throwaway; candle deps isolated here). + "crates/spike-embed-candle", ] [workspace.package] diff --git a/SPIKE_BRIEF.md b/SPIKE_BRIEF.md new file mode 100644 index 0000000..ba73ed9 --- /dev/null +++ b/SPIKE_BRIEF.md @@ -0,0 +1,57 @@ +# Track 1 / Phase 0 — candle e5-large 타당성 스파이크 (BRIEF) + +너는 이 worktree(`/build/out/kebab-worktrees/embed-candle`, 브랜치 `feat/embed-candle`)에서 작업하는 executor 다. +이건 **타당성 검증 스파이크**다 — 프로덕션 코드가 아니라, candle 트랙을 본격 구현해도 되는지 판단할 증거를 모으는 게 목적이다. 깔끔함보다 **정확한 증거**가 우선. + +## 배경 (왜) + +CPU-only 듀얼소켓 NUMA 서버에서 `kebab ingest` 가 매번 `double free or corruption (!prev)` 로 죽는다. +근본 원인: fastembed 4.9.1 이 onnxruntime intra-op 스레드를 전체 CPU(48)로 하드코딩하고 override 불가 → NUMA 에서 힙 손상. +해법 후보 1순위 = **candle(순수 Rust)로 동일 모델 multilingual-e5-large 를 돌리기**. candle-transformers 에 `xlm_roberta` 모듈이 있고 e5-large 는 XLM-RoBERTa-large 구조라 가능성 확인됨. 이 스파이크가 그 가능성을 **수치로 입증**해야 한다. + +전체 맥락: `/home/altair823/kebab/docs/superpowers/specs/2026-06-01-embedding-numa-backends-meta-spec.md` 및 `-meta-plan.md`. + +## 검증해야 할 caveat 3 + 성능 1 + +1. **수치 패리티**: candle 출력 벡터가 기존 onnxruntime(fastembed) e5-large 와 사실상 동일한가 (같은 가중치니 cosine ≥ 0.99 이어야 정상; 낮으면 padding/pooling 버그). +2. **padding_idx 위치 임베딩**: XLM-R 은 position id 가 `padding_idx(=1)+1` 부터 시작. candle `xlm_roberta` 가 이를 맞게 처리하는지 (패리티가 높으면 간접 입증). +3. **스레드 제어**: candle CPU 스레드를 캡할 수 있는가 (`RAYON_NUM_THREADS` 또는 candle API). NUMA 안전의 전제. +4. **CPU 성능**: 배치 임베딩 latency 를 측정. onnxruntime 대비 대략 비교. + +## 구체 작업 + +이 worktree 안에서 **격리된 스파이크 바이너리**를 만들어라 (프로덕션 crate 의 기본 동작 변경 금지). 예: 새 example 또는 작은 `xtask`/bin. candle 의존성(candle-core, candle-nn, candle-transformers, tokenizers, hf-hub, safetensors)은 스파이크 대상에만 추가. + +스파이크가 할 일: +1. **모델 로드 (candle, CPU)**: `intfloat/multilingual-e5-large` 의 safetensors + config.json + tokenizer.json 을 hf-hub 으로 받아 `candle_transformers::models::xlm_roberta::XLMRobertaModel` 로 로드. (참고: 이 머신의 fastembed 캐시는 ONNX 라 candle 이 못 읽는다. tokenizer.json/config.json 은 `/build/dogfood/kb/models/fastembed/models--Qdrant--multilingual-e5-large-onnx/snapshots/*/` 에서 재사용 가능.) +2. **임베딩 파이프라인 재현**: 입력에 e5 프리픽스(`query: ` / `passage: `) 적용 → 토크나이즈 → forward → **attention-mask 가중 mean pooling** → **L2 정규화**. (kebab 의 `crates/kebab-embed-local/src/lib.rs` 의 prefix/정규화 규약 참고.) +3. **패리티 비교**: 동일 문장 집합(한국어/영어 혼합, 최소 8개)을 (a) 위 candle 경로, (b) 기존 `kebab_embed_local::FastembedEmbedder`(워크스페이스에 이미 있음) 양쪽으로 임베딩 → 문장별 cosine 유사도. min/mean 보고. FastembedEmbedder 는 `/build/dogfood/config.toml` 또는 적절한 Config 로 생성(모델 캐시 `/build/dogfood/kb/models`). +4. **스레드 제어 확인**: `RAYON_NUM_THREADS=4` 등으로 실제 스레드 수가 제한되는지 확인(예: 실행 중 thread 수 또는 latency 변화). +5. **latency 측정**: 배치(예: 32문장) 임베딩 wall-clock. + +## 제약 (반드시 준수) + +- `CARGO_TARGET_DIR=/build/out/cargo-target/target` (루트 디스크 보호). 빌드 직렬, `-j 4`. candle 첫 빌드는 무거우니 `cargo build` 는 `run_in_background` 로. +- 프로덕션 crate(`kebab-embed-local` 등)의 기존 동작/기본값 변경 금지. 스파이크는 추가만. +- 네트워크: HuggingFace 접근 가능(이 머신은 됨). safetensors 다운로드는 `/build/cache/` 하위로. +- RAM 30GB, OOM 주의. 배치 작게. + +## 산출물 (필수) + +`/build/out/kebab-worktrees/embed-candle/SPIKE_REPORT.md` 에 다음을 적어라: +- **VERDICT**: PASS / FAIL (candle 본 구현 진행 권고 여부). +- 패리티: 문장별 cosine min/mean (표). +- padding_idx: 정상 여부 + 근거. +- 스레드 제어: 가능 여부 + 방법. +- latency: 배치 측정값 + onnxruntime 대략 대비. +- 막힌 점 / 리스크 / 다음 단계 권고. +- 재현 명령(스파이크 빌드+실행 커맨드). + +작업 로그는 수시로 `SPIKE_REPORT.md` 에 누적. 완료되면 변경을 `feat/embed-candle` 에 커밋(스파이크 코드 + 리포트). 커밋 메시지 끝에 `Co-Authored-By: Claude Opus 4.8 (1M context) `. + +## 합격 기준 + +- cosine 패리티 mean ≥ 0.99 (동일 가중치) → padding/pooling 정확, candle 트랙 GREEN. +- 0.95~0.99 → 경미한 차이(pooling 옵션 등), 진단 후 판단. +- < 0.95 → 구조/패딩 불일치 → 원인 규명 후 FAIL 또는 수정. +- 스레드 캡 불가 시 NUMA 안전성 위협 → 리포트에 명시. diff --git a/SPIKE_REPORT.md b/SPIKE_REPORT.md new file mode 100644 index 0000000..adbafe8 --- /dev/null +++ b/SPIKE_REPORT.md @@ -0,0 +1,108 @@ +# SPIKE REPORT — Track 1 / Phase 0 — candle multilingual-e5-large 타당성 + +- 날짜: 2026-06-01 +- 워크트리: `/build/out/kebab-worktrees/embed-candle` (브랜치 `feat/embed-candle`) +- 목적: candle(순수 Rust)로 `intfloat/multilingual-e5-large` 를 돌려 기존 onnxruntime(`FastembedEmbedder`) 와 **수치 패리티**·**스레드 제어**·**CPU 성능**을 입증, candle 본 구현 진행 여부 판단. +- 머신: 12 logical CPU, 단일 소켓(비-NUMA). **결정적 NUMA 검증은 그 듀얼소켓 서버에서만 가능**(meta-spec §4.3) — 본 스파이크는 패리티·스레드캡·성능의 사전 입증. + +> # VERDICT: **PASS** — candle 본 구현 진행 권고 (GREEN) +> +> 동일 e5-large 가중치로 onnxruntime 대비 **cosine min=mean=1.000000** (완전 일치). padding_idx/pooling 정확. `RAYON_NUM_THREADS` 로 CPU 스레드 캡 가능(NUMA 안전 전제 충족). latency 는 onnxruntime 대비 약 4배(67.5 vs 16.8 ms/문장, candle 4스레드 vs fastembed 12스레드) — 느리지만 ingest 배치에 허용 가능, 스레드 상향으로 개선 여지. + +--- + +## 1. 접근 방식 (구현 사실) + +격리 스파이크 바이너리 `crates/spike-embed-candle` 신설 (워크스페이스 멤버로 추가, candle 의존성은 이 crate 에만 — `candle-core/-nn/-transformers` 0.10.2, `hf-hub` 0.4, `tokenizers` 0.21). 프로덕션 crate(`kebab-embed-local` 등) 동작 변경 0. + +- 모델 로드: `candle_transformers::models::xlm_roberta::{Config, XLMRobertaModel}`. +- 가중치: `intfloat/multilingual-e5-large` 의 `model.safetensors`(2.2GB) + `config.json` + `tokenizer.json` 을 `hf-hub` sync API 로 다운로드(`HF_HOME=/build/cache/huggingface`). fastembed 캐시는 ONNX 라 candle 이 못 읽으므로 safetensors 별도 수령. config.json 은 candle `Config`(serde) 로 직접 역직렬화 — hidden=1024, layers=24, heads=16, pad_token_id=1, max_pos=514, pos_emb=absolute (config 의 실제 로드 로그로 확인). +- 파이프라인 재현 (`kebab-embed-local` 규약과 동일): e5 프리픽스(`passage: `) → 토크나이즈(batch-longest 패딩, max_len=512, special tokens) → forward → **attention-mask 가중 mean pooling** → **L2 정규화**. 출력 ‖v‖=1.000000 확인. +- 패리티 비교: 동일 문장 10개(한/영 혼합)를 (a) candle 경로, (b) `kebab_embed_local::FastembedEmbedder`(`/build/dogfood/config.toml`, 모델 캐시 `/build/dogfood/kb/models`) 양쪽으로 임베딩. 양쪽 모두 `EmbeddingKind::Document`(`passage: ` 프리픽스). + +## 2. 패리티 (caveat #1) — ✅ PASS (mean=1.000000) + +| # | cosine | 문장(앞 40자) | +|---|--------|---------------| +| 0 | 1.000000 | The quick brown fox jumps over the lazy | +| 1 | 1.000000 | 오늘 날씨가 정말 좋아서 산책을 나가고 싶다. | +| 2 | 1.000000 | Rust is a systems programming language f | +| 3 | 1.000000 | 벡터 검색은 임베딩 사이의 코사인 유사도를 이용한다. | +| 4 | 1.000000 | Machine learning models require large am | +| 5 | 1.000000 | 한국어와 영어가 섞인 문장도 멀티링구얼 모델은 잘 처리한다. | +| 6 | 1.000000 | The capital of France is Paris, a city k | +| 7 | 1.000000 | 이 프로젝트는 로컬 우선 지식 베이스와 검색 증강 생성을 목표로 한다. | +| 8 | 1.000000 | Database indexing dramatically speeds up | +| 9 | 1.000000 | 임베딩 모델을 candle 로 옮기면 NUMA 서버에서 안전하게 돌릴 수 | + +- **cosine min = 1.000000, mean = 1.000000** (합격선 mean≥0.99 GREEN 을 압도적 충족). +- 의미: candle 의 XLM-R forward + mean pooling + L2 가 onnxruntime e5-large 경로와 사실상 비트 단위로 동등. 본 구현으로 전환해도 **검색 품질(골든 MRR/hit@k) 회귀 없음**이 거의 보장됨 (meta-spec §6 D1 "candle 은 동일 가중치라 패리티 통과 시 품질 기준 자동 충족"과 일치). 단, meta-spec §4.2 골든 게이트는 본 구현 머지 전 별도 실측 권고. + +## 3. padding_idx (caveat #2) — ✅ 정상 (소스 + 패리티 이중 확인) + +candle-transformers 0.10.2 `xlm_roberta.rs` 의 `XLMRobertaEmbeddings::forward` 가 XLM-R 규약을 정확히 구현 (소스 확인): + +```rust +let mask = input_ids.ne(self.padding_idx)?...; // pad 아닌 위치 = 1 +let cumsum = mask.cumsum(1)?; +let position_ids = (cumsum * mask)? + padding_idx; // 위치 id 가 pad_token_id+1 부터 +``` + +HF `create_position_ids_from_input_ids` 와 동일 (position id 가 `padding_idx(=1)` 다음부터 시작). config.json 의 `pad_token_id=1` 이 `Config.pad_token_id` 로 주입됨. **패리티가 1.000000 으로 나온 것이 padding_idx·pooling 의 정확성을 결정적으로 재확인** — 위치 임베딩이 한 칸이라도 어긋나면 cosine 이 1.0 이 될 수 없음. + +## 4. 스레드 제어 (caveat #3) — ✅ 가능 (RAYON_NUM_THREADS) + +| 항목 | 값 | +|---|---| +| `RAYON_NUM_THREADS` env | 4 | +| `rayon::current_num_threads()` | **4** | +| `available_parallelism()` | 12 | +| peak OS threads (`/proc/self/status`) | 16 | + +- candle CPU 행렬연산(`gemm`)이 rayon 글로벌 풀을 사용 → `RAYON_NUM_THREADS=4` 로 **컴퓨트 스레드가 12→4 로 확실히 캡됨**. NUMA 안전(한 노드로 묶기)의 전제인 "스레드 수 제어 가능" 충족. +- 주의: peak 16 OS 스레드는 **패리티 비교를 위해 같은 프로세스에서 띄운 fastembed/onnxruntime 세션 스레드 + hf-hub 다운로드용 tokio 스레드**가 포함된 수치다. 실제 candle 전용 ingest 경로에는 fastembed 가 로드되지 않으며, candle 컴퓨트는 rayon 풀(=4)로 한정된다. 즉 **candle 백엔드는 fastembed 4.9.1 의 "48 하드코딩 + override 불가" 문제가 구조적으로 없다** (rayon 은 env/`ThreadPoolBuilder` 로 캡 가능). +- 다음 단계: 본 구현에서 `models.embedding` 에 스레드 노브(예: `KEBAB_EMBED_THREADS`→`RAYON_NUM_THREADS`/`ThreadPoolBuilder`)를 노출하고, NUMA 노드 바인딩은 `numactl`(A1 트랙)과 조합. + +## 5. CPU latency (성능) — 허용 가능 (onnxruntime 대비 ~4×) + +| 백엔드 | batch=32 wall-clock | ms/문장 | 스레드 | +|---|---|---|---| +| candle (release) | 2.161 s | 67.5 | 4 (RAYON cap) | +| fastembed (onnxruntime) | 0.536 s | 16.8 | 12 (이 머신) | + +- candle 가 문장당 약 4배 느림. 단 **스레드가 1/3(4 vs 12)** 이고 fastembed 는 ORT 의 고도 최적화(MKL/AVX-512 커널)를 쓰는 반면 candle 은 순수 `gemm`. 스레드 상향·배치 튜닝 여지 있음. +- ingest 는 배치/백그라운드 작업이라 이 정도 latency 는 허용 가능. **NUMA 서버에서 "느리지만 완주" 가 "빠르지만 double-free 크래시" 보다 압도적으로 낫다** (본 과제의 핵심 동기). +- fastembed 모델 콜드 로드 86.9s (ORT 세션 init) 는 일회성. candle 모델 로드는 mmap 이라 즉시. + +## 6. 막힌 점 / 리스크 / 다음 단계 권고 + +- **막힌 점**: 없음. 첫 빌드(candle+gemm) 2m24s, safetensors 2.2GB 다운로드 외 장애 없음. +- **리스크**: + 1. latency ~4×. 대용량(5150-doc) ingest 전체 시간이 늘어남 — 본 구현 시 wall-clock 실측 + release-notes 명시 필요. + 2. 본 스파이크는 비-NUMA 머신. **결정적 증거(5150-doc double-free 없이 EXIT=0)는 그 서버에서만**(meta-spec §4.3) — 본 구현 PR 후 사용자 실행 검증 예약. + 3. 벡터는 onnxruntime 와 1.0 일치하지만, 본 구현 시 `embedding_version` cascade 정책(재색인 여부) 명시 필요. 패리티 1.0 이면 **재색인 불필요 가능성**도 있으나(벡터 불변), 토크나이저/패딩 미세차 리스크로 보수적으로는 bump+재색인 권고 — 본 구현 spec 에서 결정. +- **다음 단계 권고 (candle 트랙 GREEN)**: + 1. `crates/kebab-embed-local` 에 `CandleEmbedder`(또는 신규 `kebab-embed-candle`) 추가, `Embedder` 4메서드 구현, `models.embedding.provider = "candle"` 분기. + 2. 스레드 노브 노출(`ThreadPoolBuilder`/`RAYON_NUM_THREADS`) + numactl 조합 문서화. + 3. `kebab-eval` 골든 스위트로 MRR/hit@k ≥ baseline 확인(§4.2) 후 default 승격 판단. + 4. 그 NUMA 서버에서 5150-doc 완주 검증(§4.3). + +## 7. 재현 명령 + +```bash +cd /build/out/kebab-worktrees/embed-candle +# 빌드 (release, candle+gemm 첫 빌드 ~2.5분) +CARGO_TARGET_DIR=/build/out/cargo-target/target cargo build -j 4 --release -p spike-embed-candle +# 실행 (safetensors 2.2GB 첫 다운로드 + onnxruntime baseline 로드) +HF_HOME=/build/cache/huggingface RAYON_NUM_THREADS=4 \ + CARGO_TARGET_DIR=/build/out/cargo-target/target \ + /build/out/cargo-target/target/release/spike-embed-candle +``` + +## 8. 작업 로그 + +- 14:1x — worktree/모델캐시/config 확인. config.json: XLMRobertaModel, pad=1, vocab 250002, hidden 1024, 24 layers, max_pos 514. +- 14:1x — candle-transformers 0.10.2 `xlm_roberta` API 소스 확인 (Config serde, `XLMRobertaModel::{new,forward}`, `prepare_4d_attention_mask`, padding_idx 처리). 스파이크 crate 작성 + 워크스페이스 멤버 추가. +- 14:16 — release 빌드 백그라운드 시작. +- 14:18 — 빌드 완료 (2m24s, EXIT=0). 바이너리 실행 (RAYON_NUM_THREADS=4). +- 14:2x — 실행 완료 (EXIT=0). cosine min=mean=1.000000, rayon 캡=4, candle 2.161s vs fastembed 0.536s (batch=32). **VERDICT=PASS**. diff --git a/crates/spike-embed-candle/Cargo.toml b/crates/spike-embed-candle/Cargo.toml new file mode 100644 index 0000000..7057360 --- /dev/null +++ b/crates/spike-embed-candle/Cargo.toml @@ -0,0 +1,32 @@ +# Track 1 / Phase 0 feasibility SPIKE — NOT production. +# Isolated binary that loads multilingual-e5-large via candle (pure Rust) +# and compares its output against the existing onnxruntime FastembedEmbedder. +# candle deps live ONLY here so the production crates stay untouched. +[package] +name = "spike-embed-candle" +version = "0.0.0" +edition = "2024" +publish = false + +[[bin]] +name = "spike-embed-candle" +path = "src/main.rs" + +[dependencies] +anyhow = "1" +serde_json = "1" +# candle stack — pinned to the current crates.io release (0.10.2). +candle-core = "0.10.2" +candle-nn = "0.10.2" +candle-transformers = "0.10.2" +# Align with workspace-locked versions so we reuse compiled artifacts. +tokenizers = "0.21" +hf-hub = { version = "0.4", features = ["ureq"] } +rayon = "1" +# Parity baseline: reuse the real production embedder + its config loader. +kebab-config = { path = "../kebab-config" } +kebab-embed = { path = "../kebab-embed" } +kebab-embed-local = { path = "../kebab-embed-local" } + +# Keep the spike out of the workspace pedantic-lint gate; it is throwaway. +[lints] diff --git a/crates/spike-embed-candle/src/main.rs b/crates/spike-embed-candle/src/main.rs new file mode 100644 index 0000000..60d76bd --- /dev/null +++ b/crates/spike-embed-candle/src/main.rs @@ -0,0 +1,251 @@ +//! Track 1 / Phase 0 feasibility SPIKE (NOT production code). +//! +//! Proves whether candle (pure Rust) can run `intfloat/multilingual-e5-large` +//! with output parity against the existing onnxruntime `FastembedEmbedder`, +//! so the NUMA double-free in fastembed 4.9.1 can be sidestepped. +//! +//! What it checks (see SPIKE_BRIEF.md): +//! 1. numeric parity — per-sentence cosine vs FastembedEmbedder +//! 2. padding_idx — XLM-R position ids start at pad_token_id+1 +//! 3. thread control — RAYON_NUM_THREADS caps candle's CPU threads +//! 4. CPU latency — batch wall-clock, rough vs onnxruntime +//! +//! Run: +//! CARGO_TARGET_DIR=/build/out/cargo-target/target \ +//! HF_HOME=/build/cache/huggingface \ +//! RAYON_NUM_THREADS=4 \ +//! cargo run -j 4 -p spike-embed-candle --release + +use std::path::PathBuf; +use std::time::Instant; + +use anyhow::{Context, Result}; +use candle_core::{DType, Device, Tensor}; +use candle_nn::VarBuilder; +use candle_transformers::models::xlm_roberta::{Config as XlmConfig, XLMRobertaModel}; +use tokenizers::{PaddingParams, PaddingStrategy, Tokenizer, TruncationParams}; + +use kebab_embed::{Embedder, EmbeddingInput, EmbeddingKind}; +use kebab_embed_local::FastembedEmbedder; + +const HF_MODEL: &str = "intfloat/multilingual-e5-large"; +const DOGFOOD_CONFIG: &str = "/build/dogfood/config.toml"; +const MAX_LEN: usize = 512; + +/// Mixed Korean / English parity set (≥ 8, brief §3). +const SENTENCES: &[&str] = &[ + "The quick brown fox jumps over the lazy dog.", + "오늘 날씨가 정말 좋아서 산책을 나가고 싶다.", + "Rust is a systems programming language focused on safety and performance.", + "벡터 검색은 임베딩 사이의 코사인 유사도를 이용한다.", + "Machine learning models require large amounts of training data.", + "한국어와 영어가 섞인 문장도 멀티링구얼 모델은 잘 처리한다.", + "The capital of France is Paris, a city known for its art and culture.", + "이 프로젝트는 로컬 우선 지식 베이스와 검색 증강 생성을 목표로 한다.", + "Database indexing dramatically speeds up query performance.", + "임베딩 모델을 candle 로 옮기면 NUMA 서버에서 안전하게 돌릴 수 있다.", +]; + +fn main() -> Result<()> { + // Touch the rayon global pool early so RAYON_NUM_THREADS is honored and + // reportable before any candle compute spins it up. + let rayon_threads = rayon::current_num_threads(); + let avail = std::thread::available_parallelism() + .map(|n| n.get()) + .unwrap_or(0); + let rayon_env = std::env::var("RAYON_NUM_THREADS").unwrap_or_else(|_| "".into()); + + println!("== spike-embed-candle =="); + println!("available_parallelism = {avail}"); + println!("RAYON_NUM_THREADS env = {rayon_env}"); + println!("rayon::current_num_threads() = {rayon_threads}"); + + let device = Device::Cpu; + + // ── 1. Fetch model files (candle reads safetensors, not the ONNX cache) ── + let cache_dir = std::env::var("HF_HOME") + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from("/build/cache/huggingface")); + let api = hf_hub::api::sync::ApiBuilder::new() + .with_cache_dir(cache_dir.clone()) + .build() + .context("build hf-hub api")?; + let repo = api.model(HF_MODEL.to_string()); + println!("\n[load] fetching {HF_MODEL} into {} ...", cache_dir.display()); + let config_path = repo.get("config.json").context("download config.json")?; + let tokenizer_path = repo.get("tokenizer.json").context("download tokenizer.json")?; + let weights_path = repo + .get("model.safetensors") + .context("download model.safetensors")?; + println!("[load] config = {}", config_path.display()); + println!("[load] tokenizer = {}", tokenizer_path.display()); + println!("[load] weights = {}", weights_path.display()); + + // ── 2. Build the candle XLM-RoBERTa model ── + let cfg_json = std::fs::read_to_string(&config_path)?; + let cfg: XlmConfig = serde_json::from_str(&cfg_json).context("parse XLM-R config")?; + println!( + "[load] config: hidden={} layers={} heads={} pad_token_id={} max_pos={} pos_emb={}", + cfg.hidden_size, + cfg.num_hidden_layers, + cfg.num_attention_heads, + cfg.pad_token_id, + cfg.max_position_embeddings, + cfg.position_embedding_type, + ); + let vb = unsafe { + VarBuilder::from_mmaped_safetensors(&[weights_path], DType::F32, &device) + .context("mmap safetensors")? + }; + let model = XLMRobertaModel::new(&cfg, vb).context("build XLMRobertaModel")?; + + let mut tokenizer = Tokenizer::from_file(&tokenizer_path) + .map_err(|e| anyhow::anyhow!("load tokenizer: {e}"))?; + tokenizer + .with_padding(Some(PaddingParams { + strategy: PaddingStrategy::BatchLongest, + ..Default::default() + })) + .with_truncation(Some(TruncationParams { + max_length: MAX_LEN, + ..Default::default() + })) + .map_err(|e| anyhow::anyhow!("set truncation: {e}"))?; + + let pad_id = cfg.pad_token_id; + + // ── 3. candle embedding path (passage prefix, masked mean pool, L2) ── + let candle_vecs = candle_embed(&model, &tokenizer, &device, pad_id, SENTENCES)?; + println!("\n[candle] embedded {} sentences, dim={}", candle_vecs.len(), candle_vecs[0].len()); + // L2 norm sanity (should be ~1.0 after normalization) + let norm0 = l2(&candle_vecs[0]); + println!("[candle] ‖v0‖ = {norm0:.6}"); + + // ── 4. FastembedEmbedder (onnxruntime) baseline ── + println!("\n[fastembed] loading FastembedEmbedder from {DOGFOOD_CONFIG} ..."); + let config = kebab_config::Config::load(Some(std::path::Path::new(DOGFOOD_CONFIG))) + .context("load dogfood config")?; + let fb_t0 = Instant::now(); + let fb = FastembedEmbedder::new(&config).context("build FastembedEmbedder")?; + println!("[fastembed] model loaded in {:.2}s", fb_t0.elapsed().as_secs_f64()); + let fb_inputs: Vec = SENTENCES + .iter() + .map(|s| EmbeddingInput { text: s, kind: EmbeddingKind::Document }) + .collect(); + let fb_vecs = fb.embed(&fb_inputs).context("fastembed embed")?; + + // ── 5. Per-sentence parity (both L2-normalized → cosine = dot) ── + println!("\n== PARITY (candle vs fastembed, EmbeddingKind::Document / passage:) =="); + let mut cosines = Vec::with_capacity(SENTENCES.len()); + for (i, s) in SENTENCES.iter().enumerate() { + let c = cosine(&candle_vecs[i], &fb_vecs[i]); + cosines.push(c); + let preview: String = s.chars().take(40).collect(); + println!(" [{i:>2}] cos={c:.6} {preview}"); + } + let min = cosines.iter().cloned().fold(f32::INFINITY, f32::min); + let mean = cosines.iter().sum::() / cosines.len() as f32; + println!(" --> cosine min={min:.6} mean={mean:.6}"); + + // ── 6. Latency: batch of 32 (replicated) through candle ── + let batch: Vec<&str> = SENTENCES.iter().cloned().cycle().take(32).collect(); + // warmup + let _ = candle_embed(&model, &tokenizer, &device, pad_id, &batch[..4])?; + let t0 = Instant::now(); + let _ = candle_embed(&model, &tokenizer, &device, pad_id, &batch)?; + let candle_lat = t0.elapsed(); + + let fb_batch: Vec = batch + .iter() + .map(|s| EmbeddingInput { text: s, kind: EmbeddingKind::Document }) + .collect(); + let t1 = Instant::now(); + let _ = fb.embed(&fb_batch)?; + let fb_lat = t1.elapsed(); + + let peak_threads = proc_threads(); + println!("\n== LATENCY (batch=32) =="); + println!(" candle : {:.3}s ({:.1} ms/sentence)", candle_lat.as_secs_f64(), candle_lat.as_secs_f64() * 1000.0 / 32.0); + println!(" fastembed : {:.3}s ({:.1} ms/sentence)", fb_lat.as_secs_f64(), fb_lat.as_secs_f64() * 1000.0 / 32.0); + + println!("\n== THREAD CONTROL =="); + println!(" RAYON_NUM_THREADS env = {rayon_env}"); + println!(" rayon::current_num_threads = {rayon_threads}"); + println!(" available_parallelism = {avail}"); + println!(" peak OS threads (/proc) = {peak_threads}"); + + // ── 7. Machine verdict line for the report ── + let verdict = if mean >= 0.99 { "PASS" } else if mean >= 0.95 { "MARGINAL" } else { "FAIL" }; + println!("\n== SUMMARY =="); + println!("VERDICT_HINT={verdict} cosine_min={min:.6} cosine_mean={mean:.6} candle_batch32_s={:.3} fb_batch32_s={:.3} rayon_threads={rayon_threads} rayon_env={rayon_env}", candle_lat.as_secs_f64(), fb_lat.as_secs_f64()); + + Ok(()) +} + +/// candle embedding: apply e5 `passage:` prefix, tokenize (batch-padded), +/// forward through XLM-R, attention-mask-weighted mean pool, L2 normalize. +fn candle_embed( + model: &XLMRobertaModel, + tokenizer: &Tokenizer, + device: &Device, + _pad_id: u32, + sentences: &[&str], +) -> Result>> { + let prefixed: Vec = sentences.iter().map(|s| format!("passage: {s}")).collect(); + let encodings = tokenizer + .encode_batch(prefixed, true) + .map_err(|e| anyhow::anyhow!("encode_batch: {e}"))?; + + let bsz = encodings.len(); + let seq = encodings[0].get_ids().len(); + + let mut ids = Vec::with_capacity(bsz * seq); + let mut mask = Vec::with_capacity(bsz * seq); + for enc in &encodings { + ids.extend(enc.get_ids().iter().copied()); + mask.extend(enc.get_attention_mask().iter().map(|&m| m as f32)); + } + + let input_ids = Tensor::from_vec(ids, (bsz, seq), device)?; + let attn_f32 = Tensor::from_vec(mask, (bsz, seq), device)?; + let token_type_ids = input_ids.zeros_like()?; + + // forward: (input_ids, attention_mask, token_type_ids, past, enc_hidden, enc_mask) + let hidden = model.forward(&input_ids, &attn_f32, &token_type_ids, None, None, None)?; + + // masked mean pool + let mask3 = attn_f32.unsqueeze(2)?; // (b, seq, 1) + let summed = hidden.broadcast_mul(&mask3)?.sum(1)?; // (b, hidden) + let counts = mask3.sum(1)?; // (b, 1) + let mean = summed.broadcast_div(&counts)?; + + // L2 normalize + let norm = mean.sqr()?.sum_keepdim(1)?.sqrt()?; + let normalized = mean.broadcast_div(&norm)?; + + Ok(normalized.to_vec2::()?) +} + +fn cosine(a: &[f32], b: &[f32]) -> f32 { + let dot: f32 = a.iter().zip(b).map(|(x, y)| x * y).sum(); + let na = l2(a); + let nb = l2(b); + dot / (na * nb) +} + +fn l2(v: &[f32]) -> f32 { + v.iter().map(|x| x * x).sum::().sqrt() +} + +/// Peak OS thread count for this process from /proc/self/status. +fn proc_threads() -> usize { + std::fs::read_to_string("/proc/self/status") + .ok() + .and_then(|s| { + s.lines() + .find(|l| l.starts_with("Threads:")) + .and_then(|l| l.split_whitespace().nth(1).map(str::to_string)) + }) + .and_then(|n| n.parse().ok()) + .unwrap_or(0) +} -- 2.49.1 From 8f7b6ee53800ad6695ba8a9cc68a7418eb594d27 Mon Sep 17 00:00:00 2001 From: altair823 Date: Mon, 1 Jun 2026 14:52:25 +0000 Subject: [PATCH 2/6] =?UTF-8?q?feat(embed):=20candle=20=EC=9E=84=EB=B2=A0?= =?UTF-8?q?=EB=94=A9=20provider=20(NUMA-=EC=95=88=EC=A0=84,=20opt-in)=20+?= =?UTF-8?q?=20v0.22.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit duo-socket NUMA 서버에서 fastembed(onnxruntime)가 intra-op 스레드를 48개로 하드코딩해 NUMA 힙 손상 → double-free 로 ingest 가 죽는 문제를 회피하기 위해, 같은 multilingual-e5-large 모델을 순수 Rust(candle)로 돌리는 opt-in 임베딩 provider 를 추가한다. - 신규 crate kebab-embed-candle: CandleEmbedder (kebab_core::Embedder). hf-hub safetensors → XLMRobertaModel forward → mask mean-pool → L2 → e5 prefix. candle 의존성 트리를 이 crate 에 격리 (core/config 외 kebab-* 의존 0). - 스레드 캡: [models.embedding].num_threads + env KEBAB_EMBED_THREADS → 글로벌 rayon 풀 1회 캡 (NUMA-안전 레버). - kebab-app::embedder() 가 provider 분기 (fastembed/onnx/"" → 기존 경로 불변, candle → CandleEmbedder, 미지값 → 에러). - Phase 0 스파이크 crate 제거 (production 흡수). - 버전 0.21.1 → 0.22.0 (신규 config surface, pre-1.0 minor bump). 패리티: cosine_min=1.000000, max abs diff=2.01e-7 (< 1e-5) → embedding_version 유지, 재색인 0. fastembed default 동작/벡터 불변. wire schema 변경 없음. 검증(파일+exit code): clippy -D warnings EXIT=0(warning 0), test EXIT=0 (candle unit 5 + thread_cap rayon=4 + config 68), parity #[ignore] EXIT=0. Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 81 ++-- Cargo.toml | 5 +- HANDOFF.md | 1 + IMPL_REPORT.md | 85 ++++ README.md | 8 + crates/kebab-app/Cargo.toml | 1 + crates/kebab-app/src/app.rs | 24 +- crates/kebab-config/src/lib.rs | 16 + crates/kebab-embed-candle/Cargo.toml | 39 ++ crates/kebab-embed-candle/src/lib.rs | 363 ++++++++++++++++++ crates/kebab-embed-candle/tests/parity.rs | 88 +++++ crates/kebab-embed-candle/tests/thread_cap.rs | 32 ++ crates/spike-embed-candle/Cargo.toml | 32 -- crates/spike-embed-candle/src/main.rs | 251 ------------ docs/ARCHITECTURE.md | 7 +- docs/SMOKE.md | 4 +- docs/release-notes/v0.22.0-draft.md | 72 ++++ tasks/HOTFIXES.md | 46 +++ 18 files changed, 825 insertions(+), 330 deletions(-) create mode 100644 IMPL_REPORT.md create mode 100644 crates/kebab-embed-candle/Cargo.toml create mode 100644 crates/kebab-embed-candle/src/lib.rs create mode 100644 crates/kebab-embed-candle/tests/parity.rs create mode 100644 crates/kebab-embed-candle/tests/thread_cap.rs delete mode 100644 crates/spike-embed-candle/Cargo.toml delete mode 100644 crates/spike-embed-candle/src/main.rs create mode 100644 docs/release-notes/v0.22.0-draft.md diff --git a/Cargo.lock b/Cargo.lock index 94edd70..f7b4da6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4530,7 +4530,7 @@ dependencies = [ [[package]] name = "kebab-app" -version = "0.21.1" +version = "0.22.0" dependencies = [ "anyhow", "base64 0.22.1", @@ -4543,6 +4543,7 @@ dependencies = [ "kebab-config", "kebab-core", "kebab-embed", + "kebab-embed-candle", "kebab-embed-local", "kebab-llm", "kebab-llm-local", @@ -4576,7 +4577,7 @@ dependencies = [ [[package]] name = "kebab-chunk" -version = "0.21.1" +version = "0.22.0" dependencies = [ "anyhow", "blake3", @@ -4594,7 +4595,7 @@ dependencies = [ [[package]] name = "kebab-cli" -version = "0.21.1" +version = "0.22.0" dependencies = [ "anyhow", "clap", @@ -4615,7 +4616,7 @@ dependencies = [ [[package]] name = "kebab-config" -version = "0.21.1" +version = "0.22.0" dependencies = [ "anyhow", "dirs 5.0.1", @@ -4631,7 +4632,7 @@ dependencies = [ [[package]] name = "kebab-core" -version = "0.21.1" +version = "0.22.0" dependencies = [ "anyhow", "blake3", @@ -4645,7 +4646,7 @@ dependencies = [ [[package]] name = "kebab-embed" -version = "0.21.1" +version = "0.22.0" dependencies = [ "anyhow", "blake3", @@ -4657,9 +4658,28 @@ dependencies = [ "tracing", ] +[[package]] +name = "kebab-embed-candle" +version = "0.22.0" +dependencies = [ + "anyhow", + "candle-core", + "candle-nn", + "candle-transformers", + "hf-hub", + "kebab-config", + "kebab-core", + "kebab-embed-local", + "rayon", + "serde_json", + "tempfile", + "tokenizers 0.21.4", + "tracing", +] + [[package]] name = "kebab-embed-local" -version = "0.21.1" +version = "0.22.0" dependencies = [ "anyhow", "fastembed", @@ -4672,7 +4692,7 @@ dependencies = [ [[package]] name = "kebab-eval" -version = "0.21.1" +version = "0.22.0" dependencies = [ "anyhow", "kebab-app", @@ -4691,7 +4711,7 @@ dependencies = [ [[package]] name = "kebab-llm" -version = "0.21.1" +version = "0.22.0" dependencies = [ "anyhow", "kebab-core", @@ -4700,7 +4720,7 @@ dependencies = [ [[package]] name = "kebab-llm-local" -version = "0.21.1" +version = "0.22.0" dependencies = [ "anyhow", "kebab-config", @@ -4717,7 +4737,7 @@ dependencies = [ [[package]] name = "kebab-mcp" -version = "0.21.1" +version = "0.22.0" dependencies = [ "anyhow", "kebab-app", @@ -4735,7 +4755,7 @@ dependencies = [ [[package]] name = "kebab-nli" -version = "0.21.1" +version = "0.22.0" dependencies = [ "anyhow", "hf-hub", @@ -4750,7 +4770,7 @@ dependencies = [ [[package]] name = "kebab-parse-code" -version = "0.21.1" +version = "0.22.0" dependencies = [ "anyhow", "gix", @@ -4773,7 +4793,7 @@ dependencies = [ [[package]] name = "kebab-parse-image" -version = "0.21.1" +version = "0.22.0" dependencies = [ "ab_glyph", "anyhow", @@ -4797,7 +4817,7 @@ dependencies = [ [[package]] name = "kebab-parse-md" -version = "0.21.1" +version = "0.22.0" dependencies = [ "anyhow", "kebab-core", @@ -4814,7 +4834,7 @@ dependencies = [ [[package]] name = "kebab-parse-pdf" -version = "0.21.1" +version = "0.22.0" dependencies = [ "anyhow", "blake3", @@ -4829,7 +4849,7 @@ dependencies = [ [[package]] name = "kebab-rag" -version = "0.21.1" +version = "0.22.0" dependencies = [ "anyhow", "blake3", @@ -4851,7 +4871,7 @@ dependencies = [ [[package]] name = "kebab-search" -version = "0.21.1" +version = "0.22.0" dependencies = [ "anyhow", "globset", @@ -4870,7 +4890,7 @@ dependencies = [ [[package]] name = "kebab-source-fs" -version = "0.21.1" +version = "0.22.0" dependencies = [ "anyhow", "blake3", @@ -4888,7 +4908,7 @@ dependencies = [ [[package]] name = "kebab-store-sqlite" -version = "0.21.1" +version = "0.22.0" dependencies = [ "anyhow", "blake3", @@ -4908,7 +4928,7 @@ dependencies = [ [[package]] name = "kebab-store-vector" -version = "0.21.1" +version = "0.22.0" dependencies = [ "anyhow", "arrow", @@ -4932,7 +4952,7 @@ dependencies = [ [[package]] name = "kebab-tui" -version = "0.21.1" +version = "0.22.0" dependencies = [ "anyhow", "crossterm", @@ -8397,23 +8417,6 @@ dependencies = [ "smallvec", ] -[[package]] -name = "spike-embed-candle" -version = "0.0.0" -dependencies = [ - "anyhow", - "candle-core", - "candle-nn", - "candle-transformers", - "hf-hub", - "kebab-config", - "kebab-embed", - "kebab-embed-local", - "rayon", - "serde_json", - "tokenizers 0.21.4", -] - [[package]] name = "spm_precompiled" version = "0.1.4" diff --git a/Cargo.toml b/Cargo.toml index 4789a65..d968f73 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ members = [ "crates/kebab-search", "crates/kebab-embed", "crates/kebab-embed-local", + "crates/kebab-embed-candle", "crates/kebab-llm", "crates/kebab-llm-local", "crates/kebab-rag", @@ -23,8 +24,6 @@ members = [ "crates/kebab-mcp", "crates/kebab-parse-code", "crates/kebab-nli", - # Track 1 / Phase 0 feasibility spike (throwaway; candle deps isolated here). - "crates/spike-embed-candle", ] [workspace.package] @@ -32,7 +31,7 @@ edition = "2024" rust-version = "1.85" license = "MIT OR Apache-2.0" repository = "https://github.com/altair823/kebab" -version = "0.21.1" # v0.21.1 — config 마이그레이션(kebab config migrate): 기존 config.toml 에 빠진 섹션 주석과 함께 추가 + deprecated 정리 + schema_version 1→2 — CLAUDE.md §Release 도그푸딩 트리거 +version = "0.22.0" # v0.22.0 — candle 임베딩 provider (NUMA-안전, opt-in `provider=candle` + `num_threads`/KEBAB_EMBED_THREADS). fastembed default 불변, embedding_version 유지(재색인 0). — CLAUDE.md §Release 도그푸딩 트리거 # pre-v0.18 workspace-wide cleanup: enable clippy::pedantic group with # intentional allow-list. The allowed lints are either cosmetic (doc style), diff --git a/HANDOFF.md b/HANDOFF.md index a34853f..0e7882f 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -30,6 +30,7 @@ P0~P5 직렬. P6~P9 P5 이후 병렬 가능. ## 머지 후 발견된 버그 / 결정 (요약) +- **candle 임베딩 백엔드 다변화** (2026-06-01, Track 1, v0.22.0): `provider = "candle"` opt-in 추가 — 같은 `multilingual-e5-large` 모델을 순수 Rust(candle)로 돌려 듀얼소켓 NUMA 서버의 onnxruntime 48-스레드 double-free 를 회피. `[models.embedding].num_threads`(+env `KEBAB_EMBED_THREADS`)로 CPU 스레드 캡. fastembed default 동작·벡터 불변, `embedding_version` 유지(재색인 0). Phase 0 스파이크 패리티 cosine 1.000000. 상세 HOTFIXES 동일 일자. - **config 마이그레이션** (2026-05-31, PR #198): `kebab config migrate` 추가 — 기존 config.toml 에 빠진 섹션을 주석과 함께 채우고 deprecated 정리(멱등·`.bak`·dry-run, 값/주석 보존). `schema_version` 1→2, `init` 도 섹션 주석 포함, doctor 에 `config_migration` 체크. 상세 HOTFIXES 동일 일자. 머지 후 발견된 모든 deviation / hotfix 의 dated 로그는 [tasks/HOTFIXES.md](tasks/HOTFIXES.md). 본 요약은 \"누군가가 인수받을 때 알아두면 시간을 많이 절약하는\" 항목만: diff --git a/IMPL_REPORT.md b/IMPL_REPORT.md new file mode 100644 index 0000000..bd272e6 --- /dev/null +++ b/IMPL_REPORT.md @@ -0,0 +1,85 @@ +# Track 1 / Phase 1 — candle 임베딩 provider 구현 보고서 + +- 날짜: 2026-06-01 +- 브랜치: `feat/embed-candle` (worktree `/build/out/kebab-worktrees/embed-candle`) +- 스펙: `docs/superpowers/specs/2026-06-01-embed-candle-track-spec.md` +- 버전: 0.21.1 → **0.22.0** + +## 1. 변경 요약 + +| 영역 | 변경 | +|------|------| +| 신규 crate | `crates/kebab-embed-candle` — `CandleEmbedder` (`kebab_core::Embedder` impl). 스파이크 파이프라인 흡수: safetensors via hf-hub → `XLMRobertaModel` forward(`Device::Cpu`) → attention-mask mean pooling → L2 → e5 prefix(`passage:`/`query:`). 모델 캐시 `{model_dir}/candle/`. deps = candle-core/nn/transformers 0.10.2, tokenizers, hf-hub, serde_json, rayon, anyhow, tracing + `kebab-core`/`kebab-config` 만 (design §8 경계 준수). | +| 스레드 캡 | `[models.embedding].num_threads: u32`(default 0=auto) + env `KEBAB_EMBED_THREADS`(우선). `apply_thread_cap()` 가 글로벌 rayon 풀 1회 캡 (이미 init 시 no-op). | +| 주입 분기 | `kebab-app::App::embedder()` 가 `provider` 분기 — `fastembed`/`onnx`/`""` → 기존 `FastembedEmbedder`(불변), `candle` → `CandleEmbedder`, 미지값 → 에러. `none` 은 기존 lexical-only. `kebab-app/Cargo.toml` 에 dep 추가. | +| config | `EmbeddingModelCfg.num_threads`(`#[serde(default)]` — 옛 config 호환) + `KEBAB_MODELS_EMBEDDING_NUM_THREADS` env + `Config::defaults()`. | +| 스파이크 제거 | `crates/spike-embed-candle` 삭제 + 워크스페이스 멤버 제거 + `spike_build.log`/`spike_run.log` 정리. | +| 문서/버전 | README Configuration, `docs/SMOKE.md` config 예시, `docs/ARCHITECTURE.md`(crate 그래프+트리), HANDOFF 한 줄, `tasks/HOTFIXES.md` 2026-06-01 dated entry, workspace `version` 0.22.0, `docs/release-notes/v0.22.0-draft.md`. | + +## 2. 검증 게이트 결과 (모두 파일 출력 + exit code 로 검증) + +> ⚠️ 주의: background shell 의 notification "exit 0" 은 wrapper 의 종료코드라 +> 신뢰 불가. 실제 결과는 각 로그의 `*_EXIT=` 라인 값으로 확정했다 +> ([[project_rerank_experiment]] 교훈). 실제로 첫 빌드는 wrapper 가 exit 0 을 +> 보고했지만 로그의 `BUILD_EXIT=101`(serde_json 미선언)이었고, dep 추가 후 통과. + +| 게이트 | 명령 | 결과 | 로그 | +|--------|------|------|------| +| 빌드 + clippy | `cargo clippy --workspace --all-targets -j 4 -- -D warnings` | **`CLIPPY_EXIT=0`**, warning 0 | `clippy.log` | +| 단위/통합 테스트 | `cargo test -p kebab-embed-candle -p kebab-config -j 4` | **`TEST_EXIT=0`** — candle lib unit 5, `thread_cap` 1 passed(rayon current=4 검증), config 68 passed, parity 1 ignored | `test_units.log` | +| config 회귀 | (위 동일 run, `kebab-config` 68 tests) | 0 failed | `test_units.log` | +| 패리티 `#[ignore]` 수동 1회 | `cargo test -p kebab-embed-candle --release -- --ignored --nocapture` | **`PARITY_EXIT=0`**, 1 passed (32.53s) | `test_parity.log` | + +## 3. 패리티 수치 (재색인 결정 근거 — 스펙 D-reindex) + +10 문장(한/영 혼합) candle vs `FastembedEmbedder`(onnxruntime): + +``` +PARITY_SUMMARY cosine_min=1.000000 max_abs_diff=2.011657e-7 +``` + +- 코사인 최소 **1.000000** (≥ 0.9999 게이트 통과). +- 차원별 **max 절대오차 = 2.01e-7** — 스펙이 정한 "사실상 동일" 기준 + (max abs diff < 1e-5) 보다 **약 50배 작다**. +- **결론: `embedding_version` 유지 = 재색인 0.** candle 과 onnxruntime 의 + 벡터는 f32 반올림 수준에서만 다르며 (e-7), 기존 LanceDB 색인을 그대로 + 재사용해도 검색 결과가 바뀌지 않는다. version bump / cascade 트리거 안 함. + +## 4. 잔여 리스크 + +- **CPU latency**: candle 는 순수 Rust 라 onnxruntime 의 네이티브 커널보다 + 느리다 (Phase 0 스파이크 ~4×). 그래서 default 는 fastembed 유지, candle 은 + NUMA 환경 opt-in. 단일 워크스테이션 사용자에게는 권하지 않음 (README 명시). +- **모델 다운로드**: candle 은 `{model_dir}/candle/` 에 safetensors(~2GB)를 + 별도 캐시 (onnx 캐시와 공유 안 함). 첫 ingest 시 ~2GB 다운로드 발생. +- **잔여 게이트 (사용자 실행, Claude 불가, meta-spec §4.3)**: 그 듀얼소켓 + NUMA 서버에서 `provider=candle` 로 5150-doc ingest 가 double-free 없이 + EXIT=0 완주하는지 — 이 머신은 GPU/NUMA 없는 단일 VM 이라 재현 불가. PR + 머지 전/후 사용자 검증 예약. +- **골든 스위트 회귀 0 (스펙 §8)**: provider=candle 로 `kebab-eval` 골든 + 스위트 실행은 본 worktree 범위 밖(사용자 도그푸딩 단계). 패리티 e-7 로 + 벡터 동일성이 입증돼 회귀 위험은 낮음. + +## 5. 재현 명령 + +```bash +cd /build/out/kebab-worktrees/embed-candle +export CARGO_TARGET_DIR=/build/out/cargo-target/target + +# 빌드 + clippy (warning 0) +cargo clippy --workspace --all-targets -j 4 -- -D warnings + +# 단위 + config 회귀 +cargo test -p kebab-embed-candle -p kebab-config -j 4 + +# 패리티 (모델 ~2GB 다운로드 + 양쪽 추론, /build/dogfood/config.toml 필요) +cargo test -p kebab-embed-candle --release -j 4 -- --ignored --nocapture +# → PARITY_SUMMARY cosine_min=1.000000 max_abs_diff=2.011657e-7 + +# candle provider 로 ingest (사용자 NUMA 검증) +KEBAB_EMBED_THREADS=8 kebab ingest --config /path/to/candle-config.toml +``` + +## 6. 커밋 + +`feat/embed-candle` 에 커밋 완료. push / PR 은 메인 세션이 처리 (본 worker 는 하지 않음). diff --git a/README.md b/README.md index 0f115cb..704928a 100644 --- a/README.md +++ b/README.md @@ -97,9 +97,17 @@ root = "~/KnowledgeBase" # 색인할 폴더. 절대 / tilde / env / 상대 경 # 상대 경로의 base 는 config.toml 위치 (cwd 무관). [models.embedding] +provider = "fastembed" # "fastembed"(기본, onnxruntime) / "candle"(순수 Rust) + # / "none"(lexical-only). candle 는 같은 모델·같은 벡터를 + # 순수 Rust 로 돌려 NUMA 서버의 onnxruntime 48-스레드 + # double-free 를 피하는 opt-in 백엔드 (재색인 불필요). model = "multilingual-e5-large" # 다국어 sentence embedding (1024-dim). # 첫 ingest 시 ONNX (~1.3GB) 자동 다운로드. + # candle provider 는 safetensors (~2GB) 다운로드. dimensions = 1024 # config 와 LanceDB stored dim 불일치 시 검색 0건. +num_threads = 0 # candle 전용 CPU 스레드 캡 (0=auto=#cores). + # env KEBAB_EMBED_THREADS 가 우선. NUMA 노드 바인딩은 + # numactl 과 조합. fastembed provider 는 무시. [models.llm] endpoint = "http://localhost:11434" # Ollama host:port diff --git a/crates/kebab-app/Cargo.toml b/crates/kebab-app/Cargo.toml index 6c2d637..c80e1d7 100644 --- a/crates/kebab-app/Cargo.toml +++ b/crates/kebab-app/Cargo.toml @@ -18,6 +18,7 @@ kebab-store-vector = { path = "../kebab-store-vector" } kebab-search = { path = "../kebab-search" } kebab-embed = { path = "../kebab-embed" } kebab-embed-local = { path = "../kebab-embed-local" } +kebab-embed-candle = { path = "../kebab-embed-candle" } kebab-llm = { path = "../kebab-llm" } kebab-llm-local = { path = "../kebab-llm-local" } kebab-rag = { path = "../kebab-rag" } diff --git a/crates/kebab-app/src/app.rs b/crates/kebab-app/src/app.rs index a47a80c..7860f70 100644 --- a/crates/kebab-app/src/app.rs +++ b/crates/kebab-app/src/app.rs @@ -43,6 +43,7 @@ use kebab_core::{ Answer, DocumentStore, Embedder, ExtractContext, Extractor, IndexVersion, LanguageModel, MediaType, Retriever, SearchHit, SearchMode, SearchOpts, SearchQuery, VectorStore, }; +use kebab_embed_candle::CandleEmbedder; use kebab_embed_local::FastembedEmbedder; use kebab_llm_local::OllamaLanguageModel; use kebab_parse_code::{ @@ -833,9 +834,26 @@ impl App { if let Some(e) = self.embedder.get() { return Ok(Some(e.clone())); } - let emb: Arc = Arc::new( - FastembedEmbedder::new(&self.config).context("kb-app: load FastembedEmbedder")?, - ); + // Provider branch (Track 1 spec §3). `embeddings_disabled()` above + // already handled `"none"`; here we route the live providers. + // `fastembed`/`onnx`/(empty) keep the default onnxruntime path + // (vectors unchanged — `embedding_version` is preserved); `candle` + // selects the pure-Rust NUMA-safe backend. + let provider = self.config.models.embedding.provider.as_str(); + let emb: Arc = match provider { + "fastembed" | "onnx" | "" => Arc::new( + FastembedEmbedder::new(&self.config).context("kb-app: load FastembedEmbedder")?, + ), + "candle" => Arc::new( + CandleEmbedder::new(&self.config).context("kb-app: load CandleEmbedder")?, + ), + other => { + return Err(anyhow!( + "kb-app: unknown embedding provider {other:?}; expected one of \ + `fastembed` (default), `candle`, or `none` (lexical-only)" + )); + } + }; // `set` returns Err if another thread won the race; in that case // the loser still returns the (now-cached) winner via `get()`. let _ = self.embedder.set(emb.clone()); diff --git a/crates/kebab-config/src/lib.rs b/crates/kebab-config/src/lib.rs index 8e66bca..e2d18fe 100644 --- a/crates/kebab-config/src/lib.rs +++ b/crates/kebab-config/src/lib.rs @@ -155,11 +155,21 @@ impl NliCfg { #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct EmbeddingModelCfg { + /// `fastembed` (default, onnxruntime) or `candle` (pure-Rust, + /// NUMA-safe). `none` disables embeddings (lexical-only). Unknown + /// values error at embedder construction. pub provider: String, pub model: String, pub version: String, pub dimensions: usize, pub batch_size: usize, + /// Cap on the CPU worker threads the `candle` provider spins up + /// (sizes the global rayon pool; env `KEBAB_EMBED_THREADS` overrides). + /// `0` = auto (rayon default = #cores). Lever to sidestep the + /// onnxruntime 48-thread NUMA double-free; ignored by the `fastembed` + /// provider. Defaulted on load so pre-0.22 config files still parse. + #[serde(default)] + pub num_threads: u32, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -707,6 +717,7 @@ impl Config { version: "v1".to_string(), dimensions: 1024, batch_size: 64, + num_threads: 0, }, llm: LlmCfg { provider: "ollama".to_string(), @@ -964,6 +975,11 @@ impl Config { self.models.embedding.batch_size = n; } } + "KEBAB_MODELS_EMBEDDING_NUM_THREADS" => { + if let Ok(n) = v.parse::() { + self.models.embedding.num_threads = n; + } + } // models.llm "KEBAB_MODELS_LLM_PROVIDER" => self.models.llm.provider = v.clone(), diff --git a/crates/kebab-embed-candle/Cargo.toml b/crates/kebab-embed-candle/Cargo.toml new file mode 100644 index 0000000..079d624 --- /dev/null +++ b/crates/kebab-embed-candle/Cargo.toml @@ -0,0 +1,39 @@ +[package] +name = "kebab-embed-candle" +version = { workspace = true } +edition = { workspace = true } +rust-version = { workspace = true } +license = { workspace = true } +repository = { workspace = true } +description = "Pure-Rust candle adapter implementing kb_core::Embedder (multilingual-e5-large, NUMA-safe thread cap)" + +[dependencies] +kebab-core = { path = "../kebab-core" } +kebab-config = { path = "../kebab-config" } +# candle stack — pinned to the workspace-locked crates.io release (0.10.x), +# same versions the Phase 0 spike compiled so build artifacts are reused. +candle-core = "0.10.2" +candle-nn = "0.10.2" +candle-transformers = "0.10.2" +tokenizers = "0.21" +hf-hub = { version = "0.4", features = ["ureq"] } +serde_json = { workspace = true } +# Thread cap: a one-shot global rayon pool sizes candle's CPU threads +# (the Phase 0 spike proved RAYON_NUM_THREADS caps candle), so a NUMA host +# can keep onnxruntime's hard-coded 48-intra-op heap corruption at bay. +rayon = "1" +anyhow = { workspace = true } +tracing = { workspace = true } + +[dev-dependencies] +# Integration-test binaries can only see the library's public API + these, +# not the library's own (non-dev) dependencies — so rayon/kebab-config/kebab-core +# are repeated here for tests/parity.rs and tests/thread_cap.rs. +kebab-embed-local = { path = "../kebab-embed-local" } +kebab-config = { path = "../kebab-config" } +kebab-core = { path = "../kebab-core" } +rayon = "1" +tempfile = { workspace = true } + +[lints] +workspace = true diff --git a/crates/kebab-embed-candle/src/lib.rs b/crates/kebab-embed-candle/src/lib.rs new file mode 100644 index 0000000..5adee8b --- /dev/null +++ b/crates/kebab-embed-candle/src/lib.rs @@ -0,0 +1,363 @@ +//! `kebab-embed-candle` — [`CandleEmbedder`], a pure-Rust (candle) +//! implementation of [`Embedder`](kebab_core::Embedder). +//! +//! Runs the same `intfloat/multilingual-e5-large` model as the default +//! [`FastembedEmbedder`](kebab_embed_local) but through `candle` +//! (`candle-transformers`' XLM-RoBERTa) instead of onnxruntime. Motivation: +//! fastembed 4.9's onnxruntime hard-codes 48 intra-op threads, which corrupts +//! the heap (double-free) on dual-socket NUMA hosts. candle's CPU backend +//! sizes its threads off the global rayon pool, so a one-shot +//! [`rayon::ThreadPoolBuilder`] cap (config `num_threads` / env +//! `KEBAB_EMBED_THREADS`) keeps the worker count NUMA-safe. +//! +//! Output parity with the onnxruntime path was proven by the Phase 0 spike +//! (cosine 1.000000); this crate absorbs that pipeline verbatim: +//! +//! 1. e5 prefix (`passage: ` for documents, `query: ` for queries — the same +//! convention as `kebab-embed-local`'s `prefix_input`); +//! 2. tokenize (max_len 512, batch-longest padding, special tokens); +//! 3. XLM-RoBERTa forward on `Device::Cpu`; +//! 4. attention-mask-weighted mean pooling; +//! 5. L2 normalization. +//! +//! Model files (`config.json`, `tokenizer.json`, `model.safetensors`) are +//! fetched via `hf-hub` into `{config.storage.model_dir}/candle/`. +//! +//! This crate is **opt-in** (`config.models.embedding.provider = "candle"`); +//! the default provider stays `fastembed`. See +//! `docs/superpowers/specs/2026-06-01-embed-candle-track-spec.md`. + +use std::sync::Mutex; + +use anyhow::{Context, Result}; +use candle_core::{DType, Device, Tensor}; +use candle_nn::VarBuilder; +use candle_transformers::models::xlm_roberta::{Config as XlmConfig, XLMRobertaModel}; +use kebab_config::{Config, expand_path}; +use kebab_core::{Embedder, EmbeddingInput, EmbeddingKind, EmbeddingModelId, EmbeddingVersion}; +use tokenizers::{PaddingParams, PaddingStrategy, Tokenizer, TruncationParams}; + +/// Subdirectory under `config.storage.model_dir` where the candle adapter +/// caches safetensors + tokenizer. Mirrors `kebab-embed-local`'s +/// `fastembed/` subdir so the two backends never collide. +const CANDLE_CACHE_SUBDIR: &str = "candle"; + +/// HuggingFace repo id for the multilingual e5 large model. Same weights the +/// onnxruntime path uses, just the safetensors variant candle can read. +const HF_MODEL: &str = "intfloat/multilingual-e5-large"; + +/// Token truncation length (e5 was trained at 512). +const MAX_LEN: usize = 512; + +/// Env var that overrides `config.models.embedding.num_threads`. Read once in +/// [`CandleEmbedder::new`]; `0`/unset/unparseable means "leave rayon default". +const ENV_EMBED_THREADS: &str = "KEBAB_EMBED_THREADS"; + +/// Pure-Rust candle adapter. Construct via [`CandleEmbedder::new`]; the +/// constructor downloads the model on first use, so share one instance. +pub struct CandleEmbedder { + // candle's `forward` is `&self`, but `XLMRobertaModel` is not guaranteed + // `Sync`; the `Mutex` both supplies that bound and serializes inference + // (callers batch sequentially anyway — same rationale as + // `FastembedEmbedder`). + model: Mutex, + tokenizer: Tokenizer, + device: Device, + model_id: EmbeddingModelId, + version: EmbeddingVersion, + dimensions: usize, + batch_size: usize, +} + +impl CandleEmbedder { + /// Build an embedder from `Config`. Applies the NUMA thread cap, fetches + /// the model into `{model_dir}/candle/`, and validates that the model's + /// hidden size matches `config.models.embedding.dimensions` before + /// returning. + pub fn new(config: &Config) -> Result { + // 1. NUMA thread cap. env `KEBAB_EMBED_THREADS` wins over the config + // field; `0`/unset leaves rayon's default. `build_global` errors if + // the pool was already initialized — intentionally ignored so a + // second embedder (or a prior rayon user) is a no-op, not a failure. + let n_threads = std::env::var(ENV_EMBED_THREADS) + .ok() + .and_then(|v| v.parse::().ok()) + .unwrap_or(config.models.embedding.num_threads as usize); + if n_threads > 0 { + if apply_thread_cap(n_threads) { + tracing::info!( + target: "kebab-embed-candle", + num_threads = n_threads, + "capped global rayon pool for candle CPU backend" + ); + } else { + tracing::debug!( + target: "kebab-embed-candle", + requested = n_threads, + "global rayon pool already initialized; thread cap not applied" + ); + } + } + + // 2. Resolve `{data_dir}/models/candle/` exactly like the fastembed + // adapter resolves its own subdir. + let data_dir = expand_path(&config.storage.data_dir, ""); + let model_dir = expand_path(&config.storage.model_dir, &data_dir.to_string_lossy()); + let cache_dir = model_dir.join(CANDLE_CACHE_SUBDIR); + std::fs::create_dir_all(&cache_dir) + .with_context(|| format!("create candle cache dir {}", cache_dir.display()))?; + + let device = Device::Cpu; + + // 3. Fetch model files via hf-hub into the candle cache. + tracing::info!( + target: "kebab-embed-candle", + cache_dir = %cache_dir.display(), + model = HF_MODEL, + "loading candle embedding model (first run downloads ~2GB safetensors)" + ); + let api = hf_hub::api::sync::ApiBuilder::new() + .with_cache_dir(cache_dir.clone()) + .build() + .context("kb-embed-candle: build hf-hub api")?; + let repo = api.model(HF_MODEL.to_string()); + let config_path = repo.get("config.json").context("download config.json")?; + let tokenizer_path = repo + .get("tokenizer.json") + .context("download tokenizer.json")?; + let weights_path = repo + .get("model.safetensors") + .context("download model.safetensors")?; + + // 4. Build the candle XLM-RoBERTa model. + let cfg_json = std::fs::read_to_string(&config_path) + .with_context(|| format!("read {}", config_path.display()))?; + let cfg: XlmConfig = + serde_json::from_str(&cfg_json).context("kb-embed-candle: parse XLM-R config")?; + + // Validate dim BEFORE building the model so a misconfigured + // `dimensions` fails cheaply (matches FastembedEmbedder's contract). + check_dim(cfg.hidden_size, config.models.embedding.dimensions)?; + + let vb = unsafe { + VarBuilder::from_mmaped_safetensors(&[weights_path], DType::F32, &device) + .context("kb-embed-candle: mmap safetensors")? + }; + let model = + XLMRobertaModel::new(&cfg, vb).context("kb-embed-candle: build XLMRobertaModel")?; + + let mut tokenizer = Tokenizer::from_file(&tokenizer_path) + .map_err(|e| anyhow::anyhow!("kb-embed-candle: load tokenizer: {e}"))?; + tokenizer + .with_padding(Some(PaddingParams { + strategy: PaddingStrategy::BatchLongest, + ..Default::default() + })) + .with_truncation(Some(TruncationParams { + max_length: MAX_LEN, + ..Default::default() + })) + .map_err(|e| anyhow::anyhow!("kb-embed-candle: set truncation: {e}"))?; + + tracing::info!( + target: "kebab-embed-candle", + dimensions = cfg.hidden_size, + layers = cfg.num_hidden_layers, + "candle embedding model loaded" + ); + + Ok(Self { + model: Mutex::new(model), + tokenizer, + device, + model_id: EmbeddingModelId(config.models.embedding.model.clone()), + version: EmbeddingVersion(config.models.embedding.version.clone()), + dimensions: cfg.hidden_size, + batch_size: config.models.embedding.batch_size.max(1), + }) + } + + /// Embed one batch (already prefixed) through the candle pipeline: + /// tokenize → forward → masked mean pool → L2 normalize. + fn embed_batch(&self, prefixed: &[String]) -> Result>> { + let encodings = self + .tokenizer + .encode_batch(prefixed.to_vec(), true) + .map_err(|e| anyhow::anyhow!("kb-embed-candle: encode_batch: {e}"))?; + + let bsz = encodings.len(); + let seq = encodings[0].get_ids().len(); + + let mut ids = Vec::with_capacity(bsz * seq); + let mut mask = Vec::with_capacity(bsz * seq); + for enc in &encodings { + ids.extend(enc.get_ids().iter().copied()); + mask.extend(enc.get_attention_mask().iter().map(|&m| m as f32)); + } + + let input_ids = Tensor::from_vec(ids, (bsz, seq), &self.device)?; + let attn_f32 = Tensor::from_vec(mask, (bsz, seq), &self.device)?; + let token_type_ids = input_ids.zeros_like()?; + + let hidden = { + let guard = self + .model + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + // forward: (input_ids, attention_mask, token_type_ids, past, + // encoder_hidden, encoder_mask) + guard.forward(&input_ids, &attn_f32, &token_type_ids, None, None, None)? + }; + + // attention-mask-weighted mean pooling + let mask3 = attn_f32.unsqueeze(2)?; // (b, seq, 1) + let summed = hidden.broadcast_mul(&mask3)?.sum(1)?; // (b, hidden) + let counts = mask3.sum(1)?; // (b, 1) + let mean = summed.broadcast_div(&counts)?; + + // L2 normalize + let norm = mean.sqr()?.sum_keepdim(1)?.sqrt()?; + let normalized = mean.broadcast_div(&norm)?; + + Ok(normalized.to_vec2::()?) + } +} + +impl Embedder for CandleEmbedder { + fn model_id(&self) -> EmbeddingModelId { + self.model_id.clone() + } + + fn model_version(&self) -> EmbeddingVersion { + self.version.clone() + } + + fn dimensions(&self) -> usize { + self.dimensions + } + + fn embed(&self, inputs: &[EmbeddingInput<'_>]) -> Result>> { + if inputs.is_empty() { + return Ok(Vec::new()); + } + + // e5 prefix per §11.3 BEFORE tokenization (same convention as + // FastembedEmbedder so the two backends produce comparable vectors). + let prefixed: Vec = inputs.iter().map(prefix_input).collect(); + + let mut out: Vec> = Vec::with_capacity(prefixed.len()); + for chunk in prefixed.chunks(self.batch_size) { + let batch = self.embed_batch(chunk)?; + for v in &batch { + if v.len() != self.dimensions { + anyhow::bail!( + "candle returned vector of length {} but adapter expects {}", + v.len(), + self.dimensions + ); + } + } + out.extend(batch); + } + + debug_assert_eq!(out.len(), inputs.len()); + Ok(out) + } +} + +/// Build the e5-prefixed string for one [`EmbeddingInput`]. Free function so +/// a unit test can pin the format without loading the model. Byte-identical to +/// `kebab-embed-local`'s `prefix_input` — the two backends MUST agree here or +/// their vectors diverge. +fn prefix_input(input: &EmbeddingInput<'_>) -> String { + match input.kind { + EmbeddingKind::Document => format!("passage: {}", input.text), + EmbeddingKind::Query => format!("query: {}", input.text), + } +} + +/// Apply a one-shot global rayon thread cap (the NUMA-safety lever). Returns +/// `true` if this call set the pool, `false` if it was already initialized +/// (cap not applied) or `n_threads == 0`. `#[doc(hidden)] pub` so the +/// thread-cap test can drive it without loading the 2GB model. +#[doc(hidden)] +pub fn apply_thread_cap(n_threads: usize) -> bool { + if n_threads == 0 { + return false; + } + rayon::ThreadPoolBuilder::new() + .num_threads(n_threads) + .build_global() + .is_ok() +} + +/// Compare model hidden size against the configured dim. Extracted so a unit +/// test can exercise the error branch without loading the model. +pub(crate) fn check_dim(model_dim: usize, cfg_dim: usize) -> Result<()> { + if model_dim != cfg_dim { + anyhow::bail!( + "dimension mismatch: model={model_dim}, config={cfg_dim}; \ + update `config.models.embedding.dimensions` to match the model \ + (or pick a different model)." + ); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + // ── prefix_input ───────────────────────────────────────────────── + // Pin the exact e5 prefix strings; these MUST match + // kebab-embed-local::prefix_input or candle vs fastembed parity breaks. + + #[test] + fn prefix_document_uses_passage() { + let input = EmbeddingInput { + text: "hello world", + kind: EmbeddingKind::Document, + }; + assert_eq!(prefix_input(&input), "passage: hello world"); + } + + #[test] + fn prefix_query_uses_query() { + let input = EmbeddingInput { + text: "hello world", + kind: EmbeddingKind::Query, + }; + assert_eq!(prefix_input(&input), "query: hello world"); + } + + #[test] + fn prefix_handles_empty_text() { + let doc = EmbeddingInput { + text: "", + kind: EmbeddingKind::Document, + }; + let qry = EmbeddingInput { + text: "", + kind: EmbeddingKind::Query, + }; + assert_eq!(prefix_input(&doc), "passage: "); + assert_eq!(prefix_input(&qry), "query: "); + } + + // ── check_dim ──────────────────────────────────────────────────── + + #[test] + fn check_dim_passes_for_1024() { + check_dim(1024, 1024).expect("matching dims must pass"); + } + + #[test] + fn check_dim_rejects_384_vs_1024() { + let err = check_dim(384, 1024).expect_err("dim mismatch must error"); + let msg = format!("{err}"); + assert!( + msg.contains("384") && msg.contains("1024"), + "error must mention both dims, got: {msg}" + ); + } +} diff --git a/crates/kebab-embed-candle/tests/parity.rs b/crates/kebab-embed-candle/tests/parity.rs new file mode 100644 index 0000000..8eeaeef --- /dev/null +++ b/crates/kebab-embed-candle/tests/parity.rs @@ -0,0 +1,88 @@ +//! Parity test (spec §7, `#[ignore]` — needs the ~2GB model + network). +//! +//! Confirms the candle backend reproduces the onnxruntime `FastembedEmbedder` +//! vectors closely enough that no re-index is required (spec D-reindex): +//! per-sentence cosine ≥ 0.9999, and reports the dimension-wise max absolute +//! difference (the number the re-index decision hangs on). +//! +//! Run manually: +//! CARGO_TARGET_DIR=/build/out/cargo-target/target \ +//! cargo test -p kebab-embed-candle --release -- --ignored --nocapture +//! +//! Uses the canonical dogfood config so both backends resolve the same model +//! identifiers and cache roots. + +use kebab_config::Config; +use kebab_core::{Embedder, EmbeddingInput, EmbeddingKind}; +use kebab_embed_candle::CandleEmbedder; +use kebab_embed_local::FastembedEmbedder; + +const DOGFOOD_CONFIG: &str = "/build/dogfood/config.toml"; + +/// Mixed Korean / English parity set (≥ 8 sentences, mirrors the Phase 0 spike). +const SENTENCES: &[&str] = &[ + "The quick brown fox jumps over the lazy dog.", + "오늘 날씨가 정말 좋아서 산책을 나가고 싶다.", + "Rust is a systems programming language focused on safety and performance.", + "벡터 검색은 임베딩 사이의 코사인 유사도를 이용한다.", + "Machine learning models require large amounts of training data.", + "한국어와 영어가 섞인 문장도 멀티링구얼 모델은 잘 처리한다.", + "The capital of France is Paris, a city known for its art and culture.", + "이 프로젝트는 로컬 우선 지식 베이스와 검색 증강 생성을 목표로 한다.", + "Database indexing dramatically speeds up query performance.", + "임베딩 모델을 candle 로 옮기면 NUMA 서버에서 안전하게 돌릴 수 있다.", +]; + +fn cosine(a: &[f32], b: &[f32]) -> f32 { + let dot: f32 = a.iter().zip(b).map(|(x, y)| x * y).sum(); + let na: f32 = a.iter().map(|x| x * x).sum::().sqrt(); + let nb: f32 = b.iter().map(|x| x * x).sum::().sqrt(); + dot / (na * nb) +} + +#[test] +#[ignore = "needs ~2GB model + network; run manually for the re-index decision"] +fn candle_matches_fastembed() { + let config = Config::load(Some(std::path::Path::new(DOGFOOD_CONFIG))) + .expect("load dogfood config for parity baseline"); + + let candle = CandleEmbedder::new(&config).expect("build CandleEmbedder"); + let fastembed = FastembedEmbedder::new(&config).expect("build FastembedEmbedder"); + + let inputs: Vec = SENTENCES + .iter() + .map(|s| EmbeddingInput { + text: s, + kind: EmbeddingKind::Document, + }) + .collect(); + + let cv = candle.embed(&inputs).expect("candle embed"); + let fv = fastembed.embed(&inputs).expect("fastembed embed"); + + assert_eq!(cv.len(), fv.len(), "embedding counts must match"); + assert_eq!(candle.dimensions(), 1024); + + let mut min_cos = f32::INFINITY; + let mut max_abs_diff = 0f32; + for (i, s) in SENTENCES.iter().enumerate() { + assert_eq!(cv[i].len(), 1024, "candle dim"); + assert_eq!(fv[i].len(), 1024, "fastembed dim"); + let c = cosine(&cv[i], &fv[i]); + min_cos = min_cos.min(c); + let diff = cv[i] + .iter() + .zip(&fv[i]) + .map(|(a, b)| (a - b).abs()) + .fold(0f32, f32::max); + max_abs_diff = max_abs_diff.max(diff); + let preview: String = s.chars().take(40).collect(); + println!("[{i:>2}] cos={c:.6} max_abs_diff={diff:.6e} {preview}"); + } + + println!("PARITY_SUMMARY cosine_min={min_cos:.6} max_abs_diff={max_abs_diff:.6e}"); + assert!( + min_cos >= 0.9999, + "candle vs fastembed cosine_min={min_cos:.6} < 0.9999 — investigate before merge" + ); +} diff --git a/crates/kebab-embed-candle/tests/thread_cap.rs b/crates/kebab-embed-candle/tests/thread_cap.rs new file mode 100644 index 0000000..7845721 --- /dev/null +++ b/crates/kebab-embed-candle/tests/thread_cap.rs @@ -0,0 +1,32 @@ +//! Thread-cap test (spec §7). Own integration binary → clean process, so the +//! one-shot global rayon pool is initialized exactly once, by us. +//! +//! Verifies that `apply_thread_cap(4)` sizes the global rayon pool to 4, which +//! is the lever that keeps candle's CPU backend NUMA-safe (vs onnxruntime's +//! hard-coded 48 intra-op threads). + +use kebab_embed_candle::apply_thread_cap; + +#[test] +fn thread_cap_sizes_global_rayon_pool() { + // Must run before any other rayon use in this process. As the only test in + // this binary that touches rayon, that holds. + let applied = apply_thread_cap(4); + assert!(applied, "first build_global call should succeed"); + assert_eq!( + rayon::current_num_threads(), + 4, + "global rayon pool must be capped at the requested 4 threads" + ); + + // A second cap attempt is a no-op (pool already built), not a panic. + assert!( + !apply_thread_cap(8), + "second build_global must report not-applied" + ); + assert_eq!( + rayon::current_num_threads(), + 4, + "thread count must stay at the first cap" + ); +} diff --git a/crates/spike-embed-candle/Cargo.toml b/crates/spike-embed-candle/Cargo.toml deleted file mode 100644 index 7057360..0000000 --- a/crates/spike-embed-candle/Cargo.toml +++ /dev/null @@ -1,32 +0,0 @@ -# Track 1 / Phase 0 feasibility SPIKE — NOT production. -# Isolated binary that loads multilingual-e5-large via candle (pure Rust) -# and compares its output against the existing onnxruntime FastembedEmbedder. -# candle deps live ONLY here so the production crates stay untouched. -[package] -name = "spike-embed-candle" -version = "0.0.0" -edition = "2024" -publish = false - -[[bin]] -name = "spike-embed-candle" -path = "src/main.rs" - -[dependencies] -anyhow = "1" -serde_json = "1" -# candle stack — pinned to the current crates.io release (0.10.2). -candle-core = "0.10.2" -candle-nn = "0.10.2" -candle-transformers = "0.10.2" -# Align with workspace-locked versions so we reuse compiled artifacts. -tokenizers = "0.21" -hf-hub = { version = "0.4", features = ["ureq"] } -rayon = "1" -# Parity baseline: reuse the real production embedder + its config loader. -kebab-config = { path = "../kebab-config" } -kebab-embed = { path = "../kebab-embed" } -kebab-embed-local = { path = "../kebab-embed-local" } - -# Keep the spike out of the workspace pedantic-lint gate; it is throwaway. -[lints] diff --git a/crates/spike-embed-candle/src/main.rs b/crates/spike-embed-candle/src/main.rs deleted file mode 100644 index 60d76bd..0000000 --- a/crates/spike-embed-candle/src/main.rs +++ /dev/null @@ -1,251 +0,0 @@ -//! Track 1 / Phase 0 feasibility SPIKE (NOT production code). -//! -//! Proves whether candle (pure Rust) can run `intfloat/multilingual-e5-large` -//! with output parity against the existing onnxruntime `FastembedEmbedder`, -//! so the NUMA double-free in fastembed 4.9.1 can be sidestepped. -//! -//! What it checks (see SPIKE_BRIEF.md): -//! 1. numeric parity — per-sentence cosine vs FastembedEmbedder -//! 2. padding_idx — XLM-R position ids start at pad_token_id+1 -//! 3. thread control — RAYON_NUM_THREADS caps candle's CPU threads -//! 4. CPU latency — batch wall-clock, rough vs onnxruntime -//! -//! Run: -//! CARGO_TARGET_DIR=/build/out/cargo-target/target \ -//! HF_HOME=/build/cache/huggingface \ -//! RAYON_NUM_THREADS=4 \ -//! cargo run -j 4 -p spike-embed-candle --release - -use std::path::PathBuf; -use std::time::Instant; - -use anyhow::{Context, Result}; -use candle_core::{DType, Device, Tensor}; -use candle_nn::VarBuilder; -use candle_transformers::models::xlm_roberta::{Config as XlmConfig, XLMRobertaModel}; -use tokenizers::{PaddingParams, PaddingStrategy, Tokenizer, TruncationParams}; - -use kebab_embed::{Embedder, EmbeddingInput, EmbeddingKind}; -use kebab_embed_local::FastembedEmbedder; - -const HF_MODEL: &str = "intfloat/multilingual-e5-large"; -const DOGFOOD_CONFIG: &str = "/build/dogfood/config.toml"; -const MAX_LEN: usize = 512; - -/// Mixed Korean / English parity set (≥ 8, brief §3). -const SENTENCES: &[&str] = &[ - "The quick brown fox jumps over the lazy dog.", - "오늘 날씨가 정말 좋아서 산책을 나가고 싶다.", - "Rust is a systems programming language focused on safety and performance.", - "벡터 검색은 임베딩 사이의 코사인 유사도를 이용한다.", - "Machine learning models require large amounts of training data.", - "한국어와 영어가 섞인 문장도 멀티링구얼 모델은 잘 처리한다.", - "The capital of France is Paris, a city known for its art and culture.", - "이 프로젝트는 로컬 우선 지식 베이스와 검색 증강 생성을 목표로 한다.", - "Database indexing dramatically speeds up query performance.", - "임베딩 모델을 candle 로 옮기면 NUMA 서버에서 안전하게 돌릴 수 있다.", -]; - -fn main() -> Result<()> { - // Touch the rayon global pool early so RAYON_NUM_THREADS is honored and - // reportable before any candle compute spins it up. - let rayon_threads = rayon::current_num_threads(); - let avail = std::thread::available_parallelism() - .map(|n| n.get()) - .unwrap_or(0); - let rayon_env = std::env::var("RAYON_NUM_THREADS").unwrap_or_else(|_| "".into()); - - println!("== spike-embed-candle =="); - println!("available_parallelism = {avail}"); - println!("RAYON_NUM_THREADS env = {rayon_env}"); - println!("rayon::current_num_threads() = {rayon_threads}"); - - let device = Device::Cpu; - - // ── 1. Fetch model files (candle reads safetensors, not the ONNX cache) ── - let cache_dir = std::env::var("HF_HOME") - .map(PathBuf::from) - .unwrap_or_else(|_| PathBuf::from("/build/cache/huggingface")); - let api = hf_hub::api::sync::ApiBuilder::new() - .with_cache_dir(cache_dir.clone()) - .build() - .context("build hf-hub api")?; - let repo = api.model(HF_MODEL.to_string()); - println!("\n[load] fetching {HF_MODEL} into {} ...", cache_dir.display()); - let config_path = repo.get("config.json").context("download config.json")?; - let tokenizer_path = repo.get("tokenizer.json").context("download tokenizer.json")?; - let weights_path = repo - .get("model.safetensors") - .context("download model.safetensors")?; - println!("[load] config = {}", config_path.display()); - println!("[load] tokenizer = {}", tokenizer_path.display()); - println!("[load] weights = {}", weights_path.display()); - - // ── 2. Build the candle XLM-RoBERTa model ── - let cfg_json = std::fs::read_to_string(&config_path)?; - let cfg: XlmConfig = serde_json::from_str(&cfg_json).context("parse XLM-R config")?; - println!( - "[load] config: hidden={} layers={} heads={} pad_token_id={} max_pos={} pos_emb={}", - cfg.hidden_size, - cfg.num_hidden_layers, - cfg.num_attention_heads, - cfg.pad_token_id, - cfg.max_position_embeddings, - cfg.position_embedding_type, - ); - let vb = unsafe { - VarBuilder::from_mmaped_safetensors(&[weights_path], DType::F32, &device) - .context("mmap safetensors")? - }; - let model = XLMRobertaModel::new(&cfg, vb).context("build XLMRobertaModel")?; - - let mut tokenizer = Tokenizer::from_file(&tokenizer_path) - .map_err(|e| anyhow::anyhow!("load tokenizer: {e}"))?; - tokenizer - .with_padding(Some(PaddingParams { - strategy: PaddingStrategy::BatchLongest, - ..Default::default() - })) - .with_truncation(Some(TruncationParams { - max_length: MAX_LEN, - ..Default::default() - })) - .map_err(|e| anyhow::anyhow!("set truncation: {e}"))?; - - let pad_id = cfg.pad_token_id; - - // ── 3. candle embedding path (passage prefix, masked mean pool, L2) ── - let candle_vecs = candle_embed(&model, &tokenizer, &device, pad_id, SENTENCES)?; - println!("\n[candle] embedded {} sentences, dim={}", candle_vecs.len(), candle_vecs[0].len()); - // L2 norm sanity (should be ~1.0 after normalization) - let norm0 = l2(&candle_vecs[0]); - println!("[candle] ‖v0‖ = {norm0:.6}"); - - // ── 4. FastembedEmbedder (onnxruntime) baseline ── - println!("\n[fastembed] loading FastembedEmbedder from {DOGFOOD_CONFIG} ..."); - let config = kebab_config::Config::load(Some(std::path::Path::new(DOGFOOD_CONFIG))) - .context("load dogfood config")?; - let fb_t0 = Instant::now(); - let fb = FastembedEmbedder::new(&config).context("build FastembedEmbedder")?; - println!("[fastembed] model loaded in {:.2}s", fb_t0.elapsed().as_secs_f64()); - let fb_inputs: Vec = SENTENCES - .iter() - .map(|s| EmbeddingInput { text: s, kind: EmbeddingKind::Document }) - .collect(); - let fb_vecs = fb.embed(&fb_inputs).context("fastembed embed")?; - - // ── 5. Per-sentence parity (both L2-normalized → cosine = dot) ── - println!("\n== PARITY (candle vs fastembed, EmbeddingKind::Document / passage:) =="); - let mut cosines = Vec::with_capacity(SENTENCES.len()); - for (i, s) in SENTENCES.iter().enumerate() { - let c = cosine(&candle_vecs[i], &fb_vecs[i]); - cosines.push(c); - let preview: String = s.chars().take(40).collect(); - println!(" [{i:>2}] cos={c:.6} {preview}"); - } - let min = cosines.iter().cloned().fold(f32::INFINITY, f32::min); - let mean = cosines.iter().sum::() / cosines.len() as f32; - println!(" --> cosine min={min:.6} mean={mean:.6}"); - - // ── 6. Latency: batch of 32 (replicated) through candle ── - let batch: Vec<&str> = SENTENCES.iter().cloned().cycle().take(32).collect(); - // warmup - let _ = candle_embed(&model, &tokenizer, &device, pad_id, &batch[..4])?; - let t0 = Instant::now(); - let _ = candle_embed(&model, &tokenizer, &device, pad_id, &batch)?; - let candle_lat = t0.elapsed(); - - let fb_batch: Vec = batch - .iter() - .map(|s| EmbeddingInput { text: s, kind: EmbeddingKind::Document }) - .collect(); - let t1 = Instant::now(); - let _ = fb.embed(&fb_batch)?; - let fb_lat = t1.elapsed(); - - let peak_threads = proc_threads(); - println!("\n== LATENCY (batch=32) =="); - println!(" candle : {:.3}s ({:.1} ms/sentence)", candle_lat.as_secs_f64(), candle_lat.as_secs_f64() * 1000.0 / 32.0); - println!(" fastembed : {:.3}s ({:.1} ms/sentence)", fb_lat.as_secs_f64(), fb_lat.as_secs_f64() * 1000.0 / 32.0); - - println!("\n== THREAD CONTROL =="); - println!(" RAYON_NUM_THREADS env = {rayon_env}"); - println!(" rayon::current_num_threads = {rayon_threads}"); - println!(" available_parallelism = {avail}"); - println!(" peak OS threads (/proc) = {peak_threads}"); - - // ── 7. Machine verdict line for the report ── - let verdict = if mean >= 0.99 { "PASS" } else if mean >= 0.95 { "MARGINAL" } else { "FAIL" }; - println!("\n== SUMMARY =="); - println!("VERDICT_HINT={verdict} cosine_min={min:.6} cosine_mean={mean:.6} candle_batch32_s={:.3} fb_batch32_s={:.3} rayon_threads={rayon_threads} rayon_env={rayon_env}", candle_lat.as_secs_f64(), fb_lat.as_secs_f64()); - - Ok(()) -} - -/// candle embedding: apply e5 `passage:` prefix, tokenize (batch-padded), -/// forward through XLM-R, attention-mask-weighted mean pool, L2 normalize. -fn candle_embed( - model: &XLMRobertaModel, - tokenizer: &Tokenizer, - device: &Device, - _pad_id: u32, - sentences: &[&str], -) -> Result>> { - let prefixed: Vec = sentences.iter().map(|s| format!("passage: {s}")).collect(); - let encodings = tokenizer - .encode_batch(prefixed, true) - .map_err(|e| anyhow::anyhow!("encode_batch: {e}"))?; - - let bsz = encodings.len(); - let seq = encodings[0].get_ids().len(); - - let mut ids = Vec::with_capacity(bsz * seq); - let mut mask = Vec::with_capacity(bsz * seq); - for enc in &encodings { - ids.extend(enc.get_ids().iter().copied()); - mask.extend(enc.get_attention_mask().iter().map(|&m| m as f32)); - } - - let input_ids = Tensor::from_vec(ids, (bsz, seq), device)?; - let attn_f32 = Tensor::from_vec(mask, (bsz, seq), device)?; - let token_type_ids = input_ids.zeros_like()?; - - // forward: (input_ids, attention_mask, token_type_ids, past, enc_hidden, enc_mask) - let hidden = model.forward(&input_ids, &attn_f32, &token_type_ids, None, None, None)?; - - // masked mean pool - let mask3 = attn_f32.unsqueeze(2)?; // (b, seq, 1) - let summed = hidden.broadcast_mul(&mask3)?.sum(1)?; // (b, hidden) - let counts = mask3.sum(1)?; // (b, 1) - let mean = summed.broadcast_div(&counts)?; - - // L2 normalize - let norm = mean.sqr()?.sum_keepdim(1)?.sqrt()?; - let normalized = mean.broadcast_div(&norm)?; - - Ok(normalized.to_vec2::()?) -} - -fn cosine(a: &[f32], b: &[f32]) -> f32 { - let dot: f32 = a.iter().zip(b).map(|(x, y)| x * y).sum(); - let na = l2(a); - let nb = l2(b); - dot / (na * nb) -} - -fn l2(v: &[f32]) -> f32 { - v.iter().map(|x| x * x).sum::().sqrt() -} - -/// Peak OS thread count for this process from /proc/self/status. -fn proc_threads() -> usize { - std::fs::read_to_string("/proc/self/status") - .ok() - .and_then(|s| { - s.lines() - .find(|l| l.starts_with("Threads:")) - .and_then(|l| l.split_whitespace().nth(1).map(str::to_string)) - }) - .and_then(|n| n.parse().ok()) - .unwrap_or(0) -} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 45cd3c5..7962edf 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -66,7 +66,8 @@ flowchart TB end subgraph Adapters ["traits + adapters"] embed["kebab-embed
(trait)"] - embedlocal["kebab-embed-local
(fastembed)"] + embedlocal["kebab-embed-local
(fastembed, default)"] + embedcandle["kebab-embed-candle
(candle, NUMA-safe opt-in)"] llm["kebab-llm
(trait)"] llmlocal["kebab-llm-local
(Ollama)"] search["kebab-search"] @@ -92,6 +93,7 @@ flowchart TB app --> sqlite app --> vector app --> embedlocal + app --> embedcandle app --> llmlocal app --> search app --> rag @@ -104,6 +106,8 @@ flowchart TB paud --> core pcode --> core embedlocal --> embed + embedcandle --> core + embedcandle --> config llmlocal --> llm rag --> search rag --> llm @@ -180,6 +184,7 @@ kebab/ │ ├── kebab-store-sqlite/ # SQLite + FTS5 (V001/V002/V003) (P1-6, P2-1, P3-3). src/derivation_cache.rs = derivation_cache 테이블 저장소 (V012, v0.21.0) │ ├── kebab-search/ # Lexical + Vector + Hybrid retriever (P2-2, P3-4) │ ├── kebab-embed/ kebab-embed-local/ # Embedder trait + fastembed adapter (P3-1, P3-2) +│ ├── kebab-embed-candle/ # candle (pure-Rust) Embedder, NUMA-safe opt-in provider=candle (Track 1, v0.22.0) │ ├── kebab-store-vector/ # LanceDB VectorStore (P3-3, P7-3 follow-up) │ ├── kebab-llm/ kebab-llm-local/ # LanguageModel trait + Ollama adapter (P4-1, P4-2) │ ├── kebab-rag/ # RAG pipeline (P4-3) diff --git a/docs/SMOKE.md b/docs/SMOKE.md index daf8e78..5275f8f 100644 --- a/docs/SMOKE.md +++ b/docs/SMOKE.md @@ -107,11 +107,13 @@ respect_markdown_headings = true chunker_version = "md-heading-v1" [models.embedding] -provider = "fastembed" # "none" 으로 두면 lexical-only — Ollama 불필요 +provider = "fastembed" # "fastembed"(기본) / "candle"(순수 Rust, NUMA-안전) + # / "none"(lexical-only — Ollama 불필요) model = "multilingual-e5-small" version = "v1" dimensions = 384 batch_size = 64 +num_threads = 0 # candle 전용 CPU 스레드 캡 (0=auto). env KEBAB_EMBED_THREADS 우선. [models.llm] provider = "ollama" diff --git a/docs/release-notes/v0.22.0-draft.md b/docs/release-notes/v0.22.0-draft.md new file mode 100644 index 0000000..d518442 --- /dev/null +++ b/docs/release-notes/v0.22.0-draft.md @@ -0,0 +1,72 @@ +--- +title: kebab v0.22.0 release notes (draft) +created: 2026-06-01 +status: draft +release_trigger: + - 신규 config surface (provider=candle, num_threads / KEBAB_EMBED_THREADS) — pre-1.0 minor bump + - 임베딩 백엔드 다변화 (NUMA-안전 candle provider 추가, opt-in) +--- + +# kebab v0.22.0 — candle 임베딩 provider (NUMA-안전, opt-in) + +v0.21.1 (config 마이그레이션) 후속 minor release. 듀얼소켓 NUMA 서버에서 +onnxruntime 의 스레드 하드코딩이 일으키던 ingest 크래시를 피하기 위해, 같은 +임베딩 모델을 **순수 Rust(candle)** 로 돌리는 opt-in provider 를 추가한다. +**기본 동작은 그대로다** — 기존 사용자는 아무것도 바꿀 필요가 없다. + +--- + +## 핵심 변경 + +### candle 임베딩 provider (`provider = "candle"`) + +**변경 사실.** `[models.embedding].provider` 에 `"candle"` 값이 추가됐다. +`"fastembed"`(기본, onnxruntime) / `"candle"`(순수 Rust) / `"none"`(lexical-only) +중 하나를 고를 수 있다. candle provider 는 fastembed 와 **완전히 같은 모델** +(`intfloat/multilingual-e5-large`, 1024-dim)을 쓰고, e5 prefix → mean pooling +→ L2 정규화 파이프라인도 동일하다. 첫 사용 시 safetensors(~2GB)를 +`{model_dir}/candle/` 아래로 자동 다운로드한다. + +```toml +[models.embedding] +provider = "candle" # 기본은 "fastembed" — NUMA 서버에서만 candle 권장 +num_threads = 8 # candle CPU 스레드 캡 (0 = auto = #cores) +``` + +```bash +# env 로도 캡 가능 (config 보다 우선) +KEBAB_EMBED_THREADS=8 kebab ingest +``` + +**Trade-off.** candle 는 순수 Rust 라 onnxruntime 의 네이티브 SIMD 커널보다 +CPU latency 가 느리다 (Phase 0 스파이크 측정 ~4×). 그래서 **기본값은 +fastembed 를 유지**하고, candle 은 onnxruntime 가 죽는 NUMA 환경에서만 켜는 +opt-in 으로 둔다. 단일 워크스테이션 사용자는 fastembed 가 더 빠르다. + +**Mitigation (왜 안전한가).** candle 의 CPU 백엔드는 글로벌 rayon 풀 크기로 +스레드를 정한다. `num_threads`(또는 env `KEBAB_EMBED_THREADS`)가 그 풀을 한 번 +캡하므로, onnxruntime 가 하드코딩하던 48 intra-op 스레드 → NUMA 힙 손상 → +double-free 경로를 원천 차단한다. NUMA 노드 바인딩이 더 필요하면 `numactl` +과 조합한다. + +**Upgrade 절차.** 재색인 **불필요**. candle 과 fastembed 의 벡터는 사실상 +동일(Phase 0 스파이크 코사인 1.000000)해서 `embedding_version` 을 유지했고, +기존 LanceDB 색인을 그대로 재사용한다. provider 를 바꿔도 검색 결과는 +바뀌지 않는다. 기존 `config.toml` 은 `num_threads` 가 자동으로 `0`(auto)으로 +채워져 그대로 로드된다 — `kebab config migrate` 도, 수동 편집도 필요 없다. + +--- + +## 그 외 + +- 신규 crate `kebab-embed-candle` (candle 의존성 트리를 이 crate 에 격리, + `kebab-core`/`kebab-config` 외 다른 kebab-* 의존 없음). +- Phase 0 feasibility 스파이크(`spike-embed-candle`)는 production 흡수 후 제거. +- 문서: README Configuration, `docs/SMOKE.md` config 예시, `docs/ARCHITECTURE.md` + crate 그래프/트리에 candle provider 반영. + +## 잔여 검증 (사용자 실행) + +듀얼소켓 NUMA 서버에서 `provider=candle` 로 5150-doc ingest 가 double-free +없이 EXIT=0 완주하는지가 본 release 의 최종 인수 게이트다 (meta-spec §4.3). +패리티 max abs diff 수치는 `IMPL_REPORT.md` 참조. diff --git a/tasks/HOTFIXES.md b/tasks/HOTFIXES.md index 57abd2b..39f1efa 100644 --- a/tasks/HOTFIXES.md +++ b/tasks/HOTFIXES.md @@ -14,6 +14,52 @@ historical contract that was implemented; this file accumulates the deltas so phase 5+ readers can find the live behavior without diffing git history. +## 2026-06-01 — candle 임베딩 provider (NUMA double-free 회피, opt-in) + +**무엇이 문제였나.** 듀얼소켓 NUMA 서버에서 `provider=fastembed`(onnxruntime)로 +대규모 ingest(5150-doc)를 돌리면 onnxruntime 가 intra-op 스레드를 48개로 +하드코딩해 NUMA 힙을 손상시키고 double-free 로 프로세스가 죽었다. 스레드 수를 +config 로 줄일 surface 가 없었고, fastembed 4.9 의 ORT 바인딩은 이를 노출하지 +않는다. + +**진단 / 결정 (사용자 승인 2026-06-01).** 같은 모델 +`intfloat/multilingual-e5-large` 를 **candle(순수 Rust)** 로 돌리는 임베딩 +provider 를 추가하기로 결정. candle 의 CPU 백엔드는 글로벌 rayon 풀 크기로 +스레드를 정하므로, 한 번의 `rayon::ThreadPoolBuilder::build_global` 캡으로 +스레드를 NUMA-안전한 수로 묶을 수 있다. **재색인 0 목표**(`embedding_version` +유지) — Phase 0 스파이크(`SPIKE_REPORT.md`, 커밋 76841af)가 candle vs +onnxruntime **코사인 1.000000** 패리티를 입증했고, 본 Track 1 구현의 패리티 +테스트로 차원별 max 절대오차를 재실측해 확정. + +**무엇을 건드렸나.** +- 신규 crate `crates/kebab-embed-candle` — `kebab_core::Embedder` 구현 + (`CandleEmbedder`). 스파이크 파이프라인(safetensors via hf-hub → XLM-RoBERTa + forward → attention-mask mean pooling → L2 → e5 prefix)을 production 으로 + 흡수. deps 는 candle 트리를 이 crate 에 격리 (core/config 외 다른 kebab-* + 의존 0 — design §8 경계). 모델 캐시 `{model_dir}/candle/`. +- 스레드 캡: `[models.embedding].num_threads`(u32, default 0=auto) + env + `KEBAB_EMBED_THREADS`(우선). `CandleEmbedder::new` 에서 n>0 이면 글로벌 rayon + 풀 1회 캡(이미 init 시 no-op). +- 주입 분기: `kebab-app::App::embedder()` 가 `config.models.embedding.provider` + 분기 — `fastembed`/`onnx`/(빈값) → 기존 `FastembedEmbedder`(동작 불변), + `candle` → `CandleEmbedder`, 미지값 → 에러. `none` 은 기존 lexical-only 유지. +- 스파이크 crate `crates/spike-embed-candle` 제거(학습은 production 으로 흡수됨). +- 버전 0.21.1 → **0.22.0** (신규 config surface — pre-1.0 minor bump). + +**패리티 증거.** Phase 0 스파이크 cosine 1.000000 (10문장 한/영 혼합). 본 +Track 1 의 `#[ignore]` 패리티 테스트 결과(max abs diff)는 +`/build/out/kebab-worktrees/embed-candle/IMPL_REPORT.md` 에 기록. + +**호환성.** fastembed default 경로의 동작/벡터 불변. `embedding_version` +유지 → 기존 색인 재사용(재색인 0). wire schema 변경 없음. 옛 config.toml 은 +`num_threads` 가 serde default(0)로 채워져 그대로 파싱. + +**잔여 게이트 (사용자 실행, Claude 불가).** 그 듀얼소켓 NUMA 서버에서 +`provider=candle` 로 5150-doc ingest 가 double-free 없이 EXIT=0 완주하는지 +PR 머지 전/후 검증 예약 (meta-spec §4.3). + +amends: `docs/superpowers/specs/2026-06-01-embed-candle-track-spec.md`. + ## 2026-05-31 — config 마이그레이션 (`kebab config migrate`) **Trigger**: config.toml 스키마가 진화해도(v0.21.0 의 `[ingest.expansion]` 등) 기존 사용자 파일은 serde default 로 *동작*만 호환될 뿐 새 섹션이 파일에 안 써져 사용자가 노브의 존재를 알 수 없었다. DB 의 V00X refinery 와 달리 config 엔 마이그레이션 메커니즘이 없어 추가. 설계 `docs/superpowers/specs/2026-05-31-config-migration-design.md`, 계획 `docs/superpowers/plans/2026-05-31-config-migration.md`, PR #198. -- 2.49.1 From 1011c75fff611895e1fd34d1f08c86d3d963e301 Mon Sep 17 00:00:00 2001 From: altair823 Date: Mon, 1 Jun 2026 16:37:46 +0000 Subject: [PATCH 3/6] chore(embed-candle): remove spike/impl process artifacts before PR Co-Authored-By: Claude Opus 4.8 (1M context) --- IMPL_REPORT.md | 85 ------------------------------------- SPIKE_BRIEF.md | 57 ------------------------- SPIKE_REPORT.md | 108 ------------------------------------------------ 3 files changed, 250 deletions(-) delete mode 100644 IMPL_REPORT.md delete mode 100644 SPIKE_BRIEF.md delete mode 100644 SPIKE_REPORT.md diff --git a/IMPL_REPORT.md b/IMPL_REPORT.md deleted file mode 100644 index bd272e6..0000000 --- a/IMPL_REPORT.md +++ /dev/null @@ -1,85 +0,0 @@ -# Track 1 / Phase 1 — candle 임베딩 provider 구현 보고서 - -- 날짜: 2026-06-01 -- 브랜치: `feat/embed-candle` (worktree `/build/out/kebab-worktrees/embed-candle`) -- 스펙: `docs/superpowers/specs/2026-06-01-embed-candle-track-spec.md` -- 버전: 0.21.1 → **0.22.0** - -## 1. 변경 요약 - -| 영역 | 변경 | -|------|------| -| 신규 crate | `crates/kebab-embed-candle` — `CandleEmbedder` (`kebab_core::Embedder` impl). 스파이크 파이프라인 흡수: safetensors via hf-hub → `XLMRobertaModel` forward(`Device::Cpu`) → attention-mask mean pooling → L2 → e5 prefix(`passage:`/`query:`). 모델 캐시 `{model_dir}/candle/`. deps = candle-core/nn/transformers 0.10.2, tokenizers, hf-hub, serde_json, rayon, anyhow, tracing + `kebab-core`/`kebab-config` 만 (design §8 경계 준수). | -| 스레드 캡 | `[models.embedding].num_threads: u32`(default 0=auto) + env `KEBAB_EMBED_THREADS`(우선). `apply_thread_cap()` 가 글로벌 rayon 풀 1회 캡 (이미 init 시 no-op). | -| 주입 분기 | `kebab-app::App::embedder()` 가 `provider` 분기 — `fastembed`/`onnx`/`""` → 기존 `FastembedEmbedder`(불변), `candle` → `CandleEmbedder`, 미지값 → 에러. `none` 은 기존 lexical-only. `kebab-app/Cargo.toml` 에 dep 추가. | -| config | `EmbeddingModelCfg.num_threads`(`#[serde(default)]` — 옛 config 호환) + `KEBAB_MODELS_EMBEDDING_NUM_THREADS` env + `Config::defaults()`. | -| 스파이크 제거 | `crates/spike-embed-candle` 삭제 + 워크스페이스 멤버 제거 + `spike_build.log`/`spike_run.log` 정리. | -| 문서/버전 | README Configuration, `docs/SMOKE.md` config 예시, `docs/ARCHITECTURE.md`(crate 그래프+트리), HANDOFF 한 줄, `tasks/HOTFIXES.md` 2026-06-01 dated entry, workspace `version` 0.22.0, `docs/release-notes/v0.22.0-draft.md`. | - -## 2. 검증 게이트 결과 (모두 파일 출력 + exit code 로 검증) - -> ⚠️ 주의: background shell 의 notification "exit 0" 은 wrapper 의 종료코드라 -> 신뢰 불가. 실제 결과는 각 로그의 `*_EXIT=` 라인 값으로 확정했다 -> ([[project_rerank_experiment]] 교훈). 실제로 첫 빌드는 wrapper 가 exit 0 을 -> 보고했지만 로그의 `BUILD_EXIT=101`(serde_json 미선언)이었고, dep 추가 후 통과. - -| 게이트 | 명령 | 결과 | 로그 | -|--------|------|------|------| -| 빌드 + clippy | `cargo clippy --workspace --all-targets -j 4 -- -D warnings` | **`CLIPPY_EXIT=0`**, warning 0 | `clippy.log` | -| 단위/통합 테스트 | `cargo test -p kebab-embed-candle -p kebab-config -j 4` | **`TEST_EXIT=0`** — candle lib unit 5, `thread_cap` 1 passed(rayon current=4 검증), config 68 passed, parity 1 ignored | `test_units.log` | -| config 회귀 | (위 동일 run, `kebab-config` 68 tests) | 0 failed | `test_units.log` | -| 패리티 `#[ignore]` 수동 1회 | `cargo test -p kebab-embed-candle --release -- --ignored --nocapture` | **`PARITY_EXIT=0`**, 1 passed (32.53s) | `test_parity.log` | - -## 3. 패리티 수치 (재색인 결정 근거 — 스펙 D-reindex) - -10 문장(한/영 혼합) candle vs `FastembedEmbedder`(onnxruntime): - -``` -PARITY_SUMMARY cosine_min=1.000000 max_abs_diff=2.011657e-7 -``` - -- 코사인 최소 **1.000000** (≥ 0.9999 게이트 통과). -- 차원별 **max 절대오차 = 2.01e-7** — 스펙이 정한 "사실상 동일" 기준 - (max abs diff < 1e-5) 보다 **약 50배 작다**. -- **결론: `embedding_version` 유지 = 재색인 0.** candle 과 onnxruntime 의 - 벡터는 f32 반올림 수준에서만 다르며 (e-7), 기존 LanceDB 색인을 그대로 - 재사용해도 검색 결과가 바뀌지 않는다. version bump / cascade 트리거 안 함. - -## 4. 잔여 리스크 - -- **CPU latency**: candle 는 순수 Rust 라 onnxruntime 의 네이티브 커널보다 - 느리다 (Phase 0 스파이크 ~4×). 그래서 default 는 fastembed 유지, candle 은 - NUMA 환경 opt-in. 단일 워크스테이션 사용자에게는 권하지 않음 (README 명시). -- **모델 다운로드**: candle 은 `{model_dir}/candle/` 에 safetensors(~2GB)를 - 별도 캐시 (onnx 캐시와 공유 안 함). 첫 ingest 시 ~2GB 다운로드 발생. -- **잔여 게이트 (사용자 실행, Claude 불가, meta-spec §4.3)**: 그 듀얼소켓 - NUMA 서버에서 `provider=candle` 로 5150-doc ingest 가 double-free 없이 - EXIT=0 완주하는지 — 이 머신은 GPU/NUMA 없는 단일 VM 이라 재현 불가. PR - 머지 전/후 사용자 검증 예약. -- **골든 스위트 회귀 0 (스펙 §8)**: provider=candle 로 `kebab-eval` 골든 - 스위트 실행은 본 worktree 범위 밖(사용자 도그푸딩 단계). 패리티 e-7 로 - 벡터 동일성이 입증돼 회귀 위험은 낮음. - -## 5. 재현 명령 - -```bash -cd /build/out/kebab-worktrees/embed-candle -export CARGO_TARGET_DIR=/build/out/cargo-target/target - -# 빌드 + clippy (warning 0) -cargo clippy --workspace --all-targets -j 4 -- -D warnings - -# 단위 + config 회귀 -cargo test -p kebab-embed-candle -p kebab-config -j 4 - -# 패리티 (모델 ~2GB 다운로드 + 양쪽 추론, /build/dogfood/config.toml 필요) -cargo test -p kebab-embed-candle --release -j 4 -- --ignored --nocapture -# → PARITY_SUMMARY cosine_min=1.000000 max_abs_diff=2.011657e-7 - -# candle provider 로 ingest (사용자 NUMA 검증) -KEBAB_EMBED_THREADS=8 kebab ingest --config /path/to/candle-config.toml -``` - -## 6. 커밋 - -`feat/embed-candle` 에 커밋 완료. push / PR 은 메인 세션이 처리 (본 worker 는 하지 않음). diff --git a/SPIKE_BRIEF.md b/SPIKE_BRIEF.md deleted file mode 100644 index ba73ed9..0000000 --- a/SPIKE_BRIEF.md +++ /dev/null @@ -1,57 +0,0 @@ -# Track 1 / Phase 0 — candle e5-large 타당성 스파이크 (BRIEF) - -너는 이 worktree(`/build/out/kebab-worktrees/embed-candle`, 브랜치 `feat/embed-candle`)에서 작업하는 executor 다. -이건 **타당성 검증 스파이크**다 — 프로덕션 코드가 아니라, candle 트랙을 본격 구현해도 되는지 판단할 증거를 모으는 게 목적이다. 깔끔함보다 **정확한 증거**가 우선. - -## 배경 (왜) - -CPU-only 듀얼소켓 NUMA 서버에서 `kebab ingest` 가 매번 `double free or corruption (!prev)` 로 죽는다. -근본 원인: fastembed 4.9.1 이 onnxruntime intra-op 스레드를 전체 CPU(48)로 하드코딩하고 override 불가 → NUMA 에서 힙 손상. -해법 후보 1순위 = **candle(순수 Rust)로 동일 모델 multilingual-e5-large 를 돌리기**. candle-transformers 에 `xlm_roberta` 모듈이 있고 e5-large 는 XLM-RoBERTa-large 구조라 가능성 확인됨. 이 스파이크가 그 가능성을 **수치로 입증**해야 한다. - -전체 맥락: `/home/altair823/kebab/docs/superpowers/specs/2026-06-01-embedding-numa-backends-meta-spec.md` 및 `-meta-plan.md`. - -## 검증해야 할 caveat 3 + 성능 1 - -1. **수치 패리티**: candle 출력 벡터가 기존 onnxruntime(fastembed) e5-large 와 사실상 동일한가 (같은 가중치니 cosine ≥ 0.99 이어야 정상; 낮으면 padding/pooling 버그). -2. **padding_idx 위치 임베딩**: XLM-R 은 position id 가 `padding_idx(=1)+1` 부터 시작. candle `xlm_roberta` 가 이를 맞게 처리하는지 (패리티가 높으면 간접 입증). -3. **스레드 제어**: candle CPU 스레드를 캡할 수 있는가 (`RAYON_NUM_THREADS` 또는 candle API). NUMA 안전의 전제. -4. **CPU 성능**: 배치 임베딩 latency 를 측정. onnxruntime 대비 대략 비교. - -## 구체 작업 - -이 worktree 안에서 **격리된 스파이크 바이너리**를 만들어라 (프로덕션 crate 의 기본 동작 변경 금지). 예: 새 example 또는 작은 `xtask`/bin. candle 의존성(candle-core, candle-nn, candle-transformers, tokenizers, hf-hub, safetensors)은 스파이크 대상에만 추가. - -스파이크가 할 일: -1. **모델 로드 (candle, CPU)**: `intfloat/multilingual-e5-large` 의 safetensors + config.json + tokenizer.json 을 hf-hub 으로 받아 `candle_transformers::models::xlm_roberta::XLMRobertaModel` 로 로드. (참고: 이 머신의 fastembed 캐시는 ONNX 라 candle 이 못 읽는다. tokenizer.json/config.json 은 `/build/dogfood/kb/models/fastembed/models--Qdrant--multilingual-e5-large-onnx/snapshots/*/` 에서 재사용 가능.) -2. **임베딩 파이프라인 재현**: 입력에 e5 프리픽스(`query: ` / `passage: `) 적용 → 토크나이즈 → forward → **attention-mask 가중 mean pooling** → **L2 정규화**. (kebab 의 `crates/kebab-embed-local/src/lib.rs` 의 prefix/정규화 규약 참고.) -3. **패리티 비교**: 동일 문장 집합(한국어/영어 혼합, 최소 8개)을 (a) 위 candle 경로, (b) 기존 `kebab_embed_local::FastembedEmbedder`(워크스페이스에 이미 있음) 양쪽으로 임베딩 → 문장별 cosine 유사도. min/mean 보고. FastembedEmbedder 는 `/build/dogfood/config.toml` 또는 적절한 Config 로 생성(모델 캐시 `/build/dogfood/kb/models`). -4. **스레드 제어 확인**: `RAYON_NUM_THREADS=4` 등으로 실제 스레드 수가 제한되는지 확인(예: 실행 중 thread 수 또는 latency 변화). -5. **latency 측정**: 배치(예: 32문장) 임베딩 wall-clock. - -## 제약 (반드시 준수) - -- `CARGO_TARGET_DIR=/build/out/cargo-target/target` (루트 디스크 보호). 빌드 직렬, `-j 4`. candle 첫 빌드는 무거우니 `cargo build` 는 `run_in_background` 로. -- 프로덕션 crate(`kebab-embed-local` 등)의 기존 동작/기본값 변경 금지. 스파이크는 추가만. -- 네트워크: HuggingFace 접근 가능(이 머신은 됨). safetensors 다운로드는 `/build/cache/` 하위로. -- RAM 30GB, OOM 주의. 배치 작게. - -## 산출물 (필수) - -`/build/out/kebab-worktrees/embed-candle/SPIKE_REPORT.md` 에 다음을 적어라: -- **VERDICT**: PASS / FAIL (candle 본 구현 진행 권고 여부). -- 패리티: 문장별 cosine min/mean (표). -- padding_idx: 정상 여부 + 근거. -- 스레드 제어: 가능 여부 + 방법. -- latency: 배치 측정값 + onnxruntime 대략 대비. -- 막힌 점 / 리스크 / 다음 단계 권고. -- 재현 명령(스파이크 빌드+실행 커맨드). - -작업 로그는 수시로 `SPIKE_REPORT.md` 에 누적. 완료되면 변경을 `feat/embed-candle` 에 커밋(스파이크 코드 + 리포트). 커밋 메시지 끝에 `Co-Authored-By: Claude Opus 4.8 (1M context) `. - -## 합격 기준 - -- cosine 패리티 mean ≥ 0.99 (동일 가중치) → padding/pooling 정확, candle 트랙 GREEN. -- 0.95~0.99 → 경미한 차이(pooling 옵션 등), 진단 후 판단. -- < 0.95 → 구조/패딩 불일치 → 원인 규명 후 FAIL 또는 수정. -- 스레드 캡 불가 시 NUMA 안전성 위협 → 리포트에 명시. diff --git a/SPIKE_REPORT.md b/SPIKE_REPORT.md deleted file mode 100644 index adbafe8..0000000 --- a/SPIKE_REPORT.md +++ /dev/null @@ -1,108 +0,0 @@ -# SPIKE REPORT — Track 1 / Phase 0 — candle multilingual-e5-large 타당성 - -- 날짜: 2026-06-01 -- 워크트리: `/build/out/kebab-worktrees/embed-candle` (브랜치 `feat/embed-candle`) -- 목적: candle(순수 Rust)로 `intfloat/multilingual-e5-large` 를 돌려 기존 onnxruntime(`FastembedEmbedder`) 와 **수치 패리티**·**스레드 제어**·**CPU 성능**을 입증, candle 본 구현 진행 여부 판단. -- 머신: 12 logical CPU, 단일 소켓(비-NUMA). **결정적 NUMA 검증은 그 듀얼소켓 서버에서만 가능**(meta-spec §4.3) — 본 스파이크는 패리티·스레드캡·성능의 사전 입증. - -> # VERDICT: **PASS** — candle 본 구현 진행 권고 (GREEN) -> -> 동일 e5-large 가중치로 onnxruntime 대비 **cosine min=mean=1.000000** (완전 일치). padding_idx/pooling 정확. `RAYON_NUM_THREADS` 로 CPU 스레드 캡 가능(NUMA 안전 전제 충족). latency 는 onnxruntime 대비 약 4배(67.5 vs 16.8 ms/문장, candle 4스레드 vs fastembed 12스레드) — 느리지만 ingest 배치에 허용 가능, 스레드 상향으로 개선 여지. - ---- - -## 1. 접근 방식 (구현 사실) - -격리 스파이크 바이너리 `crates/spike-embed-candle` 신설 (워크스페이스 멤버로 추가, candle 의존성은 이 crate 에만 — `candle-core/-nn/-transformers` 0.10.2, `hf-hub` 0.4, `tokenizers` 0.21). 프로덕션 crate(`kebab-embed-local` 등) 동작 변경 0. - -- 모델 로드: `candle_transformers::models::xlm_roberta::{Config, XLMRobertaModel}`. -- 가중치: `intfloat/multilingual-e5-large` 의 `model.safetensors`(2.2GB) + `config.json` + `tokenizer.json` 을 `hf-hub` sync API 로 다운로드(`HF_HOME=/build/cache/huggingface`). fastembed 캐시는 ONNX 라 candle 이 못 읽으므로 safetensors 별도 수령. config.json 은 candle `Config`(serde) 로 직접 역직렬화 — hidden=1024, layers=24, heads=16, pad_token_id=1, max_pos=514, pos_emb=absolute (config 의 실제 로드 로그로 확인). -- 파이프라인 재현 (`kebab-embed-local` 규약과 동일): e5 프리픽스(`passage: `) → 토크나이즈(batch-longest 패딩, max_len=512, special tokens) → forward → **attention-mask 가중 mean pooling** → **L2 정규화**. 출력 ‖v‖=1.000000 확인. -- 패리티 비교: 동일 문장 10개(한/영 혼합)를 (a) candle 경로, (b) `kebab_embed_local::FastembedEmbedder`(`/build/dogfood/config.toml`, 모델 캐시 `/build/dogfood/kb/models`) 양쪽으로 임베딩. 양쪽 모두 `EmbeddingKind::Document`(`passage: ` 프리픽스). - -## 2. 패리티 (caveat #1) — ✅ PASS (mean=1.000000) - -| # | cosine | 문장(앞 40자) | -|---|--------|---------------| -| 0 | 1.000000 | The quick brown fox jumps over the lazy | -| 1 | 1.000000 | 오늘 날씨가 정말 좋아서 산책을 나가고 싶다. | -| 2 | 1.000000 | Rust is a systems programming language f | -| 3 | 1.000000 | 벡터 검색은 임베딩 사이의 코사인 유사도를 이용한다. | -| 4 | 1.000000 | Machine learning models require large am | -| 5 | 1.000000 | 한국어와 영어가 섞인 문장도 멀티링구얼 모델은 잘 처리한다. | -| 6 | 1.000000 | The capital of France is Paris, a city k | -| 7 | 1.000000 | 이 프로젝트는 로컬 우선 지식 베이스와 검색 증강 생성을 목표로 한다. | -| 8 | 1.000000 | Database indexing dramatically speeds up | -| 9 | 1.000000 | 임베딩 모델을 candle 로 옮기면 NUMA 서버에서 안전하게 돌릴 수 | - -- **cosine min = 1.000000, mean = 1.000000** (합격선 mean≥0.99 GREEN 을 압도적 충족). -- 의미: candle 의 XLM-R forward + mean pooling + L2 가 onnxruntime e5-large 경로와 사실상 비트 단위로 동등. 본 구현으로 전환해도 **검색 품질(골든 MRR/hit@k) 회귀 없음**이 거의 보장됨 (meta-spec §6 D1 "candle 은 동일 가중치라 패리티 통과 시 품질 기준 자동 충족"과 일치). 단, meta-spec §4.2 골든 게이트는 본 구현 머지 전 별도 실측 권고. - -## 3. padding_idx (caveat #2) — ✅ 정상 (소스 + 패리티 이중 확인) - -candle-transformers 0.10.2 `xlm_roberta.rs` 의 `XLMRobertaEmbeddings::forward` 가 XLM-R 규약을 정확히 구현 (소스 확인): - -```rust -let mask = input_ids.ne(self.padding_idx)?...; // pad 아닌 위치 = 1 -let cumsum = mask.cumsum(1)?; -let position_ids = (cumsum * mask)? + padding_idx; // 위치 id 가 pad_token_id+1 부터 -``` - -HF `create_position_ids_from_input_ids` 와 동일 (position id 가 `padding_idx(=1)` 다음부터 시작). config.json 의 `pad_token_id=1` 이 `Config.pad_token_id` 로 주입됨. **패리티가 1.000000 으로 나온 것이 padding_idx·pooling 의 정확성을 결정적으로 재확인** — 위치 임베딩이 한 칸이라도 어긋나면 cosine 이 1.0 이 될 수 없음. - -## 4. 스레드 제어 (caveat #3) — ✅ 가능 (RAYON_NUM_THREADS) - -| 항목 | 값 | -|---|---| -| `RAYON_NUM_THREADS` env | 4 | -| `rayon::current_num_threads()` | **4** | -| `available_parallelism()` | 12 | -| peak OS threads (`/proc/self/status`) | 16 | - -- candle CPU 행렬연산(`gemm`)이 rayon 글로벌 풀을 사용 → `RAYON_NUM_THREADS=4` 로 **컴퓨트 스레드가 12→4 로 확실히 캡됨**. NUMA 안전(한 노드로 묶기)의 전제인 "스레드 수 제어 가능" 충족. -- 주의: peak 16 OS 스레드는 **패리티 비교를 위해 같은 프로세스에서 띄운 fastembed/onnxruntime 세션 스레드 + hf-hub 다운로드용 tokio 스레드**가 포함된 수치다. 실제 candle 전용 ingest 경로에는 fastembed 가 로드되지 않으며, candle 컴퓨트는 rayon 풀(=4)로 한정된다. 즉 **candle 백엔드는 fastembed 4.9.1 의 "48 하드코딩 + override 불가" 문제가 구조적으로 없다** (rayon 은 env/`ThreadPoolBuilder` 로 캡 가능). -- 다음 단계: 본 구현에서 `models.embedding` 에 스레드 노브(예: `KEBAB_EMBED_THREADS`→`RAYON_NUM_THREADS`/`ThreadPoolBuilder`)를 노출하고, NUMA 노드 바인딩은 `numactl`(A1 트랙)과 조합. - -## 5. CPU latency (성능) — 허용 가능 (onnxruntime 대비 ~4×) - -| 백엔드 | batch=32 wall-clock | ms/문장 | 스레드 | -|---|---|---|---| -| candle (release) | 2.161 s | 67.5 | 4 (RAYON cap) | -| fastembed (onnxruntime) | 0.536 s | 16.8 | 12 (이 머신) | - -- candle 가 문장당 약 4배 느림. 단 **스레드가 1/3(4 vs 12)** 이고 fastembed 는 ORT 의 고도 최적화(MKL/AVX-512 커널)를 쓰는 반면 candle 은 순수 `gemm`. 스레드 상향·배치 튜닝 여지 있음. -- ingest 는 배치/백그라운드 작업이라 이 정도 latency 는 허용 가능. **NUMA 서버에서 "느리지만 완주" 가 "빠르지만 double-free 크래시" 보다 압도적으로 낫다** (본 과제의 핵심 동기). -- fastembed 모델 콜드 로드 86.9s (ORT 세션 init) 는 일회성. candle 모델 로드는 mmap 이라 즉시. - -## 6. 막힌 점 / 리스크 / 다음 단계 권고 - -- **막힌 점**: 없음. 첫 빌드(candle+gemm) 2m24s, safetensors 2.2GB 다운로드 외 장애 없음. -- **리스크**: - 1. latency ~4×. 대용량(5150-doc) ingest 전체 시간이 늘어남 — 본 구현 시 wall-clock 실측 + release-notes 명시 필요. - 2. 본 스파이크는 비-NUMA 머신. **결정적 증거(5150-doc double-free 없이 EXIT=0)는 그 서버에서만**(meta-spec §4.3) — 본 구현 PR 후 사용자 실행 검증 예약. - 3. 벡터는 onnxruntime 와 1.0 일치하지만, 본 구현 시 `embedding_version` cascade 정책(재색인 여부) 명시 필요. 패리티 1.0 이면 **재색인 불필요 가능성**도 있으나(벡터 불변), 토크나이저/패딩 미세차 리스크로 보수적으로는 bump+재색인 권고 — 본 구현 spec 에서 결정. -- **다음 단계 권고 (candle 트랙 GREEN)**: - 1. `crates/kebab-embed-local` 에 `CandleEmbedder`(또는 신규 `kebab-embed-candle`) 추가, `Embedder` 4메서드 구현, `models.embedding.provider = "candle"` 분기. - 2. 스레드 노브 노출(`ThreadPoolBuilder`/`RAYON_NUM_THREADS`) + numactl 조합 문서화. - 3. `kebab-eval` 골든 스위트로 MRR/hit@k ≥ baseline 확인(§4.2) 후 default 승격 판단. - 4. 그 NUMA 서버에서 5150-doc 완주 검증(§4.3). - -## 7. 재현 명령 - -```bash -cd /build/out/kebab-worktrees/embed-candle -# 빌드 (release, candle+gemm 첫 빌드 ~2.5분) -CARGO_TARGET_DIR=/build/out/cargo-target/target cargo build -j 4 --release -p spike-embed-candle -# 실행 (safetensors 2.2GB 첫 다운로드 + onnxruntime baseline 로드) -HF_HOME=/build/cache/huggingface RAYON_NUM_THREADS=4 \ - CARGO_TARGET_DIR=/build/out/cargo-target/target \ - /build/out/cargo-target/target/release/spike-embed-candle -``` - -## 8. 작업 로그 - -- 14:1x — worktree/모델캐시/config 확인. config.json: XLMRobertaModel, pad=1, vocab 250002, hidden 1024, 24 layers, max_pos 514. -- 14:1x — candle-transformers 0.10.2 `xlm_roberta` API 소스 확인 (Config serde, `XLMRobertaModel::{new,forward}`, `prepare_4d_attention_mask`, padding_idx 처리). 스파이크 crate 작성 + 워크스페이스 멤버 추가. -- 14:16 — release 빌드 백그라운드 시작. -- 14:18 — 빌드 완료 (2m24s, EXIT=0). 바이너리 실행 (RAYON_NUM_THREADS=4). -- 14:2x — 실행 완료 (EXIT=0). cosine min=mean=1.000000, rayon 캡=4, candle 2.161s vs fastembed 0.536s (batch=32). **VERDICT=PASS**. -- 2.49.1 From 6ec4e6809f92803944dcfcff9eca5df8d1c467a0 Mon Sep 17 00:00:00 2001 From: altair823 Date: Mon, 1 Jun 2026 16:54:20 +0000 Subject: [PATCH 4/6] fix(embed-candle): address round-1 review - commit track-spec + meta-spec/plan into branch (HIGH: dangling `amends:` ref) - inline parity evidence (cosine 1.0, max_abs_diff 2.01e-7) into HOTFIXES + release notes; drop refs to deleted IMPL_REPORT/SPIKE_REPORT (MEDIUM) - model guard: reject non-e5-large `model` before the 2GB download so model_id() can't mislabel vectors (MEDIUM) + unit test - parity test now covers BOTH query: and passage: prefixes (MEDIUM) - guard encodings.first() index; document zero-attention/pooling invariant; clarify embed_batch prefixing doc (LOW) Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/kebab-embed-candle/src/lib.rs | 59 +++++++++- crates/kebab-embed-candle/tests/parity.rs | 20 ++-- docs/release-notes/v0.22.0-draft.md | 4 +- .../2026-06-01-embed-candle-track-spec.md | 78 ++++++++++++++ ...06-01-embedding-numa-backends-meta-plan.md | 77 +++++++++++++ ...06-01-embedding-numa-backends-meta-spec.md | 102 ++++++++++++++++++ tasks/HOTFIXES.md | 15 +-- 7 files changed, 339 insertions(+), 16 deletions(-) create mode 100644 docs/superpowers/specs/2026-06-01-embed-candle-track-spec.md create mode 100644 docs/superpowers/specs/2026-06-01-embedding-numa-backends-meta-plan.md create mode 100644 docs/superpowers/specs/2026-06-01-embedding-numa-backends-meta-spec.md diff --git a/crates/kebab-embed-candle/src/lib.rs b/crates/kebab-embed-candle/src/lib.rs index 5adee8b..c6b1b87 100644 --- a/crates/kebab-embed-candle/src/lib.rs +++ b/crates/kebab-embed-candle/src/lib.rs @@ -46,6 +46,11 @@ const CANDLE_CACHE_SUBDIR: &str = "candle"; /// onnxruntime path uses, just the safetensors variant candle can read. const HF_MODEL: &str = "intfloat/multilingual-e5-large"; +/// The only `config.models.embedding.model` value the candle adapter accepts +/// (the e5-large weights `HF_MODEL` resolves to). Guards against silently +/// downloading e5-large while `model_id()` reports a different name. +const SUPPORTED_MODEL: &str = "multilingual-e5-large"; + /// Token truncation length (e5 was trained at 512). const MAX_LEN: usize = 512; @@ -99,6 +104,22 @@ impl CandleEmbedder { } } + // 1b. Model guard. `HF_MODEL` is hard-coded (candle currently only wires + // e5-large), so if the operator configured a *different* model name + // we must NOT silently download e5-large and then label its vectors + // with the configured name via `model_id()` — that would mislabel + // `embedding_version` and corrupt a mixed index. Fail fast, before + // the ~2GB download. + let want = config.models.embedding.model.as_str(); + if want != SUPPORTED_MODEL && want != HF_MODEL { + anyhow::bail!( + "candle provider currently supports only '{SUPPORTED_MODEL}' (or \ + the HF id '{HF_MODEL}'), but config.models.embedding.model = \ + '{want}'. Use provider=fastembed for other models, or set \ + model = \"{SUPPORTED_MODEL}\"." + ); + } + // 2. Resolve `{data_dir}/models/candle/` exactly like the fastembed // adapter resolves its own subdir. let data_dir = expand_path(&config.storage.data_dir, ""); @@ -177,8 +198,9 @@ impl CandleEmbedder { }) } - /// Embed one batch (already prefixed) through the candle pipeline: - /// tokenize → forward → masked mean pool → L2 normalize. + /// Embed one batch of **already-prefixed** strings (the e5 `query:`/ + /// `passage:` prefix is applied by the caller [`CandleEmbedder::embed`]) + /// through the candle pipeline: tokenize → forward → masked mean pool → L2. fn embed_batch(&self, prefixed: &[String]) -> Result>> { let encodings = self .tokenizer @@ -186,7 +208,13 @@ impl CandleEmbedder { .map_err(|e| anyhow::anyhow!("kb-embed-candle: encode_batch: {e}"))?; let bsz = encodings.len(); - let seq = encodings[0].get_ids().len(); + // `embed` already returns early on empty input and `.chunks()` never + // yields an empty slice, so this is currently unreachable — but guard + // the index so a future refactor can't turn it into a panic. + let Some(first) = encodings.first() else { + return Ok(Vec::new()); + }; + let seq = first.get_ids().len(); let mut ids = Vec::with_capacity(bsz * seq); let mut mask = Vec::with_capacity(bsz * seq); @@ -212,6 +240,9 @@ impl CandleEmbedder { // attention-mask-weighted mean pooling let mask3 = attn_f32.unsqueeze(2)?; // (b, seq, 1) let summed = hidden.broadcast_mul(&mask3)?.sum(1)?; // (b, hidden) + // counts ≥ 1 always: every input is e5-prefixed AND special tokens are + // added (encode_batch(_, true)), so no row has an all-zero mask. If that + // invariant ever breaks, broadcast_div would emit NaN vectors. let counts = mask3.sum(1)?; // (b, 1) let mean = summed.broadcast_div(&counts)?; @@ -360,4 +391,26 @@ mod tests { "error must mention both dims, got: {msg}" ); } + + // ── model guard ────────────────────────────────────────────────── + // A non-e5-large model name must fail fast (BEFORE the ~2GB download), + // so we never download e5-large yet label its vectors with another name + // via model_id() — which would mislabel embedding_version. + + #[test] + fn new_rejects_unsupported_model() { + let mut config = kebab_config::Config::defaults(); + config.models.embedding.model = "multilingual-e5-small".to_string(); + // num_threads defaults to 0, so no global rayon side effect here. + // `.err()` (not `expect_err`) avoids requiring `CandleEmbedder: Debug` + // — it holds a Mutex/Tokenizer and intentionally derives no Debug. + let err = CandleEmbedder::new(&config) + .err() + .expect("unsupported model must error"); + let msg = format!("{err:#}"); + assert!( + msg.contains("candle provider currently supports only"), + "expected model-guard error, got: {msg}" + ); + } } diff --git a/crates/kebab-embed-candle/tests/parity.rs b/crates/kebab-embed-candle/tests/parity.rs index 8eeaeef..7d1a726 100644 --- a/crates/kebab-embed-candle/tests/parity.rs +++ b/crates/kebab-embed-candle/tests/parity.rs @@ -49,11 +49,14 @@ fn candle_matches_fastembed() { let candle = CandleEmbedder::new(&config).expect("build CandleEmbedder"); let fastembed = FastembedEmbedder::new(&config).expect("build FastembedEmbedder"); + // Cover BOTH prefix paths (`passage:` for Document, `query:` for Query) so + // a query-side prefix/pooling divergence can't slip through (reviewer note). let inputs: Vec = SENTENCES .iter() - .map(|s| EmbeddingInput { - text: s, - kind: EmbeddingKind::Document, + .flat_map(|s| { + [EmbeddingKind::Document, EmbeddingKind::Query] + .into_iter() + .map(move |kind| EmbeddingInput { text: s, kind }) }) .collect(); @@ -61,11 +64,12 @@ fn candle_matches_fastembed() { let fv = fastembed.embed(&inputs).expect("fastembed embed"); assert_eq!(cv.len(), fv.len(), "embedding counts must match"); + assert_eq!(cv.len(), inputs.len(), "one vector per input"); assert_eq!(candle.dimensions(), 1024); let mut min_cos = f32::INFINITY; let mut max_abs_diff = 0f32; - for (i, s) in SENTENCES.iter().enumerate() { + for (i, inp) in inputs.iter().enumerate() { assert_eq!(cv[i].len(), 1024, "candle dim"); assert_eq!(fv[i].len(), 1024, "fastembed dim"); let c = cosine(&cv[i], &fv[i]); @@ -76,8 +80,12 @@ fn candle_matches_fastembed() { .map(|(a, b)| (a - b).abs()) .fold(0f32, f32::max); max_abs_diff = max_abs_diff.max(diff); - let preview: String = s.chars().take(40).collect(); - println!("[{i:>2}] cos={c:.6} max_abs_diff={diff:.6e} {preview}"); + let kind = match inp.kind { + EmbeddingKind::Document => "doc", + EmbeddingKind::Query => "qry", + }; + let preview: String = inp.text.chars().take(36).collect(); + println!("[{i:>2}] {kind} cos={c:.6} max_abs_diff={diff:.6e} {preview}"); } println!("PARITY_SUMMARY cosine_min={min_cos:.6} max_abs_diff={max_abs_diff:.6e}"); diff --git a/docs/release-notes/v0.22.0-draft.md b/docs/release-notes/v0.22.0-draft.md index d518442..2660e66 100644 --- a/docs/release-notes/v0.22.0-draft.md +++ b/docs/release-notes/v0.22.0-draft.md @@ -69,4 +69,6 @@ double-free 경로를 원천 차단한다. NUMA 노드 바인딩이 더 필요 듀얼소켓 NUMA 서버에서 `provider=candle` 로 5150-doc ingest 가 double-free 없이 EXIT=0 완주하는지가 본 release 의 최종 인수 게이트다 (meta-spec §4.3). -패리티 max abs diff 수치는 `IMPL_REPORT.md` 참조. +패리티(candle vs onnxruntime): cosine_min = 1.000000, 차원별 max 절대오차 = +2.01e-7 — 벡터가 사실상 동일하므로 `embedding_version` 유지(재색인 0). 재현은 +`crates/kebab-embed-candle/tests/parity.rs` (`--ignored`). diff --git a/docs/superpowers/specs/2026-06-01-embed-candle-track-spec.md b/docs/superpowers/specs/2026-06-01-embed-candle-track-spec.md new file mode 100644 index 0000000..343d65b --- /dev/null +++ b/docs/superpowers/specs/2026-06-01-embed-candle-track-spec.md @@ -0,0 +1,78 @@ +# Track 1 Spec — candle e5-large 임베딩 provider (NUMA-안전) + +- 날짜: 2026-06-01 +- 우산: [meta-spec](./2026-06-01-embedding-numa-backends-meta-spec.md) / [meta-plan](./2026-06-01-embedding-numa-backends-meta-plan.md) +- 선행: Phase 0 스파이크 PASS+독립검증 (cosine 1.000000, 스레드 캡 가능, latency ~4×). 커밋 76841af. +- 브랜치: `feat/embed-candle` + +## 1. 목표 + +fastembed(onnxruntime) 의 "intra-op 스레드 48 하드코딩 → NUMA 힙 손상" 을 회피하기 위해, 동일 모델 `multilingual-e5-large` 를 **candle(순수 Rust)** 로 돌리는 임베딩 provider 를 추가한다. opt-in, 품질 중립, NUMA 스레드 캡 가능. + +## 2. 확정 결정 (사용자 승인 2026-06-01) + +- **D-reindex**: `embedding_version` **유지(재색인 0)** 를 목표. 구현 중 candle vs onnxruntime 벡터의 **차원별 max 절대오차**를 측정해 사실상 동일(예: max abs diff < 1e-5)함을 확인하고, 골든 스위트로 회귀 0 을 실측해 확정. 유의미한 차이가 나오면만 version bump + 재색인. +- **D-default**: 글로벌 default provider 는 **onnxruntime 유지**, candle 은 **opt-in** (`models.embedding.provider = "candle"`). +- **조기 종료**: candle 이 골든 baseline 충족 시 ollama/A2 트랙 생략 (A1 stopgap 문서만 별도). + +## 3. 아키텍처 + +- **신규 crate `kebab-embed-candle`** — `kebab_core::Embedder` 구현. candle 의 큰 의존성 트리를 이 crate 에 격리. + - 허용 deps: `candle-core`/`candle-nn`/`candle-transformers` (0.10.x), `tokenizers`, `hf-hub`, `kebab-core`, `kebab-config`, `anyhow`, `tracing`. **다른 `kebab-*` 의존 금지**(core/config 외) — design §8 경계. +- **주입 분기**: `kebab-app/src/app.rs` 의 `embedder()` (현 :829-837, `FastembedEmbedder::new` 무조건 생성) 를 `config.models.embedding.provider` 로 분기: + - `"fastembed"` | `"onnx"` | (빈값/기존) → `FastembedEmbedder` (default, 기존 동작 유지). + - `"candle"` → `CandleEmbedder`. + - 알 수 없는 값 → 명확한 에러. +- **facade 규칙 준수**: UI crate 는 `kebab-app` 만. `kebab-app` 이 `kebab-embed-candle` 의존 추가. + +## 4. CandleEmbedder 동작 (스파이크에서 검증된 파이프라인) + +- 모델: `intfloat/multilingual-e5-large` 의 `model.safetensors` + `config.json` + `tokenizer.json` 을 `hf-hub` 으로 `{model_dir}/candle/` (config `storage.model_dir`) 아래에 캐시. +- `candle_transformers::models::xlm_roberta::{Config, XLMRobertaModel}` 로 로드 (CPU `Device::Cpu`). +- `embed()`: e5 프리픽스(`query: `/`passage: `, `EmbeddingInput` kind 기준 — `kebab-embed-local` 의 `prefix_input` 규약과 동일) → 토크나이즈(max_len 512, batch-longest 패딩, special tokens) → forward → **attention-mask 가중 mean pooling** → **L2 정규화**. +- `dimensions()` = 1024, `model_id`/`model_version` = config 값(기존과 동일 식별자 유지). +- **스레드 캡**: config 신규 필드 `models.embedding.num_threads`(u32, 0=auto) + env `KEBAB_EMBED_THREADS`. `CandleEmbedder::new` 에서 `rayon::ThreadPoolBuilder::new().num_threads(n).build_global()` 1회 적용(이미 초기화 시 무시). 0/auto 면 미설정(rayon 기본). NUMA 노드 바인딩은 `numactl`(A1) 과 조합 — 문서화. +- `Mutex` 또는 forward 가 `&self` 면 불필요 — candle forward 는 `&self` 가능, 단 내부 가변 없으면 `Send+Sync` 보장 확인. + +## 5. config 변경 + +- `EmbeddingModelCfg` 에 `num_threads: u32`(default 0) 추가. env `KEBAB_EMBED_THREADS`. +- `provider` 허용값 문서화: `fastembed`(default)/`candle`. +- default toml + `Config::default()` 갱신, 기존 테스트 영향 확인. + +## 6. 버전/캐스케이드 + +- D-reindex 에 따라 `embedding_version` 유지 (벡터 동일). cascade(design §9) 트리거 안 함 — 기존 색인 재사용. (max abs diff 확인 실패 시에만 bump.) +- wire schema 변경 없음. + +## 7. 테스트 (산출물) + +- **단위**(`kebab-embed-candle`): `dimensions()==1024`; `embed()` 출력 L2≈1; 빈 입력 빈 출력; 프리픽스 적용 확인. +- **패리티 테스트**(`#[ignore]`, 모델 2GB+네트워크 필요): candle vs `FastembedEmbedder` 동일 문장 cosine ≥ 0.9999 + max abs diff 보고. CI 기본 제외, 수동/도그푸딩에서 실행. +- **통합**(`kebab-cli` 또는 `kebab-app`): `provider="candle"` 로 소량 fixture ingest → 청크/임베딩 카운트 > 0, 검색 1건 성공. (모델 필요 → `#[ignore]` 또는 feature.) +- **스레드 캡**: `num_threads=4` 설정 시 `rayon::current_num_threads()==4` 확인. +- **회귀**: 기존 fastembed 경로 default 동작 불변(provider 미지정 시). +- clippy `-D warnings`, 빌드 직렬 `-j 4`. + +## 8. 품질 게이트 (머지 전) + +- `kebab-eval` 골든 스위트(`/build/dogfood/golden_queries.yaml`) 를 provider=candle 로 실행 → MRR/hit@k ≥ 현 baseline (회귀 0). [[feedback_search_quality_dogfood]] +- 패러프레이즈 robustness(#195/#196) 스폿 확인. + +## 9. 문서/릴리스 (머지 시 동일 PR) + +- README: Configuration 에 `provider=candle` + `num_threads`/`KEBAB_EMBED_THREADS` 추가. SMOKE config 예시 동기화. [[feedback_readme_sync_rule]] +- ARCHITECTURE: crate 그래프 + 디렉터리에 `kebab-embed-candle` 추가. +- HANDOFF: 머지 후 한 줄(임베딩 백엔드 다변화). +- HOTFIXES: 본 날짜 dated entry (NUMA double-free 진단 + candle provider 도입 + 스파이크 패리티 증거). +- 버전 bump: 신규 config surface(provider=candle, num_threads) = pre-1.0 minor bump (0.21.1 → 0.22.0), release notes. + +## 10. 범위 밖 / 후속 + +- candle crate feature-gate 로 빌드 비용 격리 (후속). +- NUMA 노드 자동 바인딩(현재는 numactl 외부 조합). +- ollama/A2/A1 트랙 (candle 게이트 통과 시 생략). + +## 11. 잔여 게이트 (사용자 실행, Claude 불가) + +- 그 듀얼소켓 NUMA 서버에서 `provider=candle` 로 5150-doc ingest **double-free 없이 EXIT=0 완주**. PR 머지 전/후 검증 예약. (meta-spec §4.3) diff --git a/docs/superpowers/specs/2026-06-01-embedding-numa-backends-meta-plan.md b/docs/superpowers/specs/2026-06-01-embedding-numa-backends-meta-plan.md new file mode 100644 index 0000000..220774e --- /dev/null +++ b/docs/superpowers/specs/2026-06-01-embedding-numa-backends-meta-plan.md @@ -0,0 +1,77 @@ +# Meta-Plan — NUMA-안전 임베딩 백엔드 실행 계획 + +- 날짜: 2026-06-01 +- 우산 스펙: [2026-06-01-embedding-numa-backends-meta-spec.md](./2026-06-01-embedding-numa-backends-meta-spec.md) +- 실행 모델: 트랙별 worktree 격리 + omc teammate (omc-teams, sequential single-team). 트랙 내 단계는 spec → plan → 구현 → 테스트 → PR. + +## 0. 즉시 (본 계획과 병행, 무코드) + +- **A1 stopgap 문서화 + 사용자 제공**: `numactl --cpunodebind=0 --membind=0 kebab ingest` (또는 `taskset -c 0-11`). 현재 불통 해소용. 이건 트랙 4의 산출물 일부지만 지금 바로 안내. +- 사용자 NUMA 서버에서 A1 로 5150-doc 완주되는지 1회 확인 → "스레드/NUMA 가 원인" 인과 확정(메타스펙 §1 보강). + +## 1. 트랙 실행 순서 & 게이트 + +`candle → ollama → A2 → A1(정식 문서화)`. 한 트랙의 PR open + NUMA 검증 예약 전까지 다음 트랙 미착수. + +**조기 종료 (D1 확정)**: candle 또는 ollama 가 허용 품질(골든 ≥ baseline 무회귀) + NUMA 안전을 만족하면 **거기서 종료**, 이후 트랙 미진행. 둘 다 품질 미달 시에만 A2 → A1 진행. candle 은 동일 e5-large 라 패리티 통과 시 종착 유력. + +### 트랙 1 — candle (`feat/embed-candle`) + +- **Phase 0 — 타당성 스파이크 (게이트, 최우선)** + - worktree 에서 candle + candle-transformers 의존성 추가, `xlm_roberta::XLMRobertaModel` 로 `intfloat/multilingual-e5-large` safetensors 로드 (CPU). + - 몇 개 문장 임베딩 → (a) onnxruntime e5-large 벡터와 cosine 패리티, (b) CPU latency, (c) `RAYON_NUM_THREADS` 로 스레드 캡 동작, (d) padding_idx 위치 임베딩 정확성. + - 산출: 스파이크 리포트(패리티 수치 + latency + 스레드 제어 확인). **통과해야 Phase 1 진행.** +- **Phase 1 — spec**: 트랙 spec 작성 (Embedder 구현, config provider="candle", embedding_version, 재색인 절차, 테스트 매트릭스). +- **Phase 2 — plan**: 구현 plan. +- **Phase 3 — 구현**: `kebab-embed-candle`(신규 crate) 또는 `kebab-embed-local` 내 provider 분기. Embedder 구현 + app.rs 주입 분기 + config. +- **Phase 4 — 테스트**: 단위/통합 + 패리티 + 골든. 빌드는 직렬 `-j 4`. +- **Phase 5 — PR + 검증**: gitea PR. 사용자 NUMA 서버 5150-doc 완주 + 골든 baseline 확인. + +### 트랙 2 — ollama (`feat/embed-ollama`) + +- spec → plan → 구현(`OllamaEmbedder`: `/api/embed` 호출, provider="ollama", 모델 선택[e5 GGUF 또는 bge-m3]) → 테스트(패리티/골든, 프로세스 격리로 double-free 부재) → PR + NUMA 검증. + +### 트랙 3 — A2 (`feat/embed-ort-direct`) + +- spec → plan → 구현(fastembed 우회, `ort` 세션 직접 + `with_intra_threads(N)` + NUMA affinity, 토크나이즈/mean-pool/L2 재현, provider="onnx" 기본 유지) → 테스트(기존 e5 벡터와 cosine≈1.0, 재색인 0) → PR + NUMA 검증. +- **품질-중립 안전망**: 재색인 없이 즉시 default 가능. + +### 트랙 4 — A1 정식화 (`docs/embed-numa-affinity`) + +- 런처 래핑/문서 + (선택) config 노브로 affinity 힌트. README/SMOKE/HOTFIXES 동기화. + +## 2. omc teammate 운용 (메모리 규약 준수) + +- spawn: omc-teams tmux pane + brief 파일. **sequential single-team** (multi-team 동시 spawn 금지). +- 모델 라우팅: executor + initial draft + round-1 review = **opus**; closure verify / micro-patch round = **sonnet**. (`OMC_TEAM_ROLE_OVERRIDES` env) +- worker spawn 직후 completion polling shell `run_in_background=true` (phase=completed/failed 감지 → main session 자동 알림). +- 빌드/테스트 직렬, `-j 4` 기본. `CARGO_TARGET_DIR=/build` 사용 (routinely clean 금지). + +## 3. 워크트리 / 브랜치 + +| 트랙 | 브랜치 | worktree | +|---|---|---| +| 1 candle | `feat/embed-candle` | 신규 | +| 2 ollama | `feat/embed-ollama` | 신규 | +| 3 A2 | `feat/embed-ort-direct` | 신규 | +| 4 A1 | `docs/embed-numa-affinity` | 신규 | + +각 트랙 머지 후 다음 트랙 rebase. 트랙 간 공유 상태 없음(독립 provider). + +## 4. 리스크 레지스터 + +- candle Phase 0 패리티 실패 → 트랙 1 강등, ollama 우선. +- candle CPU latency 가 onnxruntime 대비 과도 → opt-in provider 로만. +- ollama 모델이 e5 아님 → 골든 회귀 가능 → default 승격 보류. +- NUMA 검증이 사용자 가용성에 의존 → 각 PR 은 검증 전까지 "merge-pending". +- ort rc.9 자체 버그가 A2 에서도 재현 가능성 → A2 스레드 캡으로도 안 죽는지 NUMA 검증 필수. + +## 5. 진행 상태 (라이브) + +- [x] candle 타당성 desk-research (xlm_roberta 모듈 존재 + cembedd 선례) — 2026-06-01 +- [ ] A1 stopgap 사용자 NUMA 서버 확인 +- [x] 트랙 1 Phase 0 스파이크 — **VERDICT=PASS** (2026-06-01). cosine min=mean=1.000000(onnxruntime 동일), RAYON 스레드 캡 가능, latency ~4×(67.5 vs 16.8 ms/문장, 4 vs 12 스레드). 커밋 76841af. → **조기 종료 유력**: candle 이 품질 baseline 자동 충족 → ollama/A2/A1 불필요 전망. 잔여 게이트=골든 실측 + NUMA 서버 5150-doc 완주. +- [ ] 트랙 1 spec/plan/impl/test/PR (진행) +- [ ] 트랙 2 … +- [ ] 트랙 3 … +- [ ] 트랙 4 … diff --git a/docs/superpowers/specs/2026-06-01-embedding-numa-backends-meta-spec.md b/docs/superpowers/specs/2026-06-01-embedding-numa-backends-meta-spec.md new file mode 100644 index 0000000..20d92e8 --- /dev/null +++ b/docs/superpowers/specs/2026-06-01-embedding-numa-backends-meta-spec.md @@ -0,0 +1,102 @@ +# Meta-Spec — NUMA-안전 임베딩 백엔드 (다중 트랙) + +- 날짜: 2026-06-01 +- 상태: DRAFT (umbrella) +- 범위: `kebab-embed-local` 및 임베더 주입 경로. 4개 트랙의 우산 스펙. +- 하위 산출물: 각 트랙은 본 메타스펙을 참조하는 자체 spec(`tasks/` 또는 `docs/superpowers/specs/`)과 plan을 가진다. + +## 1. 문제 + +CPU-only Ollama 서버(Intel Xeon Silver 4214 ×2 소켓 = 48 logical, NUMA 2노드)에서 `kebab ingest` 가 매 실행 힙 손상으로 죽는다: + +``` +ingest [> ] 3/5150 double free or corruption (!prev) +중지됨 (core dumped) +``` + +근본 원인(코드로 확정): fastembed 4.9.1 (`text_embedding/impl.rs:52,80`) 이 ONNX intra-op 스레드를 `available_parallelism()`(=48) 로 **하드코딩**하고 `InitOptions` 에 이를 덮어쓸 API 가 없다. 듀얼소켓 NUMA 에서 onnxruntime(`ort 2.0.0-rc.9`) 스레드풀이 힙을 손상시킨다. 진단 근거: `tasks/HOTFIXES.md` 의 본 날짜 entry + 대화 로그. + +- 모델/디스크/AVX/데이터 문제 아님 (모델 2.08GB 정상, AVX-512 완비). 순수 스레드/NUMA × 네이티브 런타임 버그. +- onnxruntime 공식 문서도 듀얼소켓 NUMA 는 intra-op 스레드를 한 노드로 묶으라고 권고. + +## 2. 목표 / 비목표 + +목표: +- 그 NUMA 서버에서 5150-doc 코퍼스를 **double-free 없이 완주**하는 임베딩 경로 확보. +- 검색 품질을 골든 스위트(MRR/hit@k) baseline 이상으로 유지. +- `models.embedding.provider` 로 선택 가능한 백엔드들로 구현 (기존 provider 필드 활용). + +비목표: +- 랭킹 자동 조정 (별도 보류 결정, `[[project_ranking_deferred]]`). +- 임베딩 모델 품질 개선 자체 (NUMA 안정성이 본 과제의 초점). +- GPU 경로. + +## 3. 공유 아키텍처 + +- 교체 지점은 **단일**: `crates/kebab-app/src/app.rs:836` 의 `FastembedEmbedder::new(&config)`. +- 트레이트 표면이 작다: `kebab_core::Embedder` (`traits.rs:127`) — `model_id / model_version / dimensions / embed`. 새 백엔드는 이 4개만 구현. +- 설정: `models.embedding.provider` (이미 존재), `model`, `version`, `dimensions`, `batch_size`. 신규로 트랙별 스레드/affinity 노브 추가 가능. + +## 4. 횡단 정책 (모든 트랙 공통) + +### 4.1 embedding_version & 재색인 +- 벡터가 바뀌면(=candle, ollama) **`embedding_version` bump → 전체 재색인** (design §9 cascade). A2/A1 은 동일 onnxruntime e5-large 라 벡터 불변 → 재색인 불필요. +- 재색인 비용/절차를 각 트랙 spec 에 명시. + +### 4.2 품질 검증 (필수 게이트) +- 벡터가 바뀌는 트랙은 머지 전 `kebab-eval` 골든 스위트(`/build/dogfood/golden_queries.yaml`) 로 MRR/hit@k 측정, **baseline 이상**이어야 default 승격. baseline 미달이면 opt-in provider 로만 유지. +- 패러프레이즈 robustness(#195/#196) 회귀 확인. + +### 4.3 NUMA 서버 검증 (필수 게이트, 사용자 실행) +- **결정적 증거는 그 서버에서만 난다 (Claude 접근 불가).** 각 트랙은 사용자가 그 서버에서 5150-doc 코퍼스 ingest 를 **double-free 없이 완주(EXIT=0)** 함을 확인해야 "검증 완료". +- 각 트랙 spec 에 사용자-실행 검증 절차(명령 + 기대 출력)를 문서화. + +### 4.4 스레드/NUMA 제어 +- 각 백엔드가 intra-op/worker 스레드를 캡하고 한 NUMA 노드로 묶을 수 있어야 함. 캡 못 하면 트랙 실패. + +## 5. 트랙 + +선호/구현 순서: **candle → ollama → A2 → A1**. (단 A1 은 무코드 stopgap 이라 즉시 문서화해 당장의 불통을 해소; 구현 순서와 별개.) + +| # | 트랙 | 백엔드 | 벡터 변경(재색인) | 핵심 리스크 | 격리 브랜치 | +|---|------|--------|----|------|------| +| 1 | candle | 순수 Rust (candle `xlm_roberta`) | 예 | XLM-R padding_idx/패리티/CPU 성능 | `feat/embed-candle` | +| 2 | ollama | 별 프로세스 (Ollama `/api/embed`) | 예 | 모델이 e5 아님→품질, ingest 가 Ollama 의존 | `feat/embed-ollama` | +| 3 | A2 | onnxruntime 직접(`ort` 세션) | 아니오 | fastembed 우회 후 토크나이즈/풀링 재현 정확도 | `feat/embed-ort-direct` | +| 4 | A1 | onnxruntime + 실행 래핑(taskset/numactl) | 아니오 | 코드 변경 거의 없음, 문서/런처만 | `docs/embed-numa-affinity` | + +### 5.1 트랙별 테스트 매트릭스 (각 트랙 spec 에서 구체화) + +모든 트랙: +- 단위: `embed()` 가 올바른 dim/정규화(L2≈1) 벡터 반환. +- 통합: `kebab ingest` 소량 fixture → 청크/임베딩 카운트. +- **NUMA 서버 검증**(§4.3): 5150-doc 완주. + +벡터-변경 트랙(candle/ollama) 추가: +- 패리티: onnxruntime e5-large 대비 동일 입력 cosine 유사도(가능 시) 또는 골든 스위트 동등성. +- 골든: MRR/hit@k ≥ baseline (§4.2). +- 재색인 절차 검증. + +벡터-불변 트랙(A2/A1) 추가: +- 회귀: 기존 e5-large 벡터와 cosine ≈ 1.0 (A2 는 같은 런타임이라 사실상 동일해야). + +## 6. 결정사항 (확정 2026-06-01) + +- **D1 조기 종료 (사용자 확정)**: 트랙을 선호 순서로 진행하되, candle 또는 ollama 가 **허용 품질 기준 + NUMA 안전**을 만족하면 **거기서 멈춘다** (이후 트랙 미진행). 둘 다 품질이 너무 낮으면 A2 → A1 까지 계속. + - **허용 품질 기준**: 골든 스위트 MRR/hit@k 가 현 e5-large(onnxruntime) baseline 대비 유의미한 회귀 없음. candle 은 동일 e5-large 가중치라 패리티 통과 시 이 기준을 거의 자동 충족 → candle 이 종착 가능성 높음. ollama 는 모델이 달라 경계선이면 사용자 판단. + - A2/A1 은 candle·ollama 둘 다 실패 시의 **fallback** (A2 는 재색인 0 품질-중립). +- **D2 즉시 완화**: A1(taskset/numactl) 은 무코드라 본 작업과 무관하게 지금 바로 사용자에게 워크어라운드로 제공. +- **D3 메타 산출물 위치**: 본 메타스펙 + 메타플랜은 `docs/superpowers/specs/`. 트랙별 spec 은 도달 시 작성. +- **D4 frozen design 영향**: 임베딩 백엔드 다변화는 design §(임베딩) 갱신 가능 — 트랙 머지 시 동기화. + +## 7. 성공 기준 + +- 그 NUMA 서버에서 최소 1개 트랙이 5150-doc 완주(EXIT=0). +- default 로 승격되는 백엔드는 골든 baseline 이상. +- 각 트랙이 자체 브랜치/워크트리 + 문서화된 테스트로 독립 검증. + +## 8. 시퀀싱 게이트 + +1. candle **스파이크**(Phase 0) 가 패리티+CPU 성능+스레드 제어를 입증해야 candle 본 구현 진행. 실패 시 candle 트랙 강등/스킵 후 ollama 로. +2. 각 트랙은 PR open + NUMA 서버 검증 예약 후 다음 트랙 시작 (omc-teams sequential single-team 제약). +3. 벡터-변경 트랙은 골든 게이트 통과 전 default 승격 금지. diff --git a/tasks/HOTFIXES.md b/tasks/HOTFIXES.md index 39f1efa..35ab88d 100644 --- a/tasks/HOTFIXES.md +++ b/tasks/HOTFIXES.md @@ -27,9 +27,9 @@ config 로 줄일 surface 가 없었고, fastembed 4.9 의 ORT 바인딩은 이 provider 를 추가하기로 결정. candle 의 CPU 백엔드는 글로벌 rayon 풀 크기로 스레드를 정하므로, 한 번의 `rayon::ThreadPoolBuilder::build_global` 캡으로 스레드를 NUMA-안전한 수로 묶을 수 있다. **재색인 0 목표**(`embedding_version` -유지) — Phase 0 스파이크(`SPIKE_REPORT.md`, 커밋 76841af)가 candle vs -onnxruntime **코사인 1.000000** 패리티를 입증했고, 본 Track 1 구현의 패리티 -테스트로 차원별 max 절대오차를 재실측해 확정. +유지) — Phase 0 스파이크(커밋 76841af)가 candle vs onnxruntime **코사인 +1.000000** 패리티를 입증했고, 본 Track 1 구현의 패리티 테스트로 차원별 max +절대오차를 재실측해 확정. **무엇을 건드렸나.** - 신규 crate `crates/kebab-embed-candle` — `kebab_core::Embedder` 구현 @@ -46,9 +46,12 @@ onnxruntime **코사인 1.000000** 패리티를 입증했고, 본 Track 1 구현 - 스파이크 crate `crates/spike-embed-candle` 제거(학습은 production 으로 흡수됨). - 버전 0.21.1 → **0.22.0** (신규 config surface — pre-1.0 minor bump). -**패리티 증거.** Phase 0 스파이크 cosine 1.000000 (10문장 한/영 혼합). 본 -Track 1 의 `#[ignore]` 패리티 테스트 결과(max abs diff)는 -`/build/out/kebab-worktrees/embed-candle/IMPL_REPORT.md` 에 기록. +**패리티 증거.** candle vs `FastembedEmbedder`(onnxruntime), 동일 10문장 +(한/영 혼합, e5 `passage:`/`query:` prefix): **cosine_min = 1.000000, +차원별 max 절대오차 = 2.01e-7** (f32 커널 반올림 수준 — 랭킹 영향 임계보다 +약 50배 작음). 재현: `cargo test -p kebab-embed-candle --release -- --ignored +--nocapture` (`crates/kebab-embed-candle/tests/parity.rs`, 모델 ~2GB 필요라 +CI 기본 제외). 이 수치가 `embedding_version` 유지(재색인 0) 결정의 근거. **호환성.** fastembed default 경로의 동작/벡터 불변. `embedding_version` 유지 → 기존 색인 재사용(재색인 0). wire schema 변경 없음. 옛 config.toml 은 -- 2.49.1 From edac3ae737da17abe4face91819d89e566506c27 Mon Sep 17 00:00:00 2001 From: altair823 Date: Mon, 1 Jun 2026 17:01:35 +0000 Subject: [PATCH 5/6] =?UTF-8?q?chore(embed-candle):=20PR=20#199=20?= =?UTF-8?q?=ED=9A=8C=EC=B0=A8=201=20=EB=A6=AC=EB=B7=B0=20=EB=B0=98?= =?UTF-8?q?=EC=98=81=20=E2=80=94=20SMOKE.md=20candle=20=EB=AA=A8=EB=8D=B8?= =?UTF-8?q?=20=EC=A3=BC=EC=9D=98=20=EB=AA=85=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/SMOKE.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/SMOKE.md b/docs/SMOKE.md index 5275f8f..1aaf539 100644 --- a/docs/SMOKE.md +++ b/docs/SMOKE.md @@ -109,6 +109,9 @@ chunker_version = "md-heading-v1" [models.embedding] provider = "fastembed" # "fastembed"(기본) / "candle"(순수 Rust, NUMA-안전) # / "none"(lexical-only — Ollama 불필요) + # ⚠ provider="candle" 사용 시 아래 model/dimensions 도 + # multilingual-e5-large / 1024 로 바꿔야 함 + # (candle 은 현재 e5-large 만 지원). model = "multilingual-e5-small" version = "v1" dimensions = 384 -- 2.49.1 From d85d7348a5928b8e3696a0e2909004d7be6be8df Mon Sep 17 00:00:00 2001 From: altair823 Date: Tue, 2 Jun 2026 09:08:12 +0000 Subject: [PATCH 6/6] =?UTF-8?q?docs(embed-candle):=20=EB=8F=84=EA=B7=B8?= =?UTF-8?q?=ED=91=B8=EB=94=A9=20+=20A1=20=EB=B0=98=EC=A6=9D=20+=20MKL=20?= =?UTF-8?q?=EB=B6=80=EC=A0=95=EA=B2=B0=EA=B3=BC=20=EC=A6=9D=EA=B1=B0=20?= =?UTF-8?q?=EA=B8=B0=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - HOTFIXES + release-notes: candle 전체 도그푸딩 997 docs/23,151 chunks/에러 0 (9.5h) - A1(taskset -c 0-3) 실서버 반증: 4코어 제한에도 onnxruntime segfault → candle 만이 실 해법 - MKL 가속 부정 결과: 코어 더 쓰나 38~50% 느림 → 미채택, 순수-Rust 유지 - 패리티 2.01e-7 재확인, 성능 트레이드오프 명시 Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/release-notes/v0.22.0-draft.md | 35 ++++++++++++++++++++++++----- tasks/HOTFIXES.md | 29 ++++++++++++++++++++++-- 2 files changed, 56 insertions(+), 8 deletions(-) diff --git a/docs/release-notes/v0.22.0-draft.md b/docs/release-notes/v0.22.0-draft.md index 2660e66..8c2d1fe 100644 --- a/docs/release-notes/v0.22.0-draft.md +++ b/docs/release-notes/v0.22.0-draft.md @@ -65,10 +65,33 @@ double-free 경로를 원천 차단한다. NUMA 노드 바인딩이 더 필요 - 문서: README Configuration, `docs/SMOKE.md` config 예시, `docs/ARCHITECTURE.md` crate 그래프/트리에 candle provider 반영. -## 잔여 검증 (사용자 실행) +## 검증 / 도그푸딩 -듀얼소켓 NUMA 서버에서 `provider=candle` 로 5150-doc ingest 가 double-free -없이 EXIT=0 완주하는지가 본 release 의 최종 인수 게이트다 (meta-spec §4.3). -패리티(candle vs onnxruntime): cosine_min = 1.000000, 차원별 max 절대오차 = -2.01e-7 — 벡터가 사실상 동일하므로 `embedding_version` 유지(재색인 0). 재현은 -`crates/kebab-embed-candle/tests/parity.rs` (`--ignored`). +- **패리티 (candle vs onnxruntime)**: 동일 e5-large 가중치로 cosine_min = + 1.000000, 차원별 max 절대오차 = **2.01e-7**. 벡터가 사실상 동일 → + `embedding_version` 유지(재색인 0). 재현: `crates/kebab-embed-candle/tests/parity.rs` + (`--ignored`). +- **전체 도그푸딩 (2026-06-02)**: `provider=candle` 로 도그푸딩 코퍼스 전체 + 재색인 — **997 docs / 23,151 chunks, 에러 0** 완주 (≈9.5 h, 단일소켓 VM). + candle 가 23k+ 청크를 메모리 오류 없이 처리함을 확인. +- **A1(taskset/numactl) 반증**: NUMA 서버에서 `taskset -c 0-3` 으로 스레드를 + 4개로 묶어도 onnxruntime 은 그대로 죽었다(6/5150 segfault). 스레드 축소는 + 해법이 아니며, **`provider=candle` 만이 실 해법**이다 (candle 은 onnxruntime 을 + 호출하지 않음). +- **최종 인수 게이트 (사용자)**: 그 듀얼소켓 NUMA 서버에서 `provider=candle` 로 + ingest 가 EXIT=0 완주 — 배포·실사용이 이 검증을 겸한다. + +## 성능 노트 (중요) + +candle CPU 임베딩은 onnxruntime 대비 약 **3~4× 느리다** (e5-large/512-tok 의 +순수-Rust 커널 비용). 측정상 ~1.86 s/chunk, CPU 약 4코어 활용. **이는 의도된 +트레이드오프** — onnxruntime 이 전 코어를 AVX-512 로 빡빡하게 굴리는 바로 그 +경로가 NUMA 에서 힙을 손상시켜 죽기 때문이다. "느려도 완주" > "빨라도 크래시". + +- Intel **MKL 가속을 실험했으나 부정 결과**: MKL 은 코어를 더 쓰지만(8~9코어) + 오히려 38~50% 느렸다(과다구독 + MKL 2020.1 오버헤드). 채택하지 않음. +- 더 많은 코어/스레드로는 빨라지지 않는다(병목이 코어 수가 아님). 속도가 + critical 하면 청크 길이 단축 / 더 작은 모델 / GPU 가 레버다(별도 검토). +- 9.5 h 는 **최초 전체 색인 1회 비용**이며, 이후 증분 ingest 는 새/변경 문서만 + 처리해 저렴하다. 단일 워크스테이션(비-NUMA)에서는 기본 `fastembed` 가 더 빠르니 + candle 은 NUMA 호스트 전용 opt-in 으로 둔다. diff --git a/tasks/HOTFIXES.md b/tasks/HOTFIXES.md index 35ab88d..b6bdfac 100644 --- a/tasks/HOTFIXES.md +++ b/tasks/HOTFIXES.md @@ -58,8 +58,33 @@ CI 기본 제외). 이 수치가 `embedding_version` 유지(재색인 0) 결정 `num_threads` 가 serde default(0)로 채워져 그대로 파싱. **잔여 게이트 (사용자 실행, Claude 불가).** 그 듀얼소켓 NUMA 서버에서 -`provider=candle` 로 5150-doc ingest 가 double-free 없이 EXIT=0 완주하는지 -PR 머지 전/후 검증 예약 (meta-spec §4.3). +`provider=candle` 로 ingest 가 double-free 없이 EXIT=0 완주하는지 — 사용자 +배포·실사용이 곧 이 검증을 겸한다 (meta-spec §4.3). + +**도그푸딩 (2026-06-02, 단일소켓 12-thread VM).** `provider=candle` + +`config-candle.toml`(expansion off — 임베더 격리) 로 `/build/dogfood/corpus` +전체 재색인: **scanned=998, new=997, errors=0, stderr=0, KB 997 docs / +23,151 chunks**, duration ≈ 34,329 s (9.5 h). candle 가 23k+ 청크를 메모리 +오류 0 으로 완주 — onnxruntime 이 서버에서 6/5150 에 죽던 것과 정반대. +(이 VM 은 비-NUMA 라 NUMA 자체 재현은 아니나, candle 은 onnxruntime 을 +호출하지 않으므로 동일 크래시 종류가 구조적으로 불가.) + +**A1(taskset/numactl) 워크어라운드 실서버 반증 (2026-06-02).** 사용자가 NUMA +서버에서 `taskset -c 0-3 kebab ingest`(fastembed/onnx 바이너리) 실행 → 4코어로 +제한했는데도 6/5150 에서 `세그멘테이션 오류 (core dumped)`. 스레드 축소가 +onnxruntime 힙 손상을 제거하지 못함(크래시 위치만 3→6 이동). 결론: 이 크래시는 +스레드 *수* 문제가 아니라 onnxruntime 네이티브 코드의 메모리 안전 결함 → +**A1 은 신뢰 불가 우회책. candle(onnxruntime-free)이 유일한 실 해법.** + +**MKL 가속 부정 결과 (2026-06-02).** "candle 이 코어를 더 쓰게" 하려고 candle +`mkl` feature(Intel MKL) 를 벤치 (e5-large, 512-tok 청크, N=32): +pure-Rust 1857 ms/chunk(381% CPU) vs MKL 2574 ms/chunk(896% CPU, rayon12+mkl12) +/ 2792 ms/chunk(817% CPU, rayon1+mkl12). **MKL 은 코어를 더 쓰지만 모든 설정에서 +38~50% 더 느림** (MKL 2020.1 sgemm + 스레드 오버헤드/과다구독; candle 0.10.2 는 +f16 `hgemm_` 미해결로 링크도 실패 — 벤치는 호출 안 되는 스텁으로 우회). 또 +pure-Rust 는 rayon 8↔12 간 throughput 불변(~1.86 s/chunk) — 병목은 코어 수가 +아니라 candle e5-large/512tok 커널 효율. **결론: MKL 미채택, 순수-Rust 유지(안전 +최상 + CPU 에서 더 빠름). 속도 레버는 코어가 아니라 청크 길이/모델 크기/GPU.** amends: `docs/superpowers/specs/2026-06-01-embed-candle-track-spec.md`. -- 2.49.1