znaat commited on
Commit
dcff69c
·
verified ·
1 Parent(s): a066e5c

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}")