spike(embed-candle): candle e5-large 타당성 검증 — VERDICT PASS
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) <noreply@anthropic.com>
This commit is contained in:
401
Cargo.lock
generated
401
Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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]
|
||||
|
||||
57
SPIKE_BRIEF.md
Normal file
57
SPIKE_BRIEF.md
Normal file
@@ -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) <noreply@anthropic.com>`.
|
||||
|
||||
## 합격 기준
|
||||
|
||||
- cosine 패리티 mean ≥ 0.99 (동일 가중치) → padding/pooling 정확, candle 트랙 GREEN.
|
||||
- 0.95~0.99 → 경미한 차이(pooling 옵션 등), 진단 후 판단.
|
||||
- < 0.95 → 구조/패딩 불일치 → 원인 규명 후 FAIL 또는 수정.
|
||||
- 스레드 캡 불가 시 NUMA 안전성 위협 → 리포트에 명시.
|
||||
108
SPIKE_REPORT.md
Normal file
108
SPIKE_REPORT.md
Normal file
@@ -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**.
|
||||
32
crates/spike-embed-candle/Cargo.toml
Normal file
32
crates/spike-embed-candle/Cargo.toml
Normal file
@@ -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]
|
||||
251
crates/spike-embed-candle/src/main.rs
Normal file
251
crates/spike-embed-candle/src/main.rs
Normal file
@@ -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(|_| "<unset>".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<EmbeddingInput> = 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::<f32>() / 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<EmbeddingInput> = 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<Vec<Vec<f32>>> {
|
||||
let prefixed: Vec<String> = 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::<f32>()?)
|
||||
}
|
||||
|
||||
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::<f32>().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)
|
||||
}
|
||||
Reference in New Issue
Block a user