Skip to content

Commit 8379d9b

Browse files
committed
[NPUW] Model builder: LoRA robustness fixes + build_llm test coverage
LoRA stays explicitly threaded as a const LoRAInjector* through make_linear and the Attention / SwiGLU / GELU functors (no global state). - LoRAInjector::max_rank / precision get safe defaults (0 / f32) and should_adapt() returns false for max_rank == 0, so a default-constructed injector is an inert no-op. - build_llm holds the injector on the stack and recreates only the default dense FFN as a LoRA-aware SwiGLU; custom and MoE FFN graphs are untouched. - Add unit coverage for the build_llm LoRA path (attention + FFN adapted) and the no-LoRA negative case, alongside the build_lora_adapter checks.
1 parent a8588a7 commit 8379d9b

5 files changed

Lines changed: 50 additions & 34 deletions

File tree

src/plugins/intel_npu/tests/functional/behavior/npuw/test_engine/models/model_builder.cpp

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -83,8 +83,8 @@ ov::Output<ov::Node> make_linear(const ov::Output<ov::Node>& input,
8383
result = add->output(0);
8484
}
8585

86-
// LoRA injection: input -> MatMul(A^T) -> Multiply(alpha) -> MatMul(B^T) -> Add(base_output)
87-
// Parameter names match NPUW lora_state_* pattern.
86+
// LoRA injection: input -> MatMul(A^T) -> Multiply(alpha) -> MatMul(B^T) -> Add(base).
87+
// Names match the NPUW lora_state_* convention.
8888
if (lora && lora->should_adapt(name)) {
8989
const size_t R = lora->max_rank;
9090
const auto prec = lora->precision;
@@ -804,20 +804,22 @@ std::shared_ptr<ov::Model> ModelBuilder::build_llm(const LLMConfig& config_in) {
804804
const auto hs = config.hidden_size;
805805
const auto kv_heads = config.get_kv_heads();
806806

807-
// Set up LoRA injector if requested
808-
std::unique_ptr<LoRAInjector> lora_ptr;
809-
if (config.lora_rank > 0) {
810-
lora_ptr = std::make_unique<LoRAInjector>();
811-
lora_ptr->max_rank = config.lora_rank;
812-
lora_ptr->targets = config.lora_targets;
813-
lora_ptr->precision = prec;
814-
lora_ptr->stateful = config.lora_stateful;
815-
lora_ptr->sinks = &m_sinks;
816-
// Recreate only the default dense FFN with LoRA injection; preserve custom and MoE FFN graphs.
817-
if (!has_custom_ffn && config.num_experts == 0)
818-
config.ffn = SwiGLU(config.hidden_size, config.intermediate_size, prec, config.weight, lora_ptr.get());
807+
LoRAInjector lora_injector;
808+
const bool lora_enabled = config.lora_rank > 0;
809+
if (lora_enabled) {
810+
lora_injector.max_rank = config.lora_rank;
811+
lora_injector.targets = config.lora_targets;
812+
lora_injector.precision = prec;
813+
lora_injector.stateful = config.lora_stateful;
814+
lora_injector.sinks = &m_sinks;
815+
// FFN is a functor (FFNFn); LoRA reaches it only through a struct that
816+
// forwards the injector. Recreate the default dense FFN as a LoRA-aware
817+
// SwiGLU; custom and MoE FFN graphs are left untouched.
818+
if (!has_custom_ffn && config.num_experts == 0) {
819+
config.ffn = SwiGLU(config.hidden_size, config.intermediate_size, prec, config.weight, &lora_injector);
820+
}
819821
}
820-
const LoRAInjector* lora = lora_ptr.get();
822+
const LoRAInjector* lora = lora_enabled ? &lora_injector : nullptr;
821823

822824
Attention attn{};
823825
attn.hidden_size = hs;

src/plugins/intel_npu/tests/functional/behavior/npuw/test_engine/models/model_builder_attention.hpp

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,6 @@ struct Attention {
8585

8686
ov::Output<ov::Node> sdpa_mask;
8787
ov::Output<ov::Node> shared_broadcast_shape;
88-
8988
const LoRAInjector* lora = nullptr;
9089

9190
std::string o_proj_name = "self_attn.o_proj";

src/plugins/intel_npu/tests/functional/behavior/npuw/test_engine/models/model_builder_types.hpp

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,13 +49,16 @@ using KVCacheFn =
4949
/// output = base_linear_out + (input @ A^T * alpha) @ B^T
5050
/// Names follow the NPUW lora_state_* convention.
5151
struct LoRAInjector {
52-
size_t max_rank;
52+
size_t max_rank = 0;
5353
std::vector<std::string> targets;
54-
ov::element::Type precision;
54+
ov::element::Type precision = ov::element::f32;
5555
bool stateful = false;
5656
ov::SinkVector* sinks = nullptr;
5757

5858
bool should_adapt(const std::string& name) const {
59+
if (max_rank == 0) {
60+
return false;
61+
}
5962
if (targets.empty()) {
6063
// Default target set
6164
static const std::vector<std::string> defaults =

src/plugins/intel_npu/tests/unit/npuw/llm_test_helpers.hpp

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,18 @@ inline std::shared_ptr<ov::Model> build_moe_llm_test_model() {
155155
return mb.build_llm(cfg);
156156
}
157157

158+
inline std::shared_ptr<ov::Model> build_lora_adapter_test_model() {
159+
ModelBuilder mb;
160+
return mb.build_lora_adapter(make_test_model_config<LoRAConfig>());
161+
}
162+
163+
inline std::shared_ptr<ov::Model> build_lora_llm_test_model() {
164+
auto cfg = make_test_model_config();
165+
cfg.lora_rank = 8;
166+
ModelBuilder mb;
167+
return mb.build_llm(cfg);
168+
}
169+
158170

159171
class NullPlugin : public ov::IPlugin {
160172
public:

src/plugins/intel_npu/tests/unit/npuw/model_builder_lora_test.cpp

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
#include <memory>
88
#include <string>
99

10-
#include "model_builder.hpp"
10+
#include "llm_test_helpers.hpp"
1111

1212
namespace {
1313

@@ -21,15 +21,7 @@ bool has_param(const std::shared_ptr<ov::Model>& m, const std::string& needle) {
2121
}
2222

2323
TEST(ModelBuilderLoraTest, StatelessAdapterExposesLoraParameters) {
24-
ov::test::npuw::ModelBuilder builder;
25-
ov::test::npuw::LoRAConfig config;
26-
config.num_layers = 1;
27-
config.hidden_size = 8;
28-
config.vocab_size = 32;
29-
config.precision = ov::element::f16;
30-
config.lora_targets = {"q_proj", "v_proj"};
31-
32-
auto model = builder.build_lora_adapter(config);
24+
auto model = ov::test::npuw::build_lora_adapter_test_model();
3325
ASSERT_TRUE(model);
3426
EXPECT_TRUE(has_param(model, "lora_state_"));
3527
EXPECT_TRUE(has_param(model, "q_proj.MatMul.A"));
@@ -40,17 +32,25 @@ TEST(ModelBuilderLoraTest, StatelessAdapterExposesLoraParameters) {
4032

4133
TEST(ModelBuilderLoraTest, StatefulAdapterExposesLoraStates) {
4234
ov::test::npuw::ModelBuilder builder;
43-
ov::test::npuw::LoRAConfig config;
44-
config.num_layers = 1;
45-
config.hidden_size = 8;
46-
config.vocab_size = 32;
47-
config.precision = ov::element::f16;
48-
config.lora_targets = {"q_proj"};
35+
auto config = ov::test::npuw::make_test_model_config<ov::test::npuw::LoRAConfig>();
4936
config.lora_stateful = true;
5037

5138
auto model = builder.build_lora_adapter(config);
5239
ASSERT_TRUE(model);
5340
EXPECT_FALSE(model->get_sinks().empty());
5441
}
5542

43+
TEST(ModelBuilderLoraTest, BuildLlmInjectsLoraIntoAttentionAndFfn) {
44+
auto model = ov::test::npuw::build_lora_llm_test_model();
45+
ASSERT_TRUE(model);
46+
EXPECT_TRUE(has_param(model, "q_proj.MatMul.A"));
47+
EXPECT_TRUE(has_param(model, "down_proj.MatMul.A"));
48+
}
49+
50+
TEST(ModelBuilderLoraTest, BuildLlmWithoutLoraHasNoLoraParameters) {
51+
auto model = ov::test::npuw::build_llm_test_model();
52+
ASSERT_TRUE(model);
53+
EXPECT_FALSE(has_param(model, "lora_state_"));
54+
}
55+
5656
} // namespace

0 commit comments

Comments
 (0)