neutts_nano / model.py
ashwmurt's picture
Upload neutts_nano recipe (v1)
4532b62 verified
Raw
History Blame
10.3 kB
# ---------------------------------------------------------------------
# Copyright (c) 2026 Qualcomm Technologies, Inc. and/or its subsidiaries.
# SPDX-License-Identifier: BSD-3-Clause
# ---------------------------------------------------------------------
from __future__ import annotations
import os
from functools import lru_cache
from pathlib import Path
from typing import Any, cast
import torch
from neucodec import NeuCodec
from torch import Tensor, nn
from transformers.modeling_attn_mask_utils import AttentionMaskConverter
from typing_extensions import Self
from qai_hub_models import SampleInputsType
from qai_hub_models.models._shared.llama3.model import Llama3Base
from qai_hub_models.models._shared.llm.common import LLMIOType
from qai_hub_models.utils.base_model import SerializationSettings
from qai_hub_models.utils.base_multi_graph_model import MultiGraphWorkbenchModel
from qai_hub_models.utils.input_spec import InputSpec, OutputSpec
MODEL_ID = __name__.split(".")[-2]
MODEL_ASSET_VERSION = 1
BACKBONE_REPO = "neuphonic/neutts-nano"
CODEC_REPO = "neuphonic/neucodec"
# LlamaForCausalLM architecture constants for neuphonic/neutts-nano.
NUM_LAYERS = 24
HIDDEN_SIZE = 576
NUM_ATTN_HEADS = 9
NUM_KEY_VALUE_HEADS = 3
HEAD_DIM = 64
CONTEXT_LENGTH = 2048
PREFILL_SEQ_LEN = 128
DECODE_SEQ_LEN = 1
SAMPLE_RATE = 24_000
HOP_LENGTH = 480
# Prompt used only to shape calibration / sample inputs. The tokenizer has no
# chat template, so the LLMBase default (which applies one) cannot be used.
SAMPLE_PROMPT = "My name is Jo and I am a text to speech model."
# Additive value for masked attention slots. The shared LLM helpers use -50 to
# match Genie's fixed fp16 "infinity", which leaks ~1e-3 relative error into the
# logits here; -1e4 is still fp16-representable but rounds to zero in softmax.
MASK_MIN = -1e4
def build_attention_mask(
query_len: int, num_valid_keys: int, context_length: int = CONTEXT_LENGTH
) -> Tensor:
"""4D additive causal mask over a right-aligned ``context_length`` key window."""
mask_2d = torch.zeros((1, context_length))
mask_2d[:, -num_valid_keys:] = 1.0
return (
AttentionMaskConverter(True)
.to_4d(
mask_2d,
query_length=query_len,
key_value_length=context_length,
dtype=torch.float32,
)
.clip(MASK_MIN, 0)
)
def empty_kv_cache(kv_seq_len: int) -> list[Tensor]:
"""Zeroed per-layer key/value cache tensors in graph input order."""
caches: list[Tensor] = []
for _ in range(NUM_LAYERS):
caches.append(torch.zeros(NUM_KEY_VALUE_HEADS, 1, HEAD_DIM, kv_seq_len))
caches.append(torch.zeros(NUM_KEY_VALUE_HEADS, 1, kv_seq_len, HEAD_DIM))
return caches
@lru_cache(maxsize=1)
def load_codec() -> nn.Module:
# NeuCodec is this model's speech tokenizer -- encoder on the way in, decoder
# on the way out. Like a text tokenizer it is variable-length and runs on CPU,
# outside the compiled graphs. Cached so the app holds one copy.
codec = cast(nn.Module, NeuCodec.from_pretrained(CODEC_REPO))
codec.to("cpu")
codec.eval()
return codec
class Backbone(Llama3Base):
"""NeuTTS-Nano causal LM with the KV cache exposed as graph I/O.
``neuphonic/neutts-nano`` is a ``LlamaForCausalLM``, so it inherits the
repo's Llama treatment: split-head attention, rank-4 RMS norm, rotary
embeddings supplied as ``position_ids_cos`` / ``position_ids_sin`` inputs,
and per-layer ``past_key_*`` / ``past_value_*`` in and out. Only the newly
computed cache entries are returned; the caller slides the window.
"""
min_memory_recommended = 0
def __init__(
self,
sequence_length: int = PREFILL_SEQ_LEN,
context_length: int = CONTEXT_LENGTH,
**kwargs: Any,
) -> None:
super().__init__(
checkpoint=BACKBONE_REPO,
sequence_length=sequence_length,
context_length=context_length,
**kwargs,
)
# SHA attention replaces the SDPA path that torch.export.save could not
# serialize, but the graph is still traced rather than pt2-exported.
self.serialization_settings = SerializationSettings(use_pt2=False)
@classmethod
def from_pretrained(
cls,
sequence_length: int = PREFILL_SEQ_LEN,
context_length: int = CONTEXT_LENGTH,
) -> Self:
return cls(sequence_length=sequence_length, context_length=context_length)
def get_input_spec(
self,
llm_config: dict | None = None,
sequence_length: int = PREFILL_SEQ_LEN,
context_length: int = CONTEXT_LENGTH,
llm_io_type: LLMIOType = LLMIOType.genie_input_ids,
) -> InputSpec:
return self._get_input_spec(
num_hidden_layers=NUM_LAYERS,
sequence_length=sequence_length,
context_length=context_length,
hidden_size=HIDDEN_SIZE,
num_key_value_heads=NUM_KEY_VALUE_HEADS,
num_attention_heads=NUM_ATTN_HEADS,
head_dim=HEAD_DIM,
llm_io_type=llm_io_type,
)
def get_output_spec(self) -> OutputSpec:
return self._get_output_spec(NUM_LAYERS)
def sample_graph_inputs(self, sequence_length: int) -> SampleInputsType:
"""Inputs for one graph, with every position carrying a real token.
The prompt is tiled to fill the window rather than right-aligned in it.
Masked pad positions compute arbitrary finite values that the app throws
away, but an on-device comparison still scores them: right-aligning a
14-token prompt in a 128-wide window reports 11.8 dB PSNR where the
positions that matter are at 65 dB.
"""
ids = self.tokenizer(SAMPLE_PROMPT, return_tensors="pt")["input_ids"]
repeats = -(-sequence_length // int(ids.shape[1]))
input_ids = ids.repeat(1, repeats)[:, :sequence_length].to(torch.int32)
position_ids = torch.arange(sequence_length, dtype=torch.long).reshape(1, -1)
cos, sin = self.embedding.get_embedding(position_ids)
inputs: SampleInputsType = {
"input_ids": [input_ids.numpy()],
"attention_mask": [
build_attention_mask(
sequence_length, sequence_length, self.context_length
).numpy()
],
"position_ids_cos": [cos.numpy()],
"position_ids_sin": [sin.numpy()],
}
caches = empty_kv_cache(self.context_length - sequence_length)
for layer in range(NUM_LAYERS):
inputs[f"past_key_{layer}_in"] = [caches[2 * layer].numpy()]
inputs[f"past_value_{layer}_in"] = [caches[2 * layer + 1].numpy()]
return inputs
def _sample_inputs_impl(
self, input_spec: InputSpec | None = None
) -> SampleInputsType:
return self.sample_graph_inputs(self.sequence_length)
class NeuTTSNano(MultiGraphWorkbenchModel):
"""NeuTTS-Nano backbone as two graphs of one weight-shared context binary.
Prefill and decode are the same 340M parameters at two sequence lengths. A
fixed-shape graph cannot serve both, but one traced source compiles to both,
so the weights are uploaded once and linked into a single binary rather than
carried twice.
Everything outside the transformer -- phonemization, prompt assembly,
sampling, cache management, and NeuCodec encode/decode -- is CPU work in
``NeuTTSApp``. The codec is this model's speech tokenizer and is inherently
variable-length, so it is not a compiled graph.
"""
def __init__(self, backbone: Backbone) -> None:
self.backbone = backbone
self._graph_sequence_lengths = {
f"prompt_ar{PREFILL_SEQ_LEN}_cl{CONTEXT_LENGTH}": PREFILL_SEQ_LEN,
f"token_ar{DECODE_SEQ_LEN}_cl{CONTEXT_LENGTH}": DECODE_SEQ_LEN,
}
@property
def graph_names(self) -> list[str]:
return list(self._graph_sequence_lengths)
@property
def prefill_graph(self) -> str:
return f"prompt_ar{PREFILL_SEQ_LEN}_cl{CONTEXT_LENGTH}"
@property
def decode_graph(self) -> str:
return f"token_ar{DECODE_SEQ_LEN}_cl{CONTEXT_LENGTH}"
@property
def shared_source_model(self) -> bool:
return True
def get_graph_input_spec(self, graph_name: str) -> InputSpec:
return self.backbone.get_input_spec(
sequence_length=self._graph_sequence_lengths[graph_name],
context_length=self.backbone.context_length,
)
def get_graph_output_spec(self, graph_name: str) -> OutputSpec:
return self.backbone.get_output_spec()
def get_graph_sample_inputs(
self,
graph_name: str,
input_spec: InputSpec | None = None,
use_channel_last_format: bool = True,
) -> SampleInputsType:
return self.backbone.sample_graph_inputs(
self._graph_sequence_lengths[graph_name]
)
def serialize_graph(
self,
graph_name: str,
output_dir: str | os.PathLike,
input_spec: InputSpec | None = None,
) -> Path:
"""Trace the backbone once; the same source compiles at every graph shape.
Traced at the longest sequence length, so the graph handed to the
converter covers the widest reshapes.
Parameters
----------
graph_name
Unused -- one shared source serves every graph.
output_dir
Directory to write the traced module into.
input_spec
Unused -- the trace shape is fixed at the longest sequence length.
Returns
-------
Path
Path to the serialized TorchScript module.
"""
seq_len = max(self._graph_sequence_lengths.values())
inputs = [
torch.from_numpy(value[0])
for value in self.backbone.sample_graph_inputs(seq_len).values()
]
self.backbone.eval()
output_path = Path(output_dir) / f"{self.name}.pt"
with torch.no_grad():
torch.jit.save(
torch.jit.trace(self.backbone, inputs, check_trace=False), output_path
)
return output_path
@classmethod
def from_pretrained(cls) -> Self:
return cls(Backbone.from_pretrained())