mnemos-embedkit 0.1.0a1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- embedkit/__init__.py +16 -0
- embedkit/adapters/__init__.py +51 -0
- embedkit/adapters/base.py +70 -0
- embedkit/adapters/cpu_llamacpp.py +107 -0
- embedkit/adapters/gpu_apple_mlx.py +257 -0
- embedkit/adapters/gpu_nvidia_cuda.py +171 -0
- embedkit/adapters/npu_cix_zhouyi.py +356 -0
- embedkit/engine.py +120 -0
- embedkit/models/__init__.py +4 -0
- embedkit/models/registry.py +73 -0
- embedkit/pick.py +34 -0
- embedkit/shims/__init__.py +0 -0
- mnemos_embedkit-0.1.0a1.dist-info/METADATA +200 -0
- mnemos_embedkit-0.1.0a1.dist-info/RECORD +17 -0
- mnemos_embedkit-0.1.0a1.dist-info/WHEEL +4 -0
- mnemos_embedkit-0.1.0a1.dist-info/entry_points.txt +3 -0
- mnemos_embedkit-0.1.0a1.dist-info/licenses/LICENSE +17 -0
embedkit/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""mnemos-embedkit — open embedding devkit for heterogeneous silicon.
|
|
2
|
+
|
|
3
|
+
Quick start:
|
|
4
|
+
|
|
5
|
+
import embedkit
|
|
6
|
+
eng = embedkit.Engine.auto()
|
|
7
|
+
vec = eng.embed("Hello world")
|
|
8
|
+
|
|
9
|
+
See docs/DESIGN.md for architecture.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from .engine import Engine
|
|
14
|
+
|
|
15
|
+
__version__ = "0.1.0-dev"
|
|
16
|
+
__all__ = ["Engine", "__version__"]
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Adapter registry.
|
|
2
|
+
|
|
3
|
+
Adapters are imported lazily so a missing optional dependency on one
|
|
4
|
+
adapter (e.g. `mlx` not installed on a Linux box) does not break the
|
|
5
|
+
whole kit. Engine.auto() iterates the registry, calls is_available()
|
|
6
|
+
on each class, and picks the fastest among those that report True
|
|
7
|
+
within the highest-populated tier.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from .base import AbstractAdapter
|
|
12
|
+
|
|
13
|
+
# Adapters registered alphabetically. ORDER HAS NO SEMANTIC MEANING.
|
|
14
|
+
# auto() picks within capability tier by measured throughput, not by
|
|
15
|
+
# this list order.
|
|
16
|
+
_REGISTRY_NAMES: list[tuple[str, str]] = [
|
|
17
|
+
("cpu_llamacpp", "CPULlamaCppAdapter"),
|
|
18
|
+
("cpu_sbert", "CPUSBertAdapter"),
|
|
19
|
+
("gpu_amd_rocm", "AMDROCmAdapter"),
|
|
20
|
+
("gpu_apple_mlx", "AppleMLXAdapter"),
|
|
21
|
+
("gpu_intel_igpu", "IntelIGPUAdapter"),
|
|
22
|
+
("gpu_nvidia_cuda", "NvidiaCUDAAdapter"),
|
|
23
|
+
("gpu_nvidia_trt", "NvidiaTRTAdapter"),
|
|
24
|
+
("gpu_vulkan", "VulkanAdapter"),
|
|
25
|
+
("npu_amd_xdna", "AMDXDNAAdapter"),
|
|
26
|
+
("npu_cix_zhouyi", "CixZhouyiAdapter"),
|
|
27
|
+
("npu_intel", "IntelNPUAdapter"),
|
|
28
|
+
("npu_mediatek_apu", "MediaTekAPUAdapter"),
|
|
29
|
+
("npu_rockchip", "RockchipRKNNAdapter"),
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def all_adapters() -> list[type[AbstractAdapter]]:
|
|
34
|
+
"""Return all registered adapter classes that can be imported on this host.
|
|
35
|
+
|
|
36
|
+
Adapters whose import fails (missing optional deps) are skipped silently
|
|
37
|
+
and surface only when the caller asks for that adapter explicitly.
|
|
38
|
+
"""
|
|
39
|
+
out: list[type[AbstractAdapter]] = []
|
|
40
|
+
for module_name, class_name in _REGISTRY_NAMES:
|
|
41
|
+
try:
|
|
42
|
+
module = __import__(f"embedkit.adapters.{module_name}", fromlist=[class_name])
|
|
43
|
+
except Exception:
|
|
44
|
+
continue
|
|
45
|
+
cls = getattr(module, class_name, None)
|
|
46
|
+
if cls is not None and issubclass(cls, AbstractAdapter):
|
|
47
|
+
out.append(cls)
|
|
48
|
+
return out
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
__all__ = ["AbstractAdapter", "all_adapters"]
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Abstract adapter contract.
|
|
2
|
+
|
|
3
|
+
Every silicon adapter (CPU / GPU / NPU) must implement this interface.
|
|
4
|
+
The Engine's auto() picks among adapters whose is_available() returns True
|
|
5
|
+
within the highest-populated capability tier (npu > gpu > cpu).
|
|
6
|
+
|
|
7
|
+
No vendor preference. The kit is silicon-agnostic.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from abc import ABC, abstractmethod
|
|
12
|
+
from typing import ClassVar
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class AbstractAdapter(ABC):
|
|
16
|
+
"""Contract every embedkit adapter must satisfy.
|
|
17
|
+
|
|
18
|
+
Subclasses set the class-level metadata (name, tier, model_format) at
|
|
19
|
+
class-definition time so registry probing does not require an
|
|
20
|
+
instantiation. is_available() must be cheap and side-effect-free.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
name: ClassVar[str] # canonical kit name, e.g. "cix-npu"
|
|
24
|
+
tier: ClassVar[str] # "npu" | "gpu" | "cpu"
|
|
25
|
+
model_format: ClassVar[str] # "gguf" | "onnx" | "cix" | "rknn" | "xdna" | "mlx"
|
|
26
|
+
embed_dim: int = 0 # populated after model load
|
|
27
|
+
max_tokens: int = 0 # populated after model load
|
|
28
|
+
|
|
29
|
+
def __init__(self, model_path: str, **kwargs: object) -> None:
|
|
30
|
+
self.model_path = model_path
|
|
31
|
+
self._kwargs = kwargs
|
|
32
|
+
|
|
33
|
+
@abstractmethod
|
|
34
|
+
def embed(self, text: str) -> list[float]:
|
|
35
|
+
"""Return a single embedding vector for `text`."""
|
|
36
|
+
|
|
37
|
+
@abstractmethod
|
|
38
|
+
def embed_batch(self, texts: list[str]) -> list[list[float]]:
|
|
39
|
+
"""Return embedding vectors for each entry in `texts`."""
|
|
40
|
+
|
|
41
|
+
@abstractmethod
|
|
42
|
+
def warmup(self) -> None:
|
|
43
|
+
"""Run a couple of dummy embeds to populate caches / load weights."""
|
|
44
|
+
|
|
45
|
+
@abstractmethod
|
|
46
|
+
def close(self) -> None:
|
|
47
|
+
"""Release any device handles, threads, or subprocesses."""
|
|
48
|
+
|
|
49
|
+
@classmethod
|
|
50
|
+
@abstractmethod
|
|
51
|
+
def is_available(cls) -> tuple[bool, str]:
|
|
52
|
+
"""Return (ok, reason).
|
|
53
|
+
|
|
54
|
+
ok=True iff this adapter's runtime + driver are present on the host
|
|
55
|
+
and a model load is expected to succeed. reason is a short string
|
|
56
|
+
for the doctor / debug output.
|
|
57
|
+
|
|
58
|
+
This MUST be cheap (no model load, no GPU init beyond a probe).
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
def info(self) -> dict[str, object]:
|
|
62
|
+
"""Introspection blob for `Engine.info()`."""
|
|
63
|
+
return {
|
|
64
|
+
"adapter": self.name,
|
|
65
|
+
"tier": self.tier,
|
|
66
|
+
"model_format": self.model_format,
|
|
67
|
+
"model_path": self.model_path,
|
|
68
|
+
"embed_dim": self.embed_dim,
|
|
69
|
+
"max_tokens": self.max_tokens,
|
|
70
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright (c) 2026 Jason Perlow
|
|
3
|
+
"""llama-cpp-python adapter for GGUF embedding models."""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import importlib
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from .base import AbstractAdapter
|
|
12
|
+
|
|
13
|
+
log = logging.getLogger("embedkit.adapter.cpu_llamacpp")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class CPULlamaCppAdapter(AbstractAdapter):
|
|
17
|
+
"""Embedding adapter backed by llama-cpp-python.
|
|
18
|
+
|
|
19
|
+
This path covers CPU execution and any llama.cpp accelerator backend
|
|
20
|
+
compiled into the local llama-cpp-python wheel, controlled by
|
|
21
|
+
``n_gpu_layers`` / ``N_GPU_LAYERS``.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
name = "cpu-llamacpp"
|
|
25
|
+
tier = "cpu"
|
|
26
|
+
model_format = "gguf"
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
model_path: str,
|
|
31
|
+
*,
|
|
32
|
+
n_threads: int | None = None,
|
|
33
|
+
n_ctx: int = 8192,
|
|
34
|
+
n_gpu_layers: int | None = None,
|
|
35
|
+
**kwargs: Any,
|
|
36
|
+
) -> None:
|
|
37
|
+
super().__init__(model_path, **kwargs)
|
|
38
|
+
llama_cpp = importlib.import_module("llama_cpp")
|
|
39
|
+
|
|
40
|
+
llama_kwargs = dict(kwargs)
|
|
41
|
+
llama_kwargs.pop("embedding", None)
|
|
42
|
+
llama_kwargs.pop("verbose", None)
|
|
43
|
+
|
|
44
|
+
threads = n_threads or os.cpu_count() or 4
|
|
45
|
+
gpu_layers = (
|
|
46
|
+
n_gpu_layers
|
|
47
|
+
if n_gpu_layers is not None
|
|
48
|
+
else int(os.environ.get("N_GPU_LAYERS", "0"))
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
self.max_tokens = n_ctx
|
|
52
|
+
self._llm: Any | None = llama_cpp.Llama(
|
|
53
|
+
model_path=model_path,
|
|
54
|
+
n_ctx=n_ctx,
|
|
55
|
+
n_threads=threads,
|
|
56
|
+
n_gpu_layers=gpu_layers,
|
|
57
|
+
embedding=True,
|
|
58
|
+
verbose=False,
|
|
59
|
+
**llama_kwargs,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
sample = self._create_embedding("warmup")
|
|
63
|
+
self.embed_dim = len(sample)
|
|
64
|
+
|
|
65
|
+
@classmethod
|
|
66
|
+
def is_available(cls) -> tuple[bool, str]:
|
|
67
|
+
try:
|
|
68
|
+
importlib.import_module("llama_cpp")
|
|
69
|
+
except ImportError as exc:
|
|
70
|
+
return False, f"llama-cpp-python missing: {exc}"
|
|
71
|
+
except Exception as exc:
|
|
72
|
+
return False, f"llama-cpp-python import failed: {exc}"
|
|
73
|
+
return True, "llama-cpp-python installed"
|
|
74
|
+
|
|
75
|
+
def embed(self, text: str) -> list[float]:
|
|
76
|
+
return self._create_embedding(text)
|
|
77
|
+
|
|
78
|
+
def embed_batch(self, texts: list[str]) -> list[list[float]]:
|
|
79
|
+
return [self.embed(text) for text in texts]
|
|
80
|
+
|
|
81
|
+
def warmup(self) -> None:
|
|
82
|
+
for text in ("warmup", "test", "sample"):
|
|
83
|
+
self._create_embedding(text)
|
|
84
|
+
|
|
85
|
+
def close(self) -> None:
|
|
86
|
+
llm = self._llm
|
|
87
|
+
self._llm = None
|
|
88
|
+
if llm is None:
|
|
89
|
+
return
|
|
90
|
+
close = getattr(llm, "close", None)
|
|
91
|
+
if callable(close):
|
|
92
|
+
try:
|
|
93
|
+
close()
|
|
94
|
+
except Exception as exc:
|
|
95
|
+
log.debug("llama.cpp close failed: %s", exc)
|
|
96
|
+
|
|
97
|
+
def _create_embedding(self, text: str) -> list[float]:
|
|
98
|
+
if self._llm is None:
|
|
99
|
+
raise RuntimeError("cpu-llamacpp adapter is closed")
|
|
100
|
+
response = self._llm.create_embedding(text)
|
|
101
|
+
data = response.get("data", [])
|
|
102
|
+
if not data:
|
|
103
|
+
raise RuntimeError("llama-cpp-python returned no embedding data")
|
|
104
|
+
embedding = data[0].get("embedding")
|
|
105
|
+
if embedding is None:
|
|
106
|
+
raise RuntimeError("llama-cpp-python response missing embedding")
|
|
107
|
+
return [float(value) for value in embedding]
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright (c) 2026 Jason Perlow
|
|
3
|
+
"""Apple Silicon MLX adapter."""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import importlib
|
|
7
|
+
import logging
|
|
8
|
+
import platform
|
|
9
|
+
from typing import Any, cast
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
from numpy.typing import NDArray
|
|
13
|
+
|
|
14
|
+
from .base import AbstractAdapter
|
|
15
|
+
|
|
16
|
+
log = logging.getLogger("embedkit.adapter.apple_mlx")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AppleMLXAdapter(AbstractAdapter):
|
|
20
|
+
"""Embedding adapter for MLX models on Apple Silicon."""
|
|
21
|
+
|
|
22
|
+
name = "apple-mlx"
|
|
23
|
+
tier = "gpu"
|
|
24
|
+
model_format = "mlx"
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
model_path: str,
|
|
29
|
+
*,
|
|
30
|
+
max_tokens: int = 512,
|
|
31
|
+
normalize: bool = True,
|
|
32
|
+
**kwargs: Any,
|
|
33
|
+
) -> None:
|
|
34
|
+
super().__init__(model_path, **kwargs)
|
|
35
|
+
self._mx: Any | None = importlib.import_module("mlx.core")
|
|
36
|
+
load = self._resolve_mlx_load()
|
|
37
|
+
|
|
38
|
+
load_kwargs = dict(kwargs)
|
|
39
|
+
self._model, self._tokenizer = load(model_path, **load_kwargs)
|
|
40
|
+
self.max_tokens = max_tokens
|
|
41
|
+
self._normalize = normalize
|
|
42
|
+
|
|
43
|
+
sample = self.embed("warmup")
|
|
44
|
+
self.embed_dim = len(sample)
|
|
45
|
+
|
|
46
|
+
@classmethod
|
|
47
|
+
def is_available(cls) -> tuple[bool, str]:
|
|
48
|
+
if platform.system() != "Darwin" or platform.machine() != "arm64":
|
|
49
|
+
return False, "Apple MLX requires Apple Silicon macOS"
|
|
50
|
+
try:
|
|
51
|
+
importlib.import_module("mlx.core")
|
|
52
|
+
importlib.import_module("mlx_lm")
|
|
53
|
+
except ImportError as exc:
|
|
54
|
+
return False, f"mlx/mlx_lm missing: {exc}"
|
|
55
|
+
except Exception as exc:
|
|
56
|
+
return False, f"mlx/mlx_lm probe failed: {exc}"
|
|
57
|
+
return True, "mlx + mlx_lm present on Apple Silicon"
|
|
58
|
+
|
|
59
|
+
def embed(self, text: str) -> list[float]:
|
|
60
|
+
return self.embed_batch([text])[0]
|
|
61
|
+
|
|
62
|
+
def embed_batch(self, texts: list[str]) -> list[list[float]]:
|
|
63
|
+
if not texts:
|
|
64
|
+
return []
|
|
65
|
+
if self._mx is None or self._model is None or self._tokenizer is None:
|
|
66
|
+
raise RuntimeError("apple-mlx adapter is closed")
|
|
67
|
+
|
|
68
|
+
input_ids, attention_mask = self._tokenize_batch(texts)
|
|
69
|
+
mx_input_ids = self._mx.array(input_ids)
|
|
70
|
+
mx_attention_mask = self._mx.array(attention_mask)
|
|
71
|
+
outputs = self._call_model(mx_input_ids, mx_attention_mask)
|
|
72
|
+
vectors = self._select_vectors(outputs, attention_mask)
|
|
73
|
+
vectors = self._postprocess_vectors(vectors)
|
|
74
|
+
return [[float(value) for value in row] for row in vectors.tolist()]
|
|
75
|
+
|
|
76
|
+
def warmup(self) -> None:
|
|
77
|
+
self.embed_batch(["warmup", "test", "sample"])
|
|
78
|
+
|
|
79
|
+
def close(self) -> None:
|
|
80
|
+
mx = self._mx
|
|
81
|
+
self._model = None
|
|
82
|
+
self._tokenizer = None
|
|
83
|
+
self._mx = None
|
|
84
|
+
if mx is None:
|
|
85
|
+
return
|
|
86
|
+
try:
|
|
87
|
+
metal = getattr(mx, "metal", None)
|
|
88
|
+
clear_cache = getattr(metal, "clear_cache", None)
|
|
89
|
+
if callable(clear_cache):
|
|
90
|
+
clear_cache()
|
|
91
|
+
except Exception as exc:
|
|
92
|
+
log.debug("MLX cache clear failed: %s", exc)
|
|
93
|
+
|
|
94
|
+
@staticmethod
|
|
95
|
+
def _resolve_mlx_load() -> Any:
|
|
96
|
+
mlx_lm = importlib.import_module("mlx_lm")
|
|
97
|
+
load = getattr(mlx_lm, "load", None)
|
|
98
|
+
if callable(load):
|
|
99
|
+
return load
|
|
100
|
+
mlx_lm_utils = importlib.import_module("mlx_lm.utils")
|
|
101
|
+
load = getattr(mlx_lm_utils, "load", None)
|
|
102
|
+
if not callable(load):
|
|
103
|
+
raise RuntimeError("mlx_lm does not expose a load() helper")
|
|
104
|
+
return load
|
|
105
|
+
|
|
106
|
+
def _tokenize_batch(self, texts: list[str]) -> tuple[NDArray[Any], NDArray[Any]]:
|
|
107
|
+
tokenizer = self._tokenizer
|
|
108
|
+
try:
|
|
109
|
+
encoded = tokenizer(
|
|
110
|
+
texts,
|
|
111
|
+
padding=True,
|
|
112
|
+
truncation=True,
|
|
113
|
+
max_length=self.max_tokens,
|
|
114
|
+
return_tensors="np",
|
|
115
|
+
)
|
|
116
|
+
input_ids = np.asarray(encoded["input_ids"], dtype=np.int32)
|
|
117
|
+
if "attention_mask" in encoded:
|
|
118
|
+
attention_mask = np.asarray(encoded["attention_mask"], dtype=np.int32)
|
|
119
|
+
else:
|
|
120
|
+
attention_mask = np.ones_like(input_ids, dtype=np.int32)
|
|
121
|
+
return input_ids, attention_mask
|
|
122
|
+
except TypeError:
|
|
123
|
+
pass
|
|
124
|
+
|
|
125
|
+
encode = getattr(tokenizer, "encode", None)
|
|
126
|
+
if not callable(encode):
|
|
127
|
+
raise RuntimeError("MLX tokenizer does not support batch calls or encode()")
|
|
128
|
+
|
|
129
|
+
pad_id = getattr(tokenizer, "pad_token_id", None)
|
|
130
|
+
if pad_id is None:
|
|
131
|
+
pad_id = getattr(tokenizer, "eos_token_id", 0)
|
|
132
|
+
rows: list[list[int]] = []
|
|
133
|
+
for text in texts:
|
|
134
|
+
token_ids = list(encode(text))
|
|
135
|
+
rows.append(token_ids[: self.max_tokens])
|
|
136
|
+
|
|
137
|
+
width = max(len(row) for row in rows)
|
|
138
|
+
input_ids = np.full((len(rows), width), int(pad_id or 0), dtype=np.int32)
|
|
139
|
+
attention_mask = np.zeros((len(rows), width), dtype=np.int32)
|
|
140
|
+
for index, row in enumerate(rows):
|
|
141
|
+
input_ids[index, : len(row)] = row
|
|
142
|
+
attention_mask[index, : len(row)] = 1
|
|
143
|
+
return input_ids, attention_mask
|
|
144
|
+
|
|
145
|
+
def _call_model(self, input_ids: Any, attention_mask: Any) -> Any:
|
|
146
|
+
try:
|
|
147
|
+
outputs = self._model(
|
|
148
|
+
input_ids,
|
|
149
|
+
attention_mask=attention_mask,
|
|
150
|
+
output_hidden_states=True,
|
|
151
|
+
)
|
|
152
|
+
except TypeError:
|
|
153
|
+
try:
|
|
154
|
+
outputs = self._model(input_ids, attention_mask=attention_mask)
|
|
155
|
+
except TypeError:
|
|
156
|
+
outputs = self._model(input_ids)
|
|
157
|
+
|
|
158
|
+
eval_fn = getattr(self._mx, "eval", None)
|
|
159
|
+
if callable(eval_fn):
|
|
160
|
+
try:
|
|
161
|
+
eval_fn(outputs)
|
|
162
|
+
except TypeError:
|
|
163
|
+
pass
|
|
164
|
+
return outputs
|
|
165
|
+
|
|
166
|
+
def _select_vectors(self, outputs: Any, attention_mask: NDArray[Any]) -> NDArray[Any]:
|
|
167
|
+
arrays = self._collect_named_arrays(outputs)
|
|
168
|
+
preferred_names = ("sentence", "embedding", "pooler")
|
|
169
|
+
for token in preferred_names:
|
|
170
|
+
for name, array in arrays:
|
|
171
|
+
if token in name.lower() and array.ndim == 2:
|
|
172
|
+
self._reject_logits(array)
|
|
173
|
+
return array
|
|
174
|
+
for _name, array in arrays:
|
|
175
|
+
if array.ndim == 2:
|
|
176
|
+
self._reject_logits(array)
|
|
177
|
+
return array
|
|
178
|
+
for token in ("last_hidden", "hidden", "token"):
|
|
179
|
+
for name, array in arrays:
|
|
180
|
+
if token in name.lower() and array.ndim == 3:
|
|
181
|
+
self._reject_logits(array)
|
|
182
|
+
return self._mean_pool(array, attention_mask)
|
|
183
|
+
for _name, array in arrays:
|
|
184
|
+
if array.ndim == 3:
|
|
185
|
+
self._reject_logits(array)
|
|
186
|
+
return self._mean_pool(array, attention_mask)
|
|
187
|
+
|
|
188
|
+
shapes = [(name, tuple(array.shape)) for name, array in arrays]
|
|
189
|
+
raise RuntimeError(f"could not select embedding output from MLX shapes {shapes}")
|
|
190
|
+
|
|
191
|
+
def _collect_named_arrays(self, value: Any) -> list[tuple[str, NDArray[Any]]]:
|
|
192
|
+
names = (
|
|
193
|
+
"sentence_embedding",
|
|
194
|
+
"embeddings",
|
|
195
|
+
"pooler_output",
|
|
196
|
+
"last_hidden_state",
|
|
197
|
+
"hidden_states",
|
|
198
|
+
"logits",
|
|
199
|
+
)
|
|
200
|
+
arrays: list[tuple[str, NDArray[Any]]] = []
|
|
201
|
+
|
|
202
|
+
if isinstance(value, dict):
|
|
203
|
+
for name, item in value.items():
|
|
204
|
+
arrays.extend(
|
|
205
|
+
(f"{name}.{child}", array)
|
|
206
|
+
for child, array in self._collect_named_arrays(item)
|
|
207
|
+
)
|
|
208
|
+
return arrays
|
|
209
|
+
|
|
210
|
+
for name in names:
|
|
211
|
+
if hasattr(value, name):
|
|
212
|
+
arrays.extend(
|
|
213
|
+
(f"{name}.{child}", array)
|
|
214
|
+
for child, array in self._collect_named_arrays(getattr(value, name))
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
if isinstance(value, (list, tuple)):
|
|
218
|
+
for index, item in enumerate(value):
|
|
219
|
+
arrays.extend(
|
|
220
|
+
(f"{index}.{child}", array)
|
|
221
|
+
for child, array in self._collect_named_arrays(item)
|
|
222
|
+
)
|
|
223
|
+
return arrays
|
|
224
|
+
|
|
225
|
+
array = self._as_numpy(value)
|
|
226
|
+
if array is not None:
|
|
227
|
+
arrays.append(("output", array))
|
|
228
|
+
return arrays
|
|
229
|
+
|
|
230
|
+
@staticmethod
|
|
231
|
+
def _as_numpy(value: Any) -> NDArray[Any] | None:
|
|
232
|
+
if not hasattr(value, "shape"):
|
|
233
|
+
return None
|
|
234
|
+
try:
|
|
235
|
+
return cast(NDArray[Any], np.asarray(value))
|
|
236
|
+
except Exception:
|
|
237
|
+
return None
|
|
238
|
+
|
|
239
|
+
def _reject_logits(self, array: NDArray[Any]) -> None:
|
|
240
|
+
vocab_size = getattr(self._tokenizer, "vocab_size", None)
|
|
241
|
+
if vocab_size is not None and array.shape[-1] == int(vocab_size):
|
|
242
|
+
raise RuntimeError(
|
|
243
|
+
"MLX model returned token logits, not embeddings; use an MLX embedding model"
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
@staticmethod
|
|
247
|
+
def _mean_pool(hidden: NDArray[Any], attention_mask: NDArray[Any]) -> NDArray[Any]:
|
|
248
|
+
mask = attention_mask.astype(np.float32)[:, :, None]
|
|
249
|
+
denom = np.clip(mask.sum(axis=1), 1e-9, None)
|
|
250
|
+
return cast(NDArray[Any], (hidden.astype(np.float32) * mask).sum(axis=1) / denom)
|
|
251
|
+
|
|
252
|
+
def _postprocess_vectors(self, vectors: NDArray[Any]) -> NDArray[Any]:
|
|
253
|
+
out = vectors.astype(np.float32, copy=False)
|
|
254
|
+
if self._normalize:
|
|
255
|
+
norms = np.linalg.norm(out, axis=1, keepdims=True)
|
|
256
|
+
out = out / np.clip(norms, 1e-12, None)
|
|
257
|
+
return cast(NDArray[Any], out)
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright (c) 2026 Jason Perlow
|
|
3
|
+
"""NVIDIA CUDA adapter backed by ONNX Runtime."""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import importlib
|
|
7
|
+
import logging
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, cast
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
from numpy.typing import NDArray
|
|
13
|
+
|
|
14
|
+
from .base import AbstractAdapter
|
|
15
|
+
|
|
16
|
+
log = logging.getLogger("embedkit.adapter.nvidia_cuda")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class NvidiaCUDAAdapter(AbstractAdapter):
|
|
20
|
+
"""Embedding adapter for ONNX models through ONNX Runtime CUDA EP."""
|
|
21
|
+
|
|
22
|
+
name = "nvidia-cuda"
|
|
23
|
+
tier = "gpu"
|
|
24
|
+
model_format = "onnx"
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
model_path: str,
|
|
29
|
+
*,
|
|
30
|
+
tokenizer_path: str | None = None,
|
|
31
|
+
max_tokens: int = 512,
|
|
32
|
+
normalize: bool = True,
|
|
33
|
+
provider_options: list[dict[str, Any]] | None = None,
|
|
34
|
+
**kwargs: Any,
|
|
35
|
+
) -> None:
|
|
36
|
+
super().__init__(model_path, **kwargs)
|
|
37
|
+
ort = importlib.import_module("onnxruntime")
|
|
38
|
+
transformers = importlib.import_module("transformers")
|
|
39
|
+
|
|
40
|
+
providers = ["CUDAExecutionProvider", "CPUExecutionProvider"]
|
|
41
|
+
available = ort.get_available_providers()
|
|
42
|
+
if "CUDAExecutionProvider" not in available:
|
|
43
|
+
raise RuntimeError(f"onnxruntime CUDA EP unavailable (providers: {available})")
|
|
44
|
+
|
|
45
|
+
session_options = kwargs.get("session_options")
|
|
46
|
+
self._session: Any | None = ort.InferenceSession(
|
|
47
|
+
model_path,
|
|
48
|
+
sess_options=session_options,
|
|
49
|
+
providers=providers,
|
|
50
|
+
provider_options=provider_options,
|
|
51
|
+
)
|
|
52
|
+
self._tokenizer: Any | None = transformers.AutoTokenizer.from_pretrained(
|
|
53
|
+
str(self._resolve_tokenizer_path(model_path, tokenizer_path))
|
|
54
|
+
)
|
|
55
|
+
self.max_tokens = max_tokens
|
|
56
|
+
self._normalize = normalize
|
|
57
|
+
self._input_meta = list(self._session.get_inputs())
|
|
58
|
+
self._output_names = [output.name for output in self._session.get_outputs()]
|
|
59
|
+
|
|
60
|
+
sample = self.embed("warmup")
|
|
61
|
+
self.embed_dim = len(sample)
|
|
62
|
+
|
|
63
|
+
@classmethod
|
|
64
|
+
def is_available(cls) -> tuple[bool, str]:
|
|
65
|
+
try:
|
|
66
|
+
ort = importlib.import_module("onnxruntime")
|
|
67
|
+
providers = ort.get_available_providers()
|
|
68
|
+
except ImportError as exc:
|
|
69
|
+
return False, f"onnxruntime not installed: {exc}"
|
|
70
|
+
except Exception as exc:
|
|
71
|
+
return False, f"onnxruntime probe failed: {exc}"
|
|
72
|
+
|
|
73
|
+
if "CUDAExecutionProvider" in providers:
|
|
74
|
+
return True, "onnxruntime CUDA EP present"
|
|
75
|
+
return False, f"onnxruntime found but no CUDA EP (providers: {providers})"
|
|
76
|
+
|
|
77
|
+
def embed(self, text: str) -> list[float]:
|
|
78
|
+
return self.embed_batch([text])[0]
|
|
79
|
+
|
|
80
|
+
def embed_batch(self, texts: list[str]) -> list[list[float]]:
|
|
81
|
+
if not texts:
|
|
82
|
+
return []
|
|
83
|
+
if self._session is None or self._tokenizer is None:
|
|
84
|
+
raise RuntimeError("nvidia-cuda adapter is closed")
|
|
85
|
+
|
|
86
|
+
encoded = self._tokenizer(
|
|
87
|
+
texts,
|
|
88
|
+
padding=True,
|
|
89
|
+
truncation=True,
|
|
90
|
+
max_length=self.max_tokens,
|
|
91
|
+
return_tensors="np",
|
|
92
|
+
)
|
|
93
|
+
feeds = self._build_feeds(encoded)
|
|
94
|
+
outputs = self._session.run(None, feeds)
|
|
95
|
+
vectors = self._select_vectors(outputs, encoded.get("attention_mask"))
|
|
96
|
+
vectors = self._postprocess_vectors(vectors)
|
|
97
|
+
return [[float(value) for value in row] for row in vectors.tolist()]
|
|
98
|
+
|
|
99
|
+
def warmup(self) -> None:
|
|
100
|
+
self.embed_batch(["warmup", "test", "sample"])
|
|
101
|
+
|
|
102
|
+
def close(self) -> None:
|
|
103
|
+
self._session = None
|
|
104
|
+
self._tokenizer = None
|
|
105
|
+
|
|
106
|
+
@staticmethod
|
|
107
|
+
def _resolve_tokenizer_path(model_path: str, tokenizer_path: str | None) -> Path:
|
|
108
|
+
if tokenizer_path is not None:
|
|
109
|
+
return Path(tokenizer_path)
|
|
110
|
+
path = Path(model_path)
|
|
111
|
+
return path if path.is_dir() else path.parent
|
|
112
|
+
|
|
113
|
+
def _build_feeds(self, encoded: dict[str, Any]) -> dict[str, NDArray[Any]]:
|
|
114
|
+
batch = int(np.asarray(encoded["input_ids"]).shape[0])
|
|
115
|
+
seq_len = int(np.asarray(encoded["input_ids"]).shape[1])
|
|
116
|
+
feeds: dict[str, NDArray[Any]] = {}
|
|
117
|
+
|
|
118
|
+
for meta in self._input_meta:
|
|
119
|
+
name = meta.name
|
|
120
|
+
dtype = np.int32 if "int32" in str(meta.type) else np.int64
|
|
121
|
+
if name in encoded:
|
|
122
|
+
feeds[name] = np.asarray(encoded[name], dtype=dtype)
|
|
123
|
+
elif name == "token_type_ids":
|
|
124
|
+
feeds[name] = np.zeros((batch, seq_len), dtype=dtype)
|
|
125
|
+
elif name == "position_ids":
|
|
126
|
+
positions = np.arange(seq_len, dtype=dtype)
|
|
127
|
+
feeds[name] = np.broadcast_to(positions, (batch, seq_len)).copy()
|
|
128
|
+
else:
|
|
129
|
+
raise RuntimeError(f"ONNX model requires unsupported input '{name}'")
|
|
130
|
+
|
|
131
|
+
return feeds
|
|
132
|
+
|
|
133
|
+
def _select_vectors(self, outputs: list[Any], attention_mask: Any) -> NDArray[Any]:
|
|
134
|
+
named = [
|
|
135
|
+
(name, np.asarray(value))
|
|
136
|
+
for name, value in zip(self._output_names, outputs, strict=False)
|
|
137
|
+
]
|
|
138
|
+
preferred_names = ("sentence", "embedding", "pooler")
|
|
139
|
+
|
|
140
|
+
for token in preferred_names:
|
|
141
|
+
for name, array in named:
|
|
142
|
+
if token in name.lower() and array.ndim == 2:
|
|
143
|
+
return array
|
|
144
|
+
for _name, array in named:
|
|
145
|
+
if array.ndim == 2:
|
|
146
|
+
return array
|
|
147
|
+
for token in ("last_hidden", "hidden", "token"):
|
|
148
|
+
for name, array in named:
|
|
149
|
+
if token in name.lower() and array.ndim == 3:
|
|
150
|
+
return self._mean_pool(array, attention_mask)
|
|
151
|
+
for _name, array in named:
|
|
152
|
+
if array.ndim == 3:
|
|
153
|
+
return self._mean_pool(array, attention_mask)
|
|
154
|
+
|
|
155
|
+
shapes = [tuple(array.shape) for _name, array in named]
|
|
156
|
+
raise RuntimeError(f"could not select embedding output from ONNX shapes {shapes}")
|
|
157
|
+
|
|
158
|
+
@staticmethod
|
|
159
|
+
def _mean_pool(hidden: NDArray[Any], attention_mask: Any) -> NDArray[Any]:
|
|
160
|
+
if attention_mask is None:
|
|
161
|
+
return cast(NDArray[Any], hidden.mean(axis=1))
|
|
162
|
+
mask = np.asarray(attention_mask, dtype=np.float32)[:, :, None]
|
|
163
|
+
denom = np.clip(mask.sum(axis=1), 1e-9, None)
|
|
164
|
+
return cast(NDArray[Any], (hidden.astype(np.float32) * mask).sum(axis=1) / denom)
|
|
165
|
+
|
|
166
|
+
def _postprocess_vectors(self, vectors: NDArray[Any]) -> NDArray[Any]:
|
|
167
|
+
out = vectors.astype(np.float32, copy=False)
|
|
168
|
+
if self._normalize:
|
|
169
|
+
norms = np.linalg.norm(out, axis=1, keepdims=True)
|
|
170
|
+
out = out / np.clip(norms, 1e-12, None)
|
|
171
|
+
return cast(NDArray[Any], out)
|