Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 46 additions & 9 deletions ggml/src/ggml-openvino/ggml-decoder.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,18 @@ int GgmlOvDecoder::compute_op_case(const ggml_tensor * node) const {
break;
}
case GGML_OP_VIEW: {
if (m_is_static && node->src[0] != nullptr &&
(node->src[0]->op == GGML_OP_GATED_DELTA_NET || node->src[0]->op == GGML_OP_CONCAT)) {
// VIEW slicing a GATED_DELTA_NET combined [attn|state] output, or the conv_input
// CONCAT. The consuming CPY/RMS_NORM op recovers the true window at runtime via
// ssm_state_size / the fixed conv kernel width, so this VIEW must stay an identity
// pass-through of the full source here too (it already is on the dynamic path);
// otherwise the generic static-mode Slice below would bake in the *captured*
// cgraph's token count, which is wrong once the compiled static model runs with a
// different token count (prefill chunk size or 1).
op_case = 1;
break;
}
if (node->src[0]->op == GGML_OP_VIEW) {
auto * src = node->src[0];
if (ggml_nelements(node) != ggml_nelements(src)) {
Expand Down Expand Up @@ -712,10 +724,8 @@ std::pair<ModelParams, ComputeParams> GgmlOvDecoder::compute_llm_params(ggml_cgr
ComputeParams::RsWriteback writeback;
writeback.slot_begin = (int) (dest_view->view_offs / row_bytes);
if (is_conv) {
// conv_input column the copied window starts at
writeback.src_begin = (int) (node->src[0]->view_offs / node->src[0]->view_src->nb[0]);
} else if (is_gdn) {
// first row of the state part of the gated-delta-net output
writeback.src_begin = (int) (node->src[0]->view_offs / node->src[0]->view_src->nb[1]);
}
compute_params.rs_writebacks[get_tensor_ov_name(cgraph, node)] = writeback;
Expand Down Expand Up @@ -800,7 +810,9 @@ ov::PartialShape GgmlOvDecoder::get_graph_input_shape(const ggml_tensor * op,
input_shape = ov::PartialShape{1, 1, 1, len};

} else if (is_inp_s_copy(input, op) || is_s_copy_leaf(input)) {
input_shape = ov::PartialShape{1, 1, 1, -1};
// On NPU the total slot count (n_seq_max) is fixed at translation time, so the s_copy
// index list has a static length; on CPU/GPU it may change across compiles (defrag).
input_shape = m_is_static ? ov::PartialShape{get_shape(input)} : ov::PartialShape{1, 1, 1, -1};

} else {
input_shape = ov::PartialShape{get_shape(input)};
Expand Down Expand Up @@ -852,8 +864,8 @@ void GgmlOvDecoder::add_extra_inputs() {
// see llama_kv_cache_unified::get_n_kv and llama_kv_cache_unified::get_padding.
// 2. `n_seq_active` and `seq_active_start`, used in FLASH_ATTN_EXT to indicate the active sequences in the batch

auto create_1d_input = [this](const std::string & name, int64_t value) {
m_model_extra_inputs[name] = {ov::element::i64, ov::Shape{1}, value, !m_is_static};
auto create_1d_input = [this](const std::string & name, int64_t value, bool force_parameter = false) {
m_model_extra_inputs[name] = {ov::element::i64, ov::Shape{1}, value, force_parameter || !m_is_static};
};

if (m_compute_params.attention_size != -1) {
Expand All @@ -874,17 +886,32 @@ void GgmlOvDecoder::add_extra_inputs() {
// create_1d_input("token_len", m_compute_params.token_len_per_seq * m_compute_params.n_seq_active);

if (m_compute_params.cache_rs_reset_idx != -1) {
create_1d_input("cache_rs_reset_idx", m_compute_params.cache_rs_reset_idx);
create_1d_input("cache_rs_reset_len", m_compute_params.cache_rs_reset_len);
// Whether/which cache slot to reset varies per compute call (e.g. a new sequence starting
// vs. continued decoding). can_reuse_statically() does not invalidate the cached static
// model on ComputeParams changes, so these must stay runtime Parameters even when static
// (scale.cpp op_case 1 only uses them in value comparisons, never as Slice bounds, so this
// does not reintroduce dynamic shapes).
create_1d_input("cache_rs_reset_idx", m_compute_params.cache_rs_reset_idx, /*force_parameter=*/true);
create_1d_input("cache_rs_reset_len", m_compute_params.cache_rs_reset_len, /*force_parameter=*/true);
}

if (m_compute_params.s_copy_active_slot_len != -1) {
create_1d_input("s_copy_active_slot_len", m_compute_params.s_copy_active_slot_len);
if (m_is_static) {
// Number of real tokens in the current prefill chunk. The last chunk is padded with
// fabricated token ids; attention masks them out, but the recurrent (GDN/conv) path
// would otherwise fold them into cache_r/cache_s permanently. Varies per chunk, so it
// must stay a runtime Parameter; it is only compared against a Range or used as Gather
// indices, so it does not make any shape dynamic.
create_1d_input("chunk_valid_len", get_static_n_tokens(), /*force_parameter=*/true);
}
}

for (const auto & [node_name, writeback] : m_compute_params.rs_writebacks) {
create_1d_input("rs_slot_begin_" + node_name, writeback.slot_begin);
create_1d_input("rs_src_begin_" + node_name, writeback.src_begin);
if (!m_is_static) {
create_1d_input("rs_src_begin_" + node_name, writeback.src_begin);
}
}
}

Expand Down Expand Up @@ -1850,13 +1877,23 @@ void GgmlOvDecoder::compute_node_dynamic_dims() {
auto dynamic_dim_stride = src_logical_nb[dynamic_dim_idx] / ggml_type_size(node->src[0]->type) *
ggml_type_size(node->type);
int matched_dim_count = 0;
int first_matched_dim = -1;
for (int i = 0; i < GGML_MAX_DIMS; i++) {
if (node->nb[i] == dynamic_dim_stride && node->ne[i] == node->src[0]->ne[dynamic_dim_idx]) {
if (first_matched_dim == -1) {
first_matched_dim = i;
}
m_node_dynamic_dims[node] = i;
matched_dim_count++;
}
}
if (matched_dim_count != 1) {
if (matched_dim_count > 1 && node->src[0]->ne[dynamic_dim_idx] == 1) {
// Single-token capture: every trailing dim is size 1 with the same stride, so
// the match is ambiguous. The lowest index is the real axis; the rest are
// ggml's size-1 padding. Bailing out here would bake the captured token count
// into the static prefill model, which then runs with a different one.
m_node_dynamic_dims[node] = first_matched_dim;
} else if (matched_dim_count != 1) {
m_node_dynamic_dims[node] = -1;
GGML_LOG_WARN("ggml-openvino: cannot determine dynamic dim for CONT node '%s', src[0]: '%s'\n",
node->name, node->src[0]->name);
Expand Down
11 changes: 6 additions & 5 deletions ggml/src/ggml-openvino/ggml-decoder.h
Original file line number Diff line number Diff line change
Expand Up @@ -85,14 +85,15 @@ struct ComputeParams {

struct RsWriteback {
int slot_begin = 0; // first cache slot written by the CPY
int src_begin = 0; // where the copied data starts in the source tensor (in rows of it)
int src_begin = 0; // first source row or column copied by the CPY
};

std::map<std::string, RsWriteback> rs_writebacks;
// Offsets of the state cache writeback CPY nodes, keyed by node name. They change with the
// batch (kv head, active sequence count, token count) and, with rollback enabled
// (cparams.n_rs_seq > 0), the conv state is written back once per snapshot slot, each snapshot
// taking a different conv_input window. Passed to the cached model as runtime inputs.
// Destination slot offset of each state cache writeback CPY node, keyed by node name. It
// changes with the batch (kv head, active sequence count) and, with rollback enabled
// (cparams.n_rs_seq > 0), the conv state is written back once per snapshot slot. Passed to the
// cached model as a runtime input. Dynamic models also receive the source-side offset; static
// models use a fixed end-anchored offset in the translator.
};

class GgmlOvDecoder : public ov::frontend::ggml::GgmlDecoder {
Expand Down
3 changes: 3 additions & 0 deletions ggml/src/ggml-openvino/ggml-openvino-extra.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ void ggml_openvino_device_config::init() {
"GGML_OPENVINO_DUMP_IR",
"GGML_OPENVINO_DEBUG_INPUT",
"GGML_OPENVINO_DEBUG_OUTPUT",
// Force the static (NPU-shape) compute path on any device, e.g. GGML_OPENVINO_DEVICE=CPU,
// to test the static-shape translation without NPUW/real NPU hardware in the loop.
"GGML_OPENVINO_FORCE_STATIC",
"GGML_OPENVINO_PRINT_CGRAPH_TENSOR_ADDRESS",
"GGML_OPENVINO_ENABLE_CACHE",
"GGML_OPENVINO_DISABLE_CACHE",
Expand Down
77 changes: 63 additions & 14 deletions ggml/src/ggml-openvino/openvino/op/cpy.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
#include <cstdint>
#include <cstdio>
#include <memory>
#include <numeric>
#include <openvino/frontend/exception.hpp>
#include <openvino/op/add.hpp>
#include <openvino/op/concat.hpp>
Expand All @@ -30,7 +31,6 @@ namespace op {

OutputVector translate_cpy(const NodeContext & context) {
auto op_case = context.get_op_case();
auto input = process_view_input_new(context, 0);
auto input_shape = context.get_input_shape(0);
auto output_shape = context.get_input_shape(1);

Expand Down Expand Up @@ -69,10 +69,27 @@ OutputVector translate_cpy(const NodeContext & context) {
return rename_outputs_with_suffix({res}, context.get_name());
}

// Recurrent state cache writeback into a slot block of the cache. Where the block starts and
// where the copied data starts in the source are runtime inputs, so the cached model works for
// any kv head, active sequence count and token count. The result is the full updated cache.
// Recurrent state cache writeback into a slot block of the cache. Where the block starts is a
// runtime input, so the cached model works for any kv head and active sequence count. The
// result is the full updated cache.
// op_case 1: gated-delta-net state, op_case 2: conv state, op_case 3: defrag remainder.
if (op_case == 3) {
// With -np 1 (and generally whenever there is no defrag remainder) this GET_ROWS gathers
// zero rows: nothing to write back, and the cache is unchanged. NPU rejects zero-size
// tensors, so short-circuit instead of building a degenerate Slice/Concat chain.
bool is_empty = false;
if (input_shape.rank().is_static()) {
for (const auto & d : input_shape) {
if (d.is_static() && d.get_length() == 0) {
is_empty = true;
break;
}
}
}
if (is_empty) {
return {context.get_input(1)};
}
}
const std::string slot_begin_name = "rs_slot_begin_" + context.get_name();
const bool slice_assign =
context.has_input(slot_begin_name) && !context.is_stateful() && (op_case >= 1 && op_case <= 3);
Expand All @@ -89,19 +106,49 @@ OutputVector translate_cpy(const NodeContext & context) {
ov::Output<ov::Node> begin = context.get_input(slot_begin_name);
auto base = context.get_input(1);
if (op_case == 1) {
// GDN packs [attn | state snapshots]; the state part runs from src_begin to the end.
auto src_begin = context.get_input("rs_src_begin_" + context.get_name());
auto state_part = std::make_shared<ov::op::v8::Slice>(context.get_input(0), src_begin, int_max, one, axis);
ov::Output<ov::Node> state_begin;
const std::string src_begin_name = "rs_src_begin_" + context.get_name();
if (context.has_input(src_begin_name)) {
state_begin = context.get_input(src_begin_name);
} else {
auto ssm_state_size = context.get_ssm_state_size();
if (context.has_input("s_copy_active_slot_len")) {
auto len = context.get_input("s_copy_active_slot_len");
auto state_rows = std::make_shared<ov::op::v1::Multiply>(
ov::op::v0::Constant::create(ov::element::i64, {1}, {ssm_state_size}), len);
state_begin = std::make_shared<ov::op::v0::Negative>(state_rows);
} else {
state_begin = ov::op::v0::Constant::create(ov::element::i64, {1}, {-ssm_state_size});
}
}
auto state_part =
std::make_shared<ov::op::v8::Slice>(context.get_input(0), state_begin, int_max, one, axis);
src = std::make_shared<ov::op::v1::Reshape>(state_part, feature, false);
} else if (op_case == 2) {
// conv_input is [previous conv state | new tokens]; copy the conv_kernel_size - 1 wide
// window starting at src_begin, which is the snapshot this writeback corresponds to.
// conv_input is [previous conv state | new tokens]; the snapshot is the conv_kernel_size - 1
// columns ending at the last *valid* token. Gather (rather than Slice) keeps the output
// shape static even though the window start is a runtime value.
auto window_size = (int64_t) input_shape[3].get_length();
auto src_begin = context.get_input("rs_src_begin_" + context.get_name());
auto src_end = std::make_shared<ov::op::v1::Add>(
src_begin, ov::op::v0::Constant::create(ov::element::i64, {1}, {window_size}));
auto window = std::make_shared<ov::op::v8::Slice>(context.get_input(0), src_begin, src_end, one,
ov::op::v0::Constant::create(ov::element::i64, {1}, {3}));
ov::Output<ov::Node> window;
auto col_axis = ov::op::v0::Constant::create(ov::element::i64, {1}, {3});
const std::string src_begin_name = "rs_src_begin_" + context.get_name();
if (context.has_input(src_begin_name)) {
auto src_begin = context.get_input(src_begin_name);
auto src_end = std::make_shared<ov::op::v1::Add>(
src_begin, ov::op::v0::Constant::create(ov::element::i64, {1}, {window_size}));
window = std::make_shared<ov::op::v8::Slice>(context.get_input(0), src_begin, src_end, one, col_axis);
} else if (context.has_input("chunk_valid_len")) {
std::vector<int64_t> offsets(window_size);
std::iota(offsets.begin(), offsets.end(), 0);
auto indices = std::make_shared<ov::op::v1::Add>(
ov::op::v0::Constant::create(ov::element::i64, {(size_t) window_size}, offsets),
context.get_input("chunk_valid_len"));
window = std::make_shared<ov::op::v8::Gather>(context.get_input(0), indices, col_axis);
} else {
auto window_begin = ov::op::v0::Constant::create(ov::element::i64, {1}, {-window_size});
window =
std::make_shared<ov::op::v8::Slice>(context.get_input(0), window_begin, int_max, one, col_axis);
}
const auto base_shape = base.get_partial_shape();
FRONT_END_OP_CONVERSION_CHECK(base_shape.rank().is_static() && base_shape.rank().get_length() == 4,
"CPY conv state cache update requires rank-4 base cache");
Expand Down Expand Up @@ -163,6 +210,8 @@ OutputVector translate_cpy(const NodeContext & context) {
return rename_outputs_with_suffix({res}, context.get_name());
}

auto input = process_view_input_new(context, 0);

if (op_case == 5 || op_case == 6) {
auto input_shape = context.get_input_shape(0);
auto output_shape = context.get_output_shape();
Expand Down
25 changes: 25 additions & 0 deletions ggml/src/ggml-openvino/openvino/op/gated_delta_net.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,15 @@
#include <cmath>
#include <cstdint>
#include <memory>
#include <numeric>
#include <openvino/op/add.hpp>
#include <openvino/op/broadcast.hpp>
#include <openvino/op/concat.hpp>
#include <openvino/op/constant.hpp>
#include <openvino/op/convert.hpp>
#include <openvino/op/exp.hpp>
#include <openvino/op/gather.hpp>
#include <openvino/op/less.hpp>
#include <openvino/op/loop.hpp>
#include <openvino/op/matmul.hpp>
#include <openvino/op/multiply.hpp>
Expand Down Expand Up @@ -80,6 +83,28 @@ OutputVector translate_gated_delta_net(const NodeContext & context) {
g = std::make_shared<ov::op::v0::Squeeze>(g, ov::op::v0::Constant::create(ov::element::i64, {1}, {3}));
beta = std::make_shared<ov::op::v0::Squeeze>(beta, ov::op::v0::Constant::create(ov::element::i64, {1}, {3}));

if (context.has_input("chunk_valid_len")) {
// The last prefill chunk is padded with fabricated tokens. The recurrence is
// S_t = S_{t-1} * exp(g_t) + k_t (x) ((v_t - S_{t-1}^T k_t) * beta_t)
// so forcing g = 0 and beta = 0 makes a padded step an exact identity and keeps the final
// state equal to the state after the last real token. Attention output at those positions
// is garbage but never read.
const auto & g_ps = g.get_partial_shape();
FRONT_END_OP_CONVERSION_CHECK(g_ps.rank().is_static() && g_ps.rank().get_length() == 3 && g_ps[1].is_static(),
"GATED_DELTA_NET pad masking requires a static token dimension");
const int64_t n_tokens = g_ps[1].get_length();
std::vector<int64_t> positions(n_tokens);
std::iota(positions.begin(), positions.end(), 0);
auto valid = std::make_shared<ov::op::v1::Less>(
ov::op::v0::Constant::create(ov::element::i64, {(size_t) n_tokens}, positions),
context.get_input("chunk_valid_len"));
auto mask = std::make_shared<ov::op::v0::Unsqueeze>(
std::make_shared<ov::op::v0::Convert>(valid, g.get_element_type()),
ov::op::v0::Constant::create(ov::element::i64, {2}, std::vector<int64_t>{0, 2}));
g = std::make_shared<ov::op::v1::Multiply>(g, mask);
beta = std::make_shared<ov::op::v1::Multiply>(beta, mask);
}

// std::cout << "GatedDeltaNet input shapes: q=" << q.get_partial_shape() << ", k=" << k.get_partial_shape()
// << ", v=" << v.get_partial_shape() << ", g=" << g.get_partial_shape()
// << ", beta=" << beta.get_partial_shape() << ", state=" << state.get_partial_shape() << std::endl;
Expand Down
7 changes: 7 additions & 0 deletions ggml/src/ggml-openvino/openvino/op/view.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ namespace op {
OutputVector translate_view(const NodeContext & context) {
num_inputs_check(context, 1, 1);

if (context.get_op_case() == 1) {
// Static-mode identity pass-through for VIEWs over a GATED_DELTA_NET combined output or
// the conv_input CONCAT; the consuming op (CPY/RMS_NORM) does its own runtime-correct
// slicing on the full tensor (see ggml-decoder.cpp compute_op_case, GGML_OP_VIEW).
return {context.get_input(0)};
}

if (!context.is_static()) {
// On the stateless/non-static path VIEW is normally a no-op (consumers re-slice).
// EXCEPTION: the MoE expert aggregation slices each expert plane out of
Expand Down
1 change: 1 addition & 0 deletions ggml/src/ggml-openvino/openvino/utils.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ OutputVector rename_outputs_with_suffix(const OutputVector & outputs, const std:
name += "_";
name += suffix;
node->set_friendly_name(name);
// Uncomment to dump every node's inferred shape (used to hunt down dynamic dims on NPU).
// std::cout << name << " " << output.get_partial_shape() << std::endl;
}
return outputs;
Expand Down
Loading
Loading