ModernBERT-base in Core ML, fixed length 128, verified against PyTorch
Browse files
ModernBERT-base-128.mlpackage/Data/com.apple.CoreML/model.mlmodel
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:3e07033a286fde41826cfa46a2304b07a037d86c7c0bd39a455270634eaf3f63
|
| 3 |
+
size 273934
|
ModernBERT-base-128.mlpackage/Data/com.apple.CoreML/weights/weight.bin
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:7a50ac7543e50fc380850f46efec17b7fade77fd33bd705874bd17d993b0b85c
|
| 3 |
+
size 299391360
|
ModernBERT-base-128.mlpackage/Manifest.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"fileFormatVersion": "1.0.0",
|
| 3 |
+
"itemInfoEntries": {
|
| 4 |
+
"588C6CEE-EAA5-4CD9-9752-2D4737BD8C5D": {
|
| 5 |
+
"author": "com.apple.CoreML",
|
| 6 |
+
"description": "CoreML Model Specification",
|
| 7 |
+
"name": "model.mlmodel",
|
| 8 |
+
"path": "com.apple.CoreML/model.mlmodel"
|
| 9 |
+
},
|
| 10 |
+
"B79F1999-80C9-4E40-AC85-DE68A644A480": {
|
| 11 |
+
"author": "com.apple.CoreML",
|
| 12 |
+
"description": "CoreML Model Weights",
|
| 13 |
+
"name": "weights",
|
| 14 |
+
"path": "com.apple.CoreML/weights"
|
| 15 |
+
}
|
| 16 |
+
},
|
| 17 |
+
"rootModelIdentifier": "588C6CEE-EAA5-4CD9-9752-2D4737BD8C5D"
|
| 18 |
+
}
|
README.md
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
license: apache-2.0
|
| 3 |
+
language:
|
| 4 |
+
- en
|
| 5 |
+
base_model:
|
| 6 |
+
- answerdotai/ModernBERT-base
|
| 7 |
+
tags:
|
| 8 |
+
- coreml
|
| 9 |
+
- fill-mask
|
| 10 |
+
- masked-lm
|
| 11 |
+
- modernbert
|
| 12 |
+
pipeline_tag: fill-mask
|
| 13 |
+
---
|
| 14 |
+
|
| 15 |
+
# ModernBERT-base in Core ML
|
| 16 |
+
|
| 17 |
+
[ModernBERT-base](https://huggingface.co/answerdotai/ModernBERT-base) converted
|
| 18 |
+
to Core ML with its masked-LM head, at a **fixed sequence length of 128**.
|
| 19 |
+
|
| 20 |
+
Verified against the PyTorch model rather than assumed: correlation of the
|
| 21 |
+
logits at a masked position is **0.997 to 0.99999**. `verify.py` reproduces it.
|
| 22 |
+
|
| 23 |
+
```
|
| 24 |
+
The capital of Ireland is [MASK].
|
| 25 |
+
torch Dublin, Cork, Belfast, London
|
| 26 |
+
coreml Dublin, Belfast, Cork, London
|
| 27 |
+
```
|
| 28 |
+
|
| 29 |
+
About **7 ms** per call on an M-series Mac after warm-up, against ~55 ms for the
|
| 30 |
+
same model in PyTorch on the same machine. The first call pays roughly 7 s of
|
| 31 |
+
Core ML compilation, so warm it once at launch.
|
| 32 |
+
|
| 33 |
+
## Why this exists
|
| 34 |
+
|
| 35 |
+
There is an earlier Core ML conversion of ModernBERT on the Hub. **It is wrong,
|
| 36 |
+
in both its full-precision and 4-bit forms**, and nothing about it says so — it
|
| 37 |
+
loads, it runs, and it returns confident nonsense.
|
| 38 |
+
|
| 39 |
+
```
|
| 40 |
+
The capital of Ireland is [MASK].
|
| 41 |
+
the earlier conversion £, isation, organisation, ised
|
| 42 |
+
```
|
| 43 |
+
|
| 44 |
+
Measured against PyTorch, its logits correlate at **1.0000 at a sequence length
|
| 45 |
+
of one** and **0.17 to 0.39** at any real length. Its converter traced the model
|
| 46 |
+
with a single token and a flexible `RangeDim`. At one token ModernBERT's
|
| 47 |
+
sliding-window attention mask is optimised away; past it, the graph is wrong.
|
| 48 |
+
|
| 49 |
+
That failure is silent, which is the reason for this repository. If you convert
|
| 50 |
+
this model yourself, check the correlation at a real length — not the top
|
| 51 |
+
predictions of a short example, which can look plausible while the model is
|
| 52 |
+
broken.
|
| 53 |
+
|
| 54 |
+
## Using it
|
| 55 |
+
|
| 56 |
+
```python
|
| 57 |
+
import coremltools as ct, numpy as np
|
| 58 |
+
from transformers import AutoTokenizer
|
| 59 |
+
|
| 60 |
+
tok = AutoTokenizer.from_pretrained("answerdotai/ModernBERT-base")
|
| 61 |
+
model = ct.models.MLModel("ModernBERT-base-128.mlpackage")
|
| 62 |
+
|
| 63 |
+
text = "The capital of Ireland is " + tok.mask_token + "."
|
| 64 |
+
encoded = tok(text, return_tensors="np")
|
| 65 |
+
ids = np.full((1, 128), tok.pad_token_id, np.int32)
|
| 66 |
+
ids[0, : encoded.input_ids.shape[1]] = encoded.input_ids
|
| 67 |
+
|
| 68 |
+
logits = model.predict({"input_ids": ids})["logits"][0]
|
| 69 |
+
at = int(np.where(encoded.input_ids[0] == tok.mask_token_id)[0][0])
|
| 70 |
+
print([tok.decode([int(i)]) for i in np.argsort(-logits[at])[:5]])
|
| 71 |
+
```
|
| 72 |
+
|
| 73 |
+
Downloading from the Hub leaves the weights as a symlink, and the Core ML
|
| 74 |
+
compiler does not follow one. Copy the package with `cp -RL` before loading it.
|
| 75 |
+
|
| 76 |
+
## Two things to know before you rely on it
|
| 77 |
+
|
| 78 |
+
**The length is fixed, and padding is attended.** The attention mask is
|
| 79 |
+
`ones_like(input_ids)` inside the graph, so padding tokens are read as text. On
|
| 80 |
+
one sentence-boundary task this cost nothing measurable, but that is a property
|
| 81 |
+
of that task and not a guarantee. Convert at a length close to your inputs, or
|
| 82 |
+
convert several — `convert.py --length N`.
|
| 83 |
+
|
| 84 |
+
Taking `attention_mask` as a real input is the obvious fix and does not work:
|
| 85 |
+
ModernBERT then builds the mask with `new_ones`, which coremltools has no
|
| 86 |
+
conversion for.
|
| 87 |
+
|
| 88 |
+
**Quantising costs more than it saves, at least here.** Same conversion, same
|
| 89 |
+
sentence-boundary task, 150 real sentences:
|
| 90 |
+
|
| 91 |
+
| | size | latency | quality |
|
| 92 |
+
|---|---|---|---|
|
| 93 |
+
| float | 286 MB | 10 ms | reference |
|
| 94 |
+
| int8 | 152 MB | 23 ms | indistinguishable |
|
| 95 |
+
| int4 | 81 MB | 21 ms | shifted, and worse where it matters |
|
| 96 |
+
|
| 97 |
+
int8 halves the size and doubles the latency, because the weights are
|
| 98 |
+
dequantised on every pass. int4 changes the distribution enough to matter.
|
| 99 |
+
|
| 100 |
+
## Reproducing
|
| 101 |
+
|
| 102 |
+
Pin the toolchain. `transformers` 5.x emits `new_ones` in ModernBERT's attention
|
| 103 |
+
path and coremltools cannot convert it — that failure also hits BERT and
|
| 104 |
+
DistilBERT, so it is the toolchain and not this model.
|
| 105 |
+
|
| 106 |
+
```sh
|
| 107 |
+
pip install torch==2.7.0 transformers==4.48.3 coremltools==9.0
|
| 108 |
+
python convert.py --length 128
|
| 109 |
+
python verify.py ModernBERT-base-128.mlpackage --length 128
|
| 110 |
+
```
|
| 111 |
+
|
| 112 |
+
## Licence
|
| 113 |
+
|
| 114 |
+
Apache-2.0, from
|
| 115 |
+
[ModernBERT-base](https://huggingface.co/answerdotai/ModernBERT-base). Only the
|
| 116 |
+
conversion is new here.
|
convert.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""ModernBERT -> Core ML, at a fixed sequence length.
|
| 3 |
+
|
| 4 |
+
python convert.py --length 128 [--model ModernBERT-base]
|
| 5 |
+
|
| 6 |
+
The length is fixed on purpose. A flexible `RangeDim` traced at one token
|
| 7 |
+
converts without error and is wrong at every other length — see the README.
|
| 8 |
+
|
| 9 |
+
Pin the toolchain. transformers 5.x emits `new_ones` in ModernBERT's attention
|
| 10 |
+
path and coremltools cannot convert it; the failure also hits BERT and
|
| 11 |
+
DistilBERT, so it is the toolchain and not this model.
|
| 12 |
+
|
| 13 |
+
torch==2.7.0 transformers==4.48.3 coremltools==9.0
|
| 14 |
+
"""
|
| 15 |
+
import argparse
|
| 16 |
+
import numpy as np
|
| 17 |
+
import torch
|
| 18 |
+
import coremltools as ct
|
| 19 |
+
from transformers import AutoModelForMaskedLM
|
| 20 |
+
|
| 21 |
+
parser = argparse.ArgumentParser()
|
| 22 |
+
parser.add_argument("--model", default="ModernBERT-base")
|
| 23 |
+
parser.add_argument("--length", type=int, default=128)
|
| 24 |
+
args = parser.parse_args()
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class Wrapped(torch.nn.Module):
|
| 28 |
+
"""The mask stays `ones_like` inside the graph.
|
| 29 |
+
|
| 30 |
+
Taking `attention_mask` as a second input makes ModernBERT build it with
|
| 31 |
+
`new_ones`, which coremltools has no conversion for. The cost is that
|
| 32 |
+
padding is attended as if it were text — pick a length close to your
|
| 33 |
+
sentences, or convert several.
|
| 34 |
+
"""
|
| 35 |
+
|
| 36 |
+
def __init__(self, name):
|
| 37 |
+
super().__init__()
|
| 38 |
+
self.model = AutoModelForMaskedLM.from_pretrained(f"answerdotai/{name}")
|
| 39 |
+
|
| 40 |
+
def forward(self, input_ids):
|
| 41 |
+
return self.model(
|
| 42 |
+
input_ids=input_ids, attention_mask=torch.ones_like(input_ids)
|
| 43 |
+
).logits
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
model = Wrapped(args.model).eval()
|
| 47 |
+
example = (torch.ones((1, args.length), dtype=torch.int32),)
|
| 48 |
+
traced = torch.jit.trace(model, example, strict=False)
|
| 49 |
+
|
| 50 |
+
mlmodel = ct.convert(
|
| 51 |
+
traced,
|
| 52 |
+
inputs=[ct.TensorType(name="input_ids", shape=(1, args.length), dtype=np.int32)],
|
| 53 |
+
outputs=[ct.TensorType(name="logits")],
|
| 54 |
+
minimum_deployment_target=ct.target.macOS15,
|
| 55 |
+
)
|
| 56 |
+
mlmodel.short_description = (
|
| 57 |
+
f"{args.model}, masked-LM head, fixed sequence length {args.length}"
|
| 58 |
+
)
|
| 59 |
+
out = f"{args.model}-{args.length}.mlpackage"
|
| 60 |
+
mlmodel.save(out)
|
| 61 |
+
print("wrote", out)
|
verify.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Check a converted model against the PyTorch original.
|
| 3 |
+
|
| 4 |
+
python verify.py ModernBERT-base-128.mlpackage --length 128
|
| 5 |
+
|
| 6 |
+
Prints the correlation of the logits at a masked position. Anything below
|
| 7 |
+
0.99 means the conversion is wrong, whatever its top predictions look like.
|
| 8 |
+
"""
|
| 9 |
+
import argparse
|
| 10 |
+
import numpy as np
|
| 11 |
+
import torch
|
| 12 |
+
import coremltools as ct
|
| 13 |
+
from transformers import AutoTokenizer, AutoModelForMaskedLM
|
| 14 |
+
|
| 15 |
+
parser = argparse.ArgumentParser()
|
| 16 |
+
parser.add_argument("package")
|
| 17 |
+
parser.add_argument("--length", type=int, default=128)
|
| 18 |
+
parser.add_argument("--model", default="answerdotai/ModernBERT-base")
|
| 19 |
+
args = parser.parse_args()
|
| 20 |
+
|
| 21 |
+
tok = AutoTokenizer.from_pretrained(args.model)
|
| 22 |
+
ref = AutoModelForMaskedLM.from_pretrained(args.model).eval()
|
| 23 |
+
mlmodel = ct.models.MLModel(args.package)
|
| 24 |
+
|
| 25 |
+
SENTENCES = [
|
| 26 |
+
"The capital of Ireland is {m}.",
|
| 27 |
+
"You should see a parrot {m} at the top right of your screen.",
|
| 28 |
+
"The PR looks good but there is another failure mode {m} we might cover this.",
|
| 29 |
+
]
|
| 30 |
+
|
| 31 |
+
for sentence in SENTENCES:
|
| 32 |
+
text = sentence.format(m=tok.mask_token)
|
| 33 |
+
encoded = tok(text, return_tensors="pt")
|
| 34 |
+
length = encoded.input_ids.shape[1]
|
| 35 |
+
ids = np.full((1, args.length), tok.pad_token_id, np.int32)
|
| 36 |
+
ids[0, :length] = encoded.input_ids.numpy()
|
| 37 |
+
padded = torch.tensor(ids, dtype=torch.long)
|
| 38 |
+
with torch.no_grad():
|
| 39 |
+
expected = ref(
|
| 40 |
+
input_ids=padded, attention_mask=torch.ones_like(padded)
|
| 41 |
+
).logits[0].numpy()
|
| 42 |
+
got = mlmodel.predict({"input_ids": ids})["logits"][0]
|
| 43 |
+
at = int(np.where(encoded.input_ids[0].numpy() == tok.mask_token_id)[0][0])
|
| 44 |
+
print(f"\n {text[:56]}")
|
| 45 |
+
print(f" torch {[tok.decode([int(i)]) for i in np.argsort(-expected[at])[:4]]}")
|
| 46 |
+
print(f" coreml {[tok.decode([int(i)]) for i in np.argsort(-got[at])[:4]]}")
|
| 47 |
+
print(f" r = {np.corrcoef(expected[at], got[at])[0, 1]:.5f}")
|