torch-ane 0.1.0__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.
torch_ane/__init__.py ADDED
@@ -0,0 +1,34 @@
1
+ """torch_ane — PyTorch-модели на Apple Neural Engine через Core AI (macOS 27+).
2
+
3
+ import torch_ane
4
+
5
+ m = torch_ane.compile(model.eval(), example_inputs=(x,)) # любой nn.Module -> ANE
6
+ y = m(x)
7
+
8
+ lm = torch_ane.CausalLM.from_pretrained("Qwen/Qwen2.5-0.5B-Instruct")
9
+ ids = lm.generate(prompt_ids, max_new_tokens=64)
10
+
11
+ sd = torch_ane.StableDiffusion("segmind/tiny-sd")
12
+ image, timings = sd("a cute robot", steps=20, seed=0)
13
+
14
+ Нижний уровень — torch_ane.passes (проходы экспорта, раскладка Apple, палитры/int8,
15
+ составные операции). Сборки кешируются в torch_ane.CACHE_DIR (~/.cache/torch_ane,
16
+ переменная TORCH_ANE_CACHE). Библиотека не зависит от MLX.
17
+ """
18
+ from . import passes
19
+ from .runtime import (CACHE_DIR, CompiledModule, available, cleanup_mpsgraph_tmp, clear_system_cache, compile,
20
+ compress_program, model_digest, prune_cache)
21
+
22
+ __all__ = ["compile", "CompiledModule", "available", "compress_program", "model_digest", "cleanup_mpsgraph_tmp",
23
+ "CACHE_DIR", "prune_cache", "clear_system_cache", "passes", "CausalLM", "ChunkedCausalLM", "StableDiffusion"]
24
+
25
+
26
+ def __getattr__(name):
27
+ # тяжёлые части (transformers, diffusers) — только по обращению
28
+ if name in ("CausalLM", "ChunkedCausalLM"):
29
+ from . import llm
30
+ return getattr(llm, name)
31
+ if name == "StableDiffusion":
32
+ from .diffusion import StableDiffusion
33
+ return StableDiffusion
34
+ raise AttributeError(f"module 'torch_ane' has no attribute {name!r}")
torch_ane/diffusion.py ADDED
@@ -0,0 +1,166 @@
1
+ """Stable Diffusion (diffusers) на Neural Engine: UNet, текстовый энкодер и VAE-декодер
2
+ через CompiledModule, расписание шагов и токенизатор — на CPU.
3
+
4
+ Главное для скорости — внимание в раскладке ANE (AttnProcessor): на ANE permute/reshape
5
+ больших тензоров — это копирование памяти, и внимание на 64x64 (4096 токенов) в обычной
6
+ записи было в 9 раз медленнее GPU. По рецепту Apple (BC1S, по головам, einsum без
7
+ транспонирований, softmax по ключам, куски запросов) UNet tiny-sd на ANE быстрее GPU.
8
+
9
+ sd = StableDiffusion("segmind/tiny-sd")
10
+ image, t = sd("a cute robot sitting on a desk", steps=20, seed=0)
11
+ """
12
+ import hashlib
13
+ import inspect
14
+ import time
15
+
16
+ import torch
17
+ import torch.nn as nn
18
+
19
+ Q_CHUNK = 512 # куски запросов во внимании при T > 1024
20
+
21
+
22
+ class AttnProcessor:
23
+ """Обработчик внимания diffusers в раскладке ANE (как SPLIT_EINSUM у Apple):
24
+ Q и V — [B, C, 1, T], K — [B, T, 1, C] без транспонирования; по головам einsum
25
+ bchq,bkhc->bkhq, softmax по оси ключей, длинные запросы — кусками."""
26
+
27
+ def __call__(self, attn, hidden_states, encoder_hidden_states=None, attention_mask=None, temb=None, **kw):
28
+ res = hidden_states
29
+ shape4 = hidden_states.shape if hidden_states.ndim == 4 else None
30
+ if shape4 is not None:
31
+ b, c, hh, ww = shape4
32
+ hidden_states = hidden_states.view(b, c, hh * ww).transpose(1, 2)
33
+ if attn.group_norm is not None:
34
+ hidden_states = attn.group_norm(hidden_states.transpose(1, 2)).transpose(1, 2)
35
+ ctx = hidden_states if encoder_hidden_states is None else encoder_hidden_states
36
+ if encoder_hidden_states is not None and attn.norm_cross:
37
+ ctx = attn.norm_encoder_hidden_states(ctx)
38
+ q, k, v = attn.to_q(hidden_states), attn.to_k(ctx), attn.to_v(ctx)
39
+ heads = attn.heads
40
+ d, tq = q.shape[-1] // heads, q.shape[1]
41
+ qb = q.transpose(1, 2).unsqueeze(2) # [B, C, 1, Tq]
42
+ kb = k.unsqueeze(2) # [B, Tk, 1, C]
43
+ vb = v.transpose(1, 2).unsqueeze(2) # [B, C, 1, Tk]
44
+ n = Q_CHUNK if tq > 1024 else tq
45
+ outs = []
46
+ for h in range(heads):
47
+ qh, kh, vh = qb[:, h * d:(h + 1) * d], kb[..., h * d:(h + 1) * d], vb[:, h * d:(h + 1) * d]
48
+ parts = []
49
+ for i in range(0, tq, n):
50
+ w = torch.einsum("bchq,bkhc->bkhq", qh[..., i:i + n], kh) * attn.scale
51
+ parts.append(torch.einsum("bkhq,bchk->bchq", w.softmax(dim=1), vh))
52
+ outs.append(torch.cat(parts, dim=3) if len(parts) > 1 else parts[0])
53
+ o = torch.cat(outs, dim=1).squeeze(2).transpose(1, 2) # [B, Tq, C]
54
+ o = attn.to_out[1](attn.to_out[0](o))
55
+ if shape4 is not None:
56
+ o = o.transpose(1, 2).reshape(shape4)
57
+ if attn.residual_connection:
58
+ o = o + res
59
+ return o / attn.rescale_output_factor
60
+
61
+
62
+ # версия обработчика — в простом атрибуте обёрток, чтобы кеш сборки CompiledModule менялся вместе с ним
63
+ _ATTN_TAG = hashlib.sha256(inspect.getsource(AttnProcessor).encode()).hexdigest()[:8]
64
+
65
+
66
+ class _UNet(nn.Module):
67
+ def __init__(self, unet):
68
+ super().__init__()
69
+ self.u = unet
70
+ self.attn_tag = _ATTN_TAG
71
+
72
+ def forward(self, sample, timestep, encoder_hidden_states):
73
+ return self.u(sample, timestep, encoder_hidden_states, return_dict=False)[0]
74
+
75
+
76
+ class _TextEncoder(nn.Module):
77
+ """Энкодер CLIP без таблицы эмбеддингов: вход — эмбеддинги токенов+позиций (с CPU)."""
78
+
79
+ def __init__(self, te):
80
+ super().__init__()
81
+ self.te = te
82
+
83
+ def forward(self, hidden):
84
+ h = self.te.encoder(inputs_embeds=hidden, attention_mask=None, is_causal=True).last_hidden_state
85
+ return self.te.final_layer_norm(h)
86
+
87
+
88
+ class _VAEDecoder(nn.Module):
89
+ def __init__(self, vae):
90
+ super().__init__()
91
+ self.pqc, self.dec = vae.post_quant_conv, vae.decoder
92
+ self.attn_tag = _ATTN_TAG
93
+
94
+ def forward(self, z):
95
+ if self.pqc is not None:
96
+ z = self.pqc(z)
97
+ return self.dec(z)
98
+
99
+
100
+ class StableDiffusion:
101
+ """Пайплайн Stable Diffusion из diffusers с UNet, текстовым энкодером и VAE-декодером
102
+ на заданном блоке Core AI ("ane" или "gpu"). Первая сборка программ —
103
+ минуты, дальше — из кеша."""
104
+
105
+ def __init__(self, model_id="segmind/tiny-sd", device="ane", size=512):
106
+ from diffusers import DPMSolverMultistepScheduler, StableDiffusionPipeline
107
+
108
+ from .runtime import CompiledModule
109
+ self.device, self.size = device, size
110
+ pipe = StableDiffusionPipeline.from_pretrained(model_id, torch_dtype=torch.float32, safety_checker=None,
111
+ requires_safety_checker=False)
112
+ pipe.scheduler = DPMSolverMultistepScheduler.from_config(pipe.scheduler.config)
113
+ pipe.set_progress_bar_config(disable=True)
114
+ pipe.unet.eval().set_attn_processor(AttnProcessor())
115
+ pipe.vae.eval().set_attn_processor(AttnProcessor())
116
+ te = pipe.text_encoder.eval()
117
+ self.t = {} # время по частям последней генерации
118
+ self._unet = CompiledModule(_UNet(pipe.unet).eval(), device=device, validate=False)
119
+ self._text = CompiledModule(_TextEncoder(te).eval(), device=device, validate=False)
120
+ self._vae = CompiledModule(_VAEDecoder(pipe.vae).eval(), device=device, validate=False)
121
+ sd = self
122
+
123
+ class UNetShim:
124
+ config, dtype = pipe.unet.config, pipe.unet.dtype
125
+
126
+ def __call__(self, sample, timestep, encoder_hidden_states=None, return_dict=True, **kw):
127
+ from diffusers.models.unets.unet_2d_condition import UNet2DConditionOutput
128
+ timestep = torch.as_tensor(timestep)
129
+ if timestep.dim() == 0:
130
+ timestep = timestep.float().expand(sample.shape[0])
131
+ a = time.perf_counter()
132
+ out = sd._unet(sample.float(), timestep.float(), encoder_hidden_states.float())
133
+ sd.t.setdefault("unet", []).append(time.perf_counter() - a)
134
+ return UNet2DConditionOutput(sample=out) if return_dict else (out,)
135
+
136
+ class TextShim:
137
+ config, dtype = te.config, te.dtype
138
+
139
+ def __call__(self, input_ids, attention_mask=None, output_hidden_states=False, **kw):
140
+ a = time.perf_counter()
141
+ with torch.no_grad(): # поиск по таблице эмбеддингов — на CPU
142
+ hidden = te.embeddings(input_ids=input_ids)
143
+ out = sd._text(hidden.float())
144
+ sd.t["text"] = sd.t.get("text", 0) + time.perf_counter() - a
145
+ return (out,)
146
+
147
+ def vae_decode(z, return_dict=True, generator=None, **kw):
148
+ from diffusers.models.autoencoders.vae import DecoderOutput
149
+ a = time.perf_counter()
150
+ out = sd._vae(z.float())
151
+ sd.t["vae"] = time.perf_counter() - a
152
+ return DecoderOutput(sample=out) if return_dict else (out,)
153
+
154
+ pipe.unet, pipe.text_encoder = UNetShim(), TextShim()
155
+ pipe.vae.decode = vae_decode
156
+ self.pipe = pipe
157
+
158
+ def __call__(self, prompt, steps=20, seed=None, negative_prompt="", guidance=7.5):
159
+ """-> (PIL-картинка, {"text": с, "unet": [с на шаг], "vae": с, "total": с})."""
160
+ self.t = {}
161
+ g = torch.Generator("cpu").manual_seed(seed if seed is not None else int(time.time()))
162
+ a = time.perf_counter()
163
+ img = self.pipe(prompt, negative_prompt=negative_prompt, num_inference_steps=steps,
164
+ guidance_scale=guidance, height=self.size, width=self.size, generator=g).images[0]
165
+ self.t["total"] = time.perf_counter() - a
166
+ return img, self.t
@@ -0,0 +1,13 @@
1
+ """Языковые модели на Neural Engine.
2
+
3
+ CausalLM — модель из transformers целиком в одной программе (до ~1-3B):
4
+ prefill кусками, декод окнами, KV-кеш — состояния Core AI,
5
+ батч до 8 потоков за шаг (generate_batch);
6
+ ChunkedCausalLM — большие модели (24B) кусками по несколько слоёв, веса из GGUF,
7
+ палитры 4 бит / int8.
8
+ """
9
+ from ._kv import fresh_rope
10
+ from .causal import CausalLM
11
+ from .chunked import ChunkedCausalLM
12
+
13
+ __all__ = ["CausalLM", "ChunkedCausalLM", "fresh_rope"]
torch_ane/llm/_kv.py ADDED
@@ -0,0 +1,98 @@
1
+ """Общее для LLM на ANE: KV-кеш — состояния Core AI (схема Apple: весь K одним
2
+ состоянием, весь V другим, запись куска — coreai.slice_update со смещением из входа),
3
+ слой кеша transformers поверх этих буферов и маска окна."""
4
+ import numpy as np
5
+ import torch
6
+ from transformers.cache_utils import CacheLayerMixin
7
+
8
+ DECODE_Q = 8
9
+ MASK_NEG = -40000.0 # не -inf: ANE неверно считает softmax с -inf
10
+
11
+ _lib = torch.library.Library("torch_ane", "FRAGMENT")
12
+ _lib.define("kv_write(Tensor cache, Tensor new, int layer, Tensor step) -> Tensor")
13
+
14
+
15
+ @torch.library.impl(_lib, "kv_write", "CompositeExplicitAutograd")
16
+ def _kv_write(cache, new, layer, step):
17
+ """cache [L, 1, kv, M, hd], new [1, 1, kv, C, hd] (ранг как у кеша — требование
18
+ slice_update): cache[layer, :, :, step:step+C] = new."""
19
+ out = cache.clone()
20
+ s = int(step.reshape(-1)[0])
21
+ out[layer:layer + 1, :, :, s:s + new.shape[3]] = new.to(out.dtype)
22
+ return out
23
+
24
+
25
+ @torch.library.register_fake("torch_ane::kv_write")
26
+ def _kv_write_fake(cache, new, layer, step):
27
+ return torch.empty_like(cache)
28
+
29
+
30
+ def register_lowering(conv):
31
+ from coreai._compiler.dialects import coreai
32
+ from coreai_torch._utils import get_operand
33
+
34
+ @conv.register_torch_lowering("torch_ane::kv_write.default")
35
+ def _(values_map, node, loc):
36
+ cache = get_operand(values_map, node, 0)
37
+ new = get_operand(values_map, node, 1)
38
+ layer = node.args[2]
39
+ step = get_operand(values_map, node, 3) # [1] int32
40
+ _, _, kv, n, hd = tuple(node.args[1].meta["val"].shape)
41
+ begin = coreai.concat(0, [[layer], [0], [0], step, [0]])
42
+ end = coreai.concat(0, [[layer + 1], [1], [kv], coreai.broadcasting_add(step, n), [hd]])
43
+ return coreai.slice_update(cache, begin, end, [1, 1, 1, 1, 1], new)
44
+
45
+
46
+ class StateLayer(CacheLayerMixin):
47
+ """Слой кеша transformers поверх общих буферов модуля-владельца."""
48
+
49
+ is_sliding = False
50
+
51
+ def __init__(self, owner, idx, step):
52
+ super().__init__()
53
+ self.owner, self.idx, self.step = owner, idx, step
54
+ self.is_initialized = True
55
+
56
+ def lazy_initialization(self, key_states, value_states):
57
+ pass
58
+
59
+ def update(self, key_states, value_states, *args, **kwargs):
60
+ o, i = self.owner, self.idx
61
+ o.key_cache.copy_(torch.ops.torch_ane.kv_write(o.key_cache, key_states[None], i, self.step))
62
+ o.value_cache.copy_(torch.ops.torch_ane.kv_write(o.value_cache, value_states[None], i, self.step))
63
+ dt = key_states.dtype
64
+ return o.key_cache[i].to(dt), o.value_cache[i].to(dt) # [1, kv, M, hd]
65
+
66
+ def get_mask_sizes(self, query_length):
67
+ return self.owner.max_len, 0
68
+
69
+ def get_seq_length(self):
70
+ return 0
71
+
72
+ def get_max_length(self):
73
+ return self.owner.max_len
74
+
75
+
76
+
77
+ def window_mask(start, n, q, max_len):
78
+ """Строка j (позиция start+j) видит ключи [0, start+j]; строки паддинга — ключ 0."""
79
+ m = np.full((1, 1, q, max_len), MASK_NEG, np.float16)
80
+ for j in range(q):
81
+ m[0, 0, j, :(start + j + 1 if j < n else 1)] = 0
82
+ return m
83
+
84
+
85
+
86
+
87
+ def fresh_rope(lm):
88
+ """Пересоздать модуль RoPE из конфига во fp32. Если модель загружена или хоть раз
89
+ переведена в bf16/fp16, её inv_freq округлён — ключи в кеше от ANE-prefill
90
+ слегка расходились бы с RoPE, который mlx-lm считает при генерации точно."""
91
+ rope = getattr(getattr(lm, "model", None), "rotary_emb", None)
92
+ if rope is not None:
93
+ try:
94
+ lm.model.rotary_emb = type(rope)(config=lm.config)
95
+ except TypeError:
96
+ pass
97
+ return lm
98
+
@@ -0,0 +1,317 @@
1
+ """LLM на Neural Engine с KV-кешем внутри модели — схема Apple (coreai-models,
2
+ iOS-экспорт): весь K одним состоянием Core AI, весь V другим, запись куска —
3
+ coreai.slice_update со смещением из входа. Состояния живут между вызовами и
4
+ передаются без копирования (время вызова
5
+ почти не растёт с размером кеша), в отличие от KV-кеша входом (~22 мс копирования
6
+ на шаг декода у Qwen2.5-0.5B).
7
+
8
+ Прежняя попытка (ANEChunkedPrefill: index_copy_ в отдельный буфер на каждый слой)
9
+ на ANE тихо считала неверно; с двумя общими буферами и slice_update — верно.
10
+
11
+ Как у Apple, две функции над одними состояниями: prefill кусками по PREFILL_Q
12
+ токенов и декод окнами по DECODE_Q (окно выровнено на кратное DECODE_Q, недописанный
13
+ хвост пересчитывается — запись тех же значений в те же позиции безвредна).
14
+ Модель transformers не переписывается: свой слой кеша (_StateLayer) пишет K/V в
15
+ буферы и отдаёт слою внимания срез на весь контекст, маска — 4D-вход.
16
+ """
17
+ import asyncio
18
+ import contextlib
19
+ import hashlib
20
+ import io
21
+ import warnings
22
+
23
+ import numpy as np
24
+ import torch
25
+ from transformers.cache_utils import Cache
26
+
27
+ from .. import passes
28
+ from ..runtime import CACHE_DIR, compress_program, model_digest, prune_cache, touch_cache
29
+ from ._kv import DECODE_Q, MASK_NEG, StateLayer, fresh_rope, register_lowering, window_mask
30
+
31
+ PREFILL_Q = 256
32
+ LADDER = (512, 1024, 2048, 4096, 8192)
33
+ CALIB_TEXT = ("Объясни подробно, как устроен трансформер: внимание, MLP, нормализация. "
34
+ "Explain how attention works, step by step, with a short Python example.")
35
+
36
+
37
+ class _StateStep(torch.nn.Module):
38
+ """(emb [1, Q, hidden], pos [1, Q], mask [1, 1, Q, M], step [1], pick [1, Q])
39
+ -> логиты одной строки [1, V] (строка выбирается one-hot вектором pick);
40
+ K/V куска пишутся в буферы key_cache/value_cache [L, 1, kv, M, hd].
41
+
42
+ Как у Apple: эмбеддинги приходят входом (поиск по таблице — снаружи), в графе
43
+ одна копия весов lm_head; логиты — только нужной позиции, а не всего окна.
44
+ Выбор строки — умножение на one-hot в fp16 (целочисленный индекс ANE не любит)."""
45
+
46
+ def __init__(self, lm, max_len):
47
+ super().__init__()
48
+ cfg = lm.config.get_text_config()
49
+ self.lm, self.max_len = lm, max_len
50
+ self.n_layers = cfg.num_hidden_layers
51
+ kv = cfg.num_key_value_heads
52
+ hd = getattr(cfg, "head_dim", None) or cfg.hidden_size // cfg.num_attention_heads
53
+ shape = (self.n_layers, 1, kv, max_len, hd)
54
+ self.register_buffer("key_cache", torch.zeros(shape), persistent=False)
55
+ self.register_buffer("value_cache", torch.zeros(shape), persistent=False)
56
+
57
+ def forward(self, emb, pos, mask, step, pick):
58
+ dt = self.lm.lm_head.weight.dtype
59
+ cache = Cache(layers=[StateLayer(self, i, step) for i in range(self.n_layers)])
60
+ o = self.lm.model(inputs_embeds=emb.to(dt), position_ids=pos, attention_mask=mask.to(dt),
61
+ past_key_values=cache, use_cache=True)
62
+ if pick.shape[0] > 1: # батч потоков: pick [P, Q] -> логиты P строк [P, V]
63
+ return self.lm.lm_head(torch.matmul(pick.to(dt), o.last_hidden_state[0]))
64
+ h = (pick.to(dt)[:, :, None] * o.last_hidden_state).sum(1) # [1, hidden]
65
+ return self.lm.lm_head(h)
66
+
67
+
68
+ class CausalLM:
69
+ """Causal LM из transformers целиком на ANE: prefill кусками, декод окнами, KV-кеш —
70
+ состояния Core AI. Первая сборка — минуты, дальше программа из кеша.
71
+
72
+ lm = CausalLM.from_pretrained("Qwen/Qwen2.5-0.5B-Instruct", batch=True)
73
+ tokens = lm.generate(prompt_ids, max_new_tokens=64)
74
+ outs = lm.generate_batch([ids1, ids2, ids3], max_new_tokens=64) # до 8 потоков за шаг
75
+
76
+ max_len — наибольший контекст; compress — "lut4"/"lut4g32" (палитры Apple) или сжатие
77
+ готовой программы ("int8"...); composite — составные операции Core AI ("sdpa", "rope");
78
+ calib_ids — токены настоящего текста для калибровки масштаба fp16; batch — собрать
79
+ функции для generate_batch; mlp_chunk — MLP кусками под внутреннюю память ANE."""
80
+
81
+ @classmethod
82
+ def from_pretrained(cls, model_id, **kw):
83
+ """Модель и (для калибровки) токенизатор из transformers по имени или пути."""
84
+ from transformers import AutoModelForCausalLM, AutoTokenizer
85
+ hf = AutoModelForCausalLM.from_pretrained(model_id, dtype=torch.float32).eval()
86
+ if "calib_ids" not in kw:
87
+ try:
88
+ tok = AutoTokenizer.from_pretrained(model_id)
89
+ kw["calib_ids"] = tok(CALIB_TEXT)["input_ids"][:128]
90
+ except Exception: # noqa: BLE001 — без токенизатора: фиксированные токены
91
+ pass
92
+ return cls(hf, **kw)
93
+
94
+ def __init__(self, hf_lm, max_len=2048, device="ane", prefill_q=PREFILL_Q, compress=None,
95
+ ladder=LADDER, composite=(), calib_ids=None, batch=False, mlp_chunk=True):
96
+ import copy
97
+
98
+ from ..runtime import _device
99
+ device = _device(device)
100
+ self.lm = fresh_rope(copy.deepcopy(hf_lm).float()).eval()
101
+ self._embed = self.lm.get_input_embeddings()
102
+ # мелкие активации MLP/V не тонут в субнормальных fp16 на ANE (до подмены Linear свёртками);
103
+ # масштаб — свой у каждой пары проекций, по калибровке (общий 2^8 переполнял fp16 на
104
+ # массивных активациях: Qwen2.5 на ANE зацикливался, как и Devstral)
105
+ vocab = self.lm.get_input_embeddings().num_embeddings
106
+ cal = torch.tensor([list(calib_ids) if calib_ids is not None
107
+ else [1 % vocab] + [i % vocab for i in range(1000, 1095)]])
108
+ k_for, _ = passes.calibrate_rescale(self.lm, lambda: self.lm(input_ids=cal).logits)
109
+ passes.rescale_for_fp16(self.lm, k_for=k_for)
110
+ # MLP кусками скрытого измерения под внутреннюю память ANE (passes.chunk_mlp)
111
+ self.mlp_chunk = mlp_chunk and passes.chunk_mlp(self.lm)
112
+ if compress and compress.startswith("lut"): # "lut4" / "lut4g32": палитры по рецепту Apple
113
+ bits, _, g = compress[3:].partition("g")
114
+ passes.swap_linear(self.lm.model) # проекции — свёртки 1x1 (lm_head остаётся Linear)
115
+ with contextlib.redirect_stdout(io.StringIO()), warnings.catch_warnings():
116
+ warnings.simplefilter("ignore")
117
+ self.lm = passes.palettize_lut(self.lm, int(bits), int(g or 8))
118
+ # prefill_q: куски prefill крупнее окна декода — каждый кусок заново читает все
119
+ # веса, мелкие куски упираются в память, а не в вычисления
120
+ self.max_len, self.device, self.prefill_q, self.compress = max_len, device, prefill_q, compress
121
+ self.batch = batch # функции "batch": окно декода из токенов разных потоков (generate_batch)
122
+ # лестница контекстов (как у Apple): внимание на ANE стоит пропорционально
123
+ # длине кеша, короткий разговор идёт функциями с маленьким кешем
124
+ self.ladder = tuple(b for b in ladder if b < max_len and b >= prefill_q) + (max_len,)
125
+ passes.swap_rmsnorm(self.lm)
126
+ # составные SDPA/RoPE Core AI (composite=("sdpa", "rope")) — как в экспорте Apple под macOS
127
+ self.composite = tuple(composite)
128
+ self._extra_specs = passes.use_composite_ops(self.lm, self.composite) if self.composite else []
129
+ self.loop = asyncio.new_event_loop()
130
+ import coreai.runtime as rt
131
+ self._rt = rt
132
+ self._load(self._build())
133
+ self.reset()
134
+
135
+ def _entries(self):
136
+ kinds = ("prefill", "decode") + (("batch",) if self.batch else ())
137
+ return [(kind, M) for M in self.ladder for kind in kinds]
138
+
139
+ def _build(self):
140
+ """Все функции лестницы — в одной программе Core AI: веса общие."""
141
+ from coreai_torch import ExternalizeSpec, TorchConverter
142
+ from coreai_torch.composite_ops import RMSNormImpl
143
+
144
+ tag = hashlib.sha256((f"{self.prefill_q}|{self.ladder}|{self.compress}|{self.composite}|{passes.PASSES_VERSION}"
145
+ + ("|batch" if self.batch else "") + ("|mlpchunk" if self.mlp_chunk else "")).encode()).hexdigest()[:8]
146
+ path = CACHE_DIR / f"{model_digest(self.lm)}_state_{tag}_v5.aimodel"
147
+ if path.exists():
148
+ touch_cache(path)
149
+ return path
150
+ CACHE_DIR.mkdir(parents=True, exist_ok=True)
151
+ hid = self.lm.config.get_text_config().hidden_size
152
+ with warnings.catch_warnings(), torch.no_grad(), contextlib.redirect_stdout(io.StringIO()):
153
+ warnings.simplefilter("ignore")
154
+ conv = TorchConverter()
155
+ register_lowering(conv)
156
+ mods = {}
157
+ for kind, M in self._entries():
158
+ q = self.prefill_q if kind == "prefill" else DECODE_Q
159
+ m = mods.setdefault(M, _StateStep(self.lm, M).eval()) # модули делят self.lm — и веса
160
+ # позиции — int16: ANE не знает int32, и каст int32 -> float в RoPE уходит с ANE
161
+ # (у Apple position_ids — uint16 по той же причине)
162
+ args = (torch.zeros(1, q, hid, dtype=torch.float16), torch.arange(q, dtype=torch.int16)[None],
163
+ torch.from_numpy(window_mask(0, q, q, M)), torch.zeros(1, dtype=torch.int32),
164
+ torch.zeros(q if kind == "batch" else 1, q, dtype=torch.float16))
165
+ conv.add_pytorch_module(
166
+ m, export_fn=passes.export_for_ane(m, args), entrypoint_name=f"{kind}_{M}",
167
+ externalize_modules=[ExternalizeSpec(target_class=RMSNormImpl, composite_op_name="rms_norm",
168
+ composite_attrs=["axes", "eps"])] + self._extra_specs)
169
+ prog = conv.to_coreai()
170
+ if self.compress:
171
+ # lut*: проекции уже сжаты палитрами, int8 добирает остальное (lm_head)
172
+ prog = compress_program(prog, "int8" if self.compress.startswith("lut") else self.compress)
173
+ # hardware constraints (как у Apple): состояния — IOSurface, память, которую
174
+ # ANE читает напрямую
175
+ from coreai.authoring.types import AllocationType, HardwareConstraints
176
+ hc = HardwareConstraints(AllocationType.IOSurface, alignments=(1,) * 6, interleave=(1,) * 5)
177
+ for kind, M in self._entries():
178
+ prog.set_hardware_constraints(f"{kind}_{M}", {"b_key_cache": hc, "b_value_cache": hc})
179
+ prog.save_asset(path)
180
+ prune_cache(keep=(path,))
181
+ return path
182
+
183
+ def _load(self, path):
184
+ rt = self._rt
185
+ model = self.loop.run_until_complete(rt.AIModel.load(
186
+ path, rt.SpecializationOptions.from_preferred_compute_unit_kind(getattr(rt.ComputeUnitKind, self.device)())))
187
+ self.fn = {(kind, M): model.load_function(f"{kind}_{M}") for kind, M in self._entries()}
188
+
189
+ def reset(self, M=None):
190
+ """Новые буферы состояний ступени M (по умолчанию — младшей)."""
191
+ self.M = M or self.ladder[0]
192
+ d = self.fn[("decode", self.M)].desc
193
+ self.state = {n: self._rt.NDArray.from_descriptor(d.state_descriptor(n)) for n in d.state_names}
194
+ self.pos = 0
195
+
196
+ def _run(self, kind, tokens, start):
197
+ rt = self._rt
198
+ q = self.prefill_q if kind == "prefill" else DECODE_Q
199
+ n = len(tokens)
200
+ ids = torch.zeros(1, q, dtype=torch.long)
201
+ ids[0, :n] = torch.tensor(tokens)
202
+ with torch.no_grad(): # поиск по таблице эмбеддингов — на CPU (как gather_embeddings у Apple)
203
+ emb = self._embed(ids).half().numpy()
204
+ pick = np.zeros((1, q), np.float16)
205
+ pick[0, n - 1] = 1
206
+ fn = self.fn[(kind, self.M)]
207
+ d = fn.desc
208
+ vals = (emb, np.arange(start, start + q, dtype=np.int16)[None], window_mask(start, n, q, self.M),
209
+ np.array([start], np.int32), pick)
210
+ out = self.loop.run_until_complete(fn({k: rt.NDArray(v) for k, v in zip(d.input_names, vals)},
211
+ state=self.state))
212
+ return np.array(out[d.output_names[0]].numpy())[0].astype(np.float32)
213
+
214
+ def feed(self, tokens):
215
+ """Дописать токены в кеш (после уже обработанных) -> логиты последнего."""
216
+ toks = list(tokens)
217
+ self._hist = getattr(self, "_hist", [])[:self.pos] + toks
218
+ end = len(self._hist)
219
+ if end > min(self.max_len, 32767): # позиции — int16
220
+ raise ValueError(f"контекст длиннее {self.max_len}")
221
+ if end > self.M: # не влезает в ступень: следующая ступень, история пересчитывается заново
222
+ self.reset(next(b for b in self.ladder if b >= end))
223
+ Q, logits = self.prefill_q, None
224
+ while end - self.pos > DECODE_Q: # куски prefill, последний — неполный (паддинг под маской)
225
+ start = min(self.pos, self.M - Q) # кусок целиком в кеше: у края сдвигается назад
226
+ stop = min(start + Q, end)
227
+ logits = self._run("prefill", self._hist[start:stop], start)
228
+ self.pos = stop
229
+ while self.pos < end: # хвост окнами декода, выровненными на DECODE_Q
230
+ start = (self.pos // DECODE_Q) * DECODE_Q
231
+ stop = min(start + DECODE_Q, end)
232
+ logits = self._run("decode", self._hist[start:stop], start)
233
+ self.pos = stop
234
+ return logits
235
+
236
+ def generate(self, prompt, max_new_tokens=32, eos=None):
237
+ """Жадная генерация с нуля: prompt — список id -> новые id (с eos, если встретился)."""
238
+ self.reset()
239
+ self._hist = []
240
+ out = []
241
+ logits = self.feed(prompt)
242
+ for _ in range(max_new_tokens):
243
+ tok = int(logits.argmax())
244
+ out.append(tok)
245
+ if tok == eos or len(out) == max_new_tokens:
246
+ break
247
+ logits = self.feed([tok])
248
+ return out
249
+
250
+ def _call(self, kind, tokens, pos, mask, step, pick):
251
+ """Один вызов функции: токены по строкам окна, свои позиции и маска у каждой строки."""
252
+ rt = self._rt
253
+ q = self.prefill_q if kind == "prefill" else DECODE_Q
254
+ ids = torch.zeros(1, q, dtype=torch.long)
255
+ ids[0, :len(tokens)] = torch.tensor(tokens)
256
+ with torch.no_grad():
257
+ emb = self._embed(ids).half().numpy()
258
+ p = np.zeros((1, q), np.int16)
259
+ p[0, :len(pos)] = pos
260
+ fn = self.fn[(kind, self.M)]
261
+ d = fn.desc
262
+ vals = (emb, p, mask, np.array([step], np.int32), pick)
263
+ out = self.loop.run_until_complete(fn({k: rt.NDArray(v) for k, v in zip(d.input_names, vals)},
264
+ state=self.state))
265
+ return np.array(out[d.output_names[0]].numpy()).astype(np.float32)
266
+
267
+ def generate_batch(self, prompts, max_new_tokens=32):
268
+ """Жадная генерация до DECODE_Q потоков сразу (нужен batch=True). Шаг декода — один
269
+ вызов ANE: строка j окна — очередной токен потока j со своей позицией; K/V всех
270
+ потоков лежат в общем буфере вперемешку, маска строки открывает только столбцы её
271
+ потока. Веса читаются раз на шаг, поэтому шаг стоит почти как у одного потока."""
272
+ if not self.batch:
273
+ raise ValueError("generate_batch: собери модель с batch=True")
274
+ B, Q, PQ = len(prompts), DECODE_Q, self.prefill_q
275
+ if not 1 <= B <= Q:
276
+ raise ValueError(f"потоков от 1 до {Q}")
277
+ need = sum(len(p) for p in prompts) + B * max_new_tokens + PQ
278
+ if need > self.max_len:
279
+ raise ValueError(f"не влезает в кеш: нужно {need} столбцов, max_len={self.max_len}")
280
+ self.reset(next(b for b in self.ladder if b >= need))
281
+ M, cur = self.M, 0
282
+ own = np.zeros((B, M), bool) # какие столбцы кеша принадлежат потоку
283
+ logits = []
284
+ for s, prompt in enumerate(prompts): # prefill потоков по очереди, каждый — в свой отрезок кеша
285
+ done = 0
286
+ while done < len(prompt):
287
+ chunk = prompt[done:done + PQ]
288
+ n = len(chunk)
289
+ m = np.full((1, 1, PQ, M), MASK_NEG, np.float16)
290
+ m[0, 0, :, own[s]] = 0
291
+ for j in range(PQ): # причинно внутри куска; строки паддинга видят себя
292
+ m[0, 0, j, cur:cur + (j + 1 if j < n else 0)] = 0
293
+ m[0, 0, j, cur + j] = 0
294
+ pick = np.zeros((1, PQ), np.float16)
295
+ pick[0, n - 1] = 1
296
+ lg = self._call("prefill", chunk, np.arange(done, done + n), m, cur, pick)[0]
297
+ own[s, cur:cur + n] = True
298
+ cur += n
299
+ done += n
300
+ logits.append(lg)
301
+ outs = [[int(lg.argmax())] for lg in logits]
302
+ pos = [len(p) for p in prompts]
303
+ mask = np.full((1, 1, Q, M), MASK_NEG, np.float16)
304
+ pick = np.eye(Q, dtype=np.float16)
305
+ for _ in range(max_new_tokens - 1):
306
+ mask[:] = MASK_NEG
307
+ for j in range(Q):
308
+ if j < B:
309
+ mask[0, 0, j, own[j]] = 0
310
+ mask[0, 0, j, cur + j] = 0
311
+ lg = self._call("batch", [o[-1] for o in outs], pos, mask, cur, pick)
312
+ own[np.arange(B), cur + np.arange(B)] = True
313
+ cur += B
314
+ for j in range(B):
315
+ outs[j].append(int(lg[j].argmax()))
316
+ pos[j] += 1
317
+ return outs