Skip to content

Commit c77ab70

Browse files
committed
v0.2: mmap, SIMD, DistilBERT, rayon & CI
Bump crate to v0.2 and introduce several performance, compatibility, and CI improvements. Key changes: - Version bump to 0.2.0 and add dependencies: memmap2, rayon, unicode-normalization. - Add GitHub automation: dependabot config, CI workflow (check/test/clippy/fmt/doc/bench/msrv) and release workflow. - Memory-mapped SafeTensors support: SafeTensorsFile::load_mmap, DataStore enum (Owned/Mapped), data_offset handling, and Embedder now loads weights with ModelWeights::load_mmap to avoid copying large model files. - Extended SafeTensors parsing: header parsing refactor, BF16 support (bf16_to_f32), F16/F32 handling, more robust error checks, and unit tests. - Multi-model weight support: ModelConfig.model_type, DistilBERT-aware loading path (no token_type_embeddings, different tensor names) and ModelWeights.from_safetensors abstraction; token_type_embeddings made optional. - SIMD-accelerated tensor primitives: new tensor/simd module with AVX2 implementations and scalar fallbacks; used in matmul and ops for faster matmul/elementwise/scalar operations; includes tests. - Parallel tokenization: tokenizer.encode_batch now uses rayon for parallel encoding. - Small fixes and cleanups: clamp GELU constant precision, lib crate-level clippy allows, example doc comment formatting, and test/fixture tooling (scripts/generate_golden.py and tests/golden_test.rs). These changes reduce memory usage and improve performance for large models, add broader dtype support, and improve CI/automation.
1 parent a2c04d3 commit c77ab70

22 files changed

Lines changed: 1315 additions & 83 deletions

.github/dependabot.yml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
version: 2
2+
updates:
3+
- package-ecosystem: "cargo"
4+
directory: "/"
5+
schedule:
6+
interval: "weekly"
7+
commit-message:
8+
prefix: "deps"
9+
10+
- package-ecosystem: "github-actions"
11+
directory: "/"
12+
schedule:
13+
interval: "weekly"
14+
commit-message:
15+
prefix: "ci"

.github/workflows/ci.yml

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main, master]
6+
pull_request:
7+
branches: [main, master]
8+
9+
env:
10+
CARGO_TERM_COLOR: always
11+
RUSTFLAGS: -Dwarnings
12+
13+
jobs:
14+
check:
15+
name: Check
16+
runs-on: ubuntu-latest
17+
steps:
18+
- uses: actions/checkout@v4
19+
- uses: dtolnay/rust-toolchain@stable
20+
- uses: Swatinem/rust-cache@v2
21+
- run: cargo check --all-targets
22+
23+
test:
24+
name: Test (${{ matrix.os }})
25+
runs-on: ${{ matrix.os }}
26+
strategy:
27+
fail-fast: false
28+
matrix:
29+
os: [ubuntu-latest, windows-latest, macos-latest]
30+
steps:
31+
- uses: actions/checkout@v4
32+
- uses: dtolnay/rust-toolchain@stable
33+
- uses: Swatinem/rust-cache@v2
34+
- run: cargo test --verbose
35+
36+
clippy:
37+
name: Clippy
38+
runs-on: ubuntu-latest
39+
steps:
40+
- uses: actions/checkout@v4
41+
- uses: dtolnay/rust-toolchain@stable
42+
with:
43+
components: clippy
44+
- uses: Swatinem/rust-cache@v2
45+
- run: cargo clippy --all-targets -- -D warnings
46+
47+
fmt:
48+
name: Formatting
49+
runs-on: ubuntu-latest
50+
steps:
51+
- uses: actions/checkout@v4
52+
- uses: dtolnay/rust-toolchain@stable
53+
with:
54+
components: rustfmt
55+
- run: cargo fmt --all -- --check
56+
57+
doc:
58+
name: Documentation
59+
runs-on: ubuntu-latest
60+
env:
61+
RUSTDOCFLAGS: -Dwarnings
62+
steps:
63+
- uses: actions/checkout@v4
64+
- uses: dtolnay/rust-toolchain@stable
65+
- uses: Swatinem/rust-cache@v2
66+
- run: cargo doc --no-deps
67+
68+
bench:
69+
name: Benchmarks (compile check)
70+
runs-on: ubuntu-latest
71+
steps:
72+
- uses: actions/checkout@v4
73+
- uses: dtolnay/rust-toolchain@stable
74+
- uses: Swatinem/rust-cache@v2
75+
- run: cargo bench --no-run
76+
77+
msrv:
78+
name: MSRV (1.70)
79+
runs-on: ubuntu-latest
80+
steps:
81+
- uses: actions/checkout@v4
82+
- uses: dtolnay/rust-toolchain@master
83+
with:
84+
toolchain: "1.70"
85+
- uses: Swatinem/rust-cache@v2
86+
- run: cargo check

