Skip to content

Commit 2382bb2

Browse files
authored
Enable running iOS and macOS authored models on CUDA GPUs (#99)
* authoring and relevant changes for evals Signed-off-by: Prathamesh Mandke <46148373+pkmandke@users.noreply.github.com> * fix code format Signed-off-by: Prathamesh Mandke <46148373+pkmandke@users.noreply.github.com> --------- Signed-off-by: Prathamesh Mandke <46148373+pkmandke@users.noreply.github.com>
1 parent 85e2f2d commit 2382bb2

10 files changed

Lines changed: 147 additions & 28 deletions

File tree

python/src/coreai_models/export/pipeline.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ class ExportConfig:
6262
output_name: str | None = None
6363
num_layers: int | None = None
6464
overwrite: bool = False
65+
# iOS only. When True, embedding table is not quantized to int8.
66+
disable_embedding_quantization: bool = False
6567
# Optional prebuilt coreai-opt config (KMeansPalettizerConfig or
6668
# QuantizerConfig) loaded from a user-provided YAML. When set, the pipeline
6769
# uses this directly and ignores `compression` for config resolution
@@ -210,6 +212,7 @@ async def _async_export_model(config: ExportConfig) -> str:
210212
max_context_length=max_context_length,
211213
target_dtype=target_dtype,
212214
num_layers=config.num_layers,
215+
disable_embedding_quantization=config.disable_embedding_quantization,
213216
)
214217
model = model.eval()
215218
# ---- 3. Resolve compression preset ----

python/src/coreai_models/llm/export.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,15 @@ def build_parser() -> argparse.ArgumentParser:
144144
action="store_true",
145145
help="Allow exporting models without a registry preset. Requires --compute-precision.",
146146
)
147+
parser.add_argument(
148+
"--disable-embedding-quantization-ios",
149+
action="store_true",
150+
help=(
151+
"iOS only. Skip int8 quantization of the embedding table and keep it in "
152+
"float32. Default: False (embedding is quantized). Rejected when "
153+
"--platform is macOS."
154+
),
155+
)
147156
return parser
148157

149158

@@ -314,6 +323,11 @@ def _resolve_export_config(args: argparse.Namespace) -> ExportConfig:
314323
"different quantization options and kv-cache limits."
315324
)
316325

326+
if args.disable_embedding_quantization_ios and variant != "iOS":
327+
raise SystemExit(
328+
f"--disable-embedding-quantization-ios requires --platform iOS (got '{variant}')."
329+
)
330+
317331
if args.compression_config is not None:
318332
if not args.compression_config.is_file():
319333
raise SystemExit(f"--compression-config: file not found: {args.compression_config}")
@@ -350,6 +364,7 @@ def _resolve_export_config(args: argparse.Namespace) -> ExportConfig:
350364
num_layers=args.num_layers,
351365
overwrite=args.overwrite,
352366
compression_config_object=compression_config_object,
367+
disable_embedding_quantization=args.disable_embedding_quantization_ios,
353368
)
354369

355370

@@ -413,6 +428,8 @@ def main() -> None:
413428
if config.num_layers:
414429
print(f" num_layers: {config.num_layers}")
415430
print(f" overwrite: {config.overwrite}")
431+
if config.variant == "iOS":
432+
print(f" disable_embedding_quantization: {config.disable_embedding_quantization}")
416433
return
417434

418435
result = export_model(config)

