diff --git a/README.md b/README.md index 9a8b365f0..a28455b66 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,71 @@ 当前版本依赖[`InfiniCore v0.2.9`](https://github.com/InfiniTensor/InfiniCore/releases/tag/v0.2.9)版本。 +### Mamba-2 + +Mamba-2 requires an InfiniCore build containing `mamba2_scan`, the reduction +graph support, and the graph allocator fixes accompanying this adaptation. +Use matching InfiniLM/InfiniCore revisions; the v0.2.9 release alone is insufficient. +On NVIDIA, enable Core's `nv-gpu`, `aten` (device dtype conversions), `ccl` +(TP), and `graph` (Decode graphs) build options. +On MetaX, use `metax-gpu=y use-mc=y aten=y ccl=y graph=y` and a matching +MACA PyTorch build. Set `INFINIOP_METAX_ALLOW_TF32=0` before starting a process +for strict FP32 comparisons; MetaX GEMM otherwise retains its TF32 default. + +The initial checkpoint is `state-spaces/mamba2-130m`. Prepare the native +checkpoint and the `EleutherAI/gpt-neox-20b` tokenizer from local directories: + +```bash +python scripts/prepare_mamba2_checkpoint.py \ + --source /models/mamba2-130m-native \ + --tokenizer /models/gpt-neox-tokenizer \ + --output /models/mamba2-130m + +python examples/test_infer.py --device nvidia --model /models/mamba2-130m \ + --enable-paged-attn --attn paged-attn --disable-prefix-caching \ + --num-blocks 64 --max-new-tokens 32 --prompt "The capital of France is" +``` + +Add `--enable-graph` for eager Prefill plus Decode graphs, or `--tp 2` with +two visible GPUs. The same prepared directory works with the existing service +and benchmark entrypoints. This is a base language model for text continuation. +For MetaX, replace `--device nvidia` with `--device metax`. The validated C500 +configuration is TP1 on a 50% compute / 32,000 MiB slice with MACA 3.5.3 and +PyTorch 2.8.0+metax3.5.3.9; C500 TP2 has not been validated. + +The NVIDIA and MetaX implementations cover FP32, FP16 and BF16 activations with FP32 +residuals and SSM state. The preparation tool defaults to BF16 activation +configuration and preserves source weight precision; setting `torch_dtype` +in the prepared config selects FP16 or FP32. The loader retains the source +precision of state parameters and normalization weights in FP32. + +The model uses the existing paged **request-state** interface, with separate +convolution and SSM state rather than Attention KV pages. The state pool has +`max(2, num_blocks // 4)` rows, including a reserved zero row, so its request +capacity is one less than that value. For 130M, each row occupies about +18.25 MiB in TP1 BF16. The scheduler still applies its logical page budget. + +Current scope is pure Mamba-2 with one B/C group, convolution width 4, +head-wise D, gated RMSNorm after gating, and unbounded time steps. PP, hybrid +Attention/SSM layers, quantization, prefix caching, remote state transfer, +speculative rollback and scheduler chunked Prefill are outside this adaptation. +The SSD kernel's internal chunks do not enable scheduler chunking. + +Device kernels live in InfiniCore under `src/infiniop/ops/mamba2_scan/`. +NVIDIA and MetaX share the scan kernels through runtime-specific launch wrappers +and the same indexed-state and workspace contract. Moore and Ascend backends +are not implemented by this adaptation. Model math stays on the device. + +Targeted validation can be run with a prepared checkpoint: + +```bash +INFINILM_MAMBA2_MODEL=/models/mamba2-130m \ +INFINILM_MAMBA2_TP=1 INFINILM_MAMBA2_GRAPH=1 \ +NVIDIA_TF32_OVERRIDE=0 \ +INFINIOP_METAX_ALLOW_TF32=0 \ +python -m pytest test/models/mamba2 -q +``` + ## 使用方式 #### 一、编译并安装 `InfiniCore` 编译并安装 `InfiniCore`, 详情见 InfiniCore的 [`README`](https://github.com/InfiniTensor/InfiniCore) : diff --git a/csrc/engine/compiler/paged_compiler.cpp b/csrc/engine/compiler/paged_compiler.cpp index dee3123c9..8a0f4c1df 100644 --- a/csrc/engine/compiler/paged_compiler.cpp +++ b/csrc/engine/compiler/paged_compiler.cpp @@ -4,6 +4,7 @@ #include #include +#include #include #include @@ -23,6 +24,43 @@ bool has_mamba_cache(const infinilm::global_state::ForwardContext &forward_conte return has_state(forward_context.conv_state_vec) || has_state(forward_context.ssm_state_vec); } +class CacheStateGuard { +public: + void save(const infinicore::Tensor &state, size_t rows) { + if (!state) { + return; + } + save_region(state->narrow({{0, 1, rows}})); + } + + void save_region(const infinicore::Tensor ®ion) { + auto backup = infinicore::Tensor::empty(region->shape(), region->dtype(), region->device()); + backup->copy_from(region); + saved_.emplace_back(region, backup); + } + + void restore() { + for (auto &[region, backup] : saved_) { + region->copy_from(backup); + } + if (!saved_.empty()) { + infinicore::context::syncStream(); + saved_.clear(); + } + } + + ~CacheStateGuard() { + try { + restore(); + } catch (const std::exception &error) { + spdlog::error("Failed to restore request states after graph capture: {}", error.what()); + } + } + +private: + std::vector> saved_; +}; + } // namespace PagedCompiler::PagedCompiler(const std::shared_ptr &model, RankBarrier *barrier) @@ -70,8 +108,40 @@ void PagedCompiler::compile() { throw std::runtime_error("PagedCompiler: position_id_axes must be positive"); } - size_t max_batch_size = *std::max_element(decode_batch_sizes_.begin(), decode_batch_sizes_.end()); + infinicore::context::syncStream(); compiled_map_decode_.clear(); + if (decode_batch_sizes_.empty()) { + return; + } + size_t max_batch_size = *std::max_element(decode_batch_sizes_.begin(), decode_batch_sizes_.end()); + if (has_mamba_state) { + for (const auto *states : {&forward_context.conv_state_vec, &forward_context.ssm_state_vec}) { + for (const auto &state : *states) { + if (state) { + const size_t capacity = state->size(0) == 0 ? 0 : state->size(0) - 1; + max_batch_size = std::min(max_batch_size, capacity); + } + } + } + } + if (max_batch_size == 0) { + return; + } + CacheStateGuard state_guard; + if (has_mamba_state) { + for (const auto *states : {&forward_context.conv_state_vec, &forward_context.ssm_state_vec}) { + for (const auto &state : *states) { + state_guard.save(state, max_batch_size); + } + } + } + // Warmup and capture write into physical page zero. Preserve it so + // recapturing also remains safe while a request owns that page. + for (const auto &kv : forward_context.kv_cache_vec) { + if (kv) { + state_guard.save_region(kv->narrow({{1, 0, 1}})); + } + } block_tables_holder_ = infinicore::Tensor::empty( {nblocks * max_batch_size}, infinicore::DataType::I32, infinicore::context::getDevice()); set_zeros(block_tables_holder_); @@ -109,7 +179,8 @@ void PagedCompiler::compile() { input.mamba_final_state_indices = infinicore::Tensor::empty( {b}, infinicore::DataType::I32, infinicore::context::getDevice()); std::vector init_state_indices_vec(b, 0); - std::vector final_state_indices_vec(b, 1); + std::vector final_state_indices_vec(b); + std::iota(final_state_indices_vec.begin(), final_state_indices_vec.end(), 1); infinicore::context::memcpyH2D( input.mamba_init_state_indices.value()->data(), init_state_indices_vec.data(), @@ -155,6 +226,9 @@ void PagedCompiler::compile() { } for (size_t b : decode_batch_sizes_) { + if (b > max_batch_size) { + continue; + } auto input = make_decode_input(b); barrier_->wait(); @@ -176,6 +250,7 @@ void PagedCompiler::compile() { compiled_map_decode_[b] = CompiledResult{std::move(input), std::make_tuple(graph, shared_output)}; } + state_guard.restore(); } } diff --git a/csrc/layers/quantization/none_quantization.cpp b/csrc/layers/quantization/none_quantization.cpp index 3184da991..b7d5ccc81 100644 --- a/csrc/layers/quantization/none_quantization.cpp +++ b/csrc/layers/quantization/none_quantization.cpp @@ -82,12 +82,13 @@ std::vector NoneQuantization::split_params( std::vector result; auto weight_it = params.find("weight"); auto bias_it = params.find("bias"); + const int weight_dim = weight_prepacked_ ? 1 - narrow_dim : narrow_dim; for (const auto &s : splits) { result.push_back({s.prefix + ".weight", infinicore::nn::Parameter( - weight_it->second->narrow({{static_cast(narrow_dim), s.start, s.size}}), - narrow_dim, tp_rank, tp_size, s.num_shards)}); + weight_it->second->narrow({{static_cast(weight_dim), s.start, s.size}}), + weight_dim, tp_rank, tp_size, s.num_shards)}); if (bias_it != params.end()) { result.push_back({s.prefix + ".bias", infinicore::nn::Parameter( @@ -104,7 +105,7 @@ std::shared_ptr NoneQuantization::process_weights_after_loadin int /*split_dim*/) const { // Controlled by --pre-transpose CLI flag, default off. - if (!global_state::get_infinilm_config().pre_transpose) { + if (!global_state::get_infinilm_config().pre_transpose || weight_prepacked_) { return nullptr; } @@ -115,15 +116,13 @@ std::shared_ptr NoneQuantization::process_weights_after_loadin // subsequent forwards can feed it directly to GEMM. params["weight"] = weight_it->second->permute({1, 0})->contiguous(); - // Mark as pre-packed so forward() uses linear_packed. - weight_prepacked_ = true; + // A quantization object may be shared by several unprocessed linears. + auto packed = std::make_shared(get_config()); + packed->weight_prepacked_ = true; + return packed; } - // Must return non-null so that BaseLinear::process_weights_after_loading - // writes the modified params back into parameters_. - // Returning shared_from_this() triggers the "quantization changed" path - // which calls parameters_.clear() + re-insert from params. - return std::const_pointer_cast(shared_from_this()); + return nullptr; } } // namespace infinilm::quantization diff --git a/csrc/layers/quantization/none_quantization.hpp b/csrc/layers/quantization/none_quantization.hpp index 108e87d7e..69bb3a57a 100644 --- a/csrc/layers/quantization/none_quantization.hpp +++ b/csrc/layers/quantization/none_quantization.hpp @@ -6,7 +6,7 @@ namespace infinilm::quantization { class NoneQuantization : public BaseQuantization { public: explicit NoneQuantization(const nlohmann::json &quant_config) - : BaseQuantization(quant_config){}; + : BaseQuantization(quant_config) {}; NoneQuantization(); @@ -40,15 +40,14 @@ class NoneQuantization : public BaseQuantization { int narrow_dim, int tp_rank, int tp_size, int tp_num_heads) const override; - // Ascend: pre-pack weight to [IC, OC] after loading to skip runtime permute. - // Returns shared_from_this() only on Ascend; nullptr otherwise (no-op). + // Pre-pack to [IC, OC] when enabled and return a per-linear layout state. std::shared_ptr process_weights_after_loading( ParamsMap ¶ms, const infinicore::Device &device, int split_dim = -1) const override; private: - mutable bool weight_prepacked_ = false; // true when weight was pre-packed for Ascend + bool weight_prepacked_ = false; }; } // namespace infinilm::quantization diff --git a/csrc/models/mamba2/mamba2_for_causal_lm.cpp b/csrc/models/mamba2/mamba2_for_causal_lm.cpp new file mode 100644 index 000000000..68fbfbde8 --- /dev/null +++ b/csrc/models/mamba2/mamba2_for_causal_lm.cpp @@ -0,0 +1,132 @@ +#include "mamba2_for_causal_lm.hpp" +#include "../models_registry.hpp" +#include +#include + +namespace infinilm::models::mamba2 { +namespace { + +class TiedLMHead final : public layers::linear::ReplicatedLinear { +public: + explicit TiedLMHead(const infinicore::Tensor &weight) + : layers::linear::ReplicatedLinear(weight->size(1), weight->size(0), false, weight->dtype(), weight->device()) { + register_parameter("weight", infinicore::nn::Parameter(weight)); + } + + // Preserve the embedding's shared layout when other projections are packed. + void process_weights_after_loading() override {} +}; + +} // namespace + +std::shared_ptr create_mamba2_model_config(std::shared_ptr config) { + auto &j = config->get_config_json(); + if (j.at("model_type") != "mamba2") { + throw std::runtime_error("Expected `model_type=mamba2`."); + } + for (const auto *key : {"hidden_size", "num_hidden_layers", "vocab_size", "num_heads", "head_dim", "state_size"}) { + if (config->get(key) <= 0) { + throw std::runtime_error(std::string("Mamba-2 requires positive `") + key + "`."); + } + } + const auto hidden = config->get("hidden_size"); + const auto intermediate = hidden * config->get_or("expand", 2); + const auto heads = config->get("num_heads"); + const auto head_dim = config->get("head_dim"); + if (config->get_or("expand", 2) <= 0 || heads * head_dim != intermediate + || config->get("state_size") > 256 + || config->get_or("hidden_act", "silu") != "silu" + || config->get_or("intermediate_size", intermediate) != intermediate + || config->get_or("n_groups", 1) != 1 + || config->get_or("conv_kernel", 4) != 4 + || config->get_or("use_bias", false) + || !config->get_or("use_conv_bias", true) + || config->get_or("norm_before_gate", false) + || !config->get_or("residual_in_fp32", true) + || !config->get_or("rms_norm", true) + || !config->get_or("rmsnorm", true) + || config->get_or("D_has_hdim", false) + || config->get_or("d_ssm", intermediate) != intermediate + || config->get_or("d_intermediate", 0) != 0 + || (j.contains("attn_layer_idx") && !j["attn_layer_idx"].empty()) + || (j.contains("quantization_config") && !j["quantization_config"].empty())) { + throw std::runtime_error("Unsupported Mamba-2 configuration; use the checkpoint preparation tool."); + } + for (const auto *key : {"dt_limit", "time_step_limit"}) { + if (j.contains(key)) { + throw std::runtime_error("Explicit time-step limits are not supported; omit the field for the unbounded Mamba-2 scan."); + } + } + j["intermediate_size"] = intermediate; + j["layer_norm_epsilon"] = j.value("layer_norm_epsilon", 1e-5); + j["rms_norm_eps"] = j["layer_norm_epsilon"]; + const double epsilon = config->get("layer_norm_epsilon"); + if (!std::isfinite(epsilon) || epsilon <= 0) { + throw std::runtime_error("Mamba-2 requires a finite positive normalization epsilon."); + } + return config; +} + +Mamba2ForCausalLM::Mamba2ForCausalLM(std::shared_ptr config, const infinicore::Device &device) + : TextCausalLM(config, device) { + if (config->get_or("tie_word_embeddings", true)) { + lm_head_ = register_module("lm_head", model_->embedding_weight()); + } +} + +Mamba2Model::Mamba2Model(std::shared_ptr config, const infinicore::Device &device) + : dtype_(config->get_dtype()) { + const auto &rank = global_state::get_tensor_model_parallel_rank_info(); + if (rank.pp_size != 1) { + throw std::runtime_error("Mamba-2 currently requires `pp_size=1`."); + } + const auto hidden = config->get("hidden_size"); + INFINICORE_NN_MODULE_INIT(embeddings, config->get("vocab_size"), hidden, std::nullopt, dtype_, device); + for (size_t i = 0; i < config->get("num_hidden_layers"); ++i) { + layers_.push_back(register_module("layers." + std::to_string(i), config, i, device)); + } + INFINICORE_NN_MODULE_INIT(norm_f, hidden, config->get("layer_norm_epsilon"), infinicore::DataType::F32, device); +} + +infinicore::Tensor Mamba2Model::forward(const InfinilmModel::Input &input) const { + if (!input.input_offsets || !input.mamba_init_state_indices || !input.mamba_final_state_indices) { + throw std::runtime_error("Mamba-2 requires packed offsets and request state indices."); + } + auto ids = input.input_ids.value()->view({1, input.input_ids.value()->numel()}); + auto residual = cast_activation(embeddings_->forward(ids), infinicore::DataType::F32); + for (const auto &layer : layers_) { + residual = layer->forward(residual); + } + return cast_activation(norm_f_->forward(residual), dtype_); +} + +void Mamba2ForCausalLM::reset_cache(const cache::CacheConfig *config) { + auto &context = global_state::get_forward_context(); + context.kv_cache_vec.clear(); + context.conv_state_vec.clear(); + context.ssm_state_vec.clear(); + cache_config_ = config ? config->unique_copy() : nullptr; + if (config == nullptr) { + return; + } + const auto *paged = dynamic_cast(config); + if (paged == nullptr) { + throw std::runtime_error("Mamba-2 requires the paged request-state cache interface."); + } + const size_t pool = std::max(2, paged->num_blocks() / 4); + const auto heads = model_config_->get("num_heads") / global_state::get_tensor_model_parallel_world_size(); + const auto head_dim = model_config_->get("head_dim"); + const auto state_size = model_config_->get("state_size"); + const auto conv_dim = heads * head_dim + 2 * state_size; + const auto device = infinicore::context::getDevice(); + for (size_t i = 0; i < model_config_->get("num_hidden_layers"); ++i) { + context.conv_state_vec.push_back(infinicore::Tensor::zeros({pool, conv_dim, 3}, model_config_->get_dtype(), device)); + context.ssm_state_vec.push_back(infinicore::Tensor::zeros({pool, heads, head_dim, state_size}, infinicore::DataType::F32, device)); + } +} + +} // namespace infinilm::models::mamba2 + +namespace { +INFINILM_REGISTER_CAUSAL_LM_MODEL(mamba2, infinilm::models::mamba2::Mamba2ForCausalLM, infinilm::models::mamba2::create_mamba2_model_config); +} // namespace diff --git a/csrc/models/mamba2/mamba2_for_causal_lm.hpp b/csrc/models/mamba2/mamba2_for_causal_lm.hpp new file mode 100644 index 000000000..657949d1d --- /dev/null +++ b/csrc/models/mamba2/mamba2_for_causal_lm.hpp @@ -0,0 +1,30 @@ +#pragma once + +#include "../../layers/causal_lm_templates/text_causal_lm.hpp" +#include "infinicore/nn/embedding.hpp" +#include "mamba2_mixer.hpp" + +namespace infinilm::models::mamba2 { + +class Mamba2Model : public infinicore::nn::Module { +public: + Mamba2Model(std::shared_ptr config, const infinicore::Device &device); + infinicore::Tensor forward(const InfinilmModel::Input &input) const; + infinicore::Tensor embedding_weight() const { return embeddings_->weight(); } + +private: + infinicore::DataType dtype_; + INFINICORE_NN_MODULE(infinicore::nn::Embedding, embeddings); + INFINICORE_NN_MODULE_VEC(Mamba2Block, layers); + INFINICORE_NN_MODULE(infinicore::nn::RMSNorm, norm_f); +}; + +class Mamba2ForCausalLM : public layers::causal_lm_templates::TextCausalLM { +public: + Mamba2ForCausalLM(std::shared_ptr config, const infinicore::Device &device); + void reset_cache(const cache::CacheConfig *config) override; +}; + +std::shared_ptr create_mamba2_model_config(std::shared_ptr config); + +} // namespace infinilm::models::mamba2 diff --git a/csrc/models/mamba2/mamba2_mixer.cpp b/csrc/models/mamba2/mamba2_mixer.cpp new file mode 100644 index 000000000..68885ee08 --- /dev/null +++ b/csrc/models/mamba2/mamba2_mixer.cpp @@ -0,0 +1,130 @@ +#include "mamba2_mixer.hpp" +#include "../../global_state/global_state.hpp" +#include "infinicore/ops/cat.hpp" +#include "infinicore/ops/distributed/allreduce.hpp" +#include "infinicore/ops/float_power.hpp" +#include "infinicore/ops/mamba2_scan.hpp" +#include "infinicore/ops/mul.hpp" +#include "infinicore/ops/sum.hpp" + +namespace infinilm::models::mamba2 { + +infinicore::Tensor cast_activation(const infinicore::Tensor &input, infinicore::DataType dtype) { + if (input->dtype() == dtype) { + return input; + } + auto output = infinicore::Tensor::empty(input->shape(), dtype, input->device()); + infinicore::op::cast_(output, input); + return output; +} + +Mamba2Mixer::Mamba2Mixer(std::shared_ptr config, size_t layer_idx, + const infinicore::Device &device) + : layer_idx_(layer_idx), intermediate_(config->get("intermediate_size")), + heads_(config->get("num_heads")), head_dim_(config->get("head_dim")), + state_size_(config->get("state_size")), conv_dim_(intermediate_ + 2 * state_size_) { + const auto &rank = global_state::get_tensor_model_parallel_rank_info(); + tp_rank_ = rank.tp_rank; + tp_size_ = rank.tp_size; + communicator_ = rank.comm; + if (heads_ % tp_size_ != 0) { + throw std::runtime_error("Mamba-2 heads must divide evenly across tensor-parallel ranks."); + } + const size_t total_heads = heads_, total_intermediate = intermediate_; + heads_ /= tp_size_; + intermediate_ /= tp_size_; + conv_dim_ = intermediate_ + 2 * state_size_; + const auto dtype = config->get_dtype(); + const auto hidden = config->get("hidden_size"); + const auto fp32 = infinicore::DataType::F32; + auto register_fn = [this](const std::string &name, infinicore::nn::Parameter parameter) { + register_parameter(name, std::move(parameter)); + }; + // Reuse asymmetric projection sharding: per-head z/x/dt, replicated B/C. + in_proj_ = std::make_shared( + hidden, 2 * head_dim_ + 1, state_size_, state_size_, total_heads, 1, 1, + false, false, false, "in_proj_zxd", "in_proj_b", "in_proj_c", register_fn, + nullptr, dtype, device, rank); + INFINICORE_NN_MODULE_INIT(out_proj, total_intermediate, hidden, false, dtype, device, tp_rank_, tp_size_, communicator_); + INFINICORE_NN_MODULE_INIT(norm, total_intermediate, config->get("layer_norm_epsilon"), fp32, device); + conv1d_weight_ = infinicore::Tensor::empty({conv_dim_, 1, 4}, dtype, device); + conv1d_bias_ = infinicore::Tensor::empty({conv_dim_}, dtype, device); + for (size_t i = 0; i < 3; ++i) { + const size_t start = i == 0 ? 0 : intermediate_ + (i - 1) * state_size_; + const size_t count = i == 0 ? intermediate_ : state_size_; + const std::string name = i == 0 ? "conv1d_x" : (i == 1 ? "conv1d_b" : "conv1d_c"); + const auto shards = i == 0 ? tp_size_ : 1; + const auto shard_rank = i == 0 ? tp_rank_ : 0; + register_parameter(name + "_weight", infinicore::nn::Parameter(conv1d_weight_->narrow({{0, start, count}}), 0, shard_rank, shards)); + register_parameter(name + "_bias", infinicore::nn::Parameter(conv1d_bias_->narrow({{0, start, count}}), 0, shard_rank, shards)); + } + INFINICORE_NN_PARAMETER_INIT(A, ({total_heads}, fp32, device, 0, tp_rank_, tp_size_)); + INFINICORE_NN_PARAMETER_INIT(D, ({total_heads}, fp32, device, 0, tp_rank_, tp_size_)); + INFINICORE_NN_PARAMETER_INIT(dt_bias, ({total_heads}, fp32, device, 0, tp_rank_, tp_size_)); + if (tp_size_ > 1) { + const float scale = 1.0f / total_intermediate; + const float epsilon = config->get("layer_norm_epsilon"); + norm_scale_ = infinicore::Tensor::empty({1, 1, 1}, fp32, device); + norm_epsilon_ = infinicore::Tensor::empty({1, 1, 1}, fp32, device); + infinicore::context::memcpyH2D(norm_scale_->data(), &scale, sizeof(scale), false); + infinicore::context::memcpyH2D(norm_epsilon_->data(), &epsilon, sizeof(epsilon), false); + } +} + +infinicore::Tensor Mamba2Mixer::forward(infinicore::Tensor input) const { + auto &context = global_state::get_forward_context(); + const auto &metadata = context.mamba_metadata; + const auto tokens = input->numel() / input->size(input->ndim() - 1); + auto [zxd, projected_b, projected_c] = in_proj_->forward_split(input); + auto per_head = zxd->contiguous()->view({1, tokens, heads_, 2 * head_dim_ + 1}); + auto gate = per_head->narrow({{3, 0, head_dim_}})->contiguous()->view({1, tokens, intermediate_}); + auto projected_x = per_head->narrow({{3, head_dim_, head_dim_}})->contiguous()->view({1, tokens, intermediate_}); + auto conv_input = infinicore::op::cat({projected_x, projected_b, projected_c}, 2); + auto dt = per_head->narrow({{3, 2 * head_dim_, 1}})->contiguous()->view({tokens, heads_}); + auto convolved = infinicore::op::causal_conv1d( + conv_input, context.conv_state_vec.at(layer_idx_), conv1d_weight_, conv1d_bias_, + metadata.input_offsets, metadata.init_state_indices, metadata.final_state_indices); + convolved = infinicore::op::silu(convolved); + auto x = convolved->narrow({{2, 0, intermediate_}})->contiguous()->view({tokens, heads_, head_dim_}); + auto b = convolved->narrow({{2, intermediate_, state_size_}})->contiguous()->view({tokens, 1, state_size_}); + auto c = convolved->narrow({{2, intermediate_ + state_size_, state_size_}})->contiguous()->view({tokens, 1, state_size_}); + auto scan = infinicore::op::mamba2_scan( + x, dt, b, c, A_, D_, dt_bias_, context.ssm_state_vec.at(layer_idx_), + metadata.input_offsets.value(), metadata.init_state_indices.value(), metadata.final_state_indices.value()); + auto y = cast_activation(scan->view({1, tokens, intermediate_}), infinicore::DataType::F32); + auto z = infinicore::op::silu(cast_activation(gate, infinicore::DataType::F32)); + auto gated = infinicore::op::mul(y, z); + infinicore::Tensor normalized; + if (tp_size_ == 1) { + normalized = norm_->forward(gated); + } else { + auto squares = infinicore::op::mul(gated, gated); + auto total = infinicore::op::sum(squares, {2}, true); + infinicore::op::distributed::allreduce_(total, total, INFINICCL_SUM, communicator_); + auto scale = norm_scale_->as_strided(total->shape(), {0, 0, 0}); + auto epsilon = norm_epsilon_->as_strided(total->shape(), {0, 0, 0}); + auto variance = infinicore::op::add(infinicore::op::mul(total, scale), epsilon); + auto inverse_rms = infinicore::Tensor::empty(variance->shape(), variance->dtype(), variance->device()); + infinicore::op::float_power_(inverse_rms, variance, -0.5); + auto weight = norm_->weight()->narrow({{0, tp_rank_ * intermediate_, intermediate_}})->as_strided(gated->shape(), {0, 0, 1}); + auto factors = inverse_rms->as_strided(gated->shape(), {0, 1, 0}); + normalized = infinicore::op::mul(infinicore::op::mul(gated, factors), weight); + } + normalized = cast_activation(normalized, input->dtype()); + return out_proj_->forward(normalized); +} + +Mamba2Block::Mamba2Block(std::shared_ptr config, size_t layer_idx, + const infinicore::Device &device) + : dtype_(config->get_dtype()) { + INFINICORE_NN_MODULE_INIT(norm, config->get("hidden_size"), config->get("layer_norm_epsilon"), infinicore::DataType::F32, device); + INFINICORE_NN_MODULE_INIT(mixer, config, layer_idx, device); +} + +infinicore::Tensor Mamba2Block::forward(const infinicore::Tensor &residual) const { + auto input = cast_activation(norm_->forward(residual), dtype_); + auto output = cast_activation(mixer_->forward(input), infinicore::DataType::F32); + return infinicore::op::add(residual, output); +} + +} // namespace infinilm::models::mamba2 diff --git a/csrc/models/mamba2/mamba2_mixer.hpp b/csrc/models/mamba2/mamba2_mixer.hpp new file mode 100644 index 000000000..9c9aab047 --- /dev/null +++ b/csrc/models/mamba2/mamba2_mixer.hpp @@ -0,0 +1,43 @@ +#pragma once + +#include "../../config/model_config.hpp" +#include "../../layers/linear/linear.hpp" +#include "infinicore/nn/rmsnorm.hpp" + +namespace infinilm::models::mamba2 { + +infinicore::Tensor cast_activation(const infinicore::Tensor &input, infinicore::DataType dtype); + +class Mamba2Mixer : public infinicore::nn::Module { +public: + Mamba2Mixer(std::shared_ptr config, size_t layer_idx, + const infinicore::Device &device); + infinicore::Tensor forward(infinicore::Tensor input) const; + void process_weights_after_loading() override { in_proj_->process_weights_after_loading(); } + +private: + size_t layer_idx_, intermediate_, heads_, head_dim_, state_size_, conv_dim_; + size_t tp_rank_, tp_size_; + infinicclComm_t communicator_; + std::shared_ptr in_proj_; + INFINICORE_NN_MODULE(layers::linear::RowParallelLinear, out_proj); + INFINICORE_NN_MODULE(infinicore::nn::RMSNorm, norm); + infinicore::Tensor conv1d_weight_, conv1d_bias_, norm_scale_, norm_epsilon_; + INFINICORE_NN_PARAMETER(A); + INFINICORE_NN_PARAMETER(D); + INFINICORE_NN_PARAMETER(dt_bias); +}; + +class Mamba2Block : public infinicore::nn::Module { +public: + Mamba2Block(std::shared_ptr config, size_t layer_idx, + const infinicore::Device &device); + infinicore::Tensor forward(const infinicore::Tensor &residual) const; + +private: + infinicore::DataType dtype_; + INFINICORE_NN_MODULE(infinicore::nn::RMSNorm, norm); + INFINICORE_NN_MODULE(Mamba2Mixer, mixer); +}; + +} // namespace infinilm::models::mamba2 diff --git a/examples/bench.py b/examples/bench.py index 17bfe1a6d..4e3b5791d 100644 --- a/examples/bench.py +++ b/examples/bench.py @@ -10,7 +10,7 @@ from infinilm.base_config import BaseConfig from infinilm.cache import PagedKVCacheConfig, StaticKVCacheConfig from infinilm.distributed import DistConfig -from infinilm.infer_engine import GenerationConfig, InferEngine +from infinilm.infer_engine import GenerationConfig, InferEngine, model_uses_mamba_cache from infinilm.llm.llm import LLM from infinilm.llm.sampling_params import SamplingParams from infinilm.modeling_utils import load_model_state_dict_by_file @@ -132,15 +132,40 @@ def get_test_cases( for batch_size in batch_size_list: for input_len in input_len_list: for output_len in output_len_list: - for data_type in ["bfloat16"]: + for data_type in [ + config.get("torch_dtype", "bfloat16") + if model_type == "mamba2" + else "bfloat16" + ]: data_type_bytes = DATA_TYPE_BYTES[data_type] total_seq_len = input_len + output_len - kvcache_memory_bytes = ( - data_type_bytes - * (batch_size * total_seq_len * num_key_value_heads * head_dim) - * num_hidden_layers - ) + if model_type == "mamba2": + # Estimate active request states plus the reserved zero row. + inner = config["num_heads"] * head_dim + conv_channels = ( + inner + 2 * config["n_groups"] * config["state_size"] + ) + state_bytes = 4 * inner * config["state_size"] + state_bytes += ( + data_type_bytes + * conv_channels + * (config["conv_kernel"] - 1) + ) + kvcache_memory_bytes = ( + (batch_size + 1) * num_hidden_layers * state_bytes + ) + else: + kvcache_memory_bytes = ( + data_type_bytes + * ( + batch_size + * total_seq_len + * num_key_value_heads + * head_dim + ) + * num_hidden_layers + ) kvcache_memory_gb = kvcache_memory_bytes / (1024 * 1024 * 1024) case_list.append( @@ -855,6 +880,9 @@ def run( max_benchmark_cache_len = max( case["input_len"] + case["output_len"] for case in cases_dict.values() ) + has_mamba_cache = model_uses_mamba_cache( + read_json_file(os.path.join(model_path, "config.json")) + ) # -------------------------------------------------------- # # 测试 # -------------------------------------------------------- # @@ -884,6 +912,8 @@ def run( ) max_num_blocks = max(max_num_blocks, warmup_num_blocks) max_batch_size = max(batch_size) + if has_mamba_cache: + max_num_blocks = max(max_num_blocks, 4 * (max_batch_size + 1)) cache_config = PagedKVCacheConfig( max_num_blocks, paged_kv_block_size, @@ -995,6 +1025,8 @@ def run( (warmup_input_len + warmup_decode_len + paged_kv_block_size - 1) // paged_kv_block_size ) * warmup_batch + if has_mamba_cache: + warmup_num_blocks = max(warmup_num_blocks, 4 * (warmup_batch + 1)) warmup_cache_config = PagedKVCacheConfig( warmup_num_blocks, paged_kv_block_size, diff --git a/python/infinilm/infer_engine.py b/python/infinilm/infer_engine.py index 117d82f9a..92ddcd349 100644 --- a/python/infinilm/infer_engine.py +++ b/python/infinilm/infer_engine.py @@ -55,8 +55,8 @@ def model_uses_mamba_cache(config: dict) -> bool: layer_types = llm_config.get("layer_types") or [] linear_attn_config = llm_config.get("linear_attn_config") or {} return ( - config.get("model_type") == "mamba" - or llm_config.get("model_type") == "mamba" + config.get("model_type") in ("mamba", "mamba2") + or llm_config.get("model_type") in ("mamba", "mamba2") or "linear_attention" in layer_types or all( key in llm_config @@ -452,6 +452,8 @@ def forward_raw( cu_seqlens=None, block_tables=None, slot_mapping=None, + mamba_init_state_indices=None, + mamba_final_state_indices=None, pixel_values=None, image_bound=None, tgt_sizes=None, @@ -474,6 +476,8 @@ def forward_raw( cu_seqlens=cu_seqlens, block_tables=block_tables, slot_mapping=slot_mapping, + mamba_init_state_indices=mamba_init_state_indices, + mamba_final_state_indices=mamba_final_state_indices, pixel_values=pixel_values, image_bound=image_bound, tgt_sizes=tgt_sizes, diff --git a/python/infinilm/llm/llm.py b/python/infinilm/llm/llm.py index 59d5a1eca..9334de6af 100644 --- a/python/infinilm/llm/llm.py +++ b/python/infinilm/llm/llm.py @@ -50,6 +50,20 @@ def __init__(self, config: EngineConfig): "--disable-prefix-caching." ) + if hf_config.get("model_type") == "mamba2": + if config.pipeline_parallel_size != 1: + raise ValueError("Mamba-2 requires `pipeline_parallel_size=1`.") + if config.cache_type != "paged": + raise ValueError( + "Mamba-2 requires the paged request-state cache interface." + ) + if config.draft_model_path is not None: + raise ValueError("Mamba-2 does not support speculative state rollback.") + if config.kv_transfer_config and config.kv_transfer_config.kv_connector: + raise ValueError( + "Mamba-2 state transfer is not supported by KV connectors." + ) + if config.pipeline_parallel_size > 1 and config.pipeline_parallel_stage != 0: raise ValueError( "LLMEngine can only run pipeline stage 0; launch non-host nodes " @@ -123,7 +137,9 @@ def __init__(self, config: EngineConfig): self.cache_type = config.cache_type # Get EOS token IDs from model config - self.eos_token_ids = self.model_runner.eos_token_id or [] + self.eos_token_ids = self.model_runner.eos_token_id + if self.eos_token_ids is None: + self.eos_token_ids = [] if isinstance(self.eos_token_ids, int): self.eos_token_ids = [self.eos_token_ids] diff --git a/python/infinilm/modeling_utils.py b/python/infinilm/modeling_utils.py index b210b0c56..610022a7e 100644 --- a/python/infinilm/modeling_utils.py +++ b/python/infinilm/modeling_utils.py @@ -204,6 +204,15 @@ def load_model_state_dict_by_file( preserve_fp32_suffixes = (".e_score_correction_bias",) if model_type == "kimi_k3": preserve_fp32_suffixes += (".A_log", ".dt_bias") + elif model_type == "mamba2": + preserve_fp32_suffixes += ( + ".A_log", + ".A", + ".D", + ".dt_bias", + ".norm.weight", + ".norm_f.weight", + ) torch_device = "cpu" torch_dtype = infinicore.utils.to_torch_dtype(dtype) @@ -215,7 +224,6 @@ def load_model_state_dict_by_file( already_loaded_keys = [] embed_tokens_torch_unscaled = None - weights_processed = False remapper = _WEIGHT_REMAPPER.get(model_type) @@ -290,9 +298,6 @@ def load_model_state_dict_by_file( embed_tokens_torch_unscaled = None gc.collect() - model.process_weights_after_loading() - weights_processed = True - elif os.path.exists(os.path.join(model_path, "pytorch_model.bin")): file_path = os.path.join(model_path, "pytorch_model.bin") model_params = torch.load(file_path, weights_only=True, map_location="cpu") @@ -352,8 +357,8 @@ def load_model_state_dict_by_file( check_parameters(model_keys, already_loaded_keys) - if not weights_processed: - model.process_weights_after_loading() + # All weights, including a tied output head, must exist before packing/capture. + model.process_weights_after_loading() t2 = time.time() print(f" load weights over! {(t2 - t1) * 1000} ms \n") @@ -728,6 +733,54 @@ def _remap_gpt2(state_dict, config=None): return remapped +def _remap_mamba2(state_dict, config=None): + """Map Mamba-2 weights and prepare constant FP32 state parameters once.""" + remapped = {} + config = config or {} + for name, tensor in state_dict.items(): + name = name.replace("backbone.", "model.", 1) + if name.endswith(".mixer.in_proj.weight"): + heads, head_dim = config["num_heads"], config["head_dim"] + inner, state = heads * head_dim, config["state_size"] + z, x, b, c, dt = tensor.split([inner, inner, state, state, heads], dim=0) + prefix = name.removesuffix("in_proj.weight") + zxd = torch.cat( + [ + z.reshape(heads, head_dim, -1), + x.reshape(heads, head_dim, -1), + dt[:, None], + ], + dim=1, + ) + remapped[prefix + "in_proj_zxd.weight"] = zxd.flatten(0, 1).contiguous() + remapped[prefix + "in_proj_b.weight"] = b.contiguous() + remapped[prefix + "in_proj_c.weight"] = c.contiguous() + continue + if name.endswith((".mixer.conv1d.weight", ".mixer.conv1d.bias")): + suffix = name.rsplit(".", 1)[-1] + prefix = name.rsplit("conv1d.", 1)[0] + inner = config["num_heads"] * config["head_dim"] + for part, value in zip( + ("x", "b", "c"), + tensor.split( + [inner, config["state_size"], config["state_size"]], dim=0 + ), + ): + remapped[prefix + f"conv1d_{part}_{suffix}"] = value.contiguous() + continue + if name.endswith(".A_log"): + name = name.removesuffix("A_log") + "A" + tensor = -torch.exp(tensor.float()) + elif name.endswith((".D", ".dt_bias", ".norm.weight", ".norm_f.weight")): + tensor = tensor.float() + remapped[name] = tensor + if config.get("tie_word_embeddings", False): + embedding = remapped.get("model.embeddings.weight") + if embedding is not None: + remapped["lm_head.weight"] = embedding + return remapped + + def _remap_mamba(state_dict, config=None): """Remap HuggingFace Mamba weights to InfiniLM native names.""" remapped = {} @@ -1077,6 +1130,7 @@ def _remap_kimi_k3(state_dict, config): "baichuan": _remap_baichuan, "gpt2": _remap_gpt2, "mamba": _remap_mamba, + "mamba2": _remap_mamba2, "videonsa": _remap_videonsa, "qwen3_5": _remap_qwen3_5, "ernie4_5_moe_vl": _remap_ernie4_5_moe_vl, diff --git a/python/infinilm/processors/mamba2_processor.py b/python/infinilm/processors/mamba2_processor.py new file mode 100644 index 000000000..e0669af6b --- /dev/null +++ b/python/infinilm/processors/mamba2_processor.py @@ -0,0 +1,26 @@ +import infinicore + +from .mamba_processor import MambaProcessor +from .processor import register_processor + + +@register_processor("mamba2") +class Mamba2Processor(MambaProcessor): + def build_model_inputs(self, scheduler_output, *args, **kwargs): + inputs = super().build_model_inputs(scheduler_output, *args, **kwargs) + initial, final = [], [] + for request in scheduler_output.scheduled_requests: + index = request.mamba_cache_index + if index is None or index <= 0: + raise RuntimeError("Mamba-2 requires an allocated nonzero state row.") + initial.append(0 if request.num_local_cached_tokens == 0 else index) + final.append(index) + if len(set(final)) != len(final): + raise RuntimeError("Mamba-2 requests cannot share a writable state row.") + inputs["mamba_init_state_indices"] = infinicore.from_list( + initial, dtype=infinicore.int32 + ) + inputs["mamba_final_state_indices"] = infinicore.from_list( + final, dtype=infinicore.int32 + ) + return inputs diff --git a/scripts/prepare_mamba2_checkpoint.py b/scripts/prepare_mamba2_checkpoint.py new file mode 100644 index 000000000..3aa6f28a8 --- /dev/null +++ b/scripts/prepare_mamba2_checkpoint.py @@ -0,0 +1,132 @@ +"""Prepare a native state-spaces Mamba-2 checkpoint for the standard loader.""" + +import argparse +import json +from pathlib import Path + +import torch +from safetensors.torch import save_file +from transformers import AutoTokenizer + + +def normalize_config(native: dict, tokenizer) -> dict: + """Make the native Mamba-2 inference defaults explicit.""" + ssm = native.get("ssm_cfg", {}) + if ssm.get("layer") != "Mamba2": + raise ValueError("Expected a native `Mamba2` checkpoint.") + if native.get("attn_layer_idx") or native.get("d_intermediate", 0): + raise ValueError( + "Hybrid attention and additional MLP layers are not supported." + ) + hidden = native["d_model"] + expand = ssm.get("expand", 2) + state_size = ssm.get("d_state", 128) + if ( + hidden <= 0 + or expand <= 0 + or native["n_layer"] <= 0 + or not 1 <= state_size <= 256 + ): + raise ValueError("Model dimensions must be positive and `d_state` at most 256.") + intermediate = hidden * expand + if ( + ssm.get("d_ssm", intermediate) not in (None, intermediate) + or ssm.get("D_has_hdim", False) + or not ssm.get("rmsnorm", True) + or ssm.get("norm_before_gate", False) + or not native.get("rms_norm", True) + or ssm.get("bias", False) + or not ssm.get("conv_bias", True) + or not native.get("residual_in_fp32", True) + or list(ssm.get("dt_limit", (0.0, float("inf")))) != [0.0, float("inf")] + ): + raise ValueError("This checkpoint uses an unsupported Mamba-2 variant.") + head_dim = ssm.get("headdim", 64) + groups = ssm.get("ngroups", 1) + if head_dim <= 0 or groups <= 0 or intermediate % (head_dim * groups): + raise ValueError("SSM heads must divide evenly into groups.") + if groups != 1: + raise ValueError("The current model integration requires `ngroups=1`.") + if ssm.get("d_conv", 4) != 4: + raise ValueError("The current causal convolution requires a kernel of size 4.") + vocab = native["vocab_size"] + multiple = native.get("pad_vocab_size_multiple", 8) + if multiple <= 0 or vocab <= 0: + raise ValueError("Vocabulary size and padding multiple must be positive.") + padded_vocab = ((vocab + multiple - 1) // multiple) * multiple + if len(tokenizer) > padded_vocab: + raise ValueError("The tokenizer vocabulary exceeds the checkpoint vocabulary.") + return { + "model_type": "mamba2", + "architectures": ["Mamba2ForCausalLM"], + "torch_dtype": "bfloat16", + "hidden_size": hidden, + "num_hidden_layers": native["n_layer"], + "vocab_size": padded_vocab, + "unpadded_vocab_size": vocab, + "intermediate_size": intermediate, + "expand": expand, + "num_heads": intermediate // head_dim, + "head_dim": head_dim, + "n_groups": groups, + "state_size": state_size, + "conv_kernel": 4, + "use_bias": ssm.get("bias", False), + "use_conv_bias": ssm.get("conv_bias", True), + "hidden_act": "silu", + "layer_norm_epsilon": 1e-5, + "rms_norm_eps": 1e-5, + "norm_before_gate": False, + "residual_in_fp32": native.get("residual_in_fp32", True), + "tie_word_embeddings": native.get("tie_embeddings", True), + "chunk_size": ssm.get("chunk_size", 256), + "bos_token_id": tokenizer.bos_token_id, + "eos_token_id": tokenizer.eos_token_id, + "pad_token_id": ( + tokenizer.eos_token_id + if tokenizer.pad_token_id is None + else tokenizer.pad_token_id + ), + } + + +def prepare_checkpoint(source: Path, output: Path, tokenizer_path: Path) -> None: + """Convert local files without changing the source or downloading dependencies.""" + if output.exists() and any(output.iterdir()): + raise ValueError("The output directory must be empty.") + native = json.loads((source / "config.json").read_text()) + tokenizer = AutoTokenizer.from_pretrained(tokenizer_path, local_files_only=True) + config = normalize_config(native, tokenizer) + weights = torch.load( + source / "pytorch_model.bin", map_location="cpu", weights_only=True + ) + expected_embedding = (config["vocab_size"], config["hidden_size"]) + if tuple(weights["backbone.embedding.weight"].shape) != expected_embedding: + raise ValueError("Embedding shape does not match the normalized configuration.") + if config["tie_word_embeddings"]: + head = weights.pop("lm_head.weight", None) + if head is not None and not torch.equal( + head, weights["backbone.embedding.weight"] + ): + raise ValueError( + "The checkpoint declares tied but unequal embedding weights." + ) + # HF and InfiniLM both use `embeddings`; native Mamba uses `embedding`. + weights["backbone.embeddings.weight"] = weights.pop("backbone.embedding.weight") + output.mkdir(parents=True, exist_ok=True) + save_file( + {name: tensor.contiguous() for name, tensor in weights.items()}, + output / "model.safetensors", + metadata={"format": "pt"}, + ) + (output / "config.json").write_text(json.dumps(config, indent=2) + "\n") + tokenizer.save_pretrained(output) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--source", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--tokenizer", type=Path, required=True) + args = parser.parse_args() + prepare_checkpoint(args.source, args.output, args.tokenizer) diff --git a/test/bench/backends/infinilm.py b/test/bench/backends/infinilm.py index fbd99379d..92a8be29e 100644 --- a/test/bench/backends/infinilm.py +++ b/test/bench/backends/infinilm.py @@ -19,6 +19,7 @@ def __init__( attn_backend="default", ): from infinilm import LLM + from infinilm.infer_engine import model_uses_mamba_cache super().__init__(benchmark) @@ -60,6 +61,7 @@ def __init__( block_size=256, enable_graph=enable_graph, attn_backend=attn_backend, + enable_prefix_caching=not model_uses_mamba_cache(self.config_dict), ) self.processor = self.model.engine.processor self.tokenizer = self.processor.get_tokenizer() diff --git a/test/layers/test_pre_transpose.py b/test/layers/test_pre_transpose.py new file mode 100644 index 000000000..cdabda10e --- /dev/null +++ b/test/layers/test_pre_transpose.py @@ -0,0 +1,92 @@ +"""Check fused projections sharing a quantization object after weight packing.""" + +import json + +import pytest +import torch + + +@pytest.mark.skipif(not torch.cuda.is_available(), reason="A CUDA device is required.") +def test_pre_transpose_and_reprocessing_preserve_logits(tmp_path): + import infinicore + from infinilm.cache import PagedKVCacheConfig + from infinilm.infer_engine import InferEngine + from infinilm.modeling_utils import load_model_state_dict_by_file + from safetensors.torch import save_file + + config = { + "model_type": "qwen2", + "hidden_size": 128, + "intermediate_size": 256, + "num_hidden_layers": 1, + "num_attention_heads": 2, + "num_key_value_heads": 2, + "head_dim": 64, + "vocab_size": 128, + "torch_dtype": "float16", + "rms_norm_eps": 1e-5, + "max_position_embeddings": 256, + "rope_theta": 10000, + "hidden_act": "silu", + "eos_token_id": 0, + "tie_word_embeddings": True, + } + (tmp_path / "config.json").write_text(json.dumps(config)) + results, decode_results = [], [] + for packed in (False, True): + engine = InferEngine( + str(tmp_path), + device=infinicore.device("cuda", 0), + cache_config=PagedKVCacheConfig(8, 256, 1), + attention_backend="paged-attn", + pre_transpose=packed, + enable_graph_compiling=True, + ) + generator = torch.Generator().manual_seed(318) + weights = {} + for name, parameter in sorted(engine.state_dict()[0].items()): + if name == "lm_head.weight": + continue + tensor = infinicore.Tensor(parameter) + values = torch.randn(tensor.shape, generator=generator) * 0.03 + if "norm" in name and name.endswith("weight"): + values.fill_(1) + values = values.to(infinicore.utils.to_torch_dtype(tensor.dtype)) + weights[name] = values + save_file(weights, tmp_path / "model.safetensors") + load_model_state_dict_by_file(engine, str(tmp_path), dtype=engine.dtype) + + def forward(model, tokens, past=0): + def tensor(values, dtype=infinicore.int32): + return infinicore.from_list(values, dtype=dtype) + + end = past + len(tokens) + output = model.forward_raw( + tensor([tokens], infinicore.int64), + position_ids=tensor(list(range(past, end)), infinicore.int64), + past_kv_lengths=tensor([past]), + total_kv_lengths=tensor([end]), + input_offsets=tensor([0, len(tokens)]), + cu_seqlens=tensor([0, end]), + block_tables=tensor([[0]]), + slot_mapping=tensor(list(range(past, end)), infinicore.int64), + sample_all_positions=past == 0, + )["logits"] + copied = torch.empty(output.shape, dtype=torch.float16) + infinicore.from_torch(copied).copy_(output) + infinicore.sync_device() + assert torch.isfinite(copied).all() + return copied + + for recapture in (False, True): + engine.process_weights_after_loading() + results.append(forward(engine, [17, 83, 51])) + if recapture: + # Reprocessing also recaptures graphs and must preserve live KV. + engine.process_weights_after_loading() + decode_results.append(forward(engine, [23], past=3)) + del engine + for actual in results[1:]: + torch.testing.assert_close(actual, results[0], atol=1e-3, rtol=1e-3) + for actual in decode_results[1:]: + torch.testing.assert_close(actual, decode_results[0], atol=1e-3, rtol=1e-3) diff --git a/test/models/mamba2/test_adaptation.py b/test/models/mamba2/test_adaptation.py new file mode 100644 index 000000000..57c1c4b61 --- /dev/null +++ b/test/models/mamba2/test_adaptation.py @@ -0,0 +1,375 @@ +"""Mamba-2 loading and request-state regression tests. + +Set `INFINILM_MAMBA2_MODEL` to a prepared checkpoint for the device tests. +For strict FP32 comparisons, launch with `NVIDIA_TF32_OVERRIDE=0` on NVIDIA +or `INFINIOP_METAX_ALLOW_TF32=0` on MetaX. +""" + +import importlib.util +import json +import os +from pathlib import Path +from types import SimpleNamespace + +import pytest +import torch + + +@pytest.fixture(scope="module") +def preparation(): + path = Path(__file__).resolve().parents[3] / "scripts/prepare_mamba2_checkpoint.py" + spec = importlib.util.spec_from_file_location("prepare_mamba2", path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +class TokenizerStub: + eos_token_id = 0 + bos_token_id = 0 + pad_token_id = None + + def __len__(self): + return 50277 + + +def native_config(): + return { + "d_model": 768, + "n_layer": 24, + "vocab_size": 50277, + "ssm_cfg": {"layer": "Mamba2"}, + "pad_vocab_size_multiple": 16, + "tie_embeddings": True, + } + + +def test_native_checkpoint_contract(preparation): + config = preparation.normalize_config(native_config(), TokenizerStub()) + assert config["vocab_size"] == 50288 + assert config["intermediate_size"] == 1536 + assert config["num_heads"] * config["head_dim"] == 1536 + assert config["n_groups"] == 1 + assert config["state_size"] == 128 + assert config["residual_in_fp32"] + assert config["tie_word_embeddings"] + assert config["eos_token_id"] == 0 + + +@pytest.mark.parametrize( + "changes", + [ + {"attn_layer_idx": [1]}, + {"d_intermediate": 128}, + {"ssm_cfg": {"layer": "Mamba1"}}, + {"ssm_cfg": {"layer": "Mamba2", "d_conv": 3}}, + {"ssm_cfg": {"layer": "Mamba2", "norm_before_gate": True}}, + {"ssm_cfg": {"layer": "Mamba2", "D_has_hdim": True}}, + {"ssm_cfg": {"layer": "Mamba2", "ngroups": 2}}, + {"ssm_cfg": {"layer": "Mamba2", "bias": True}}, + {"ssm_cfg": {"layer": "Mamba2", "conv_bias": False}}, + {"ssm_cfg": {"layer": "Mamba2", "d_state": 257}}, + {"d_model": 0}, + {"residual_in_fp32": False}, + ], +) +def test_unsupported_variants_are_rejected(preparation, changes): + with pytest.raises(ValueError): + preparation.normalize_config({**native_config(), **changes}, TokenizerStub()) + + +def test_zero_padding_token_is_preserved(preparation): + tokenizer = TokenizerStub() + tokenizer.pad_token_id = 0 + tokenizer.eos_token_id = 7 + assert preparation.normalize_config(native_config(), tokenizer)["pad_token_id"] == 0 + + +def test_weight_mapping_preserves_state_parameters(): + from infinilm.modeling_utils import _remap_mamba2 + + embedding = torch.randn(16, 8, dtype=torch.bfloat16) + a_log = torch.tensor([-2.0, 1.5], dtype=torch.float16) + dt_bias = torch.tensor([-3.1, -2.9], dtype=torch.float16) + converted = _remap_mamba2( + { + "backbone.embeddings.weight": embedding, + "backbone.layers.0.mixer.A_log": a_log, + "backbone.layers.0.mixer.dt_bias": dt_bias, + }, + {"tie_word_embeddings": True}, + ) + assert converted["lm_head.weight"] is converted["model.embeddings.weight"] + assert converted["model.layers.0.mixer.A"].dtype == torch.float32 + torch.testing.assert_close( + converted["model.layers.0.mixer.A"], -a_log.float().exp() + ) + torch.testing.assert_close( + converted["model.layers.0.mixer.dt_bias"], dt_bias.float() + ) + + +@pytest.fixture(scope="module") +def engine(): + model = os.environ.get("INFINILM_MAMBA2_MODEL") + if not model: + pytest.skip("Set `INFINILM_MAMBA2_MODEL` to run real-weight device tests.") + import infinicore + from infinilm.cache import PagedKVCacheConfig + from infinilm.distributed import DistConfig + from infinilm.infer_engine import InferEngine + from infinilm.modeling_utils import load_model_state_dict_by_file + + instance = InferEngine( + model, + device=infinicore.device("cuda", 0), + distributed_config=DistConfig(int(os.environ.get("INFINILM_MAMBA2_TP", "1"))), + cache_config=PagedKVCacheConfig(64, 256, 4), + enable_graph_compiling=os.environ.get("INFINILM_MAMBA2_GRAPH") == "1", + attention_backend="paged-attn", + pre_transpose=os.environ.get("INFINILM_MAMBA2_PRE_TRANSPOSE") == "1", + ) + load_model_state_dict_by_file(instance, model, dtype=instance.dtype) + return instance + + +def forward(engine, sequences, initial, final, past=None, *, sample_all_positions=True): + import infinicore + + past = [0] * len(sequences) if past is None else past + lengths = [len(sequence) for sequence in sequences] + offsets = [0] + for length in lengths: + offsets.append(offsets[-1] + length) + ids = [token for sequence in sequences for token in sequence] + positions = [ + position + for start, length in zip(past, lengths) + for position in range(start, start + length) + ] + + def i32(values): + return infinicore.from_list(values, dtype=infinicore.int32) + + result = engine.forward_raw( + infinicore.from_list([ids], dtype=infinicore.int64), + position_ids=infinicore.from_list(positions, dtype=infinicore.int64), + past_kv_lengths=i32(past), + total_kv_lengths=i32([p + n for p, n in zip(past, lengths)]), + input_offsets=i32(offsets), + cu_seqlens=i32(offsets), + block_tables=i32([[0] for _ in sequences]), + slot_mapping=infinicore.from_list([0] * len(ids), dtype=infinicore.int64), + mamba_init_state_indices=i32(initial), + mamba_final_state_indices=i32(final), + sample_all_positions=sample_all_positions, + ) + logits = result["logits"] + copied = torch.empty( + logits.shape, dtype=infinicore.utils.to_torch_dtype(logits.dtype) + ) + infinicore.from_torch(copied).copy_(logits) + infinicore.sync_device() + return copied.float().reshape(-1, logits.shape[-1]) + + +def test_prefill_continuation_and_decode(engine): + import infinicore + + ids = torch.randint( + 1, 1000, (258,), generator=torch.Generator().manual_seed(19) + ).tolist() + full = forward(engine, [ids], [0], [1]) + first = forward(engine, [ids[:3]], [0], [2]) + middle = forward(engine, [ids[3:257]], [2], [2], [3]) + last = forward(engine, [ids[257:]], [2], [2], [257]) + continued = torch.cat([first, middle, last]) + assert torch.isfinite(continued).all() + if engine.dtype == infinicore.float32: + # The independent FP32 recurrence also changes slightly across GEMM shapes. + torch.testing.assert_close(continued, full, atol=5e-4, rtol=3e-4) + else: + # Low-precision rounding changes raw logits, including a common offset. + # Check the predictive distribution; the scan/state algebra is checked + # separately in FP32 and against the independent operator reference. + p, q = continued.log_softmax(-1), full.log_softmax(-1) + mean_kl = (q.exp() * (q - p)).sum(-1).mean() + is_bf16 = engine.dtype == infinicore.bfloat16 + assert mean_kl < (2e-2 if is_bf16 else 5e-4) + agreement = (continued.argmax(-1) == full.argmax(-1)).float().mean() + assert agreement >= (0.90 if is_bf16 else 0.98) + + +def test_tied_embeddings_share_device_storage(engine): + for parameters in engine.state_dict(): + assert ( + parameters["lm_head.weight"].data_ptr() + == parameters["model.embeddings.weight"].data_ptr() + ) + + +@pytest.mark.parametrize( + "changes", + [ + {"rms_norm": False}, + {"rmsnorm": False}, + {"D_has_hdim": True}, + {"d_ssm": 128}, + {"d_intermediate": 128}, + {"attn_layer_idx": [1]}, + {"quantization_config": {"quant_method": "unsupported"}}, + {"dt_limit": [0, 1]}, + {"time_step_limit": [0, 1]}, + {"layer_norm_epsilon": 0}, + {"layer_norm_epsilon": -1e-5}, + ], +) +def test_cpp_rejects_unsupported_config_before_model_creation( + preparation, tmp_path, changes +): + import infinicore + from infinilm.infer_engine import InferEngine + + config = { + **preparation.normalize_config(native_config(), TokenizerStub()), + **changes, + } + (tmp_path / "config.json").write_text(json.dumps(config)) + with pytest.raises((ValueError, RuntimeError)): + InferEngine(str(tmp_path), device=infinicore.device("cpu", 0)) + + +def test_cache_rebuild_preserves_weights_and_rebinds_state(engine): + from infinilm.cache import PagedKVCacheConfig + + prompts = [[17, 83, 51], [142, 73, 6]] + forward(engine, prompts, [0, 0], [1, 2]) + expected = forward( + engine, [[91], [92]], [1, 2], [1, 2], [3, 3], sample_all_positions=False + ) + for blocks in (48, 64): + engine.reset_cache(PagedKVCacheConfig(blocks, 256, 4)) + test_tied_embeddings_share_device_storage(engine) + forward(engine, prompts, [0, 0], [5, 3]) + actual = forward( + engine, [[91], [92]], [5, 3], [5, 3], [3, 3], sample_all_positions=False + ) + torch.testing.assert_close(actual, expected, atol=5e-4, rtol=3e-4) + + +def test_projection_remap_preserves_each_heads_inputs(): + from infinilm.modeling_utils import _remap_mamba2 + + heads, dim, state, hidden = 4, 3, 2, 5 + weight = torch.randn(2 * heads * dim + 2 * state + heads, hidden) + remapped = _remap_mamba2( + {"backbone.layers.0.mixer.in_proj.weight": weight}, + { + "num_heads": heads, + "head_dim": dim, + "state_size": state, + }, + ) + x = torch.randn(7, hidden) + original = torch.nn.functional.linear(x, weight) + prefix = "model.layers.0.mixer." + projected = torch.nn.functional.linear( + x, remapped[prefix + "in_proj_zxd.weight"] + ).reshape(7, heads, 2 * dim + 1) + torch.testing.assert_close( + projected[..., :dim].flatten(1), original[:, : heads * dim] + ) + torch.testing.assert_close( + projected[..., dim : 2 * dim].flatten(1), + original[:, heads * dim : 2 * heads * dim], + ) + torch.testing.assert_close(projected[..., -1], original[:, -heads:]) + for i, name in enumerate(("in_proj_b", "in_proj_c")): + torch.testing.assert_close( + torch.nn.functional.linear(x, remapped[prefix + name + ".weight"]), + original[ + :, 2 * heads * dim + i * state : 2 * heads * dim + (i + 1) * state + ], + ) + + +def test_decode_graph_reorders_requests_and_preserves_active_states(engine): + if os.environ.get("INFINILM_MAMBA2_GRAPH") != "1": + pytest.skip("Set `INFINILM_MAMBA2_GRAPH=1` for graph replay checks.") + lengths = [3] * 5 + prompts = [[17 + i, 83, 51] for i in range(5)] + forward(engine, prompts, [0] * 5, [1, 2, 3, 4, 5]) + forward(engine, prompts, [0] * 5, [6, 7, 8, 9, 10]) + for step in range(16): + # Batch five exceeds the captured maximum and exercises device eager fallback. + count = (1, 2, 4, 5)[step % 4] + order = [(step + i) % 5 for i in range(count)] + if step == 8: + # Recompiling with live requests must preserve their recurrent states. + engine.process_weights_after_loading() + sequences = [[100 + step + i] for i in order] + past = [lengths[i] for i in order] + eager_rows, graph_rows = [i + 1 for i in order], [i + 6 for i in order] + expected = forward(engine, sequences, eager_rows, eager_rows, past) + actual = forward( + engine, sequences, graph_rows, graph_rows, past, sample_all_positions=False + ) + torch.testing.assert_close( + actual, expected, atol=5e-4, rtol=3e-4, msg=f"Step {step}, batch {count}." + ) + for i in order: + lengths[i] += 1 + + +def test_request_reordering_and_slot_reuse(engine): + prompts = [[17, 83, 51], [142, 73, 6, 89, 13]] + singles = [forward(engine, [prompt], [0], [1]) for prompt in prompts] + packed = forward(engine, prompts[::-1], [0, 0], [5, 2]) + torch.testing.assert_close(packed[:5], singles[1], atol=8e-2, rtol=8e-2) + torch.testing.assert_close(packed[5:], singles[0], atol=8e-2, rtol=8e-2) + # A new request reads zero even when its destination contains another prompt. + reused = forward(engine, [prompts[0]], [0], [5]) + torch.testing.assert_close(reused, singles[0], atol=8e-2, rtol=8e-2) + + +def test_processor_rejects_shared_writable_slots(): + from unittest.mock import patch + + from infinilm.processors.mamba2_processor import Mamba2Processor + from infinilm.processors.mamba_processor import MambaProcessor + + processor = object.__new__(Mamba2Processor) + requests = [SimpleNamespace(mamba_cache_index=1, num_local_cached_tokens=0)] * 2 + with patch.object(MambaProcessor, "build_model_inputs", return_value={}): + with pytest.raises(RuntimeError, match="share"): + processor.build_model_inputs(SimpleNamespace(scheduled_requests=requests)) + + +@pytest.mark.parametrize( + "options, message", + [ + ({"cache_type": "static"}, "paged request-state"), + ({"pipeline_parallel_size": 2}, "pipeline_parallel_size"), + ({"draft_model_path": "unused-draft"}, "rollback"), + ( + {"kv_transfer_config": SimpleNamespace(kv_connector="unused-connector")}, + "state transfer", + ), + ({"enable_prefix_caching": True}, "Prefix caching"), + ], +) +def test_unsupported_service_combinations_fail_before_loading( + monkeypatch, options, message +): + from infinilm.config.engine_config import EngineConfig + from infinilm.llm import llm + + monkeypatch.setattr(llm, "read_hf_config", lambda _: {"model_type": "mamba2"}) + + def unexpected_runner(_): + pytest.fail("Unsupported configurations must fail before worker setup.") + + monkeypatch.setattr(llm, "ModelRunner", unexpected_runner) + config = EngineConfig("unused-model", **{"enable_prefix_caching": False, **options}) + with pytest.raises((ValueError, RuntimeError), match=message): + llm.LLMEngine(config) diff --git a/test/models/mamba2/test_service.py b/test/models/mamba2/test_service.py new file mode 100644 index 000000000..c06981d87 --- /dev/null +++ b/test/models/mamba2/test_service.py @@ -0,0 +1,181 @@ +"""Mamba-2 request ownership and service-level recurrence checks.""" + +import os + +import pytest +from test_adaptation import forward + + +@pytest.fixture(scope="module") +def service(): + model = os.environ.get("INFINILM_MAMBA2_MODEL") + if not model: + pytest.skip("Set `INFINILM_MAMBA2_MODEL` for real-weight service tests.") + from infinilm.config.engine_config import EngineConfig + from infinilm.llm.llm import LLMEngine + + instance = LLMEngine( + EngineConfig( + model_path=model, + tensor_parallel_size=int(os.environ.get("INFINILM_MAMBA2_TP", "1")), + enable_graph=os.environ.get("INFINILM_MAMBA2_GRAPH") == "1", + enable_prefix_caching=False, + num_blocks=24, + block_size=16, + max_batch_size=8, + ) + ) + yield instance + instance.close() + + +def request(name, tokens, count=4): + from infinilm.llm.request import InferenceRequest + from infinilm.llm.sampling_params import SamplingParams + + return InferenceRequest( + request_id=name, + prompt_token_ids=tokens, + sampling_params=SamplingParams(max_tokens=count, top_k=1, ignore_eos=True), + ) + + +def assert_released(service): + manager = service.scheduler.mamba_cache_manager + assert not manager.used_block_ids + assert len(manager.free_block_ids) == manager.num_blocks - 1 + assert len(set(manager.free_block_ids)) == len(manager.free_block_ids) + blocks = service.scheduler.cache_manager + assert blocks.get_total_usable_blocks() == blocks.num_blocks + assert all(block.ref_count == 0 for block in blocks.blocks) + + +def test_service_decode_matches_explicit_state_continuation(service): + manager = service.scheduler.mamba_cache_manager + requests = [request("a", [17, 83, 51]), request("b", [142, 73, 6, 89, 13])] + reference_rows = [manager.allocate() for _ in requests] + for item in requests: + service.add_request(item) + try: + for step in range(4): + sequences = [ + list(item.prompt_token_ids) + if step == 0 + else [item.generated_token_ids[-1]] + for item in requests + ] + past = [ + 0 if step == 0 else item.get_total_length() - 1 for item in requests + ] + # Identical batch shapes isolate scheduler state ownership from + # low-precision changes between full-prefix and single-token GEMMs. + expected = ( + forward( + service.model_runner.model_engine, + sequences, + [0] * len(requests) if step == 0 else reference_rows, + reference_rows, + past, + sample_all_positions=False, + ) + .argmax(-1) + .tolist() + ) + assert service.step()[0] + assert [item.generated_token_ids[-1] for item in requests] == expected + assert all(item.is_finished() for item in requests) + finally: + for row in reference_rows: + manager.free(row) + for item in requests: + if not item.is_finished(): + item.mark_canceled() + service.step() + assert_released(service) + + +def test_state_pool_exhaustion_and_deferred_admission(service): + manager = service.scheduler.mamba_cache_manager + items = [ + request(f"pool-{i}", [17 + i, 83, 51], count=5) + for i in range(manager.num_blocks + 1) + ] + for item in items: + service.add_request(item) + assert service.step()[0] + assert len(manager.used_block_ids) == manager.num_blocks - 1 + assert sum(item.mamba_cache_index is None for item in items) == 2 + # Cancel one admitted request and one waiting request, then admit the survivor. + items[0].mark_canceled() + items[-1].mark_canceled() + for _ in range(20): + service.step() + assert ( + len(manager.used_block_ids) + len(manager.free_block_ids) + == manager.num_blocks - 1 + ) + if all(item.is_finished() for item in items): + break + assert all(item.is_finished() for item in items) + assert len(items[-2].generated_token_ids) == 5 + service.step() + assert_released(service) + + +def test_eos_configuration_and_early_finish_release_state(service): + from infinilm.llm.request import FinishReason + + configured_eos = service.model_runner.eos_token_id + if isinstance(configured_eos, int): + assert service.eos_token_ids == [configured_eos] + manager = service.scheduler.mamba_cache_manager + row = manager.allocate() + try: + token = ( + forward( + service.model_runner.model_engine, + [[17, 83, 51]], + [0], + [row], + sample_all_positions=False, + ) + .argmax(-1) + .item() + ) + finally: + manager.free(row) + item = request("early-eos", [17, 83, 51], count=8) + item.eos_token_ids = [token] + item.sampling_params.ignore_eos = False + service.add_request(item) + assert service.step()[0] + assert item.finish_reason == FinishReason.EOS_TOKEN + assert len(item.generated_token_ids) == 1 + service.step() + assert_released(service) + + +def test_repeated_finish_cancel_and_slot_reuse(service): + first_token = None + for iteration in range(12): + item = request(f"cycle-{iteration}", [17, 83, 51], count=3) + service.add_request(item) + mode = iteration % 4 + if mode == 0: + item.mark_canceled() + else: + assert service.step()[0] + if first_token is None: + first_token = item.generated_token_ids[0] + assert item.generated_token_ids[0] == first_token + if mode == 1: + item.mark_canceled() + else: + assert service.step()[0] + if mode == 2: + item.mark_canceled() + else: + assert service.step()[0] + service.step() + assert item.is_finished() + assert_released(service)