.github/workflows/release.yml

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
name: Release
2+
3+
on:
4+
push:
5+
tags:
6+
- "v*"
7+
8+
permissions:
9+
contents: write
10+
11+
env:
12+
CARGO_TERM_COLOR: always
13+
14+
jobs:
15+
# Run full CI first
16+
ci:
17+
uses: ./.github/workflows/ci.yml
18+
19+
publish-check:
20+
name: Publish dry-run
21+
needs: ci
22+
runs-on: ubuntu-latest
23+
steps:
24+
- uses: actions/checkout@v4
25+
- uses: dtolnay/rust-toolchain@stable
26+
- uses: Swatinem/rust-cache@v2
27+
- run: cargo publish --dry-run
28+
29+
release:
30+
name: Create GitHub Release
31+
needs: [ci, publish-check]
32+
runs-on: ubuntu-latest
33+
steps:
34+
- uses: actions/checkout@v4
35+
- name: Extract version from tag
36+
id: version
37+
run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
38+
39+
- name: Create Release
40+
uses: softprops/action-gh-release@v2
41+
with:
42+
name: v${{ steps.version.outputs.VERSION }}
43+
generate_release_notes: true
44+
draft: false
45+
prerelease: ${{ contains(github.ref, '-') }}

Cargo.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "hypembed"
3-
version = "0.1.0"
3+
version = "0.2.0"
44
edition = "2021"
55
rust-version = "1.70"
66
description = "A pure-Rust, local-first embedding inference library. No external ML runtimes."
@@ -13,6 +13,9 @@ categories = ["science", "text-processing"]
1313
serde = { version = "1", features = ["derive"] }
1414
serde_json = "1"
1515
thiserror = "2"
16+
memmap2 = "0.9"
17+
rayon = "1.10"
18+
unicode-normalization = "0.1"
1619

1720
[dev-dependencies]
1821
criterion = { version = "0.5", features = ["html_reports"] }

examples/basic_embed.rs

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
1-
///! Basic example: load a model and generate embeddings.
2-
///!
3-
///! To run this example, download a BERT-like model (e.g., all-MiniLM-L6-v2)
4-
///! and place config.json, vocab.txt, and model.safetensors in a directory.
5-
///!
6-
///! ```bash
7-
///! cargo run --example basic_embed -- ./path/to/model
8-
///! ```
1+
//! Basic example: load a model and generate embeddings.
2+
//!
3+
//! To run this example, download a BERT-like model (e.g., all-MiniLM-L6-v2)
4+
//! and place config.json, vocab.txt, and model.safetensors in a directory.
5+
//!
6+
//! ```bash
7+
//! cargo run --example basic_embed -- ./path/to/model
8+
//! ```
99
1010
use hypembed::{Embedder, EmbeddingOptions, PoolingStrategy};
1111
use std::env;

scripts/generate_golden.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
#!/usr/bin/env python3
2+
"""Generate golden reference embeddings for the HypEmbed test suite.
3+
4+
Usage:
5+
python scripts/generate_golden.py \
6+
--model sentence-transformers/all-MiniLM-L6-v2 \
7+
--output tests/fixtures/golden.json
8+
9+
Requires: pip install sentence-transformers
10+
"""
11+
12+
import argparse
13+
import json
14+
15+
def main():
16+
parser = argparse.ArgumentParser(description="Generate golden embeddings")
17+
parser.add_argument("--model", default="sentence-transformers/all-MiniLM-L6-v2",
18+
help="HuggingFace model name or path")
19+
parser.add_argument("--output", default="tests/fixtures/golden.json",
20+
help="Output JSON file path")
21+
args = parser.parse_args()
22+
23+
from sentence_transformers import SentenceTransformer
24+
25+
# Test sentences covering various edge cases
26+
sentences = [
27+
"Hello world",
28+
"Rust is a systems programming language.",
29+
"Machine learning models generate embedding vectors.",
30+
"The quick brown fox jumps over the lazy dog.",
31+
"café résumé naïve", # Accented characters
32+
"你好世界", # Chinese characters
33+
"Short",
34+
"", # Empty string
35+
"A " * 100, # Long repeated text
36+
"Hello, World! How are you doing today?",
37+
]
38+
39+
model = SentenceTransformer(args.model)
40+
41+
golden = {}
42+
for sentence in sentences:
43+
if not sentence.strip():
44+
continue # Skip empty
45+
embedding = model.encode([sentence], normalize_embeddings=True)
46+
golden[sentence] = embedding.tolist()
47+
48+
with open(args.output, "w") as f:
49+
json.dump(golden, f, indent=2)
50+
51+
print(f"Generated golden embeddings for {len(golden)} sentences")
52+
print(f"Model: {args.model}")
53+
print(f"Output: {args.output}")
54+
print(f"Embedding dim: {len(list(golden.values())[0][0])}")
55+
56+
if __name__ == "__main__":
57+
main()