python/src/coreai_models/models/base.py

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,7 @@ def from_hf(
340340
target_dtype: torch.dtype = torch.float16,
341341
mmap_path: str | None = None,
342342
num_layers: int | None = None,
343+
disable_embedding_quantization: bool = False,
343344
) -> T:
344345
"""Load model from HuggingFace model hub.
345346
@@ -353,6 +354,9 @@ def from_hf(
353354
num_layers: Optional number of transformer layers. When set, only layers
354355
0..num_layers-1 are loaded and the config is truncated.
355356
Useful for fast smoke tests.
357+
disable_embedding_quantization: iOS only. When True, the
358+
embedding table is not quantized to int8.
359+
Ignored for macOS model classes.
356360
357361
Returns:
358362
Instance of the model class loaded with HuggingFace weights
@@ -370,8 +374,12 @@ def from_hf(
370374
hf_model.config, max_context_length, num_layers=num_layers
371375
)
372376

373-
# Create our model instance and load the state dict
374-
model = cls(config, model_device="meta")
377+
# Create our model instance and load the state dict.
378+
# disable_embedding_quantization is only accepted by the iOS base class.
379+
init_kwargs: dict = {"config": config, "model_device": "meta"}
380+
if issubclass(cls, BaseForCausalLMForiOS):
381+
init_kwargs["disable_embedding_quantization"] = disable_embedding_quantization
382+
model = cls(**init_kwargs)
375383
model.to(dtype=target_dtype)
376384
state_dict = hf_model.state_dict()
377385
if not isinstance(state_dict, collections.abc.MutableMapping):
@@ -414,6 +422,7 @@ def from_hf_memory_efficient(
414422
num_layers: int | None = None,
415423
hf_config_attr: str | None = None,
416424
hf_state_dict_prefix: str = "",
425+
disable_embedding_quantization: bool = False,
417426
) -> T:
418427
"""Load model from HuggingFace with layer-by-layer memory offloading.
419428
@@ -439,6 +448,9 @@ def from_hf_memory_efficient(
439448
prefix are loaded. The prefix is stripped before assigning.
440449
Use for multimodal checkpoints where text weights live under
441450
a prefix (e.g. ``"language_model."``).
451+
disable_embedding_quantization: iOS only. When True, the
452+
embedding table is not quantized to int8.
453+
Ignored for non-iOS model classes.
442454
"""
443455
model_dir = snapshot_download(
444456
huggingface_model_id,
@@ -450,7 +462,11 @@ def from_hf_memory_efficient(
450462

451463
config = cls._get_reauthored_config(hf_config, max_context_length, num_layers=num_layers)
452464

453-
model = cls(config, model_device="meta")
465+
# disable_embedding_quantization is only accepted by the iOS base class.
466+
init_kwargs: dict = {"config": config, "model_device": "meta"}
467+
if issubclass(cls, BaseForCausalLMForiOS):
468+
init_kwargs["disable_embedding_quantization"] = disable_embedding_quantization
469+
model = cls(**init_kwargs)
454470
model.to(dtype=target_dtype)
455471

456472
safetensors_files = _resolve_safetensors_files(model_dir)

python/src/coreai_models/models/macos/mixtral.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,10 @@ def forward(self, x: torch.Tensor) -> torch.Tensor:
3838
active_experts_scores = torch.softmax(top_logits, dim=-1).to(x.dtype)
3939

4040
y_active_experts = self.switch_mlp(x, active_experts_indices)
41-
active_experts_scores = active_experts_scores.unsqueeze(-1)
41+
active_experts_scores = active_experts_scores.unsqueeze(-1).to(y_active_experts.device)
4242
y_active_experts_weighted_by_scores = y_active_experts * active_experts_scores
4343
y_active_experts_summary = torch.sum(y_active_experts_weighted_by_scores, dim=-2)
44-
return y_active_experts_summary.to(x.dtype)
44+
return y_active_experts_summary.to(device=x.device, dtype=x.dtype)
4545

4646

4747
class Attention(nn.Module):

python/src/coreai_models/primitives/ios/cache.py

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -95,15 +95,23 @@ def __init__(self: Self, n_layers: int, hidden_size: int):
9595
def gen_slice_args(
9696
self, layer_idx: int, offset: torch.IntTensor, num_token_updates: int
9797
) -> tuple[torch.Tensor, torch.Tensor]:
98-
layer_index = self._layer_indices[layer_idx]
99-
layer_index_end = self._layer_indices_end[layer_idx]
100-
begin = torch.cat([layer_index, self._zero, self._zero, self._zero, offset])
98+
layer_index = self._layer_indices[layer_idx].to(offset.device)
99+
layer_index_end = self._layer_indices_end[layer_idx].to(offset.device)
100+
begin = torch.cat(
101+
[
102+
layer_index,
103+
self._zero.to(offset.device),
104+
self._zero.to(offset.device),
105+
self._zero.to(offset.device),
106+
offset,
107+
]
108+
)
101109
end = torch.cat(
102110
[
103111
layer_index_end,
104-
self._one,
105-
self._hidden_size,
106-
self._one,
112+
self._one.to(offset.device),
113+
self._hidden_size.to(offset.device),
114+
self._one.to(offset.device),
107115
offset + num_token_updates,
108116
]
109117
)

python/src/coreai_models/primitives/ios/rope.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -72,17 +72,17 @@ def __init__(
7272
self._compute_sin_and_cos()
7373

7474
def _apply(self, fn):
75-
# the `.to()` function implicitly calls into this function,
76-
# and we should recompute the cos / sin rather then just do
75+
# The `.to()` function implicitly calls into this function,
76+
# and we should recompute the cos / sin rather than just do
7777
# a simple cast.
7878
super()._apply(fn)
79-
dummy = torch.tensor(0.0)
80-
transformed = fn(dummy)
81-
self._compute_sin_and_cos(transformed.dtype)
82-
83-
target_device = transformed.device
84-
self.cos_cached = self.cos_cached.to(device=target_device)
85-
self.sin_cached = self.sin_cached.to(device=target_device)
79+
# Read dtype/device from the buffer post-apply so device-only
80+
# and dtype-only .to(...) calls are both honored.
81+
new_dtype = self.cos_cached.dtype
82+
new_device = self.cos_cached.device
83+
self._compute_sin_and_cos(new_dtype)
84+
self.cos_cached = self.cos_cached.to(new_device)
85+
self.sin_cached = self.sin_cached.to(new_device)
8686
return self
8787

8888
def _compute_sin_and_cos(self, dtype: torch.dtype = torch.float32) -> None:

python/src/coreai_models/primitives/ios/sdpa.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,11 @@
33
# Use of this source code is governed by a BSD-3-clause license that can
44
# be found in the LICENSE file or at https://opensource.org/licenses/BSD-3-Clause
55

6+
import os
7+
68
import torch
79
import torch.nn as nn
10+
import torch.nn.functional as F
811

912

1013
class SDPA(nn.Module):
@@ -31,6 +34,7 @@ def __init__(
3134
if isinstance(scale, torch.Tensor)
3235
else nn.Buffer(torch.tensor(scale), persistent=False)
3336
)
37+
self._use_hf_impl = os.environ.get("USE_HF_IMPL", "").lower() == "true"
3438

3539
# Efficient implementation equivalent to the following:
3640
def forward(
@@ -52,6 +56,47 @@ def forward(
5256
torch.Tensor: Attention output with shape (batch_size, n_heads*head_dim, 1, seq_len)
5357
"""
5458

59+
# use FlashAttention to avoid
60+
# materializing the full attention score matrix.
61+
# Trim K/V from max_pos to seq_len (cache positions beyond seq_len
62+
# are zeros masked by -inf) so we can use is_causal=True, which is
63+
# required for the FlashAttention kernel.
64+
if query.is_cuda and self._use_hf_impl:
65+
B, _, _, S = query.shape
66+
n_heads = query.shape[1] // self.head_dim
67+
n_kv_heads = key.shape[1] // self.head_dim
68+
69+
# This path is currently prefill-only: it trims K/V to the first S positions
70+
# and relies on is_causal=True. In prefill every valid KV position
71+
# lies within [0, S)- a valid (unmasked, == 0) entry at KV index
72+
# >= S means this is an extend/decode call, which the trim and
73+
# is_causal=True below would silently mishandle.
74+
assert not (causal_mask[:, S:] == 0).any(), (
75+
"CUDA/HF SDPA path is prefill-only. Got a causal_mask with "
76+
"valid KV positions beyond query length S (extend/decode not "
77+
"supported)."
78+
)
79+
80+
q = query.reshape(B, n_heads, self.head_dim, S).transpose(2, 3).contiguous()
81+
k = key[..., :S].reshape(B, n_kv_heads, self.head_dim, S).transpose(2, 3).contiguous()
82+
v = value[..., :S].reshape(B, n_kv_heads, self.head_dim, S).transpose(2, 3).contiguous()
83+
84+
out = F.scaled_dot_product_attention(
85+
q,
86+
k,
87+
v,
88+
# is_causal=True is required by the FlashAttention kernel we
89+
# target here, so causal_mask is intentionally not passed as
90+
# attn_mask. This path is only taken for prefill, where the
91+
# mask is guaranteed causal (asserted above), so is_causal=True
92+
# and the provided causal_mask are equivalent.
93+
is_causal=True,
94+
scale=self._scale_factor,
95+
enable_gqa=(n_kv_heads != n_heads),
96+
)
97+
98+
return out.transpose(2, 3).reshape(B, n_heads * self.head_dim, 1, S)
99+
55100
# Apply the scale factor before QK^T for numerical stability
56101
key = key.transpose(-3, -1) * self._scale_factor
57102
queries = query.split(self.head_dim, dim=1)

python/src/coreai_models/primitives/macos/cache.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,12 @@ def update_and_fetch(
108108
torch._check_is_size(seq_len)
109109
device = self._k_cache.device
110110

111+
compute_device = k.device
112+
cross_device = compute_device != device
113+
if cross_device:
114+
k = k.to(device)
115+
v = v.to(device)
116+
111117
layer_index = torch.tensor((layer_idx,), dtype=torch.int32, device=device)
112118
layer_index_end = torch.tensor((layer_idx + 1,), dtype=torch.int32, device=device)
113119

@@ -162,7 +168,11 @@ def update_and_fetch(
162168
# return the slice k, v
163169
k = self._k_cache.narrow(0, layer_idx, 1).narrow(-2, 0, seq_len)
164170
v = self._v_cache.narrow(0, layer_idx, 1).narrow(-2, 0, seq_len)
165-
return k.squeeze(0), v.squeeze(0)
171+
k_out = k.squeeze(0)
172+
v_out = v.squeeze(0)
173+
if cross_device:
174+
return k_out.to(compute_device), v_out.to(compute_device)
175+
return k_out, v_out
166176

167177

168178
class SSMState:

python/src/coreai_models/primitives/macos/rope.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ def forward(
109109
return self._rope(
110110
x,
111111
position_ids=position_ids,
112-
freqs=self._freqs,
112+
freqs=self._freqs.to(x.device),
113113
offset=offset,
114114
)
115115

python/src/coreai_models/primitives/macos/switch.py

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,11 @@ def __init__(
7878
self.up_proj = SwitchLinear(hidden_size, moe_intermediate_size, 1, num_experts, bias=bias)
7979
self.down_proj = SwitchLinear(moe_intermediate_size, hidden_size, 1, num_experts, bias=bias)
8080
self._activate = activation if activation is not None else SwiGLU()
81+
# Eager-only optimization. When set, tokens are processed in chunks of
82+
# this size to bound the peak GatherMM intermediate. Left None for
83+
# export/production so the traced
84+
# graph carries no data-dependent control flow on the token dimension.
85+
self.eager_chunk_size: int | None = None
8186

8287
def forward(
8388
self: Self,
@@ -90,12 +95,27 @@ def forward(
9095
x = x.reshape((-1, 1, 1, hidden_size))
9196
# batch size mul query length x num active experts
9297
indices = indices.reshape((-1, num_active_experts))
93-
# batch size mul query length x num active experts x 1 x moe intermediate size
94-
gate = self.gate_proj(x, indices)
95-
up = self.up_proj(x, indices)
96-
gated_up = self._activate(up, gate)
97-
# batch size mul query length x num active experts x 1 x hidden size
98-
x = self.down_proj(gated_up, indices)
98+
bsql = x.shape[0]
99+
100+
chunk_size = self.eager_chunk_size
101+
if chunk_size is None or bsql <= chunk_size:
102+
gate = self.gate_proj(x, indices)
103+
up = self.up_proj(x, indices)
104+
gated_up = self._activate(up, gate)
105+
# nws x bsql x nae x 1 x hidden size
106+
x = self.down_proj(gated_up, indices)
107+
else:
108+
chunks = []
109+
for start in range(0, bsql, chunk_size):
110+
x_c = x[start : start + chunk_size]
111+
idx_c = indices[start : start + chunk_size]
112+
gate_c = self.gate_proj(x_c, idx_c)
113+
up_c = self.up_proj(x_c, idx_c)
114+
gated_c = self._activate(up_c, gate_c)
115+
chunks.append(self.down_proj(gated_c, idx_c))
116+
# nws x bsql x nae x 1 x hidden size
117+
x = torch.cat(chunks, dim=1)
118+
99119
# batch size x query length x num active experts x hidden size
100120
x = x.reshape((batch_size, query_length, num_active_experts, hidden_size))
101121
return x

0 commit comments

Comments
 (0)