vortex-alpha / vortex_model.py
arthu1's picture
Publish Vortex Alpha research preview
d3ab6f5 verified
Raw
History Blame Contribute Delete
17.5 kB
"""Capability-first Vortex 175M decoder.
This file intentionally uses only standard PyTorch CUDA primitives. The
model is deep-and-thin, uses GQA and SwiGLU, and ties the input/output
embedding. Those choices are much easier to train and export than a custom
SSM kernel while retaining the main sub-billion-parameter wins reported by
MobileLLM-style studies.
"""
from __future__ import annotations
import math
from dataclasses import asdict, dataclass
import torch
import torch.nn.functional as F
from torch import nn
from torch.utils.checkpoint import checkpoint
@dataclass
class VortexConfig:
vocab_size: int = 8_192
max_seq_len: int = 4_096
n_layer: int = 12
n_embd: int = 1024
n_head: int = 16
n_kv_head: int = 4
head_dim: int = 64
intermediate_size: int = 3_664
rope_theta: float = 100_000.0
norm_eps: float = 1e-5
logits_chunk_tokens: int = 16_384
gradient_checkpointing: bool = False
use_transformer_engine: bool = False
attn_input_format: str = "bshd"
def to_dict(self) -> dict:
return asdict(self)
class RMSNorm(nn.Module):
def __init__(self, dim: int, eps: float) -> None:
super().__init__()
self.weight = nn.Parameter(torch.ones(dim))
self.eps = eps
def forward(self, x: torch.Tensor) -> torch.Tensor:
return F.rms_norm(x, (x.shape[-1],), self.weight, self.eps)
def _linear(config: VortexConfig, in_features: int, out_features: int) -> nn.Module:
"""Create a bias-free projection, optionally backed by Transformer Engine."""
if not config.use_transformer_engine:
return nn.Linear(in_features, out_features, bias=False)
try:
import transformer_engine.pytorch as te
except ImportError as exc: # pragma: no cover - exercised only on TE runs
raise RuntimeError(
"use_transformer_engine=True requires transformer-engine[pytorch]"
) from exc
return te.Linear(
in_features,
out_features,
bias=False,
params_dtype=torch.bfloat16,
device="cuda",
)
class RotaryEmbedding(nn.Module):
def __init__(self, dim: int, max_seq_len: int, theta: float) -> None:
super().__init__()
inv_freq = 1.0 / (theta ** (torch.arange(0, dim, 2).float() / dim))
positions = torch.arange(max_seq_len, dtype=torch.float32)
frequencies = torch.outer(positions, inv_freq)
# NeoX-style rotate-half layout: the first and second halves share
# the same frequencies, so rotate_half() remains allocation-free in
# the hot attention path apart from its concatenation.
angles = torch.cat((frequencies, frequencies), dim=-1)
self.register_buffer("cos_cached", angles.cos()[None, None], persistent=False)
self.register_buffer("sin_cached", angles.sin()[None, None], persistent=False)
@staticmethod
def rotate_half(x: torch.Tensor) -> torch.Tensor:
half = x.shape[-1] // 2
return torch.cat((-x[..., half:], x[..., :half]), dim=-1)
def forward(self, q: torch.Tensor, k: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
seq_len = q.shape[-2]
if seq_len > self.cos_cached.shape[-2]:
raise ValueError(f"sequence length {seq_len} exceeds configured maximum")
cos = self.cos_cached[:, :, :seq_len].to(dtype=q.dtype)
sin = self.sin_cached[:, :, :seq_len].to(dtype=q.dtype)
return (
q * cos + self.rotate_half(q) * sin,
k * cos + self.rotate_half(k) * sin,
)
class GQAAttention(nn.Module):
def __init__(self, config: VortexConfig) -> None:
super().__init__()
if config.n_head % config.n_kv_head:
raise ValueError("n_head must be divisible by n_kv_head")
if config.n_head * config.head_dim != config.n_embd:
raise ValueError("n_head * head_dim must equal n_embd")
self.n_head = config.n_head
self.n_kv_head = config.n_kv_head
self.head_dim = config.head_dim
kv_dim = config.n_kv_head * config.head_dim
self.q_proj = _linear(config, config.n_embd, config.n_embd)
self.k_proj = _linear(config, config.n_embd, kv_dim)
self.v_proj = _linear(config, config.n_embd, kv_dim)
self.o_proj = _linear(config, config.n_embd, config.n_embd)
# QK-Norm keeps attention logits well-conditioned at the deliberately
# high pretraining learning rate. These are per-head, parameter-light
# norms, not full hidden-size projections.
self.q_norm = RMSNorm(config.head_dim, config.norm_eps)
self.k_norm = RMSNorm(config.head_dim, config.norm_eps)
self.rope = RotaryEmbedding(config.head_dim, config.max_seq_len, config.rope_theta)
def forward(self, x: torch.Tensor) -> torch.Tensor:
batch, seq_len, _ = x.shape
q = self.q_proj(x).view(batch, seq_len, self.n_head, self.head_dim).transpose(1, 2)
k = self.k_proj(x).view(batch, seq_len, self.n_kv_head, self.head_dim).transpose(1, 2)
v = self.v_proj(x).view(batch, seq_len, self.n_kv_head, self.head_dim).transpose(1, 2)
q, k = self.rope(self.q_norm(q), self.k_norm(k))
# PyTorch dispatches this to the fused flash/efficient causal kernel
# when the local CUDA build supports it. enable_gqa avoids material-
# izing repeated K/V heads.
y = F.scaled_dot_product_attention(
q, k, v, is_causal=True, enable_gqa=True
)
y = y.transpose(1, 2).contiguous().view(batch, seq_len, -1)
return self.o_proj(y)
class SwiGLU(nn.Module):
def __init__(self, config: VortexConfig) -> None:
super().__init__()
self.gate_proj = _linear(config, config.n_embd, config.intermediate_size)
self.up_proj = _linear(config, config.n_embd, config.intermediate_size)
self.down_proj = _linear(config, config.intermediate_size, config.n_embd)
def forward(self, x: torch.Tensor) -> torch.Tensor:
return self.down_proj(F.silu(self.gate_proj(x)) * self.up_proj(x))
class VortexBlock(nn.Module):
def __init__(self, config: VortexConfig, layer_number: int | None = None) -> None:
super().__init__()
self.use_transformer_engine = config.use_transformer_engine
self.attn_input_format = config.attn_input_format
if self.use_transformer_engine:
try:
import transformer_engine.pytorch as te
except ImportError as exc: # pragma: no cover - TE-only path
raise RuntimeError(
"use_transformer_engine=True requires transformer-engine[pytorch]"
) from exc
# This fused layer has the same parameter shapes as the explicit
# reference block: fused GQA QKV, RMSNorm, QK-Norm, SwiGLU, and
# the causal attention kernel. It is used only for the TE backend;
# the reference PyTorch path remains readable and exportable.
self.te_layer = te.TransformerLayer(
hidden_size=config.n_embd,
ffn_hidden_size=config.intermediate_size,
num_attention_heads=config.n_head,
num_gqa_groups=config.n_kv_head,
layernorm_epsilon=config.norm_eps,
hidden_dropout=0.0,
attention_dropout=0.0,
kv_channels=config.head_dim,
layer_number=layer_number,
bias=False,
activation="swiglu",
normalization="RMSNorm",
qk_norm_type="RMSNorm",
qk_norm_before_rope=True,
fuse_qkv_params=True,
self_attn_mask_type="causal",
attn_input_format=config.attn_input_format,
params_dtype=torch.bfloat16,
device="cuda",
)
return
self.norm1 = RMSNorm(config.n_embd, config.norm_eps)
self.attn = GQAAttention(config)
self.norm2 = RMSNorm(config.n_embd, config.norm_eps)
self.ffn = SwiGLU(config)
def forward(
self,
x: torch.Tensor,
rotary_pos_emb: torch.Tensor | None = None,
is_first_microbatch: bool | None = None,
inference_params=None,
attention_mask: torch.Tensor | None = None,
inference_decode_bshd: bool = False,
) -> torch.Tensor:
if self.use_transformer_engine:
if inference_params is not None:
# A packed THD prompt handles variable-length prefill
# efficiently. Subsequent one-token decode is cheaper and
# more numerically stable through the regular BSHD cache
# path; switch the TE attention format explicitly between the
# two phases.
effective_format = "bshd" if inference_decode_bshd else self.attn_input_format
self.te_layer.self_attention.qkv_format = effective_format
te_kwargs = {
"attention_mask": attention_mask,
"self_attn_mask_type": (
"padding_causal" if inference_params is not None else None
),
"rotary_pos_emb": rotary_pos_emb,
"is_first_microbatch": is_first_microbatch,
"inference_params": inference_params,
}
if inference_params is not None and not inference_decode_bshd and self.attn_input_format == "thd":
batch_size = len(inference_params.sequences)
cu_seqlens = inference_params.cu_seqlens_q[: batch_size + 1]
sequence_lengths = cu_seqlens[1:] - cu_seqlens[:-1]
te_kwargs.update(
{
"cu_seqlens_q": cu_seqlens,
"cu_seqlens_q_padded": cu_seqlens,
"max_seqlen_q": int(sequence_lengths.max().item()),
"max_seqlen_kv": int(sequence_lengths.max().item()),
}
)
return self.te_layer(
x,
**te_kwargs,
)
x = x + self.attn(self.norm1(x))
x = x + self.ffn(self.norm2(x))
return x
class VortexForCausalLM(nn.Module):
def __init__(self, config: VortexConfig | None = None) -> None:
super().__init__()
self.config = config or VortexConfig()
self.embed_tokens = nn.Embedding(self.config.vocab_size, self.config.n_embd)
self.layers = nn.ModuleList(
VortexBlock(self.config, layer_number=index + 1)
for index in range(self.config.n_layer)
)
self.norm = RMSNorm(self.config.n_embd, self.config.norm_eps)
if self.config.use_transformer_engine:
import transformer_engine.pytorch as te
self.rotary = te.RotaryPositionEmbedding(
self.config.head_dim,
rotary_base=self.config.rope_theta,
interleaved=False,
)
self._initialize_weights()
def _initialize_weights(self) -> None:
# Scale residual outputs down with depth; this gives a forgiving high-
# LR start without adding trainable parameters.
output_std = 0.02 / math.sqrt(2.0 * self.config.n_layer)
nn.init.normal_(self.embed_tokens.weight, mean=0.0, std=0.02)
for block in self.layers:
if self.config.use_transformer_engine:
for name, parameter in block.named_parameters():
if parameter.ndim == 1:
nn.init.ones_(parameter)
else:
is_output = (
name.endswith("self_attention.proj.weight")
or name.endswith("layernorm_mlp.fc2_weight")
)
nn.init.normal_(
parameter,
mean=0.0,
std=output_std if is_output else 0.02,
)
else:
for child in block.modules():
if isinstance(child, nn.Linear):
is_output = child is block.attn.o_proj or child is block.ffn.down_proj
nn.init.normal_(
child.weight,
mean=0.0,
std=output_std if is_output else 0.02,
)
def parameter_count(self) -> int:
return sum(parameter.numel() for parameter in self.parameters())
def parameter_breakdown(self) -> dict[str, int]:
c = self.config
embedding = c.vocab_size * c.n_embd
q = c.n_layer * c.n_embd * c.n_embd
k = c.n_layer * c.n_embd * (c.n_kv_head * c.head_dim)
v = k
o = q
# One learned head-dimension scale is shared across all query heads,
# and another across all KV heads, matching the module definitions
# above (not one scale vector per physical head).
qk_norm = c.n_layer * 2 * c.head_dim
ffn = c.n_layer * 3 * c.n_embd * c.intermediate_size
block_norm = c.n_layer * 2 * c.n_embd
final_norm = c.n_embd
return {
"input_embedding_and_tied_output": embedding,
"attention_q_projection": q,
"attention_k_projection": k,
"attention_v_projection": v,
"attention_o_projection": o,
"attention_qk_norm": qk_norm,
"ffn_swiglu": ffn,
"block_rmsnorm": block_norm,
"final_rmsnorm": final_norm,
"total": self.parameter_count(),
}
def _chunked_tied_loss(self, hidden: torch.Tensor, targets: torch.Tensor) -> torch.Tensor:
chunk = self.config.logits_chunk_tokens
total = hidden.new_zeros((), dtype=torch.float32)
for start in range(0, hidden.shape[0], chunk):
end = min(hidden.shape[0], start + chunk)
logits = F.linear(hidden[start:end], self.embed_tokens.weight)
total = total + F.cross_entropy(logits, targets[start:end]).float() * (end - start)
return total / hidden.shape[0]
def forward(
self,
input_ids: torch.Tensor,
labels: torch.Tensor | None = None,
is_first_microbatch: bool | None = None,
inference_params=None,
inference_attention_mask=None,
inference_decode_bshd: bool = False,
) -> tuple[torch.Tensor | None, torch.Tensor | None]:
x = self.embed_tokens(input_ids)
rotary_pos_emb = None
attention_mask = None
if self.config.use_transformer_engine:
if inference_params is not None:
# TE's cached-attention path applies the correct absolute
# offset from inference_params. Supplying the full table is
# necessary when the current query is only one token long but
# starts after a long cached prefix.
rotary_pos_emb = self.rotary(self.config.max_seq_len)
if self.config.attn_input_format == "thd" and not inference_decode_bshd:
attention_mask = None
elif inference_attention_mask is None:
query_padding_mask = torch.zeros(
(x.shape[0], 1, 1, x.shape[1]),
dtype=torch.bool,
device=x.device,
)
key_padding_mask = torch.ones(
(x.shape[0], 1, 1, inference_params.max_sequence_length),
dtype=torch.bool,
device=x.device,
)
for batch_index, sequence_length in enumerate(
inference_params.sequences.values()
):
key_padding_mask[batch_index, :, :, :sequence_length] = False
# TE switches the cached self-attention implementation to
# its cross-attention backend internally; that backend
# expects the query and key padding masks as a pair.
attention_mask = (query_padding_mask, key_padding_mask)
else:
attention_mask = inference_attention_mask
else:
rotary_pos_emb = self.rotary(x.shape[1])
for block in self.layers:
if self.training and self.config.gradient_checkpointing:
x = checkpoint(
block,
x,
rotary_pos_emb,
is_first_microbatch,
use_reentrant=False,
)
else:
x = block(
x,
rotary_pos_emb,
is_first_microbatch,
inference_params,
attention_mask,
inference_decode_bshd,
)
x = self.norm(x)
if labels is None:
return F.linear(x, self.embed_tokens.weight), None
hidden = x[:, :-1].reshape(-1, x.shape[-1])
targets = labels[:, 1:].reshape(-1)
return None, self._chunked_tied_loss(hidden, targets)
if __name__ == "__main__":
config = VortexConfig()
model = VortexForCausalLM(config)
print(config.to_dict())
print(model.parameter_breakdown())