Files
wehub-resource-sync 7ce4c8e27e
pre-commit / pre-run-check (push) Has been cancelled
pre-commit / pre-commit (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:55:37 +08:00

528 lines
20 KiB
Python

# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""
HRM-Text: Hierarchical Reasoning Model — Text variant.
Reference Hugging Face implementation:
src/transformers/models/hrm_text/modeling_hrm_text.py
The model performs a hierarchical recurrent forward over two transformer
stacks (``H`` slow, ``L`` fast) inside nested loops. Each recurrence step
gets its own KV cache slot via a unique vLLM-visible layer index. The
PrefixLM attention pattern (prompt bidirectional, response causal) is
realized by reusing ``EncoderOnlyAttention`` (which sets ``causal=False``
unconditionally on every metadata build) but with ``attn_type=DECODER``
so the KV cache is allocated; see ``HrmTextAttention`` for usage.
The on-disk ``attn.gqkv_proj.weight`` (rows concatenated as
``[gate | q | k | v]``) is loaded by a single
``MergedColumnParallelLinear`` with four equal-sized output partitions;
its weight loader auto-splits the fused tensor along the output dim by
``output_sizes`` (the same path used by Phi-3's fused gate_up_proj).
"""
from collections.abc import Iterable
from typing import Literal
import torch
from torch import nn
from transformers import PretrainedConfig
from vllm.compilation.decorators import support_torch_compile
from vllm.config import CacheConfig, VllmConfig
from vllm.distributed import get_tensor_model_parallel_world_size
from vllm.model_executor.layers.activation import SiluAndMul
from vllm.model_executor.layers.attention import PrefillPrefixLMAttention
from vllm.model_executor.layers.layernorm import RMSNorm
from vllm.model_executor.layers.linear import (
MergedColumnParallelLinear,
RowParallelLinear,
)
from vllm.model_executor.layers.logits_processor import LogitsProcessor
from vllm.model_executor.layers.quantization import QuantizationConfig
from vllm.model_executor.layers.rotary_embedding import get_rope
from vllm.model_executor.layers.vocab_parallel_embedding import (
ParallelLMHead,
VocabParallelEmbedding,
)
from vllm.sequence import IntermediateTensors
from .utils import AutoWeightsLoader, WeightsMapper, maybe_prefix
class HrmTextMLP(nn.Module):
def __init__(
self,
hidden_size: int,
intermediate_size: int,
hidden_act: str,
bias: bool = False,
quant_config: QuantizationConfig | None = None,
prefix: str = "",
) -> None:
super().__init__()
if hidden_act != "silu":
raise ValueError(
f"HrmTextMLP only supports hidden_act='silu', got {hidden_act!r}"
)
self.gate_up_proj = MergedColumnParallelLinear(
hidden_size,
[intermediate_size] * 2,
bias=bias,
quant_config=quant_config,
prefix=f"{prefix}.gate_up_proj",
)
self.down_proj = RowParallelLinear(
intermediate_size,
hidden_size,
bias=bias,
quant_config=quant_config,
prefix=f"{prefix}.down_proj",
)
self.act_fn = SiluAndMul()
def forward(self, x: torch.Tensor) -> torch.Tensor:
gate_up, _ = self.gate_up_proj(x)
x = self.act_fn(gate_up)
x, _ = self.down_proj(x)
return x
class HrmTextAttention(nn.Module):
"""One self-attention block; weights shared across recurrence steps.
HF transformers writes a single fused ``attn.gqkv_proj.weight`` on
disk (per ``transformers/conversion_mapping.py`` ``"hrm_text"``
mapping; rows are concatenated as ``[gate | q | k | v]`` along
``dim=0``). We mirror that on the model side with a single
``MergedColumnParallelLinear`` whose four equal output partitions
are sharded along the head axis under TP; its weight loader
auto-splits the fused tensor (same path used by Phi-3's fused
gate_up_proj). HF's runtime config currently hardcodes MHA
(``num_key_value_groups=1``); GQA would require ``QKVParallelLinear``
semantics for q/k/v shard replication and is left for a follow-up
if/when HF adds it.
Holds:
- parameters: gqkv_proj, o_proj, rotary_emb (shared across cycles).
- ``attn_per_step``: a ``nn.ModuleDict`` keyed by recurrence step
(as a string), each value an ``EncoderOnlyAttention`` (with
``attn_type=DECODER`` so the KV cache is allocated; the
``EncoderOnlyAttention`` wrapper sets ``causal=False`` on every
metadata build). The L stack steps are
``[high_cycle_idx*(L_cycles+1)+low_cycle_idx]`` and the H stack
steps are ``[high_cycle_idx*(L_cycles+1)+L_cycles]``; the two
ranges are disjoint so each instance registers a unique vLLM
``layer_name``
(``model.{H,L}_module.layers.{global_idx}.self_attn``) and gets
its own KV cache slot. The global layer index per recurrence step
is ``step * num_layers_per_stack + layer_idx_in_stack``, matching
the HF transformers ``cycle_offset`` formula in
``modeling_hrm_text.py``.
"""
def __init__(
self,
config: PretrainedConfig,
layer_idx_in_stack: int,
stack_kind: Literal["L", "H"],
cache_config: CacheConfig | None = None,
quant_config: QuantizationConfig | None = None,
prefix: str = "",
) -> None:
super().__init__()
self.config = config
self.hidden_size = config.hidden_size
tp_size = get_tensor_model_parallel_world_size()
self.total_num_heads = config.num_attention_heads
assert self.total_num_heads % tp_size == 0, (
f"num_attention_heads={self.total_num_heads} must be divisible "
f"by tp_size={tp_size}"
)
# HF main hardcodes MHA (num_key_value_groups=1). We follow.
self.total_num_kv_heads = config.num_attention_heads
self.num_heads = self.total_num_heads // tp_size
self.num_kv_heads = self.total_num_kv_heads // tp_size
self.head_dim = getattr(
config, "head_dim", self.hidden_size // self.total_num_heads
)
self.q_size = self.num_heads * self.head_dim
self.kv_size = self.num_kv_heads * self.head_dim
self.scaling = self.head_dim**-0.5
bias = getattr(config, "attention_bias", False)
# gqkv_proj: 4-way fused [gate | q | k | v] matching the on-disk
# `attn.gqkv_proj.weight` row layout. MergedColumnParallelLinear's
# weight_loader auto-splits the fused disk tensor along the output
# dim by `output_sizes` (Phi-3's fused gate_up_proj path). MHA
# only: GQA (num_kv_heads != num_heads) would need
# QKVParallelLinear semantics for q/k/v shard replication.
per_head_size = self.total_num_heads * self.head_dim
self.gqkv_proj = MergedColumnParallelLinear(
input_size=self.hidden_size,
output_sizes=[per_head_size] * 4,
bias=bias,
quant_config=quant_config,
prefix=f"{prefix}.gqkv_proj",
)
self.o_proj = RowParallelLinear(
input_size=self.total_num_heads * self.head_dim,
output_size=self.hidden_size,
bias=bias,
quant_config=quant_config,
prefix=f"{prefix}.o_proj",
)
# vllm get_rope accepts ``rope_parameters`` directly, matching
# the dict-shaped HF config field.
self.rotary_emb = get_rope(
head_size=self.head_dim,
max_position=config.max_position_embeddings,
rope_parameters=config.rope_parameters,
)
# Create one Attention instance per recurrence step actually used
# by this stack. L runs at steps {h*(L+1)+l : 0 <= l < L_cycles},
# H at steps {h*(L+1)+L : 0 <= h < H_cycles}; the sets are
# disjoint, so one global index per (step, layer_in_stack) gives
# each Attention its own ``layer_name`` and KV cache slot.
H_cycles = config.H_cycles
L_cycles = config.L_cycles
num_layers_per_stack = config.num_layers_per_stack
if stack_kind == "L":
steps_used = [
high_cycle_idx * (L_cycles + 1) + low_cycle_idx
for high_cycle_idx in range(H_cycles)
for low_cycle_idx in range(L_cycles)
]
else: # "H"
steps_used = [
high_cycle_idx * (L_cycles + 1) + L_cycles
for high_cycle_idx in range(H_cycles)
]
# `PrefillPrefixLMAttention` forces `causal=False` on every metadata
# build, so the prompt attends bidirectionally during prefill (matching
# the HRM-Text training distribution), while `attn_type=DECODER` keeps
# the KV cache allocation needed by the recurrent forward. At
# single-token decode `causal=False` is a no-op. See
# `PrefillPrefixLMAttention`.
self.attn_per_step = nn.ModuleDict()
for step in steps_used:
global_idx = step * num_layers_per_stack + layer_idx_in_stack
unique_prefix = prefix.replace(
f"layers.{layer_idx_in_stack}", f"layers.{global_idx}"
)
self.attn_per_step[str(step)] = PrefillPrefixLMAttention(
num_heads=self.num_heads,
head_size=self.head_dim,
scale=self.scaling,
num_kv_heads=self.num_kv_heads,
cache_config=cache_config,
quant_config=quant_config,
prefix=f"{unique_prefix}.attn",
)
def forward(
self,
positions: torch.Tensor,
hidden_states: torch.Tensor,
current_step: int,
) -> torch.Tensor:
gqkv, _ = self.gqkv_proj(hidden_states)
g, q, k, v = gqkv.split(
[self.q_size, self.q_size, self.kv_size, self.kv_size], dim=-1
)
q, k = self.rotary_emb(positions, q, k)
attn_out = self.attn_per_step[str(current_step)](q, k, v)
# Sigmoid gate. Shapes: attn_out is (..., q_size); g is (..., q_size).
attn_out = torch.sigmoid(g) * attn_out
out, _ = self.o_proj(attn_out)
return out
class HrmTextDecoderLayer(nn.Module):
def __init__(
self,
config: PretrainedConfig,
layer_idx_in_stack: int,
stack_kind: str,
cache_config: CacheConfig | None = None,
quant_config: QuantizationConfig | None = None,
prefix: str = "",
) -> None:
super().__init__()
self.hidden_size = config.hidden_size
# Attribute name `self_attn` matches HF's model class. The on-disk
# `attn.{gqkv_proj,o_proj}.weight` keys are renamed to
# `self_attn.{gqkv_proj,o_proj}.weight` by the `WeightsMapper` in
# `HrmTextForCausalLM` so vLLM's standard `AutoWeightsLoader`
# handles the rest.
self.self_attn = HrmTextAttention(
config=config,
layer_idx_in_stack=layer_idx_in_stack,
stack_kind=stack_kind,
cache_config=cache_config,
quant_config=quant_config,
prefix=f"{prefix}.self_attn",
)
self.mlp = HrmTextMLP(
hidden_size=config.hidden_size,
intermediate_size=config.intermediate_size,
hidden_act=config.hidden_act,
bias=getattr(config, "mlp_bias", False),
quant_config=quant_config,
prefix=f"{prefix}.mlp",
)
# Parameterless RMSNorm (HF main: HrmTextRMSNorm has no weight).
self.input_layernorm = RMSNorm(
config.hidden_size, eps=config.rms_norm_eps, has_weight=False
)
self.post_attention_layernorm = RMSNorm(
config.hidden_size, eps=config.rms_norm_eps, has_weight=False
)
def forward(
self,
positions: torch.Tensor,
hidden_states: torch.Tensor,
current_step: int,
) -> torch.Tensor:
residual = hidden_states
hidden_states = self.input_layernorm(hidden_states)
hidden_states = self.self_attn(
positions=positions,
hidden_states=hidden_states,
current_step=current_step,
)
hidden_states = residual + hidden_states
residual = hidden_states
hidden_states = self.post_attention_layernorm(hidden_states)
hidden_states = self.mlp(hidden_states)
hidden_states = residual + hidden_states
return hidden_states
class HrmTextStack(nn.Module):
"""A single transformer stack — used twice (H and L)."""
def __init__(
self,
config: PretrainedConfig,
stack_kind: str,
cache_config: CacheConfig | None = None,
quant_config: QuantizationConfig | None = None,
prefix: str = "",
) -> None:
super().__init__()
self.layers = nn.ModuleList(
[
HrmTextDecoderLayer(
config=config,
layer_idx_in_stack=i,
stack_kind=stack_kind,
cache_config=cache_config,
quant_config=quant_config,
prefix=f"{prefix}.layers.{i}",
)
for i in range(config.num_layers_per_stack)
]
)
self.final_norm = RMSNorm(
config.hidden_size, eps=config.rms_norm_eps, has_weight=False
)
def forward(
self,
positions: torch.Tensor,
hidden_states: torch.Tensor,
current_step_base: int,
) -> torch.Tensor:
for layer in self.layers:
hidden_states = layer(
positions=positions,
hidden_states=hidden_states,
current_step=current_step_base,
)
return self.final_norm(hidden_states)
@support_torch_compile
class HrmTextModel(nn.Module):
"""Hierarchical recurrent transformer body.
Forward (matches HF main exactly,
src/transformers/models/hrm_text/modeling_hrm_text.py:495-547):
hidden_states_high_cycle = embed(input_ids) * embedding_scale
hidden_states_low_cycle = z_L_init.expand_as(hidden_states_high_cycle)
for high_cycle_idx in range(H_cycles):
for low_cycle_idx in range(L_cycles):
step = high_cycle_idx * (L_cycles + 1) + low_cycle_idx
hidden_states_low_cycle = L_module(
hidden_states_low_cycle + hidden_states_high_cycle,
current_step=step,
)
step = high_cycle_idx * (L_cycles + 1) + L_cycles
hidden_states_high_cycle = H_module(
hidden_states_high_cycle + hidden_states_low_cycle,
current_step=step,
)
return hidden_states_high_cycle
"""
def __init__(self, *, vllm_config: VllmConfig, prefix: str = "") -> None:
super().__init__()
config = vllm_config.model_config.hf_config
cache_config = vllm_config.cache_config
quant_config = vllm_config.quant_config
self.config = config
self.embed_tokens = VocabParallelEmbedding(
config.vocab_size,
config.hidden_size,
quant_config=quant_config,
prefix=f"{prefix}.embed_tokens",
)
self.L_module = HrmTextStack(
config=config,
stack_kind="L",
cache_config=cache_config,
quant_config=quant_config,
prefix=f"{prefix}.L_module",
)
self.H_module = HrmTextStack(
config=config,
stack_kind="H",
cache_config=cache_config,
quant_config=quant_config,
prefix=f"{prefix}.H_module",
)
# Frozen learned initial L state. HF inits to zeros and sets
# requires_grad_(False); for inference we just load the tensor.
self.z_L_init = nn.Parameter(
torch.zeros(config.hidden_size), requires_grad=False
)
# Embedding scale: HF uses config.embedding_scale (default
# 1 / initializer_range = 50.0 when initializer_range=0.02). NOT
# sqrt(hidden_size) like Gemma.
self.embedding_scale = getattr(config, "embedding_scale", None)
if self.embedding_scale is None:
init_range = getattr(config, "initializer_range", 0.02)
self.embedding_scale = 1.0 / init_range
def embed_input_ids(self, input_ids: torch.Tensor) -> torch.Tensor:
return self.embed_tokens(input_ids) * self.embedding_scale
def forward(
self,
input_ids: torch.Tensor | None,
positions: torch.Tensor,
intermediate_tensors: IntermediateTensors | None = None,
inputs_embeds: torch.Tensor | None = None,
) -> torch.Tensor:
if inputs_embeds is None:
assert input_ids is not None
inputs_embeds = self.embed_input_ids(input_ids)
hidden_states_high_cycle = inputs_embeds
hidden_states_low_cycle = self.z_L_init.to(
dtype=hidden_states_high_cycle.dtype,
device=hidden_states_high_cycle.device,
).expand_as(hidden_states_high_cycle)
H_cycles = self.config.H_cycles
L_cycles = self.config.L_cycles
for high_cycle_idx in range(H_cycles):
for low_cycle_idx in range(L_cycles):
step = high_cycle_idx * (L_cycles + 1) + low_cycle_idx
hidden_states_low_cycle = self.L_module(
positions=positions,
hidden_states=hidden_states_low_cycle + hidden_states_high_cycle,
current_step_base=step,
)
step = high_cycle_idx * (L_cycles + 1) + L_cycles
hidden_states_high_cycle = self.H_module(
positions=positions,
hidden_states=hidden_states_high_cycle + hidden_states_low_cycle,
current_step_base=step,
)
return hidden_states_high_cycle
class HrmTextForCausalLM(nn.Module):
"""Hierarchical Reasoning Model — Text variant, causal LM.
Reference: src/transformers/models/hrm_text/modeling_hrm_text.py
"""
# On-disk weight key remap: HF stores attention weights as
# `attn.{gqkv_proj,o_proj}.weight`; our model uses `self_attn.*`
# (matching HF's runtime model class). Both `gqkv_proj` (4-way fused
# gate/q/k/v) and `mlp.gate_up_proj` (2-way fused gate/up) are loaded
# directly via MergedColumnParallelLinear's fused-on-disk path; no
# packed_modules_mapping entries are needed.
hf_to_vllm_mapper = WeightsMapper(
orig_to_new_substr={".attn.": ".self_attn."},
)
def __init__(self, *, vllm_config: VllmConfig, prefix: str = "") -> None:
super().__init__()
config = vllm_config.model_config.hf_config
quant_config = vllm_config.quant_config
self.config = config
self.quant_config = quant_config
if vllm_config.parallel_config.pipeline_parallel_size > 1:
raise ValueError(
"HrmTextForCausalLM does not support pipeline parallelism."
)
self.model = HrmTextModel(
vllm_config=vllm_config, prefix=maybe_prefix(prefix, "model")
)
if config.tie_word_embeddings:
self.lm_head = self.model.embed_tokens
else:
self.lm_head = ParallelLMHead(
config.vocab_size,
config.hidden_size,
quant_config=quant_config,
prefix=maybe_prefix(prefix, "lm_head"),
)
self.logits_processor = LogitsProcessor(config.vocab_size)
def embed_input_ids(self, input_ids: torch.Tensor) -> torch.Tensor:
return self.model.embed_input_ids(input_ids)
def forward(
self,
input_ids: torch.Tensor | None,
positions: torch.Tensor,
intermediate_tensors: IntermediateTensors | None = None,
inputs_embeds: torch.Tensor | None = None,
) -> torch.Tensor:
return self.model(
input_ids=input_ids,
positions=positions,
intermediate_tensors=intermediate_tensors,
inputs_embeds=inputs_embeds,
)
def compute_logits(
self,
hidden_states: torch.Tensor,
) -> torch.Tensor | None:
return self.logits_processor(self.lm_head, hidden_states)
def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
skip_prefixes = ["lm_head."] if self.config.tie_word_embeddings else None
loader = AutoWeightsLoader(self, skip_prefixes=skip_prefixes)
return loader.load_weights(weights, mapper=self.hf_to_vllm_mapper)