src/lib.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
#![allow(clippy::too_many_arguments)]
2+
#![allow(clippy::empty_line_after_doc_comments)]
3+
#![allow(clippy::needless_range_loop)]
14
//! # HypEmbed
25
//!
36
//! A pure-Rust, local-first embedding inference library.

src/model/config.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,11 @@ pub struct ModelConfig {
3939
/// Number of token type (segment) embeddings.
4040
#[serde(default = "default_type_vocab_size")]
4141
pub type_vocab_size: usize,
42+
43+
/// Model type (e.g., "bert", "distilbert", "xlm-roberta").
44+
/// Auto-detected from config.json.
45+
#[serde(default)]
46+
pub model_type: Option<String>,
4247
}
4348

4449
fn default_layer_norm_eps() -> f64 {
@@ -88,6 +93,16 @@ impl ModelConfig {
8893
pub fn ln_eps(&self) -> f32 {
8994
self.layer_norm_eps as f32
9095
}
96+
97+
/// Check if this is a DistilBERT model.
98+
pub fn is_distilbert(&self) -> bool {
99+
self.model_type.as_deref() == Some("distilbert")
100+
}
101+
102+
/// Check if this is a standard BERT-type model (BERT, MiniLM, etc.).
103+
pub fn is_bert(&self) -> bool {
104+
!self.is_distilbert()
105+
}
91106
}
92107

93108
#[cfg(test)]

src/model/embedding.rs

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ use crate::tensor::layernorm;
1616
/// - `token_type_ids`: [batch_size, seq_len] segment IDs (0 or 1)
1717
/// - `word_embeddings`: [vocab_size, hidden_size]
1818
/// - `position_embeddings`: [max_positions, hidden_size]
19-
/// - `token_type_embeddings`: [type_vocab_size, hidden_size]
19+
/// - `token_type_embeddings`: [type_vocab_size, hidden_size] or None for models without (e.g., DistilBERT)
2020
/// - `ln_weight`, `ln_bias`: LayerNorm parameters [hidden_size]
2121
/// - `eps`: LayerNorm epsilon
2222
///
@@ -27,7 +27,7 @@ pub fn compute_embeddings(
2727
token_type_ids: &[Vec<u32>],
2828
word_embeddings: &Tensor,
2929
position_embeddings: &Tensor,
30-
token_type_embeddings: &Tensor,
30+
token_type_embeddings: Option<&Tensor>,
3131
ln_weight: &Tensor,
3232
ln_bias: &Tensor,
3333
eps: f32,
@@ -39,24 +39,30 @@ pub fn compute_embeddings(
3939
let mut output = vec![0.0f32; batch_size * seq_len * hidden_size];
4040
let word_data = word_embeddings.data();
4141
let pos_data = position_embeddings.data();
42-
let tt_data = token_type_embeddings.data();
42+
let tt_data = token_type_embeddings.map(|t| t.data());
4343

4444
for b in 0..batch_size {
4545
for s in 0..seq_len {
4646
let token_id = input_ids[b][s] as usize;
47-
let type_id = token_type_ids[b][s] as usize;
4847
let out_offset = (b * seq_len + s) * hidden_size;
4948

50-
// word_embed[token_id] + position_embed[s] + token_type_embed[type_id]
49+
// word_embed[token_id] + position_embed[s]
5150
let word_offset = token_id * hidden_size;
5251
let pos_offset = s * hidden_size;
53-
let tt_offset = type_id * hidden_size;
5452

5553
for h in 0..hidden_size {
5654
output[out_offset + h] =
5755
word_data[word_offset + h]
58-
+ pos_data[pos_offset + h]
59-
+ tt_data[tt_offset + h];
56+
+ pos_data[pos_offset + h];
57+
}
58+
59+
// + token_type_embed[type_id] (if available)
60+
if let Some(tt) = tt_data {
61+
let type_id = token_type_ids[b][s] as usize;
62+
let tt_offset = type_id * hidden_size;
63+
for h in 0..hidden_size {
64+
output[out_offset + h] += tt[tt_offset + h];
65+
}
6066
}
6167
}
6268
}
@@ -89,7 +95,7 @@ mod tests {
8995

9096
let result = compute_embeddings(
9197
&input_ids, &token_type_ids,
92-
&word_emb, &pos_emb, &tt_emb,
98+
&word_emb, &pos_emb, Some(&tt_emb),
9399
&ln_w, &ln_b, 1e-12,
94100
).unwrap();
95101

src/model/encoder.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ mod tests {
8888
layer_norm_eps: 1e-12,
8989
hidden_act: "gelu".into(),
9090
type_vocab_size: 2,
91+
model_type: None,
9192
}
9293
}
9394

0 commit comments

Comments
 (0)