diff --git a/cactus-engine/src/complete.cpp b/cactus-engine/src/complete.cpp index c3a7a7b0a..934a9653d 100644 --- a/cactus-engine/src/complete.cpp +++ b/cactus-engine/src/complete.cpp @@ -13,6 +13,8 @@ #include #include #include +#include +#include #include using namespace cactus::engine; @@ -214,6 +216,82 @@ std::vector extract_schema_required(const std::string& schema) { return required; } +std::string serialize_needle_tools(const std::vector& tools) { + if (tools.empty()) return "[]"; + + std::ostringstream oss; + oss << "["; + for (size_t i = 0; i < tools.size(); ++i) { + if (i > 0) oss << ","; + + oss << "{\"name\":\"" << escape_json_string(tools[i].name) << "\""; + if (!tools[i].description.empty()) { + oss << ",\"description\":\"" << escape_json_string(tools[i].description) << "\""; + } + + oss << ",\"parameters\":"; + auto schema_it = tools[i].parameters.find("schema"); + if (schema_it == tools[i].parameters.end()) { + oss << "{}"; + } else { + auto properties = extract_schema_property_types(schema_it->second); + auto required = extract_schema_required(schema_it->second); + std::unordered_set required_set(required.begin(), required.end()); + + if (properties.empty()) { + oss << "{}"; + } else { + std::string properties_object = extract_json_object_field(schema_it->second, "properties"); + oss << "{"; + for (size_t p = 0; p < properties.size(); ++p) { + if (p > 0) oss << ","; + const std::string& param_name = properties[p].first; + const std::string& param_type = properties[p].second; + bool is_required = required_set.count(param_name) > 0; + + std::string description; + std::string prop_obj = extract_json_object_field(properties_object, param_name); + if (!prop_obj.empty()) { + std::string desc_pattern = "\"description\":\""; + size_t desc_pos = prop_obj.find(desc_pattern); + if (desc_pos != std::string::npos) { + size_t desc_start = desc_pos + desc_pattern.size(); + size_t desc_end = desc_start; + bool escaped = false; + while (desc_end < prop_obj.size()) { + if (escaped) { + escaped = false; + desc_end++; + continue; + } + if (prop_obj[desc_end] == '\\') { + escaped = true; + desc_end++; + continue; + } + if (prop_obj[desc_end] == '"') break; + desc_end++; + } + description = prop_obj.substr(desc_start, desc_end - desc_start); + } + } + + oss << "\"" << escape_json_string(param_name) << "\":{"; + oss << "\"type\":\"" << escape_json_string(param_type) << "\""; + if (!description.empty()) { + oss << ",\"description\":\"" << description << "\""; + } + oss << ",\"required\":" << (is_required ? "true" : "false") << "}"; + } + oss << "}"; + } + } + oss << "}"; + } + oss << "]"; + return oss.str(); +} + std::vector> build_stop_sequences( Tokenizer* tokenizer, const std::vector& stop_sequences, @@ -382,6 +460,11 @@ bool prompt_context_matches( if (handle->processed_tokens.empty()) { return false; } + if (handle->model && + handle->model->get_config().model_type == Config::ModelType::NEEDLE && + prompt.tokens.size() != handle->processed_tokens.size() + 1) { + return false; + } if (prompt.context_token_count < handle->processed_tokens.size()) { return false; } @@ -428,10 +511,6 @@ PreparedPrompt prepare_prompt( } } - if (apply_tool_constraints) { - setup_tool_constraints(handle, prompt.tools, prompt.options.force_tools, prompt.options.temperature); - } - auto* tokenizer = handle->model->get_tokenizer(); if (!tokenizer) { throw std::runtime_error("Tokenizer unavailable"); @@ -484,20 +563,31 @@ PreparedPrompt prepare_prompt( } } - std::string formatted_tools = gemma::format_tools(prompt.tools, true); + if (prompt.model_type == Config::ModelType::NEEDLE && !prompt.tools.empty()) { + prompt.options.force_tools = true; + } - { - std::string full_prompt = tokenizer->format_chat_prompt( - prompt.messages, - add_generation_prompt, - formatted_tools, - prompt.options.enable_thinking_if_supported - ); - if (full_prompt.find("ERROR:") == 0) { - throw std::runtime_error(full_prompt.substr(6)); - } - prompt.tokens = tokenizer->encode(full_prompt); + std::string formatted_tools; + if (prompt.model_type == Config::ModelType::NEEDLE) { + formatted_tools = serialize_needle_tools(prompt.tools); + } else { + formatted_tools = gemma::format_tools(prompt.tools, true); + } + + if (apply_tool_constraints) { + setup_tool_constraints(handle, prompt.tools, prompt.options.force_tools, prompt.options.temperature); + } + + std::string full_prompt = tokenizer->format_chat_prompt( + prompt.messages, + add_generation_prompt, + formatted_tools, + prompt.options.enable_thinking_if_supported + ); + if (full_prompt.find("ERROR:") == 0) { + throw std::runtime_error(full_prompt.substr(6)); } + prompt.tokens = tokenizer->encode(full_prompt); prompt.context_token_count = prompt.tokens.size(); prompt.images = images_from_message(prompt.messages); return prompt; @@ -926,7 +1016,10 @@ int cactus_complete( } std::string local_completion = regular_response; - if (local_completion.empty() && function_calls.empty()) { + const bool parsed_empty_needle_tool_call = + prompt.model_type == Config::ModelType::NEEDLE && + response_text.find("") != std::string::npos; + if (local_completion.empty() && function_calls.empty() && !parsed_empty_needle_tool_call) { local_completion = response_text; } std::string primary_response = local_completion; diff --git a/cactus-engine/src/constraints.cpp b/cactus-engine/src/constraints.cpp index 76bee3729..e21378cb0 100644 --- a/cactus-engine/src/constraints.cpp +++ b/cactus-engine/src/constraints.cpp @@ -5,6 +5,7 @@ namespace engine { constexpr float FORCE_BIAS = 500.0f; constexpr float BLOCK_BIAS = -500.0f; +constexpr float NEEDLE_BLOCK_BIAS = -1e9f; static bool contains_odd_quote_tags(const std::string& text) { static constexpr const char* kQuoteTag = "<|\"|>"; @@ -69,6 +70,184 @@ void ToolCallConstrainer::init_common_tokens() { add_tokens_for_string("` ", backtick_tokens_); } +void ToolCallConstrainer::needle_insert_word(NeedleTrieNode* root, const std::string& word) { + if (!root) return; + NeedleTrieNode* node = root; + for (char ch : word) { + auto& child = node->children[ch]; + if (!child) child = std::make_unique(); + node = child.get(); + } + node->is_terminal = true; +} + +const ToolCallConstrainer::NeedleTrieNode* ToolCallConstrainer::needle_get_trie_node( + const NeedleTrieNode* root, + const std::string& prefix +) const { + const NeedleTrieNode* node = root; + for (char ch : prefix) { + if (!node) return nullptr; + auto it = node->children.find(ch); + if (it == node->children.end()) return nullptr; + node = it->second.get(); + } + return node; +} + +bool ToolCallConstrainer::needle_check_token_valid(const std::string& token_text, + const NeedleTrieNode* trie_node) const { + const NeedleTrieNode* node = trie_node; + if (!node) return false; + for (char ch : token_text) { + if (ch == '"') return node->is_terminal; + auto it = node->children.find(ch); + if (it == node->children.end()) return false; + node = it->second.get(); + } + return true; +} + +void ToolCallConstrainer::reset_needle_constraints() { + needle_json_state_ = NeedleJsonState::FREE; + needle_buffer_.clear(); + needle_constrained_buf_.clear(); + needle_current_function_.clear(); + needle_in_arguments_ = false; + needle_arguments_depth_ = 0; + needle_nesting_depth_ = 0; + needle_in_string_value_ = false; + needle_prev_char_escape_ = false; +} + +bool ToolCallConstrainer::needle_at_arg_key_start() const { + if (needle_buffer_.size() < 2) return false; + return needle_buffer_.compare(needle_buffer_.size() - 2, 2, "{\"") == 0 || + needle_buffer_.compare(needle_buffer_.size() - 2, 2, ",\"") == 0; +} + +bool ToolCallConstrainer::needle_is_value_string_start() const { + if (needle_buffer_.empty()) return false; + for (size_t i = needle_buffer_.size() - 1; i-- > 0;) { + char ch = needle_buffer_[i]; + if (ch == ' ' || ch == '\t' || ch == '\n' || ch == '\r') continue; + return ch == ':'; + } + return false; +} + +void ToolCallConstrainer::feed_needle_char(char ch) { + if (needle_json_state_ == NeedleJsonState::IN_NAME || + needle_json_state_ == NeedleJsonState::IN_ARG_KEY) { + if (ch == '"') { + if (needle_json_state_ == NeedleJsonState::IN_NAME) { + needle_current_function_ = needle_constrained_buf_; + } + needle_constrained_buf_.clear(); + needle_json_state_ = NeedleJsonState::FREE; + } else { + needle_constrained_buf_.push_back(ch); + } + needle_buffer_.push_back(ch); + return; + } + + needle_buffer_.push_back(ch); + + if (needle_in_string_value_) { + if (needle_prev_char_escape_) { + needle_prev_char_escape_ = false; + return; + } + if (ch == '\\') { + needle_prev_char_escape_ = true; + return; + } + if (ch == '"') needle_in_string_value_ = false; + return; + } + + if (ch == '{' || ch == '[') { + needle_nesting_depth_++; + } else if (ch == '}' || ch == ']') { + needle_nesting_depth_ = std::max(0, needle_nesting_depth_ - 1); + if (ch == '}' && needle_in_arguments_ && needle_nesting_depth_ < needle_arguments_depth_) { + needle_in_arguments_ = false; + } + } + + if (needle_buffer_.size() >= 8 && !needle_in_arguments_ && + needle_buffer_.compare(needle_buffer_.size() - 8, 8, "\"name\":\"") == 0) { + needle_json_state_ = NeedleJsonState::IN_NAME; + needle_constrained_buf_.clear(); + return; + } + + if (needle_buffer_.size() >= 13 && + needle_buffer_.compare(needle_buffer_.size() - 13, 13, "\"arguments\":{") == 0) { + needle_in_arguments_ = true; + needle_arguments_depth_ = needle_nesting_depth_; + return; + } + + if (needle_in_arguments_ && + needle_nesting_depth_ == needle_arguments_depth_ && + needle_at_arg_key_start()) { + needle_json_state_ = NeedleJsonState::IN_ARG_KEY; + needle_constrained_buf_.clear(); + return; + } + + if (ch == '"' && needle_is_value_string_start()) { + needle_in_string_value_ = true; + } +} + +void ToolCallConstrainer::feed_needle_text(const std::string& text) { + for (char ch : text) feed_needle_char(ch); +} + +void ToolCallConstrainer::init_needle_constraints() { + reset_needle_constraints(); + + needle_name_trie_ = std::make_unique(); + needle_param_tries_.clear(); + for (const auto& tool : tool_specs_) { + if (!tool.name.empty()) needle_insert_word(needle_name_trie_.get(), tool.name); + + auto param_root = std::make_unique(); + for (const auto& param_name : tool.parameter_names) { + if (!param_name.empty()) needle_insert_word(param_root.get(), param_name); + } + needle_param_tries_[tool.name] = std::move(param_root); + } + + const uint32_t vocab_size = tokenizer_ ? tokenizer_->get_vocab_size() : 0; + if (needle_token_strings_.size() != vocab_size) { + needle_token_strings_.assign(vocab_size, ""); + needle_token_index_.clear(); + + const uint32_t eos = tokenizer_->get_eos_token(); + const uint32_t bos = tokenizer_->get_bos_token(); + const uint32_t unk = tokenizer_->get_unk_token(); + constexpr uint32_t pad = 0; + + for (uint32_t token_id = 0; token_id < vocab_size; ++token_id) { + if (token_id == pad || token_id == eos || token_id == bos || token_id == unk) continue; + std::string decoded = tokenizer_->decode({token_id}); + if (decoded.size() == 1) { + unsigned char b = static_cast(decoded[0]); + if (b >= 0x80) { + decoded.assign({static_cast(0xC0 | (b >> 6)), + static_cast(0x80 | (b & 0x3F))}); + } + } + needle_token_strings_[token_id] = decoded; + if (!decoded.empty()) needle_token_index_[decoded.front()].push_back(token_id); + } + } +} + void ToolCallConstrainer::tokenize_grammar_elements() { if (!tokenizer_) return; @@ -112,9 +291,11 @@ void ToolCallConstrainer::init(Config::ModelType model_type, generated_text_.clear(); brace_depth_ = 0; in_argument_string_ = false; - active_ = !function_names_.empty() && tokenizer != nullptr; + const bool model_supported = is_needle() || Config::is_gemma_family(model_type_); + active_ = model_supported && !function_names_.empty() && tokenizer != nullptr; - state_ = State::GEMMA_START; + reset_needle_constraints(); + state_ = is_needle() ? State::NEEDLE_START : State::GEMMA_START; call_start_tag_ = "<|tool_call>"; call_end_tag_ = ""; @@ -123,6 +304,9 @@ void ToolCallConstrainer::init(Config::ModelType model_type, } tokenize_grammar_elements(); + if (is_needle()) { + init_needle_constraints(); + } compute_bias(); } @@ -131,6 +315,13 @@ void ToolCallConstrainer::update(uint32_t /*token_id*/, const std::string& decod generated_text_ += decoded_text; + if (is_needle()) { + feed_needle_text(decoded_text); + generated_text_.clear(); + compute_bias(); + return; + } + switch (state_) { case State::GEMMA_START: if (generated_text_.find(call_start_tag_) != std::string::npos) { @@ -212,12 +403,58 @@ void ToolCallConstrainer::compute_bias() { if (!active_) return; - for (uint32_t t : backtick_tokens_) { - current_bias_[t] = BLOCK_BIAS; + if (!is_needle()) { + for (uint32_t t : backtick_tokens_) { + current_bias_[t] = BLOCK_BIAS; + } + + for (uint32_t t : gemma_response_start_tokens_) { + current_bias_[t] = BLOCK_BIAS; + } } - for (uint32_t t : gemma_response_start_tokens_) { - current_bias_[t] = BLOCK_BIAS; + if (is_needle()) { + if (needle_json_state_ == NeedleJsonState::FREE) return; + + const NeedleTrieNode* trie_node = nullptr; + if (needle_json_state_ == NeedleJsonState::IN_NAME) { + trie_node = needle_get_trie_node(needle_name_trie_.get(), needle_constrained_buf_); + } else if (needle_json_state_ == NeedleJsonState::IN_ARG_KEY) { + auto it = needle_param_tries_.find(needle_current_function_); + if (it != needle_param_tries_.end()) { + trie_node = needle_get_trie_node(it->second.get(), needle_constrained_buf_); + } + } + if (!trie_node) return; + + std::vector valid_tokens(needle_token_strings_.size(), false); + bool has_valid = false; + auto mark_valid_tokens = [&](char first_char) { + auto idx_it = needle_token_index_.find(first_char); + if (idx_it == needle_token_index_.end()) return; + for (uint32_t token_id : idx_it->second) { + if (!valid_tokens[token_id] && + needle_check_token_valid(needle_token_strings_[token_id], trie_node)) { + valid_tokens[token_id] = true; + has_valid = true; + } + } + }; + + for (const auto& [first_char, _] : trie_node->children) { + mark_valid_tokens(first_char); + } + if (trie_node->is_terminal) { + mark_valid_tokens('"'); + } + if (!has_valid) return; + + for (size_t token_id = 0; token_id < valid_tokens.size(); ++token_id) { + if (!valid_tokens[token_id]) { + current_bias_[static_cast(token_id)] = NEEDLE_BLOCK_BIAS; + } + } + return; } switch (state_) { @@ -314,8 +551,9 @@ void ToolCallConstrainer::reset() { current_bias_.clear(); brace_depth_ = 0; in_argument_string_ = false; + reset_needle_constraints(); - state_ = State::GEMMA_START; + state_ = is_needle() ? State::NEEDLE_START : State::GEMMA_START; if (active_) { compute_bias(); diff --git a/cactus-engine/src/engine.h b/cactus-engine/src/engine.h index e63041b25..471c3def9 100644 --- a/cactus-engine/src/engine.h +++ b/cactus-engine/src/engine.h @@ -20,7 +20,9 @@ namespace npu { struct NPUNamedInput { std::string name; - const __fp16* data; + enum class DataType { FP16, INT32 }; + const void* data; + DataType data_type = DataType::FP16; std::vector shape; }; @@ -58,6 +60,8 @@ struct Config { uint32_t vocab_size = 151936; uint32_t bos_token_id = 151643; uint32_t eos_token_id = 151645; + uint32_t decoder_start_token_id = 151643; + std::vector decoder_prompt_token_ids; uint32_t num_layers = 28; uint32_t hidden_dim = 1024; uint32_t ffn_intermediate_dim = 3072; @@ -341,7 +345,7 @@ class Tokenizer { size_t get_image_soft_token_count() const { return image_soft_token_count_; } protected: - enum class ModelType { UNKNOWN, GEMMA4, QWEN, LFM2 }; + enum class ModelType { UNKNOWN, GEMMA4, QWEN, LFM2, NEEDLE }; ModelType model_type_ = ModelType::UNKNOWN; enum class ModelVariant { DEFAULT, VLM, EXTRACT, RAG}; ModelVariant model_variant_ = ModelVariant::DEFAULT; @@ -365,6 +369,7 @@ class Tokenizer { std::string format_gemma4_style(const std::vector& messages, bool add_generation_prompt, const std::string& tools_json, bool enable_thinking_if_supported = false) const; std::string format_qwen_style(const std::vector& messages, bool add_generation_prompt, const std::string& tools_json, bool enable_thinking_if_supported = false) const; std::string format_lfm2_style(const std::vector& messages, bool add_generation_prompt, const std::string& tools_json, bool enable_thinking_if_supported = false) const; + std::string format_needle_style(const std::vector& messages, bool add_generation_prompt, const std::string& tools_json) const; }; class BPETokenizer : public Tokenizer { @@ -495,7 +500,8 @@ class ToolCallConstrainer { GEMMA_IN_FUNC_NAME, GEMMA_EXPECT_BRACE, GEMMA_IN_ARGUMENTS, - GEMMA_EXPECT_END + GEMMA_EXPECT_END, + NEEDLE_START }; void init(Config::ModelType model_type, @@ -548,6 +554,41 @@ class ToolCallConstrainer { void add_tokens_for_prefix_string(const std::string& prefix, std::unordered_set& token_set); void tokenize_function_names(bool quote_names); void init_common_tokens(); + + bool is_needle() const { return model_type_ == Config::ModelType::NEEDLE; } + enum class NeedleJsonState { + FREE, + IN_NAME, + IN_ARG_KEY + }; + struct NeedleTrieNode { + std::unordered_map> children; + bool is_terminal = false; + }; + + NeedleJsonState needle_json_state_ = NeedleJsonState::FREE; + std::string needle_buffer_; + std::string needle_constrained_buf_; + std::string needle_current_function_; + bool needle_in_arguments_ = false; + int needle_arguments_depth_ = 0; + int needle_nesting_depth_ = 0; + bool needle_in_string_value_ = false; + bool needle_prev_char_escape_ = false; + std::unique_ptr needle_name_trie_; + std::unordered_map> needle_param_tries_; + std::vector needle_token_strings_; + std::unordered_map> needle_token_index_; + + void init_needle_constraints(); + void reset_needle_constraints(); + void feed_needle_text(const std::string& text); + void feed_needle_char(char ch); + bool needle_at_arg_key_start() const; + bool needle_is_value_string_start() const; + void needle_insert_word(NeedleTrieNode* root, const std::string& word); + const NeedleTrieNode* needle_get_trie_node(const NeedleTrieNode* root, const std::string& prefix) const; + bool needle_check_token_valid(const std::string& token_text, const NeedleTrieNode* trie_node) const; }; class Model { @@ -640,6 +681,9 @@ class Model { bool load_npu_vision_encoder(const std::string& model_path); bool has_npu_vision_encoder() const { return npu_vision_encoder_ != nullptr; } + bool load_npu_source_encoder(const std::string& model_path); + + void remove_thinking_tokens(const std::vector>& ranges); void compact_kv_cache() {} void compress_kv_cache_keydiff(const cactus::kvcompress::Params& params); @@ -682,6 +726,7 @@ class Model { std::vector logical_outputs; std::vector bindings; std::vector cache_states; + std::map metadata; std::unique_ptr graph; std::vector> input_buffers; }; @@ -706,7 +751,19 @@ class Model { void run_encoder_step(uint32_t token_id, size_t position); void run_media_step(size_t position, const uint8_t* feature_row, size_t feature_row_bytes, Precision feature_precision); + void reset_encoder_cross_kv_route_state(); + bool finish_encoder_cross_kv_prepare(); + bool finish_encoder_cross_kv_prepare_after_source(); + bool prepare_encoder_cross_kv_from_text(const std::vector& tokens); + bool prepare_encoder_cross_kv_from_audio(const std::vector& audio_features); + bool run_encoder_cross_kv_decoder_step(uint32_t token_id, size_t position); + std::vector run_encoder_cross_kv_decode_loop( + const std::vector& decoder_prompt_tokens, + size_t max_tokens, + const std::vector>& stop_token_sequences, + const std::atomic* should_stop); void copy_component_outputs_to_inputs(const Component& source, Component& target); + bool copy_cross_kv_outputs_to_decoder_cache_inputs(const Component& source, Component& target, size_t source_len); void copy_encoder_outputs_to_decoder(const Component& enc); void copy_component_outputs_to_chunk_inputs(const Component& source, Component& target, size_t token_index); void copy_component_outputs_to_chunk_inputs_range(const Component& source, Component& target, size_t token_offset); @@ -747,8 +804,10 @@ class Model { Component* decoder_ = nullptr; Component* decoder_prefill_ = nullptr; Component* prefill_encoder_ = nullptr; - enum class DecodeRoute { CACHED_STEP, DIRECT_DECODER_STEP, FULL_CONTEXT_TEXT }; + enum class DecodeRoute { CACHED_STEP, DIRECT_DECODER_STEP, FULL_CONTEXT_TEXT, ENCODER_CROSS_KV_STEP }; DecodeRoute decode_route_ = DecodeRoute::CACHED_STEP; + Component* source_encoder_ = nullptr; + Component* decoder_cross_kv_ = nullptr; Component* vision_encoder_ = nullptr; Component* audio_encoder_ = nullptr; Component* lm_encoder_media_step_ = nullptr; @@ -756,16 +815,22 @@ class Model { Component* lm_encoder_ = nullptr; Component* lm_encoder_text_chunk_ = nullptr; Component* lm_encoder_media_chunk_ = nullptr; + std::string encoder_cross_kv_source_kind_; + bool encoder_cross_kv_ready_ = false; + size_t encoder_cross_kv_source_len_ = 0; std::string family_; std::string npu_audio_encoder_mlpackage_; std::string npu_vision_encoder_mlpackage_; + std::string npu_source_encoder_mlpackage_; std::unique_ptr npu_audio_encoder_; std::unique_ptr npu_vision_encoder_; + std::unique_ptr npu_source_encoder_; bool audio_encode_via_npu(const std::vector& audio_features); bool vision_encode_via_npu(const std::vector& pixel_values); + bool source_encode_via_npu(const std::vector& tokens); std::map> media_features_; std::map> media_feature_shapes_; diff --git a/cactus-engine/src/model.cpp b/cactus-engine/src/model.cpp index 7017cfe08..7a8251fd1 100644 --- a/cactus-engine/src/model.cpp +++ b/cactus-engine/src/model.cpp @@ -15,6 +15,7 @@ #include #include #include +#include #include #include #include @@ -25,6 +26,24 @@ namespace cactus { namespace engine { +std::vector parse_config_uint_list(const std::string& value) { + std::vector numbers; + size_t pos = 0; + while (pos < value.size()) { + while (pos < value.size() && !std::isdigit(static_cast(value[pos]))) { + ++pos; + } + if (pos >= value.size()) break; + size_t end = pos; + while (end < value.size() && std::isdigit(static_cast(value[end]))) { + ++end; + } + numbers.push_back(static_cast(std::stoul(value.substr(pos, end - pos)))); + pos = end; + } + return numbers; +} + float read_scalar_value(Precision precision, const uint8_t* data, size_t index) { const uint8_t* ptr = data + PrecisionTraits::byte_offset_of(precision, index); switch (precision) { @@ -84,6 +103,64 @@ bool copy_component_tensor(CactusGraph& source_graph, return true; } +struct CrossKVCacheMetadata { + uint64_t current_seq_len; + uint64_t max_seq_len; + uint64_t num_kv_heads; + uint64_t head_dim; + uint64_t sink_size; + uint64_t reserved[3]; +}; + +static_assert(sizeof(CrossKVCacheMetadata) == 64, "CrossKVCacheMetadata must be 64 bytes"); + +size_t cross_kv_cache_buffer_size(size_t max_seq, size_t kv_heads, size_t head_dim) { + size_t num_groups = (head_dim + KV_QUANT_GROUP_SIZE - 1) / KV_QUANT_GROUP_SIZE; + return sizeof(CrossKVCacheMetadata) + max_seq * kv_heads * head_dim + + max_seq * kv_heads * num_groups * sizeof(float); +} + +bool write_cross_kv_cache_buffer(const BufferDesc& src_desc, + const void* src_ptr, + const BufferDesc& dst_desc, + std::vector& dst_buffer, + size_t source_len, + const std::string& name) { + if (src_desc.precision != Precision::FP16 || dst_desc.precision != Precision::INT8) return false; + if (src_desc.shape.size() != 4) return false; + + const size_t max_seq = src_desc.shape[1]; + const size_t kv_heads = src_desc.shape[2]; + const size_t head_dim = src_desc.shape[3]; + const size_t expected_bytes = cross_kv_cache_buffer_size(max_seq, kv_heads, head_dim); + if (dst_buffer.size() < expected_bytes || dst_desc.byte_size < expected_bytes) { + CACTUS_LOG_ERROR("model", "cross-KV cache input buffer is too small for " << name); + return false; + } + + source_len = std::min(source_len, max_seq); + std::fill(dst_buffer.begin(), dst_buffer.end(), 0); + auto* meta = reinterpret_cast(dst_buffer.data()); + meta->current_seq_len = static_cast(source_len); + meta->max_seq_len = static_cast(max_seq); + meta->num_kv_heads = static_cast(kv_heads); + meta->head_dim = static_cast(head_dim); + meta->sink_size = 0; + + if (source_len == 0) return true; + auto* int8_base = reinterpret_cast(dst_buffer.data() + sizeof(CrossKVCacheMetadata)); + const size_t int8_bytes = max_seq * kv_heads * head_dim; + auto* scale_base = reinterpret_cast(dst_buffer.data() + sizeof(CrossKVCacheMetadata) + int8_bytes); + cactus_quantize_kv_fp16_to_int8( + static_cast(src_ptr), + int8_base, + scale_base, + source_len, + kv_heads, + head_dim); + return true; +} + void ConvCache::init(size_t layers, size_t hidden_dim, size_t window_len, Precision model_precision) { num_layers = layers; hidden_size = hidden_dim; @@ -385,20 +462,41 @@ bool Model::init(const std::string& bundle_dir, size_t context_size, } std::string encoder_name; std::string decoder_name; + std::string source_encoder_name; + std::string decoder_cross_kv_name; std::unordered_set required_components; - const bool is_whisper_transcription = - config_.model_type == Config::ModelType::WHISPER && - components_.count("audio_encoder") && - components_.count("decoder_cross_kv") && - components_.count("decoder_step"); + for (const auto& [name, comp] : components_) { + auto route_it = comp.metadata.find("runtime_route"); + if (route_it == comp.metadata.end() || route_it->second != "encoder_cross_kv_decoder_step") { + continue; + } + auto role_it = comp.metadata.find("runtime_role"); + if (role_it == comp.metadata.end()) { + continue; + } + if (role_it->second == "source_encoder") { + source_encoder_name = name; + auto source_kind_it = comp.metadata.find("source_kind"); + if (source_kind_it != comp.metadata.end()) { + encoder_cross_kv_source_kind_ = source_kind_it->second; + } + } else if (role_it->second == "decoder_cross_kv") { + decoder_cross_kv_name = name; + } else if (role_it->second == "decoder_step") { + decoder_name = name; + } + } + const bool has_metadata_encoder_cross_kv_route = + !source_encoder_name.empty() && + !decoder_cross_kv_name.empty() && + !decoder_name.empty(); bool has_chunked_prefill = components_.count("lm_encoder_step") && components_.count("decoder_media_step") && components_.count("lm_encoder_text_chunk") && components_.count("decoder_prefill_chunk"); - if (is_whisper_transcription) { - decoder_name = "decoder_step"; - decode_route_ = DecodeRoute::DIRECT_DECODER_STEP; - required_components = {"audio_encoder", "decoder_cross_kv", decoder_name}; + if (has_metadata_encoder_cross_kv_route) { + decode_route_ = DecodeRoute::ENCODER_CROSS_KV_STEP; + required_components = {source_encoder_name, decoder_cross_kv_name, decoder_name}; } else if (has_chunked_prefill) { encoder_name = "lm_encoder_step"; decoder_name = "decoder_media_step"; @@ -437,7 +535,7 @@ bool Model::init(const std::string& bundle_dir, size_t context_size, decode_route_ = DecodeRoute::DIRECT_DECODER_STEP; required_components = {"audio_encoder", decoder_name}; } else { - CACTUS_LOG_ERROR("model", "Bundle missing required components: need lm_encoder_step+decoder_step (LM), audio_encoder+decoder (transcription), or Whisper audio_encoder+decoder_cross_kv+decoder_step"); + CACTUS_LOG_ERROR("model", "Bundle missing required components: need lm_encoder_step+decoder_step (LM), text_lm_encoder+decoder, audio_encoder+decoder (transcription), or source/audio_encoder+decoder_cross_kv+decoder_step"); return false; } for (const auto& optional : { @@ -454,6 +552,8 @@ bool Model::init(const std::string& bundle_dir, size_t context_size, if (!load_components(required_components)) return false; if (!encoder_name.empty()) encoder_ = &components_.at(encoder_name); if (!decoder_name.empty()) decoder_ = &components_.at(decoder_name); + if (!source_encoder_name.empty()) source_encoder_ = &components_.at(source_encoder_name); + if (!decoder_cross_kv_name.empty()) decoder_cross_kv_ = &components_.at(decoder_cross_kv_name); if (components_.count("decoder_prefill_chunk") && components_.at("decoder_prefill_chunk").graph) { decoder_prefill_ = &components_.at("decoder_prefill_chunk"); decoder_prefill_chunk_ = decoder_prefill_; @@ -469,8 +569,10 @@ bool Model::init(const std::string& bundle_dir, size_t context_size, lm_encoder_media_chunk_ = components_.count("lm_encoder_media_chunk") ? &components_.at("lm_encoder_media_chunk") : nullptr; std::vector to_bind = { encoder_, + source_encoder_, prefill_encoder_, decoder_, + decoder_cross_kv_, decoder_prefill_, vision_encoder_, audio_encoder_, @@ -479,9 +581,6 @@ bool Model::init(const std::string& bundle_dir, size_t context_size, lm_encoder_text_chunk_, lm_encoder_media_chunk_, }; - if (components_.count("decoder_cross_kv")) { - to_bind.push_back(&components_.at("decoder_cross_kv")); - } std::unordered_set bound; for (Component* comp : to_bind) { if (!comp || !comp->graph || bound.count(comp)) continue; @@ -512,6 +611,12 @@ bool Model::init(const std::string& bundle_dir, size_t context_size, CACTUS_LOG_WARN("model", "NPU vision encoder load failed for " << full_path << "; falling back to CPU"); } } + if (!npu_source_encoder_mlpackage_.empty()) { + std::string full_path = bundle_dir + "/" + npu_source_encoder_mlpackage_; + if (!load_npu_source_encoder(full_path)) { + CACTUS_LOG_WARN("model", "NPU source encoder load failed for " << full_path << "; falling back to CPU"); + } + } if (load_handoff_probe() && decoder_ && output_index(*decoder_, "probe_hidden") < 0) { CACTUS_LOG_WARN("cloud_handoff", "Handoff probe is packaged, but decoder_step does not expose probe_hidden; " "reconvert Gemma4 to enable probe-based handoff"); @@ -541,6 +646,9 @@ bool Model::load_manifest() { if (obj.count("npu_vision_encoder") && obj.at("npu_vision_encoder").is()) { npu_vision_encoder_mlpackage_ = obj.at("npu_vision_encoder").get(); } + if (obj.count("npu_source_encoder") && obj.at("npu_source_encoder").is()) { + npu_source_encoder_mlpackage_ = obj.at("npu_source_encoder").get(); + } if (!obj.count("components")) return false; for (const auto& cv : obj.at("components").get()) { const auto& c = cv.get(); @@ -563,6 +671,13 @@ bool Model::load_manifest() { for (const auto& v : c.at("logical_outputs").get()) comp.logical_outputs.push_back(v.get()); } + if (c.count("metadata") && c.at("metadata").is()) { + for (const auto& mv : c.at("metadata").get()) { + if (mv.second.is()) { + comp.metadata[mv.first] = mv.second.get(); + } + } + } if (c.count("bound_constant_bindings")) { for (const auto& bv : c.at("bound_constant_bindings").get()) { const auto& b = bv.get(); @@ -774,6 +889,28 @@ void Model::copy_component_outputs_to_inputs(const Component& source, Component& }) } +bool Model::copy_cross_kv_outputs_to_decoder_cache_inputs(const Component& source, Component& target, size_t source_len) { + bool copied_any = false; + for (size_t i = 0; i < source.output_node_ids.size() && i < source.logical_outputs.size(); ++i) { + const std::string& out_name = source.logical_outputs[i]; + if (out_name.rfind("cross_k_", 0) != 0 && out_name.rfind("cross_v_", 0) != 0) { + continue; + } + int dst_idx = input_index(target, out_name); + if (dst_idx < 0) continue; + size_t src_node = static_cast(source.output_node_ids[i]); + const auto& src_desc = source.graph->get_output_buffer(src_node); + const void* src_ptr = source.graph->get_output(src_node); + size_t dst_node = static_cast(target.runtime_input_node_ids[dst_idx]); + const auto& dst_desc = target.graph->get_output_buffer(dst_node); + if (!write_cross_kv_cache_buffer(src_desc, src_ptr, dst_desc, target.input_buffers[dst_idx], source_len, out_name)) { + return false; + } + copied_any = true; + } + return copied_any; +} + void Model::copy_component_outputs_to_chunk_inputs(const Component& source, Component& target, size_t token_index) { FOR_EACH_MATCHED_OUTPUT(source, target, { size_t chunk_tokens = component_chunk_tokens(target, out_name); @@ -1341,8 +1478,17 @@ uint32_t Model::argmax_component_logits(Component& comp, size_t logit_row, float size_t row_off = row * vocab; uint32_t best = 0; float best_v = -std::numeric_limits::infinity(); + const auto& tool_bias = tool_constrainer_.get_bias(); + auto score_with_bias = [&](size_t token_id, float value) { + auto tool_it = tool_bias.find(static_cast(token_id)); + if (tool_it != tool_bias.end()) value += tool_it->second; + auto vocab_it = vocab_bias_.find(static_cast(token_id)); + if (vocab_it != vocab_bias_.end()) value += vocab_it->second; + return value; + }; float second_v = -std::numeric_limits::infinity(); auto observe_logit = [&](size_t i, float v) { + v = score_with_bias(i, v); if (v > best_v) { second_v = best_v; best_v = v; @@ -1383,6 +1529,25 @@ bool Model::prefill_and_sample_first_token(const std::vector& tokens, if (tokens.empty() || !decoder_ || cache_total_seq_len_ != 0) { return false; } + if (decode_route_ == DecodeRoute::ENCODER_CROSS_KV_STEP && encoder_cross_kv_source_kind_ == "text_tokens") { + std::vector source_tokens = tokens; + std::vector decoder_seed = {config_.decoder_start_token_id}; + if (!source_tokens.empty() && source_tokens.back() == config_.decoder_start_token_id) { + decoder_seed = {source_tokens.back()}; + source_tokens.pop_back(); + } + if (!prepare_encoder_cross_kv_from_text(source_tokens)) { + return false; + } + std::vector emitted = run_encoder_cross_kv_decode_loop( + decoder_seed, + 1, + {}, + nullptr); + if (emitted.empty()) return false; + out_token = emitted.front(); + return true; + } cache_token_ids_ = tokens; if (decode_route_ == DecodeRoute::FULL_CONTEXT_TEXT) { context_tokens_.insert(context_tokens_.end(), tokens.begin(), tokens.end()); @@ -1440,6 +1605,11 @@ void Model::prefill(const std::vector& tokens, size_t /*chunk_size*/, last_prefill_cache_copy_ms_ = 0.0; last_prefill_padding_tokens_ = 0; last_prefill_scalar_tail_tokens_ = 0; + if (decode_route_ == DecodeRoute::ENCODER_CROSS_KV_STEP && encoder_cross_kv_source_kind_ == "text_tokens") { + (void)prepare_decode; + prepare_encoder_cross_kv_from_text(tokens); + return; + } if (decode_route_ == DecodeRoute::FULL_CONTEXT_TEXT) { context_tokens_.insert(context_tokens_.end(), tokens.begin(), tokens.end()); if (!context_tokens_.empty()) run_full_context_text(); @@ -1618,6 +1788,157 @@ std::vector qwen3_vl_position_ids(const std::vector& tokens, } // namespace +void Model::reset_encoder_cross_kv_route_state() { + if (!decoder_) return; + reset_component_cache_states(*decoder_); + cache_total_seq_len_ = 0; + token_history_.clear(); + encoder_cross_kv_ready_ = false; + encoder_cross_kv_source_len_ = 0; +} + +bool Model::finish_encoder_cross_kv_prepare() { + if (!source_encoder_ || !decoder_cross_kv_ || !decoder_) return false; + source_encoder_->graph->execute(); + copy_component_outputs_to_inputs(*source_encoder_, *decoder_cross_kv_); + decoder_cross_kv_->graph->execute(); + copy_component_outputs_to_inputs(*source_encoder_, *decoder_); + if (!copy_cross_kv_outputs_to_decoder_cache_inputs(*decoder_cross_kv_, *decoder_, encoder_cross_kv_source_len_)) { + copy_component_outputs_to_inputs(*decoder_cross_kv_, *decoder_); + } + encoder_cross_kv_ready_ = true; + return true; +} + +bool Model::finish_encoder_cross_kv_prepare_after_source() { + if (!decoder_cross_kv_ || !decoder_) return false; + decoder_cross_kv_->graph->execute(); + if (!copy_cross_kv_outputs_to_decoder_cache_inputs(*decoder_cross_kv_, *decoder_, encoder_cross_kv_source_len_)) { + copy_component_outputs_to_inputs(*decoder_cross_kv_, *decoder_); + } + encoder_cross_kv_ready_ = true; + return true; +} + +bool Model::prepare_encoder_cross_kv_from_text(const std::vector& tokens) { + if (!source_encoder_ || !decoder_cross_kv_ || !decoder_ || tokens.empty()) return false; + if (encoder_cross_kv_source_kind_ != "text_tokens") return false; + + reset_encoder_cross_kv_route_state(); + for (auto& buf : source_encoder_->input_buffers) { + std::fill(buf.begin(), buf.end(), 0); + } + + int ids_idx = input_index(*source_encoder_, "input_ids"); + if (ids_idx < 0) { + CACTUS_LOG_ERROR("model", "source encoder missing input_ids input"); + return false; + } + size_t ids_node = static_cast(source_encoder_->runtime_input_node_ids[ids_idx]); + const auto& ids_desc = source_encoder_->graph->get_output_buffer(ids_node); + if (tokens.size() > ids_desc.total_size) { + CACTUS_LOG_ERROR("model", "source token count exceeds source encoder capacity"); + return false; + } + encoder_cross_kv_source_len_ = tokens.size(); + for (size_t i = 0; i < tokens.size(); ++i) { + write_int_input_at(*source_encoder_, "input_ids", i, static_cast(tokens[i])); + } + + int mask_idx = input_index(*source_encoder_, "attention_mask"); + if (mask_idx >= 0) { + for (size_t i = 0; i < tokens.size(); ++i) { + write_int_input_at(*source_encoder_, "attention_mask", i, 1); + } + } + + if (source_encode_via_npu(tokens)) { + return finish_encoder_cross_kv_prepare_after_source(); + } + + return finish_encoder_cross_kv_prepare(); +} + +bool Model::prepare_encoder_cross_kv_from_audio(const std::vector& audio_features) { + if (!source_encoder_ || !decoder_cross_kv_ || !decoder_) return false; + int feature_idx = input_index(*source_encoder_, "input_features"); + if (feature_idx < 0) { + feature_idx = input_index(*source_encoder_, "audio_features"); + } + if (feature_idx < 0) { + CACTUS_LOG_ERROR("model", "audio source encoder missing input_features/audio_features input"); + return false; + } + + reset_encoder_cross_kv_route_state(); + + auto& feature_buf = source_encoder_->input_buffers[feature_idx]; + size_t feature_node = static_cast(source_encoder_->runtime_input_node_ids[feature_idx]); + const auto& feature_desc = source_encoder_->graph->get_output_buffer(feature_node); + write_typed_buffer( + feature_buf, + feature_desc.precision, + audio_features.data(), + audio_features.size() * sizeof(float), + Precision::FP32); + + return finish_encoder_cross_kv_prepare(); +} + +bool Model::run_encoder_cross_kv_decoder_step(uint32_t token_id, size_t position) { + if (!encoder_cross_kv_ready_ || !decoder_) return false; + int ids_idx = input_index(*decoder_, "decoder_input_ids"); + const char* ids_name = "decoder_input_ids"; + if (ids_idx < 0) { + ids_idx = input_index(*decoder_, "input_ids"); + ids_name = "input_ids"; + } + int pos_idx = input_index(*decoder_, "position_ids"); + if (ids_idx < 0 || pos_idx < 0) { + CACTUS_LOG_ERROR("model", "decoder_step missing decoder_input_ids/input_ids or position_ids input"); + return false; + } + write_int_input(*decoder_, ids_name, static_cast(token_id)); + write_int_input(*decoder_, "position_ids", static_cast(position)); + decoder_->graph->execute(); + return true; +} + +std::vector Model::run_encoder_cross_kv_decode_loop( + const std::vector& decoder_prompt_tokens, + size_t max_tokens, + const std::vector>& stop_token_sequences, + const std::atomic* should_stop) { + std::vector emitted; + if (!encoder_cross_kv_ready_ || decoder_prompt_tokens.empty() || max_tokens == 0) return emitted; + + std::vector tokens = decoder_prompt_tokens; + auto stopped = [&]() { + for (const auto& stop_seq : stop_token_sequences) { + if (stop_seq.empty() || emitted.size() < stop_seq.size()) continue; + if (std::equal(stop_seq.rbegin(), stop_seq.rend(), emitted.rbegin())) return true; + } + return false; + }; + + for (size_t i = 0; i < max_tokens; ++i) { + if (should_stop && should_stop->load()) break; + const size_t start = cache_total_seq_len_ < tokens.size() ? cache_total_seq_len_ : tokens.size() - 1; + for (size_t pos = start; pos < tokens.size(); ++pos) { + if (!run_encoder_cross_kv_decoder_step(tokens[pos], pos)) return emitted; + } + cache_total_seq_len_ = tokens.size(); + + uint32_t next_token = argmax_last_logits(); + record_sampled_token(next_token); + emitted.push_back(next_token); + if (stopped()) break; + tokens.push_back(next_token); + } + + return emitted; +} + bool Model::build_lm_encoder_outputs_dynamic_gemma4( const std::vector& tokens, std::map>& store_bytes, @@ -2191,6 +2512,32 @@ uint32_t Model::decode(const std::vector& tokens, float /*temperature* size_t /*top_k*/, const std::string& /*profile_file*/, float* out_entropy, float /*min_p*/, float /*repetition_penalty*/) { if (tokens.empty()) return 0; + if (decode_route_ == DecodeRoute::ENCODER_CROSS_KV_STEP) { + if (!encoder_cross_kv_ready_ && encoder_cross_kv_source_kind_ == "text_tokens") { + std::vector source_tokens = tokens; + std::vector decoder_seed = {config_.decoder_start_token_id}; + if (!source_tokens.empty() && source_tokens.back() == config_.decoder_start_token_id) { + decoder_seed = {source_tokens.back()}; + source_tokens.pop_back(); + } + if (!prepare_encoder_cross_kv_from_text(source_tokens)) return 0; + std::vector emitted = run_encoder_cross_kv_decode_loop( + decoder_seed, + 1, + {}, + nullptr); + if (out_entropy) *out_entropy = 0.0f; + return emitted.empty() ? 0 : emitted.front(); + } + for (size_t i = 0; i < tokens.size(); ++i) { + if (!run_encoder_cross_kv_decoder_step(tokens[i], cache_total_seq_len_ + i)) return 0; + } + cache_total_seq_len_ += tokens.size(); + if (out_entropy) *out_entropy = 0.0f; + uint32_t result = argmax_last_logits(); + record_sampled_token(result); + return result; + } if (decode_route_ == DecodeRoute::FULL_CONTEXT_TEXT) { context_tokens_.insert(context_tokens_.end(), tokens.begin(), tokens.end()); run_full_context_text(); @@ -2226,117 +2573,17 @@ std::vector Model::transcribe_whisper_seq2seq( size_t max_tokens, const std::vector>& stop_token_sequences, const std::atomic* should_stop) { - std::vector emitted; - if (decoder_prompt_tokens.empty() || max_tokens == 0) return emitted; - - Component* audio_enc = components_.count("audio_encoder") ? &components_.at("audio_encoder") : nullptr; - Component* cross_kv = components_.count("decoder_cross_kv") ? &components_.at("decoder_cross_kv") : nullptr; - Component* step = components_.count("decoder_step") ? &components_.at("decoder_step") : nullptr; - if (!audio_enc || !cross_kv || !step) { - CACTUS_LOG_ERROR("model", "Whisper bundle missing audio_encoder, decoder_cross_kv, or decoder_step component"); - return emitted; + if (decoder_prompt_tokens.empty() || max_tokens == 0) return {}; + if (decode_route_ != DecodeRoute::ENCODER_CROSS_KV_STEP || encoder_cross_kv_source_kind_ != "audio_features") { + CACTUS_LOG_ERROR("model", "Whisper bundle missing encoder_cross_kv_decoder_step route metadata"); + return {}; } - if (!bind_runtime_buffers(*audio_enc)) return emitted; - if (!bind_runtime_buffers(*cross_kv)) return emitted; - if (!bind_runtime_buffers(*step)) return emitted; - - reset_component_cache_states(*step); - cache_total_seq_len_ = 0; - token_history_.clear(); - - const int feat_idx = input_index(*audio_enc, "input_features"); - if (feat_idx < 0) { - CACTUS_LOG_ERROR("model", "Whisper audio_encoder has no input_features input"); - return emitted; - } - auto& feat_buf = audio_enc->input_buffers[feat_idx]; - const size_t feat_node = static_cast(audio_enc->runtime_input_node_ids[feat_idx]); - const auto& feat_desc = audio_enc->graph->get_output_buffer(feat_node); - write_typed_buffer( - feat_buf, - feat_desc.precision, - audio_features.data(), - audio_features.size() * sizeof(float), - Precision::FP32); - audio_enc->graph->execute(); - - const int hidden_idx = output_index(*audio_enc, "encoder_hidden_states"); - if (hidden_idx < 0) { - CACTUS_LOG_ERROR("model", "Whisper audio_encoder has no encoder_hidden_states output"); - return emitted; - } - const size_t hidden_node = static_cast(audio_enc->output_node_ids[hidden_idx]); - const auto& hidden_desc = audio_enc->graph->get_output_buffer(hidden_node); - const void* hidden_ptr = audio_enc->graph->get_output(hidden_node); - if (hidden_ptr == nullptr || hidden_desc.byte_size == 0) { - CACTUS_LOG_ERROR("model", "Whisper encoder_hidden_states output is empty"); - return emitted; - } - - const int cross_hidden_idx = input_index(*cross_kv, "encoder_hidden_states"); - if (cross_hidden_idx < 0) { - CACTUS_LOG_ERROR("model", "Whisper decoder_cross_kv missing encoder_hidden_states input"); - return emitted; - } - auto& cross_hidden_buf = cross_kv->input_buffers[cross_hidden_idx]; - const size_t cross_hidden_node = static_cast(cross_kv->runtime_input_node_ids[cross_hidden_idx]); - const auto& cross_hidden_desc = cross_kv->graph->get_output_buffer(cross_hidden_node); - write_typed_buffer( - cross_hidden_buf, - cross_hidden_desc.precision, - hidden_ptr, - hidden_desc.byte_size, - hidden_desc.precision); - cross_kv->graph->execute(); - - for (size_t i = 0; i < cross_kv->output_node_ids.size() && i < cross_kv->logical_outputs.size(); ++i) { - const std::string& name = cross_kv->logical_outputs[i]; - int idx = input_index(*step, name); - if (idx < 0) continue; - const size_t src_node = static_cast(cross_kv->output_node_ids[i]); - const auto& src_desc = cross_kv->graph->get_output_buffer(src_node); - const void* src_ptr = cross_kv->graph->get_output(src_node); - if (src_ptr == nullptr || src_desc.byte_size == 0) continue; - auto& dst_buf = step->input_buffers[idx]; - const size_t dst_node = static_cast(step->runtime_input_node_ids[idx]); - const auto& dst_desc = step->graph->get_output_buffer(dst_node); - write_typed_buffer(dst_buf, dst_desc.precision, src_ptr, src_desc.byte_size, src_desc.precision); - } - - const int ids_idx = input_index(*step, "decoder_input_ids"); - const int pos_idx = input_index(*step, "position_ids"); - if (ids_idx < 0 || pos_idx < 0) { - CACTUS_LOG_ERROR("model", "Whisper decoder_step missing decoder_input_ids or position_ids input"); - return emitted; - } - - std::vector tokens = decoder_prompt_tokens; - auto stopped = [&]() { - for (const auto& stop_seq : stop_token_sequences) { - if (stop_seq.empty() || emitted.size() < stop_seq.size()) continue; - if (std::equal(stop_seq.rbegin(), stop_seq.rend(), emitted.rbegin())) return true; - } - return false; - }; - - for (size_t i = 0; i < max_tokens; ++i) { - if (should_stop && should_stop->load()) break; - const size_t start = cache_total_seq_len_ < tokens.size() ? cache_total_seq_len_ : tokens.size() - 1; - for (size_t pos = start; pos < tokens.size(); ++pos) { - write_int_input(*step, "decoder_input_ids", static_cast(tokens[pos])); - write_int_input(*step, "position_ids", static_cast(pos)); - step->graph->execute(); - } - cache_total_seq_len_ = tokens.size(); - - uint32_t next_token = argmax_last_logits(); - record_sampled_token(next_token); - emitted.push_back(next_token); - if (stopped()) break; - tokens.push_back(next_token); - } - - return emitted; + if (!prepare_encoder_cross_kv_from_audio(audio_features)) return {}; + return run_encoder_cross_kv_decode_loop( + decoder_prompt_tokens, + max_tokens, + stop_token_sequences, + should_stop); } std::vector Model::transcribe_parakeet_tdt(const std::vector& audio_features) { @@ -2707,6 +2954,7 @@ std::vector Model::get_audio_embeddings(const std::vector& mel_bin void Model::reset_cache() { cache_total_seq_len_ = 0; last_logit_position_ = 0; + encoder_cross_kv_ready_ = false; context_tokens_.clear(); cache_token_ids_.clear(); special_rows_.clear(); @@ -3014,6 +3262,7 @@ bool Config::from_json(const std::string& config_path) { } std::string line; + bool decoder_start_token_seen = false; while (std::getline(file, line)) { if (line.empty() || line[0] == '#') continue; @@ -3031,6 +3280,11 @@ bool Config::from_json(const std::string& config_path) { if (key == "vocab_size") vocab_size = static_cast(std::stoul(value)); else if (key == "bos_token_id") bos_token_id = static_cast(std::stoul(value)); else if (key == "eos_token_id") eos_token_id = static_cast(std::stoul(value)); + else if (key == "decoder_start_token_id") { + decoder_start_token_id = static_cast(std::stoul(value)); + decoder_start_token_seen = true; + } + else if (key == "decoder_prompt_token_ids") decoder_prompt_token_ids = parse_config_uint_list(value); else if (key == "num_layers") num_layers = static_cast(std::stoul(value)); else if (key == "hidden_dim") hidden_dim = static_cast(std::stoul(value)); else if (key == "ffn_intermediate_dim") ffn_intermediate_dim = static_cast(std::stoul(value)); @@ -3279,6 +3533,10 @@ bool Config::from_json(const std::string& config_path) { } } + if (!decoder_start_token_seen) { + decoder_start_token_id = bos_token_id; + } + validate_kv_compress(); return true; } diff --git a/cactus-engine/src/model_npu.cpp b/cactus-engine/src/model_npu.cpp index b5be8fb72..9302dcfd2 100644 --- a/cactus-engine/src/model_npu.cpp +++ b/cactus-engine/src/model_npu.cpp @@ -30,6 +30,106 @@ bool Model::load_npu_vision_encoder(const std::string& model_path) { return true; } +bool Model::load_npu_source_encoder(const std::string& model_path) { + auto encoder = npu::create_encoder(); + if (!encoder) return false; + if (!encoder->load(model_path)) return false; + if (!encoder->is_available()) return false; + npu_source_encoder_ = std::move(encoder); + CACTUS_LOG_INFO("model", "NPU source encoder loaded from: " << model_path); + return true; +} + +bool Model::source_encode_via_npu(const std::vector& tokens) { + if (!npu_source_encoder_ || !npu_source_encoder_->is_available() || + !source_encoder_ || !decoder_cross_kv_) { + return false; + } + + int ids_idx = input_index(*source_encoder_, "input_ids"); + if (ids_idx < 0) return false; + size_t ids_node = static_cast(source_encoder_->runtime_input_node_ids[ids_idx]); + const auto& ids_desc = source_encoder_->graph->get_output_buffer(ids_node); + if (tokens.size() > ids_desc.total_size) return false; + + std::vector input_shape; + input_shape.reserve(ids_desc.shape.size()); + for (size_t dim : ids_desc.shape) input_shape.push_back(static_cast(dim)); + + std::vector input_ids(ids_desc.total_size, 0); + std::vector attention_mask(ids_desc.total_size, 0); + for (size_t i = 0; i < tokens.size(); ++i) { + input_ids[i] = static_cast(tokens[i]); + attention_mask[i] = 1; + } + + std::vector npu_inputs; + npu_inputs.push_back({ + "input_ids", + input_ids.data(), + npu::NPUNamedInput::DataType::INT32, + input_shape, + }); + if (input_index(*source_encoder_, "attention_mask") >= 0) { + npu_inputs.push_back({ + "attention_mask", + attention_mask.data(), + npu::NPUNamedInput::DataType::INT32, + input_shape, + }); + } + + int hidden_idx = input_index(*decoder_cross_kv_, "encoder_hidden_states"); + if (hidden_idx < 0) return false; + size_t hidden_node = static_cast(decoder_cross_kv_->runtime_input_node_ids[hidden_idx]); + const auto& hidden_desc = decoder_cross_kv_->graph->get_output_buffer(hidden_node); + std::vector<__fp16> hidden(hidden_desc.total_size, __fp16(0)); + size_t written = npu_source_encoder_->encode_multimodal_input( + npu_inputs, + hidden.data(), + "encoder_hidden_states"); + if (written == 0) return false; + + auto copy_fp16_to_component_input = [&](Component& comp, int input_idx, const __fp16* src, size_t src_elems) { + if (input_idx < 0 || !src) return false; + size_t node = static_cast(comp.runtime_input_node_ids[static_cast(input_idx)]); + const auto& desc = comp.graph->get_output_buffer(node); + auto& dst = comp.input_buffers[static_cast(input_idx)]; + std::fill(dst.begin(), dst.end(), 0); + const size_t elems = std::min(src_elems, desc.total_size); + if (desc.precision == Precision::FP16) { + std::memcpy(dst.data(), src, elems * sizeof(__fp16)); + return true; + } + if (desc.precision == Precision::FP32) { + float* out = reinterpret_cast(dst.data()); + for (size_t i = 0; i < elems; ++i) out[i] = static_cast(src[i]); + return true; + } + CACTUS_LOG_WARN("model", "NPU source encoder output precision mismatch for encoder_hidden_states"); + return false; + }; + + if (!copy_fp16_to_component_input(*decoder_cross_kv_, hidden_idx, hidden.data(), written)) { + return false; + } + + auto fill_mask = [&](Component& comp) { + int idx = input_index(comp, "encoder_attention_mask"); + if (idx < 0) return; + std::fill(comp.input_buffers[static_cast(idx)].begin(), + comp.input_buffers[static_cast(idx)].end(), + 0); + for (size_t i = 0; i < tokens.size(); ++i) { + write_int_input_at(comp, "encoder_attention_mask", i, 1); + } + }; + fill_mask(*decoder_cross_kv_); + if (decoder_) fill_mask(*decoder_); + + return true; +} + bool Model::audio_encode_via_npu(const std::vector& audio_features) { if (!npu_audio_encoder_ || !npu_audio_encoder_->is_available() || !audio_encoder_) { return false; diff --git a/cactus-engine/src/npu_ane.mm b/cactus-engine/src/npu_ane.mm index a108cab28..116f91585 100644 --- a/cactus-engine/src/npu_ane.mm +++ b/cactus-engine/src/npu_ane.mm @@ -650,6 +650,59 @@ static void copyFP16ToMLArray(const __fp16* data, size_t count, MLMultiArray* ar } } +static void copyInt32ToMLArray(const int32_t* data, size_t count, MLMultiArray* array) { + if (!array || !data || count == 0) return; + + std::vector dims; + std::vector strides; + const size_t rank = array.shape.count; + bool have_layout = rank == array.strides.count && rank > 0; + if (have_layout) { + dims.resize(rank); + strides.resize(rank); + for (size_t i = 0; i < rank; ++i) { + NSInteger d = [array.shape[i] integerValue]; + NSInteger s = [array.strides[i] integerValue]; + if (d <= 0 || s < 0) { + have_layout = false; + break; + } + dims[i] = static_cast(d); + strides[i] = static_cast(s); + } + } + + auto write_at = [&](size_t offset, size_t i) { + if (array.dataType == MLMultiArrayDataTypeInt32) { + ((int32_t*)array.dataPointer)[offset] = data[i]; + } else if (array.dataType == MLMultiArrayDataTypeFloat16) { + ((__fp16*)array.dataPointer)[offset] = static_cast<__fp16>(data[i]); + } else { + ((float*)array.dataPointer)[offset] = static_cast(data[i]); + } + }; + + if (!have_layout) { + for (size_t i = 0; i < count; ++i) write_at(i, i); + return; + } + + std::vector idx(rank, 0); + for (size_t i = 0; i < count; ++i) { + size_t offset = 0; + for (size_t d = 0; d < rank; ++d) { + offset += idx[d] * strides[d]; + } + write_at(offset, i); + + for (size_t d = rank; d-- > 0;) { + idx[d]++; + if (idx[d] < dims[d]) break; + idx[d] = 0; + } + } +} + - (BOOL)preallocateMultiInputBuffersWithOutputName:(NSString*)outputName { if (!_model || !_modelDescription) return NO; @@ -917,7 +970,11 @@ - (size_t)predictMultiInputWithDict:(NSMutableDictionary(input.data), total, array); + } else { + copyFP16ToMLArray(static_cast(input.data), total, array); + } inputDict[nsName] = [MLFeatureValue featureValueWithMultiArray:array]; } diff --git a/cactus-engine/src/tokenizer.cpp b/cactus-engine/src/tokenizer.cpp index 14f1336d0..54b0214f7 100644 --- a/cactus-engine/src/tokenizer.cpp +++ b/cactus-engine/src/tokenizer.cpp @@ -11,6 +11,25 @@ namespace engine { namespace { +std::string format_needle_query_text(const std::vector& messages) { + std::string system_text; + std::string user_query; + + for (const auto& msg : messages) { + if (msg.role == "system" || msg.role == "developer") { + if (!system_text.empty()) system_text += "\n"; + system_text += msg.content; + } else if (msg.role == "user") { + user_query = msg.content; + } + } + + if (user_query.empty() && !messages.empty()) user_query = messages.back().content; + if (system_text.empty()) return user_query; + if (user_query.empty()) return system_text; + return system_text + "\n\n" + user_query; +} + std::string format_tool_call_for_prompt(const std::string& name, const std::string& arguments, bool gemma4) { if (gemma4) { return "\n<|tool_call>\ncall:" + name + "(" + arguments + ")\n\n"; @@ -339,7 +358,9 @@ void Tokenizer::detect_model_type(const std::string& config_path) { std::transform(lower_line.begin(), lower_line.end(), lower_line.begin(), ::tolower); if (lower_line.find("model_type") != std::string::npos) { - if (lower_line.find("qwen") != std::string::npos) { + if (lower_line.find("needle") != std::string::npos) { + model_type_ = ModelType::NEEDLE; + } else if (lower_line.find("qwen") != std::string::npos) { model_type_ = ModelType::QWEN; } else if (lower_line.find("lfm2") != std::string::npos) { model_type_ = ModelType::LFM2; @@ -370,6 +391,9 @@ void Tokenizer::detect_model_type(const std::string& config_path) { } std::string Tokenizer::get_default_stop_sequence() const { + if (model_type_ == ModelType::NEEDLE) { + return ""; + } if (model_type_ == ModelType::QWEN || model_type_ == ModelType::LFM2) { return "<|im_end|>"; } @@ -388,6 +412,9 @@ std::string Tokenizer::format_chat_prompt(const std::vector& messag if (model_type_ == ModelType::LFM2) { return format_lfm2_style(messages, add_generation_prompt, tools_json, enable_thinking_if_supported); } + if (model_type_ == ModelType::NEEDLE) { + return format_needle_style(messages, add_generation_prompt, tools_json); + } return format_gemma4_style(messages, add_generation_prompt, tools_json, enable_thinking_if_supported); } @@ -454,6 +481,12 @@ std::string Tokenizer::format_lfm2_style(const std::vector& message return "<|startoftext|>" + format_qwen_style(messages, add_generation_prompt, tools_json, enable_thinking_if_supported); } +std::string Tokenizer::format_needle_style(const std::vector& messages, bool /*add_generation_prompt*/, + const std::string& tools_json) const { + std::string serialized_tools = tools_json.empty() ? "[]" : tools_json; + return format_needle_query_text(messages) + "" + serialized_tools + ""; +} + std::string Tokenizer::format_gemma4_style(const std::vector& messages, bool add_generation_prompt, const std::string& tools_json, bool enable_thinking_if_supported) const { std::string result = ""; diff --git a/cactus-engine/src/transcribe.cpp b/cactus-engine/src/transcribe.cpp index ae91ad236..9a650b91c 100644 --- a/cactus-engine/src/transcribe.cpp +++ b/cactus-engine/src/transcribe.cpp @@ -235,7 +235,7 @@ int cactus_transcribe( if (prompt && prompt[0] != '\0') { tokens = tokenizer->encode(prompt); } else if (is_whisper) { - tokens = tokenizer->encode("<|startoftranscript|><|en|><|transcribe|><|notimestamps|>"); + tokens = handle->model->get_config().decoder_prompt_token_ids; } if (tokens.empty() && is_whisper) { diff --git a/cactus-engine/src/utils.h b/cactus-engine/src/utils.h index ca64eb549..b1cbad14b 100644 --- a/cactus-engine/src/utils.h +++ b/cactus-engine/src/utils.h @@ -1501,6 +1501,29 @@ inline void parse_function_calls_from_response(const std::string& response_text, regular_response = response_text; function_calls.clear(); + const std::string needle_marker = ""; + size_t needle_pos = regular_response.find(needle_marker); + if (needle_pos != std::string::npos) { + size_t array_start = regular_response.find('[', needle_pos + needle_marker.size()); + if (array_start != std::string::npos) { + size_t array_end = find_matching_delimiter(regular_response, array_start, '[', ']'); + if (array_end <= regular_response.size()) { + size_t pos = array_start + 1; + while (pos < array_end) { + size_t obj_start = regular_response.find('{', pos); + if (obj_start == std::string::npos || obj_start >= array_end) break; + size_t obj_end = find_matching_delimiter(regular_response, obj_start, '{', '}'); + if (obj_end > regular_response.size() || obj_end > array_end) break; + function_calls.push_back(regular_response.substr(obj_start, obj_end - obj_start)); + pos = obj_end; + } + regular_response = trim_string( + regular_response.substr(0, needle_pos) + regular_response.substr(array_end)); + return; + } + } + } + gemma::parse_function_calls(regular_response, function_calls); const char* FUNCTION_CALL_MARKER = "\"function_call\""; diff --git a/python/cactus/cli/model.py b/python/cactus/cli/model.py index a24e4d6d0..0a310e7e4 100644 --- a/python/cactus/cli/model.py +++ b/python/cactus/cli/model.py @@ -56,6 +56,7 @@ def ensure_weights(model_id, *, bits=4, token=None, reconvert=False, output_dir= class _TranspileSpec: task: str components: tuple[str, ...] = () + default_max_new_tokens: int | None = None needs_image: bool = False needs_audio: bool = False force_component_pipeline: bool = False @@ -65,6 +66,7 @@ def _spec_from_plan(plan): return _TranspileSpec( task=plan.task, components=tuple(plan.components or ()), + default_max_new_tokens=plan.default_max_new_tokens, needs_image=bool(plan.needs_image), needs_audio=bool(plan.needs_audio), force_component_pipeline=bool(plan.force_component_pipeline), @@ -95,12 +97,14 @@ def _infer_transpile_spec(*, task, plan): return _spec_from_plan(plan) -def _default_max_new_tokens(task): +def _default_max_new_tokens(spec): + if spec.default_max_new_tokens is not None: + return int(spec.default_max_new_tokens) return { "seq2seq_transcription": 128, "multimodal_causal_lm_logits": 512, "causal_lm_logits": 128, - }.get(task, 32) + }.get(spec.task, 32) def _default_multimodal_assets(): @@ -257,7 +261,7 @@ def ensure_bundle(model_id, *, bits=4, token=None, elif spec.task in _AUDIO_TASKS and not spec_audio_file: raise RuntimeError(f"{spec.task} transpile requires --audio-file.") - effective_max_new_tokens = opts.max_new_tokens or _default_max_new_tokens(spec.task) + effective_max_new_tokens = opts.max_new_tokens or _default_max_new_tokens(spec) extra_args = [ "--weights-dir", str(output_dir), diff --git a/python/cactus/convert/cactus_adapters/config_utils.py b/python/cactus/convert/cactus_adapters/config_utils.py index 0579b260f..b17b7c229 100644 --- a/python/cactus/convert/cactus_adapters/config_utils.py +++ b/python/cactus/convert/cactus_adapters/config_utils.py @@ -44,6 +44,8 @@ def detect_model_type(cfg, config, output_dir=None): return 'qwen' elif 'moonshine' in model_type_str: return 'moonshine' + elif 'needle' in model_type_str: + return 'needle' elif 'llama' in model_type_str: if output_dir and 'smol' in str(output_dir): return 'smol' @@ -89,6 +91,15 @@ def extract_base_config(cfg, config): rope_theta = cfg_get(config, 'rope_theta', 10000.0) num_experts_per_tok = cfg_get(cfg, 'num_experts_per_tok', cfg_get(cfg, 'moe_top_k', cfg_get(cfg, 'num_top_experts', cfg_get(cfg, 'top_k_experts', 0)))) or 0 + decoder_start_token_id = cfg_get( + cfg, + 'decoder_start_token_id', + cfg_get( + config, + 'decoder_start_token_id', + cfg_get(cfg, 'bos_token_id', cfg_get(config, 'bos_token_id', 0)), + ), + ) base = { 'vocab_size': cfg_get(cfg, 'vocab_size', cfg_get(config, 'vocab_size', 0)), @@ -101,6 +112,7 @@ def extract_base_config(cfg, config): 'rope_theta': rope_theta, 'attention_head_dim': int(cfg_get(cfg, 'head_dim', int(cfg_get(cfg, 'hidden_size', cfg_get(cfg, 'hidden_dim', 0)) // max(1, cfg_get(cfg, 'num_attention_heads', 1))))), 'layer_norm_eps': cfg_get(cfg, 'layer_norm_eps', cfg_get(cfg, 'layer_norm_epsilon', cfg_get(cfg, 'rms_norm_eps', cfg_get(cfg, 'norm_eps', 1e-6)))), + 'decoder_start_token_id': int(decoder_start_token_id or 0), 'num_experts': cfg_get(cfg, 'num_experts', 0) or 0, 'num_shared_experts': cfg_get(cfg, 'num_shared_experts', 0), 'num_top_experts': num_experts_per_tok, @@ -253,6 +265,12 @@ def extract_whisper_config(config): attention_heads = int(cfg_get(config, 'decoder_attention_heads', cfg_get(config, 'encoder_attention_heads', 0))) encoder_layers = int(cfg_get(config, 'encoder_layers', cfg_get(config, 'num_encoder_layers', 0))) decoder_layers = int(cfg_get(config, 'decoder_layers', cfg_get(config, 'num_decoder_layers', 0))) + decoder_start_token_id = int(cfg_get(config, 'decoder_start_token_id', cfg_get(config, 'bos_token_id', 0)) or 0) + decoder_prompt_token_ids = [decoder_start_token_id] + forced_decoder_ids = cfg_get(config, 'forced_decoder_ids', None) or [] + for item in sorted(forced_decoder_ids, key=lambda pair: int(pair[0]) if isinstance(pair, (list, tuple)) and pair else 0): + if isinstance(item, (list, tuple)) and len(item) == 2: + decoder_prompt_token_ids.append(int(item[1])) return { 'vocab_size': int(cfg_get(config, 'vocab_size', 0)), 'hidden_dim': hidden_dim, @@ -270,6 +288,8 @@ def extract_whisper_config(config): 'pad_token_id': int(cfg_get(config, 'pad_token_id', 0)), 'bos_token_id': int(cfg_get(config, 'bos_token_id', 0)), 'eos_token_id': int(cfg_get(config, 'eos_token_id', 0)), + 'decoder_start_token_id': decoder_start_token_id, + 'decoder_prompt_token_ids': decoder_prompt_token_ids, 'tie_word_embeddings': bool(cfg_get(config, 'tie_word_embeddings', True)), } diff --git a/python/cactus/convert/cactus_adapters/tokenizer.py b/python/cactus/convert/cactus_adapters/tokenizer.py index f041ce616..80289ea87 100644 --- a/python/cactus/convert/cactus_adapters/tokenizer.py +++ b/python/cactus/convert/cactus_adapters/tokenizer.py @@ -137,6 +137,21 @@ def convert_hf_tokenizer(tokenizer, output_dir, token=None, model_id=None, label except Exception as e: print(f" Warning: Could not save tokenizer JSON: {e}") + source_dir = Path(getattr(tokenizer, "name_or_path", "") or "") + source_tokenizer_model = source_dir / "tokenizer.model" + output_tokenizer_model = output_dir / "tokenizer.model" + if (model_type == "needle" or "needle" in model_name_l) and ( + output_tokenizer_model.exists() or source_tokenizer_model.exists() + ): + tokenizer_model_path = output_tokenizer_model if output_tokenizer_model.exists() else source_tokenizer_model + convert_sentencepiece_tokenizer( + tokenizer_model_path, + output_dir, + getattr(tokenizer, "model_max_length", 1024), + ) + print(" Saved Needle SentencePiece tokenizer") + return + tokenizer_model = tokenizer_json_data.get("model", {}) if tokenizer_json_data else {} tokenizer_model_type = str(tokenizer_model.get("type", "")).upper() is_unigram = tokenizer_model_type == "UNIGRAM" @@ -321,6 +336,24 @@ def write_merges_file(merges_list): added_tokens_decoder = {} tool_tokens = {} + def add_additional_special_token(token_str): + if not isinstance(token_str, str) or not token_str: + return + token_id = None + try: + converted = tokenizer.convert_tokens_to_ids(token_str) + if converted is not None and converted != getattr(tokenizer, 'unk_token_id', None): + token_id = int(converted) + except Exception: + token_id = None + if token_id is None: + token_id = vocab_lookup.get(token_str) + if token_id is None: + return + special_tokens[token_id] = token_str + if not any(item["token"] == token_str and item["id"] == int(token_id) for item in additional_special_tokens): + additional_special_tokens.append({"token": token_str, "id": int(token_id)}) + try: config_path = None if hasattr(tokenizer, 'name_or_path') and hf_hub_download: @@ -362,12 +395,25 @@ def write_merges_file(merges_list): print(f" Found tool token: {content} (ID: {token_id})") special_tokens[token_id] = content + for token_str in tokenizer_full_config.get("additional_special_tokens", []) or []: + add_additional_special_token(token_str) + except Exception as e: print(f" Note: Could not load full tokenizer config: {e}") pass except Exception: pass + try: + if hasattr(tokenizer, 'name_or_path') and Path(tokenizer.name_or_path).is_dir(): + special_map_path = Path(tokenizer.name_or_path) / "special_tokens_map.json" + if special_map_path.exists(): + special_map = json.loads(special_map_path.read_text(encoding="utf-8")) + for token_str in special_map.get("additional_special_tokens", []) or []: + add_additional_special_token(token_str) + except Exception: + pass + # Extend id_to_token to include special/added tokens so vocab.txt is self-contained if special_tokens: diff --git a/python/cactus/convert/model_adapters/adapters.py b/python/cactus/convert/model_adapters/adapters.py index fbc032aa0..3a44cdf66 100644 --- a/python/cactus/convert/model_adapters/adapters.py +++ b/python/cactus/convert/model_adapters/adapters.py @@ -494,6 +494,7 @@ def module_target_name(self, source_name: str, _model: Any) -> str | None: "lfm2": Lfm2Adapter(), "moonshine": FamilyAdapter("moonshine"), "nomic": NomicAdapter(), + "needle": FamilyAdapter("needle"), "whisper": WhisperAdapter(), "parakeet": ParakeetAdapter(), "parakeet_tdt": ParakeetTDTAdapter(), diff --git a/python/cactus/convert/model_adapters/detection.py b/python/cactus/convert/model_adapters/detection.py index 52fae904e..7a72a4edf 100644 --- a/python/cactus/convert/model_adapters/detection.py +++ b/python/cactus/convert/model_adapters/detection.py @@ -3,7 +3,7 @@ from ..cactus_adapters.config_utils import cfg_get, detect_model_type -SUPPORTED_FAMILIES = {"auto", "gemma4", "qwen", "lfm2", "whisper", "parakeet", "parakeet_tdt", "moonshine", "nomic", "generic"} +SUPPORTED_FAMILIES = {"auto", "gemma4", "qwen", "lfm2", "whisper", "parakeet", "parakeet_tdt", "moonshine", "nomic", "needle", "generic"} def detect_family(config, requested: str = "auto") -> str: @@ -12,6 +12,6 @@ def detect_family(config, requested: str = "auto") -> str: text_config = cfg_get(config, "text_config", None) base = text_config if text_config is not None else config detected = detect_model_type(base, config) - if detected in {"gemma4", "qwen", "lfm2", "whisper", "moonshine", "parakeet", "parakeet_tdt", "nomic"}: + if detected in {"gemma4", "qwen", "lfm2", "whisper", "moonshine", "parakeet", "parakeet_tdt", "nomic", "needle"}: return detected return "generic" diff --git a/python/cactus/convert/model_adapters/naming.py b/python/cactus/convert/model_adapters/naming.py index cebbd9014..b85844152 100644 --- a/python/cactus/convert/model_adapters/naming.py +++ b/python/cactus/convert/model_adapters/naming.py @@ -249,6 +249,16 @@ def _parakeet_tdt_predictor_match(name: str) -> str | None: return None +def _needle_gate_match(name: str) -> str | None: + m = re.fullmatch(r"model\.encoder\.layers\.(\d+)\.attn_gate", name) + if m: + return f"encoder_layer_{int(m.group(1))}_attn_gate.weights" + m = re.fullmatch(r"model\.decoder\.layers\.(\d+)\.(self_attn_gate|cross_attn_gate)", name) + if m: + return f"layer_{int(m.group(1))}_{m.group(2)}.weights" + return None + + def _suffix_layer_match(name: str, i: int, family: str) -> tuple[str, bool] | None: if family == "whisper": return _whisper_layer_match(name, i) @@ -256,6 +266,10 @@ def _suffix_layer_match(name: str, i: int, family: str) -> tuple[str, bool] | No out = _parakeet_layer_match(name, i) if out: return out, False + if family == "needle": + out = _needle_gate_match(name) + if out: + return out, False prefixes = [p.format(i=i) for p in wp.LAYER_PREFIXES] + [f"h.{i}."] for p in prefixes: if not name.startswith(p): @@ -267,13 +281,21 @@ def _suffix_layer_match(name: str, i: int, family: str) -> tuple[str, bool] | No rx = re.escape(pat).replace(r"\{channel\}", r"(\d+)") m = re.fullmatch(rx, suffix) if m: - return out_name.format(channel=m.group(1)), transpose + matched_out = out_name.format(channel=m.group(1)) + if family == "needle" and p == f"model.encoder.layers.{i}.": + matched_out = matched_out.replace(f"layer_{i}_", f"encoder_layer_{i}_", 1) + return matched_out, transpose elif suffix == pat: - return out_name, transpose + matched_out = out_name + if family == "needle" and p == f"model.encoder.layers.{i}.": + matched_out = matched_out.replace(f"layer_{i}_", f"encoder_layer_{i}_", 1) + return matched_out, transpose if suffix.endswith(".weight"): - return f"layer_{i}_{suffix[:-7].replace('.', '_')}.weights", False + prefix = f"encoder_layer_{i}_" if family == "needle" and p == f"model.encoder.layers.{i}." else f"layer_{i}_" + return f"{prefix}{suffix[:-7].replace('.', '_')}.weights", False if suffix.endswith(".bias"): - return f"layer_{i}_{suffix[:-5].replace('.', '_')}.bias", False + prefix = f"encoder_layer_{i}_" if family == "needle" and p == f"model.encoder.layers.{i}." else f"layer_{i}_" + return f"{prefix}{suffix[:-5].replace('.', '_')}.bias", False return None diff --git a/python/cactus/transpile/component_plan.py b/python/cactus/transpile/component_plan.py index 558a2756f..837dda211 100644 --- a/python/cactus/transpile/component_plan.py +++ b/python/cactus/transpile/component_plan.py @@ -14,6 +14,7 @@ class ComponentPlan: task: str components: tuple[str, ...] = () + default_max_new_tokens: int | None = None needs_image: bool = False needs_audio: bool = False force_component_pipeline: bool = False @@ -44,6 +45,7 @@ def _plan_from_profile( return ComponentPlan( task=profile.default_task, components=profile.default_components, + default_max_new_tokens=profile.default_max_new_tokens, needs_image=profile.needs_image, needs_audio=profile.needs_audio, force_component_pipeline=profile.force_component_pipeline, diff --git a/python/cactus/transpile/hf_model.py b/python/cactus/transpile/hf_model.py index d13b4cfea..f0edbd570 100644 --- a/python/cactus/transpile/hf_model.py +++ b/python/cactus/transpile/hf_model.py @@ -302,6 +302,7 @@ def _write_component_bundle( optimized_component_graphs: dict[str, IRGraph], transpiled_component_graphs: dict[str, TranspiledGraph] | None = None, component_io_signatures: dict[str, dict[str, tuple[str, ...]]] | None = None, + component_metadata: dict[str, dict[str, object]] | None = None, graph_filename: str = "graph.cactus", npu_encoder_mlpackages: dict[str, str] | None = None, ) -> Path: @@ -309,6 +310,7 @@ def _write_component_bundle( component_order = [ component for component in ( + "source_encoder", "audio_encoder", "vision_encoder", "text_embedding", @@ -316,6 +318,7 @@ def _write_component_bundle( "lm_encoder_text_chunk", "decoder_prefill_chunk", "decoder", + "decoder_cross_kv", "lm_encoder_step", "lm_encoder_media_step", "decoder_media_step", @@ -389,6 +392,7 @@ def _write_component_bundle( "outputs": list(graph_for_signature.outputs), "logical_inputs": list((component_io_signatures or {}).get(component, {}).get("input_keys", ())), "logical_outputs": list((component_io_signatures or {}).get(component, {}).get("output_keys", ())), + "metadata": _serialize_json_compatible((component_metadata or {}).get(component, {})), "node_count": len(graph_for_signature.order), "weight_binding_count": _count_weight_bindings(graph_for_signature), "runtime_input_node_ids": [] if transpiled_graph is None else [int(tensor.id) for tensor in transpiled_graph.runtime_inputs], @@ -591,6 +595,10 @@ def _run_component_pipeline_transpile( } for name, captured in captured_components.items() } + component_metadata = { + name: dict(captured.metadata) + for name, captured in captured_components.items() + } raw_ir_nodes = sum(len(graph.order) for graph in raw_component_graphs.values()) optimized_ir_nodes = sum(len(graph.order) for graph in optimized_component_graphs.values()) @@ -707,6 +715,7 @@ def _run_component_pipeline_transpile( optimized_component_graphs=optimized_component_graphs, transpiled_component_graphs=transpiled_component_graphs, component_io_signatures=component_io_signatures, + component_metadata=component_metadata, graph_filename=args.graph_filename, npu_encoder_mlpackages=npu_encoder_mlpackages, ) diff --git a/python/cactus/transpile/lower.py b/python/cactus/transpile/lower.py index e16af4f9b..14e7eabea 100644 --- a/python/cactus/transpile/lower.py +++ b/python/cactus/transpile/lower.py @@ -104,9 +104,9 @@ def transpile_preoptimized_ir(ir: IRGraph) -> TranspiledGraph: bound_constant_bindings: list[dict[str, object]] = [] bound_constant_value_ids: dict[int, str] = {} - for value_id in ir.inputs: + for input_index, value_id in enumerate(ir.inputs): value = ir.values[value_id] - tensor = _lower_input_value(g, value) + tensor = _lower_input_value(g, value, ir=ir, input_index=input_index) env[value_id] = tensor runtime_inputs.append(tensor) @@ -171,9 +171,45 @@ def transpile_preoptimized_ir(ir: IRGraph) -> TranspiledGraph: ) -def _lower_input_value(g: Graph, value: IRValue) -> Tensor: +def _cross_kv_cache_buffer_elements(max_seq_len: int, num_kv_heads: int, head_dim: int) -> int: + kv_quant_group_size = 32 + num_groups = (int(head_dim) + kv_quant_group_size - 1) // kv_quant_group_size + kv_values = int(max_seq_len) * int(num_kv_heads) * int(head_dim) + kv_scales = int(max_seq_len) * int(num_kv_heads) * num_groups * 4 + return int(64 + kv_values + kv_scales) + + +def _external_cross_kv_cache_input_indices(ir: IRGraph) -> set[int]: + if not bool(ir.meta.get("external_cross_kv_cache_inputs", False)): + return set() + start = int(ir.meta.get("cross_kv_input_start_index", -1)) + count = int(ir.meta.get("cross_kv_input_count", 0)) + if start < 0 or count <= 0: + return set() + return set(range(start, start + count)) + + +def _is_external_cross_kv_cache_value(ir: IRGraph, value_id: str) -> bool: + return any( + input_value_id == value_id and input_index in _external_cross_kv_cache_input_indices(ir) + for input_index, input_value_id in enumerate(ir.inputs) + ) + + +def _lower_input_value(g: Graph, value: IRValue, *, ir: IRGraph | None = None, input_index: int | None = None) -> Tensor: if value.shape is None or value.dtype is None: raise ValueError(f"IR input missing shape or dtype: {value.id}") + if ir is not None and input_index is not None and input_index in _external_cross_kv_cache_input_indices(ir): + shape = tuple(int(dim) for dim in value.shape) + if len(shape) != 4: + raise ValueError(f"external cross-KV cache input expects original rank-4 K/V tensor, got {shape}") + layout = str(ir.meta.get("cross_kv_input_layout", "bthd") or "bthd").lower() + if layout == "bhsd": + max_seq_len, num_kv_heads, head_dim = shape[2], shape[1], shape[3] + else: + max_seq_len, num_kv_heads, head_dim = shape[1], shape[2], shape[3] + cache_elements = _cross_kv_cache_buffer_elements(max_seq_len, num_kv_heads, head_dim) + return g.input(shape=(cache_elements,), dtype=Graph.INT8) return g.input(shape=value.shape, dtype=_map_ir_dtype(value.dtype)) @@ -1173,6 +1209,42 @@ def _lower_ir_node(g: Graph, node: IRNode, env: dict[str, Any], ir: IRGraph) -> value = _attention_tensor(env, node.inputs[2]) if q_layout != "bthd": query = g.permute(query, (0, 2, 1, 3)) + if _is_external_cross_kv_cache_value(ir, node.inputs[1]) and _is_external_cross_kv_cache_value(ir, node.inputs[2]): + key_value = ir.values.get(node.inputs[1]) + value_value = ir.values.get(node.inputs[2]) + if key_value is None or value_value is None or key_value.shape is None or value_value.shape is None: + raise ValueError("external cross-KV cache attention requires original key/value shapes") + key_shape = tuple(int(dim) for dim in key_value.shape) + value_shape = tuple(int(dim) for dim in value_value.shape) + if len(key_shape) != 4 or len(value_shape) != 4: + raise ValueError(f"external cross-KV cache attention expects rank-4 key/value tensors, got {key_shape} and {value_shape}") + if k_layout == "bhsd": + num_kv_heads, head_dim = key_shape[1], key_shape[3] + else: + num_kv_heads, head_dim = key_shape[2], key_shape[3] + v_head_dim = value_shape[3] + dummy_key = _materialize_constant_tensor( + g, + torch.zeros((1, 1, int(num_kv_heads), int(head_dim)), dtype=torch.float16), + ) + dummy_value = _materialize_constant_tensor( + g, + torch.zeros((1, 1, int(num_kv_heads), int(v_head_dim)), dtype=torch.float16), + ) + out = g.attention_cached( + query, + dummy_key, + dummy_value, + key, + value, + scale=_resolve_attention_scale(node, query), + position_offset=_DYNAMIC_KV_QUERY_POSITION_OFFSET, + window_size=int(node.attrs.get("window_size", 0)), + v_head_dim=int(v_head_dim), + ) + if output_layout == "bthd": + return [out] + return [g.permute(out, (0, 2, 1, 3))] if k_layout != "bthd": key = g.permute(key, (0, 2, 1, 3)) if v_layout != "bthd": diff --git a/python/cactus/transpile/model_adapters.py b/python/cactus/transpile/model_adapters.py index 0d427dc9f..701fddd98 100644 --- a/python/cactus/transpile/model_adapters.py +++ b/python/cactus/transpile/model_adapters.py @@ -4908,76 +4908,6 @@ def forward(self, input_features: torch.Tensor) -> torch.Tensor: return self.encoder.layer_norm(hidden_states) -class WhisperDecoderComponentAdapter(torch.nn.Module): - def __init__(self, decoder: torch.nn.Module, proj_out: torch.nn.Module, *, pad_token_id: int | None): - super().__init__() - self.decoder = decoder - self.proj_out = proj_out - self.pad_token_id = pad_token_id - - @staticmethod - def _build_causal_mask(input_ids: torch.Tensor, *, dtype: torch.dtype) -> torch.Tensor: - seq_len = int(input_ids.shape[1]) - allowed = torch.tril( - torch.ones((seq_len, seq_len), dtype=torch.bool, device=input_ids.device), - ).view(1, 1, seq_len, seq_len) - allowed_values = torch.ones( - (1, 1, seq_len, seq_len), - dtype=dtype, - device=input_ids.device, - ) * 0.0 - blocked_values = torch.ones( - (1, 1, seq_len, seq_len), - dtype=dtype, - device=input_ids.device, - ) * torch.finfo(dtype).min - return torch.where(allowed, allowed_values, blocked_values) - - def forward( - self, - decoder_input_ids: torch.Tensor, - encoder_hidden_states: torch.Tensor, - position_ids: torch.Tensor | None = None, - ) -> torch.Tensor: - hidden_states = self.decoder.embed_tokens(decoder_input_ids) - if position_ids is None: - position_ids = torch.arange( - hidden_states.shape[1], - device=hidden_states.device, - ).unsqueeze(0).expand(hidden_states.shape[0], -1) - positions = self.decoder.embed_positions( - decoder_input_ids, - past_key_values_length=0, - position_ids=position_ids, - ) - hidden_states = hidden_states + positions.to( - device=hidden_states.device, - dtype=hidden_states.dtype, - ) - - causal_mask = self._build_causal_mask( - decoder_input_ids, - dtype=hidden_states.dtype, - ) - for layer in self.decoder.layers: - hidden_states = layer( - hidden_states, - attention_mask=causal_mask, - encoder_hidden_states=encoder_hidden_states, - encoder_attention_mask=None, - past_key_values=None, - use_cache=False, - ) - - hidden_states = self.decoder.layer_norm(hidden_states) - hidden_states = _select_last_non_pad_token( - hidden_states, - decoder_input_ids, - pad_token_id=self.pad_token_id, - ) - return self.proj_out(hidden_states) - - class WhisperDecoderCrossKVComponentAdapter(torch.nn.Module): def __init__(self, decoder: torch.nn.Module): super().__init__() @@ -5061,6 +4991,91 @@ def forward( return self.proj_out(hidden_states) +class NeedleSourceEncoderComponentAdapter(torch.nn.Module): + def __init__(self, model: torch.nn.Module): + super().__init__() + self.model = model + + def forward(self, input_ids: torch.Tensor, attention_mask: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]: + encode = getattr(self.model, "cactus_source_encode", None) + if not callable(encode): + raise TypeError(f"{type(self.model).__name__} does not expose cactus_source_encode") + return encode(input_ids, attention_mask) + + def get_transpile_metadata(self): + return { + "graph": { + **_transpile_graph_meta( + self.model, + adapter_family="needle", + adapter_type=type(self).__name__, + input_names=("input_ids", "attention_mask"), + ), + "task": "causal_lm_logits", + } + } + + +class NeedleDecoderCrossKVComponentAdapter(torch.nn.Module): + def __init__(self, model: torch.nn.Module): + super().__init__() + self.model = model + + def forward( + self, + encoder_hidden_states: torch.Tensor, + encoder_attention_mask: torch.Tensor, + ) -> tuple[torch.Tensor, ...]: + cross_kv = getattr(self.model, "cactus_decoder_cross_kv", None) + if not callable(cross_kv): + raise TypeError(f"{type(self.model).__name__} does not expose cactus_decoder_cross_kv") + return cross_kv(encoder_hidden_states, encoder_attention_mask) + + def get_transpile_metadata(self): + return { + "graph": { + **_transpile_graph_meta( + self.model, + adapter_family="needle", + adapter_type=type(self).__name__, + input_names=("encoder_hidden_states", "encoder_attention_mask"), + ), + "task": "causal_lm_logits", + } + } + + +class NeedleDecoderStepComponentAdapter(torch.nn.Module): + def __init__(self, model: torch.nn.Module): + super().__init__() + self.model = model + + def forward( + self, + decoder_input_ids: torch.Tensor, + position_ids: torch.Tensor, + encoder_attention_mask: torch.Tensor, + *cross_kv: torch.Tensor, + ) -> torch.Tensor: + step = getattr(self.model, "cactus_decoder_step", None) + if not callable(step): + raise TypeError(f"{type(self.model).__name__} does not expose cactus_decoder_step") + return step(decoder_input_ids, position_ids, encoder_attention_mask, *cross_kv) + + def get_transpile_metadata(self): + return { + "graph": { + **_transpile_graph_meta( + self.model, + adapter_family="needle", + adapter_type=type(self).__name__, + input_names=("decoder_input_ids", "position_ids", "encoder_attention_mask"), + ), + "task": "causal_lm_logits", + } + } + + def _family_key(model: torch.nn.Module) -> str: explicit_family = getattr(model, "family", None) if isinstance(explicit_family, str) and explicit_family: @@ -5087,11 +5102,120 @@ def _family_key(model: torch.nn.Module) -> str: if module_name.startswith("transformers.models.lfm2."): return "lfm2" model_type = str(getattr(getattr(model, "config", None), "model_type", "") or "").lower() + if model_type == "needle": + return "needle" if "nomic" in module_name.lower() or "nomic" in model_type: return "nomic" return "generic" +_ENCODER_CROSS_KV_ROUTE = "encoder_cross_kv_decoder_step" + + +def _cross_kv_output_keys(num_layers: int) -> tuple[str, ...]: + return tuple( + name + for layer_index in range(int(num_layers)) + for name in (f"cross_k_{layer_index}", f"cross_v_{layer_index}") + ) + + +def _component_spec( + *, + component: str, + module: torch.nn.Module, + example_inputs: tuple[torch.Tensor, ...], + input_keys: tuple[str, ...], + output_keys: tuple[str, ...], + common_graph_meta: dict[str, object], + family: str, + task: str, + graph_meta: dict[str, object] | None = None, + runtime_role: str | None = None, + source_kind: str | None = None, +) -> ComponentModuleSpec: + component_metadata: dict[str, object] = {"family": family, "task": task} + if runtime_role is not None: + component_metadata["runtime_route"] = _ENCODER_CROSS_KV_ROUTE + component_metadata["runtime_role"] = runtime_role + if source_kind is not None: + component_metadata["source_kind"] = source_kind + return ComponentModuleSpec( + component=component, + module=module, + example_inputs=example_inputs, + input_keys=input_keys, + output_keys=output_keys, + graph_meta={**common_graph_meta, "component": component, **(graph_meta or {})}, + metadata=component_metadata, + ) + + +def _encoder_cross_kv_step_specs( + *, + source_component: str, + source_module: torch.nn.Module, + source_inputs: tuple[torch.Tensor, ...], + source_input_keys: tuple[str, ...], + source_output_keys: tuple[str, ...], + source_kind: str, + cross_kv_module: torch.nn.Module, + cross_kv_inputs: tuple[torch.Tensor, ...], + cross_kv_input_keys: tuple[str, ...], + cross_kv_output_keys: tuple[str, ...], + decoder_step_module: torch.nn.Module, + decoder_step_inputs: tuple[torch.Tensor, ...], + decoder_step_input_keys: tuple[str, ...], + common_graph_meta: dict[str, object], + family: str, + task: str, + max_cache_seq_len: int, + decoder_step_graph_meta: dict[str, object] | None = None, +) -> list[ComponentModuleSpec]: + return [ + _component_spec( + component=source_component, + module=source_module, + example_inputs=source_inputs, + input_keys=source_input_keys, + output_keys=source_output_keys, + common_graph_meta=common_graph_meta, + family=family, + task=task, + runtime_role="source_encoder", + source_kind=source_kind, + ), + _component_spec( + component="decoder_cross_kv", + module=cross_kv_module, + example_inputs=cross_kv_inputs, + input_keys=cross_kv_input_keys, + output_keys=cross_kv_output_keys, + common_graph_meta=common_graph_meta, + family=family, + task=task, + runtime_role="decoder_cross_kv", + ), + _component_spec( + component="decoder_step", + module=decoder_step_module, + example_inputs=decoder_step_inputs, + input_keys=decoder_step_input_keys, + output_keys=("logits",), + common_graph_meta=common_graph_meta, + family=family, + task=task, + graph_meta={ + "use_internal_kv_cache": True, + "max_cache_seq_len": max_cache_seq_len, + "cache_sink_size": 0, + **(decoder_step_graph_meta or {}), + }, + runtime_role="decoder_step", + ), + ] + + def _build_whisper_seq2seq_component_specs( model: torch.nn.Module, *, @@ -5127,13 +5251,7 @@ def _build_whisper_seq2seq_component_specs( target_token_count = int(metadata.get("target_token_count", len(decoder_prompt_ids)) or len(decoder_prompt_ids)) target_token_count = max(target_token_count, len(decoder_prompt_ids)) pad_token_id = int(metadata.get("pad_token_id", getattr(getattr(model, "config", None), "pad_token_id", 0)) or 0) - encoder_adapter = WhisperEncoderComponentAdapter(encoder).eval() - decoder_adapter = WhisperDecoderComponentAdapter( - decoder, - proj_out, - pad_token_id=pad_token_id, - ).eval() decoder_cross_kv_adapter = WhisperDecoderCrossKVComponentAdapter(decoder).eval() decoder_step_adapter = WhisperDecoderStepWithCrossKVComponentAdapter( decoder, @@ -5145,17 +5263,8 @@ def _build_whisper_seq2seq_component_specs( encoder_hidden_states = encoder_adapter(input_features) decoder_cross_kv = decoder_cross_kv_adapter(encoder_hidden_states) - decoder_input_ids = torch.full( - (1, target_token_count), - pad_token_id, - dtype=torch.int64, - device=input_features.device, - ) - decoder_input_ids[:, : len(decoder_prompt_ids)] = torch.tensor( - decoder_prompt_ids, - dtype=torch.int64, - device=input_features.device, - ) + decoder_input_ids = torch.tensor([[decoder_prompt_ids[0]]], dtype=torch.int64, device=input_features.device) + step_position_ids = torch.zeros_like(decoder_input_ids, dtype=torch.int64) common_graph_meta = { **_transpile_graph_meta( @@ -5169,71 +5278,113 @@ def _build_whisper_seq2seq_component_specs( } if weights_dir: common_graph_meta["weights_dir"] = weights_dir - specs = [ - ComponentModuleSpec( - component="audio_encoder", - module=encoder_adapter, - example_inputs=(input_features,), - input_keys=("input_features",), - output_keys=("encoder_hidden_states",), - graph_meta={**common_graph_meta, "component": "audio_encoder"}, - metadata={"family": "whisper", "task": "seq2seq_transcription"}, - ), - ComponentModuleSpec( - component="decoder", - module=decoder_adapter, - example_inputs=(decoder_input_ids, encoder_hidden_states), - input_keys=("decoder_input_ids", "encoder_hidden_states"), - output_keys=("logits",), - graph_meta={**common_graph_meta, "component": "decoder"}, - metadata={"family": "whisper", "task": "seq2seq_transcription"}, - ), - ComponentModuleSpec( - component="decoder_cross_kv", - module=decoder_cross_kv_adapter, - example_inputs=(encoder_hidden_states,), - input_keys=("encoder_hidden_states",), - output_keys=tuple( - name - for layer_index in range(len(decoder.layers)) - for name in (f"cross_k_{layer_index}", f"cross_v_{layer_index}") - ), - graph_meta={**common_graph_meta, "component": "decoder_cross_kv"}, - metadata={"family": "whisper", "task": "seq2seq_transcription"}, - ), - ] + cross_kv_output_keys = _cross_kv_output_keys(len(decoder.layers)) + return _encoder_cross_kv_step_specs( + source_component="audio_encoder", + source_module=encoder_adapter, + source_inputs=(input_features,), + source_input_keys=("input_features",), + source_output_keys=("encoder_hidden_states",), + source_kind="audio_features", + cross_kv_module=decoder_cross_kv_adapter, + cross_kv_inputs=(encoder_hidden_states,), + cross_kv_input_keys=("encoder_hidden_states",), + cross_kv_output_keys=cross_kv_output_keys, + decoder_step_module=decoder_step_adapter, + decoder_step_inputs=(decoder_input_ids, step_position_ids, *decoder_cross_kv), + decoder_step_input_keys=("decoder_input_ids", "position_ids", *cross_kv_output_keys), + common_graph_meta=common_graph_meta, + family="whisper", + task="seq2seq_transcription", + max_cache_seq_len=max(16, int(target_token_count)), + ) + + +def _resolve_decoder_start_token_id(model: torch.nn.Module, *, fallback: int = 0) -> int: + config = getattr(model, "config", None) + generation_config = getattr(model, "generation_config", None) + for owner in (config, generation_config): + for attr_name in ("decoder_start_token_id", "bos_token_id", "eos_token_id", "pad_token_id"): + value = getattr(owner, attr_name, None) if owner is not None else None + if value is not None: + return int(value) + return int(fallback) + - step_position_ids = torch.zeros( - (int(decoder_input_ids.shape[0]), 1), +def _build_needle_causal_lm_component_specs( + model: torch.nn.Module, + *, + named_tensors: dict[str, torch.Tensor], + weights_dir: str | None = None, +) -> list[ComponentModuleSpec] | None: + input_ids = named_tensors.get("input_ids") + if input_ids is None: + return None + for method_name in ("cactus_source_encode", "cactus_decoder_cross_kv", "cactus_decoder_step"): + if not callable(getattr(model, method_name, None)): + raise RuntimeError(f"{type(model).__name__} does not expose {method_name}") + + pad_token_id = _resolve_model_pad_token_id(model) + if pad_token_id is None: + attention_mask = torch.ones_like(input_ids, dtype=torch.int64) + else: + attention_mask = (input_ids != int(pad_token_id)).to(dtype=torch.int64) + source_encoder = NeedleSourceEncoderComponentAdapter(model).eval() + decoder_cross_kv = NeedleDecoderCrossKVComponentAdapter(model).eval() + decoder_step = NeedleDecoderStepComponentAdapter(model).eval() + + with torch.no_grad(): + encoder_hidden_states, encoder_attention_mask = source_encoder(input_ids, attention_mask) + cross_kv = decoder_cross_kv(encoder_hidden_states, encoder_attention_mask) + + decoder_start_token_id = _resolve_decoder_start_token_id(model, fallback=int(input_ids[0, 0].item())) + decoder_input_ids = torch.full( + (int(input_ids.shape[0]), 1), + decoder_start_token_id, dtype=torch.int64, - device=input_features.device, + device=input_ids.device, ) - specs.append( - ComponentModuleSpec( - component="decoder_step", - module=decoder_step_adapter, - example_inputs=(decoder_input_ids[:, :1], step_position_ids, *decoder_cross_kv), - input_keys=( - "decoder_input_ids", - "position_ids", - *( - name - for layer_index in range(len(decoder.layers)) - for name in (f"cross_k_{layer_index}", f"cross_v_{layer_index}") - ), - ), - output_keys=("logits",), - graph_meta={ - **common_graph_meta, - "component": "decoder_step", - "use_internal_kv_cache": True, - "max_cache_seq_len": max(16, int(target_token_count)), - "cache_sink_size": 0, - }, - metadata={"family": "whisper", "task": "seq2seq_transcription"}, - ) + step_position_ids = torch.zeros_like(decoder_input_ids, dtype=torch.int64) + cross_kv_output_keys = _cross_kv_output_keys(len(cross_kv) // 2) + + common_graph_meta = { + **_transpile_graph_meta( + model, + adapter_family="needle", + adapter_type="component_pipeline", + input_names=("input_ids",), + ), + "task": "causal_lm_logits", + "adapter_family": "needle", + } + if weights_dir: + common_graph_meta["weights_dir"] = weights_dir + + return _encoder_cross_kv_step_specs( + source_component="source_encoder", + source_module=source_encoder, + source_inputs=(input_ids, attention_mask), + source_input_keys=("input_ids", "attention_mask"), + source_output_keys=("encoder_hidden_states", "encoder_attention_mask"), + source_kind="text_tokens", + cross_kv_module=decoder_cross_kv, + cross_kv_inputs=(encoder_hidden_states, encoder_attention_mask), + cross_kv_input_keys=("encoder_hidden_states", "encoder_attention_mask"), + cross_kv_output_keys=cross_kv_output_keys, + decoder_step_module=decoder_step, + decoder_step_inputs=(decoder_input_ids, step_position_ids, encoder_attention_mask, *cross_kv), + decoder_step_input_keys=("decoder_input_ids", "position_ids", "encoder_attention_mask", *cross_kv_output_keys), + common_graph_meta=common_graph_meta, + family="needle", + task="causal_lm_logits", + max_cache_seq_len=max(16, int(input_ids.shape[1])), + decoder_step_graph_meta={ + "external_cross_kv_cache_inputs": True, + "cross_kv_input_start_index": 3, + "cross_kv_input_count": len(cross_kv_output_keys), + "cross_kv_input_layout": "bthd", + }, ) - return specs def _build_nomic_text_embedding_component_specs( @@ -5626,6 +5777,12 @@ def build_component_module_specs( components=components, cache_context_length=cache_context_length, ) + if family == "needle" and task == "causal_lm_logits": + return _build_needle_causal_lm_component_specs( + model, + named_tensors=named_tensors, + weights_dir=weights_dir, + ) if family == "parakeet_tdt" and task == "tdt_transcription": from cactus.transpile.tdt_runtime import build_parakeet_tdt_component_specs diff --git a/python/cactus/transpile/model_profiles.py b/python/cactus/transpile/model_profiles.py index 4f875d69b..9d94c9cc6 100644 --- a/python/cactus/transpile/model_profiles.py +++ b/python/cactus/transpile/model_profiles.py @@ -29,6 +29,7 @@ class ModelProfile: text_only_component: str | None = None default_task: str | None = None default_components: tuple[str, ...] = () + default_max_new_tokens: int | None = None needs_image: bool = False needs_audio: bool = False force_component_pipeline: bool = False @@ -201,12 +202,25 @@ class ModelProfile: ) +NEEDLE_PROFILE = ModelProfile( + family="needle", + model_types=("needle",), + model_id_markers=("needle",), + avoid_native_loader=True, + default_task="causal_lm_logits", + default_components=("source_encoder", "decoder_cross_kv", "decoder_step"), + default_max_new_tokens=1022, + force_component_pipeline=True, +) + + PROFILES: tuple[ModelProfile, ...] = ( GEMMA4_PROFILE, LFM2_PROFILE, PARAKEET_TDT_PROFILE, WHISPER_PROFILE, QWEN_PROFILE, + NEEDLE_PROFILE, ) diff --git a/python/cactus/transpile/npu/pipeline.py b/python/cactus/transpile/npu/pipeline.py index 017226368..d864fbf9e 100644 --- a/python/cactus/transpile/npu/pipeline.py +++ b/python/cactus/transpile/npu/pipeline.py @@ -3,11 +3,13 @@ from pathlib import Path from .audio import emit_audio_encoder_mlpackage +from .source import emit_source_encoder_mlpackage from .vision import emit_vision_encoder_mlpackage _ENCODER_COMPONENTS = { "audio_encoder": (emit_audio_encoder_mlpackage, "audio_encoder.mlpackage"), + "source_encoder": (emit_source_encoder_mlpackage, "source_encoder.mlpackage"), "vision_encoder": (emit_vision_encoder_mlpackage, "vision_encoder.mlpackage"), } @@ -36,6 +38,7 @@ def _resolve(per_component: int | None, default: int | None) -> int | None: component_quants = { "audio_encoder": _resolve(audio_quantize_bits, default=8), + "source_encoder": _resolve(None, default=None), "vision_encoder": _resolve(vision_quantize_bits, default=None), } @@ -53,14 +56,24 @@ def _resolve(per_component: int | None, default: int | None) -> int | None: qbits = component_quants[component] qdesc = f"int{qbits}" if qbits else "fp16" print(f"npu.pipeline: emitting {component} quant={qdesc}") - emitted = emit_fn( - spec.module, - bundle_root, - example_input=primary, - baked_inputs=baked, - filename=filename, - quantize_bits=qbits, - ) + if component == "source_encoder": + emitted = emit_fn( + spec.module, + bundle_root, + example_inputs=example_inputs, + input_names=tuple(getattr(spec, "input_keys", ()) or ()), + filename=filename, + quantize_bits=qbits, + ) + else: + emitted = emit_fn( + spec.module, + bundle_root, + example_input=primary, + baked_inputs=baked, + filename=filename, + quantize_bits=qbits, + ) if emitted: results[f"npu_{component}"] = f"components/{emitted}" return results diff --git a/python/cactus/transpile/npu/source.py b/python/cactus/transpile/npu/source.py new file mode 100644 index 000000000..8dad63283 --- /dev/null +++ b/python/cactus/transpile/npu/source.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +from pathlib import Path + +import numpy as np +import torch +import torch.nn.functional as F + +from .audio import _apply_weight_quantization, _import_coremltools + + +class SourceEncoderWrapper(torch.nn.Module): + def __init__( + self, + source_module: torch.nn.Module, + input_dtypes: tuple[torch.dtype, ...], + ): + super().__init__() + self.source = source_module + self.input_dtypes = input_dtypes + + def forward(self, *args: torch.Tensor) -> torch.Tensor: + typed_args = [] + for arg, dtype in zip(args, self.input_dtypes, strict=True): + typed_args.append(arg.to(dtype=dtype) if arg.dtype != dtype else arg) + out = self.source(*typed_args) + if isinstance(out, tuple): + return out[0] + return out + + +def emit_source_encoder_mlpackage( + source_module: torch.nn.Module, + bundle_dir: Path, + *, + example_inputs: tuple[torch.Tensor, ...], + input_names: tuple[str, ...], + filename: str = "source_encoder.mlpackage", + output_name: str = "encoder_hidden_states", + minimum_deployment_target: str = "iOS18", + quantize_bits: int | None = None, +) -> str | None: + ct = _import_coremltools() + if not example_inputs: + print("npu.source: source_encoder spec has no example inputs; skipping") + return None + + input_dtypes = tuple(t.dtype for t in example_inputs) + exported_inputs = tuple( + t.to(dtype=torch.int32) if t.dtype in {torch.int64, torch.long} else t + for t in example_inputs + ) + wrapper = SourceEncoderWrapper(source_module, input_dtypes) + wrapper.eval() + + original_sdpa = F.scaled_dot_product_attention + + def _sdpa_without_gqa(query, key, value, *args, **kwargs): + enable_gqa = bool(kwargs.pop("enable_gqa", False)) + if enable_gqa and query.dim() >= 3 and key.dim() >= 3 and query.shape[-3] != key.shape[-3]: + repeats = int(query.shape[-3]) // max(int(key.shape[-3]), 1) + key = key.repeat_interleave(repeats, dim=-3) + value = value.repeat_interleave(repeats, dim=-3) + return original_sdpa(query, key, value, *args, **kwargs) + + try: + F.scaled_dot_product_attention = _sdpa_without_gqa + with torch.no_grad(): + exported = torch.export.export(wrapper, exported_inputs) + exported = exported.run_decompositions({}) + except Exception as exc: + print(f"npu.source: torch.export failed ({type(exc).__name__}: {exc}); skipping mlpackage emit") + return None + finally: + F.scaled_dot_product_attention = original_sdpa + + del wrapper + import gc as _gc + _gc.collect() + + target_attr = getattr(ct.target, minimum_deployment_target, None) or ct.target.iOS17 + input_types = [] + for name, tensor in zip(input_names, exported_inputs, strict=True): + dtype = np.int32 if tensor.dtype in {torch.int32, torch.int64, torch.long} else None + input_types.append(ct.TensorType(name=name, shape=tuple(tensor.shape), dtype=dtype)) + + from .coremltools_patches import build_cactus_pass_pipeline + try: + mlmodel = ct.convert( + exported, + inputs=input_types, + outputs=[ct.TensorType(name=output_name)], + compute_precision=ct.precision.FLOAT16, + convert_to="mlprogram", + minimum_deployment_target=target_attr, + pass_pipeline=build_cactus_pass_pipeline(), + ) + except Exception as exc: + print(f"npu.source: coremltools.convert failed ({type(exc).__name__}: {exc})") + return None + + if quantize_bits is not None: + before_id = id(mlmodel) + mlmodel = _apply_weight_quantization(mlmodel, quantize_bits) + if id(mlmodel) != before_id: + print(f"npu.source: applied int{quantize_bits} weight quantization") + + bundle_dir.mkdir(parents=True, exist_ok=True) + out_path = bundle_dir / filename + try: + mlmodel.save(str(out_path)) + except Exception as exc: + print(f"npu.source: mlpackage save failed ({type(exc).__name__}: {exc})") + return None + + shapes = ",".join(f"{name}={tuple(t.shape)}" for name, t in zip(input_names, exported_inputs, strict=True)) + print(f"npu.source: wrote {out_path} ({shapes})") + return filename diff --git a/python/tests/test_encoder_cross_kv_route.py b/python/tests/test_encoder_cross_kv_route.py new file mode 100644 index 000000000..bba3627e9 --- /dev/null +++ b/python/tests/test_encoder_cross_kv_route.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +from types import SimpleNamespace + +import torch + +from cactus.transpile.model_adapters import build_component_module_specs + + +class ToyNeedle(torch.nn.Module): + def __init__(self) -> None: + super().__init__() + self.config = SimpleNamespace( + model_type="needle", + decoder_start_token_id=1, + pad_token_id=0, + ) + + def cactus_source_encode( + self, + input_ids: torch.Tensor, + attention_mask: torch.Tensor, + ) -> tuple[torch.Tensor, torch.Tensor]: + hidden = input_ids.to(dtype=torch.float32).unsqueeze(-1).expand(-1, -1, 2).contiguous() + return hidden, attention_mask[:, None, None, :].to(dtype=torch.bool) + + def cactus_decoder_cross_kv( + self, + encoder_hidden_states: torch.Tensor, + encoder_attention_mask: torch.Tensor, + ) -> tuple[torch.Tensor, ...]: + del encoder_attention_mask + batch, seq, _ = encoder_hidden_states.shape + return tuple(torch.zeros((batch, seq, 1, 2), dtype=torch.float32) for _ in range(4)) + + def cactus_decoder_step( + self, + decoder_input_ids: torch.Tensor, + position_ids: torch.Tensor, + encoder_attention_mask: torch.Tensor, + *cross_kv: torch.Tensor, + ) -> torch.Tensor: + del position_ids, encoder_attention_mask, cross_kv + return torch.zeros((decoder_input_ids.shape[0], decoder_input_ids.shape[1], 8), dtype=torch.float32) + + +def test_needle_encoder_cross_kv_route_specs_are_metadata_driven() -> None: + specs = build_component_module_specs( + ToyNeedle(), + task="causal_lm_logits", + named_tensors={"input_ids": torch.tensor([[1, 2, 0]], dtype=torch.long)}, + ) + + assert specs is not None + assert [spec.component for spec in specs] == ["source_encoder", "decoder_cross_kv", "decoder_step"] + + source, cross_kv, decoder_step = specs + assert source.input_keys == ("input_ids", "attention_mask") + assert source.output_keys == ("encoder_hidden_states", "encoder_attention_mask") + assert source.metadata["runtime_route"] == "encoder_cross_kv_decoder_step" + assert source.metadata["runtime_role"] == "source_encoder" + assert source.metadata["source_kind"] == "text_tokens" + + assert cross_kv.input_keys == ("encoder_hidden_states", "encoder_attention_mask") + assert cross_kv.output_keys == ("cross_k_0", "cross_v_0", "cross_k_1", "cross_v_1") + assert cross_kv.metadata["runtime_role"] == "decoder_cross_kv" + + assert decoder_step.input_keys == ( + "decoder_input_ids", + "position_ids", + "encoder_attention_mask", + "cross_k_0", + "cross_v_0", + "cross_k_1", + "cross_v_1", + ) + assert decoder_step.output_keys == ("logits",) + assert decoder_step.metadata["runtime_role"] == "decoder_step" + assert decoder_step.graph_meta["use_internal_kv_cache"] is True