From 4313f210765193b66e7008eb66a031fc16dd4931 Mon Sep 17 00:00:00 2001 From: Graffioh Date: Wed, 2 Sep 2026 08:54:36 +0200 Subject: [PATCH] refactor(server): seal backend construction behind a plan --- server/CMakeLists.txt | 15 +- server/src/common/backend_args.h | 50 +++-- server/src/common/backend_factory.cpp | 233 +++++++++------------- server/src/common/backend_factory.h | 131 ++++++------ server/src/common/backend_plan.cpp | 135 +++++++++++++ server/src/common/backend_plan_internal.h | 18 ++ server/src/common/feature_gate.cpp | 36 ++-- server/src/common/feature_gate.h | 10 +- server/src/server/server_main.cpp | 143 ++++++------- server/test/test_backend_plan.cpp | 176 ++++++++++++++++ server/test/test_feature_gate.cpp | 47 ++--- 11 files changed, 637 insertions(+), 357 deletions(-) create mode 100644 server/src/common/backend_plan.cpp create mode 100644 server/src/common/backend_plan_internal.h create mode 100644 server/test/test_backend_plan.cpp diff --git a/server/CMakeLists.txt b/server/CMakeLists.txt index 0b237c94a..269023299 100644 --- a/server/CMakeLists.txt +++ b/server/CMakeLists.txt @@ -541,6 +541,7 @@ add_library(dflash_common STATIC src/common/backend_precision.cpp src/common/daemon_loop.cpp src/common/gguf_inspect.cpp + src/common/backend_plan.cpp src/common/backend_factory.cpp src/common/feature_gate.cpp src/placement/placement_config.cpp @@ -1816,16 +1817,18 @@ if(DFLASH27B_TESTS) list(APPEND _raw_unit_test_targets test_model_smoke) endif() - # Feature/architecture gate tests. check_feature_compatibility(), - # collect_feature_warnings() and the capability table are pure functions, - # so this target deliberately compiles only feature_gate.cpp and - # placement_config.cpp — no dflash_common, no ggml, no GPU toolkit. That - # keeps a gate rule testable in seconds instead of behind a full backend - # build, which is the whole reason these tests do not live in + # Backend planning and feature/architecture gate tests. The plan builder, + # check_feature_compatibility(), collect_feature_warnings(), and the + # capability table are pure policy, so this target compiles only their + # sources and placement_config.cpp. It needs no dflash_common, ggml, or GPU + # toolkit. This keeps policy rules testable in seconds instead of behind a + # full backend build, which is why these tests do not live in # test_server_unit. if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/test/test_feature_gate.cpp") add_executable(test_feature_gate test/test_feature_gate.cpp) target_sources(test_feature_gate PRIVATE + test/test_backend_plan.cpp + src/common/backend_plan.cpp src/common/feature_gate.cpp src/placement/placement_config.cpp) target_include_directories(test_feature_gate PRIVATE diff --git a/server/src/common/backend_args.h b/server/src/common/backend_args.h index 611dd8d77..0c98c4924 100644 --- a/server/src/common/backend_args.h +++ b/server/src/common/backend_args.h @@ -1,11 +1,13 @@ // Raw backend construction arguments. // // This contains only caller-requested configuration. Runtime facts derived -// from the model or compiled binary belong in ResolvedBackendPlan instead. +// from the model or compiled binary belong in BackendPlan instead. #pragma once #include +#include +#include #include "placement/draft_residency.h" #include "placement/placement_config.h" @@ -15,35 +17,40 @@ namespace dflash::common { -// Server-owned features that participate in backend admission even though -// they are not consumed by ModelBackend construction itself. Keep these -// separate from BackendArgs so the factory API remains usable by callers that -// do not run the HTTP server. -struct BackendFeatureConfig { +enum class KvFlashRequest { + Off, + Auto, + Fixed, +}; + +// Server-owned facts that participate in backend admission without becoming +// backend construction arguments. This is the only projection the HTTP server +// may pass into backend preparation. +struct BackendAdmissionContext { bool pflash_enabled = false; bool pflash_drafter_configured = false; DraftResidencyPolicy draft_residency = DraftResidencyPolicy::Auto; - // MoE-only server features. Recorded here so the gate can report them as - // inert on a dense architecture; both are applied via env vars at parse - // time rather than through BackendArgs. - bool routing_stats_requested = false; // --freq / --collect-routing - bool adaptive_experts_requested = false; // --adaptive-experts + // Automatic sizing remains backend-owned because only the initialized + // backend has the VRAM budget. Fixed pools can participate in admission. + KvFlashRequest kvflash = KvFlashRequest::Off; - // A fixed KVFlash pool requested through DFLASH_KVFLASH. "auto" is - // resolved later by the backend because only it has the VRAM budget needed - // to know whether a pool will actually be active. - bool kvflash_enabled = false; + bool kvflash_requested() const { + return kvflash != KvFlashRequest::Off; + } + bool fixed_kvflash_requested() const { + return kvflash == KvFlashRequest::Fixed; + } }; // A superset of all per-architecture config fields. The factory reads only // those relevant to the resolved architecture; unused fields are ignored. struct BackendArgs { // Required - const char * model_path = nullptr; // target .gguf + std::string model_path; // target .gguf // Optional: speculative decode draft model (qwen35 only) - const char * draft_path = nullptr; + std::optional draft_path; // Device placement DevicePlacement device; @@ -82,13 +89,22 @@ struct BackendArgs { bool fast_rollback = true; bool seq_verify = false; bool specla_mode = false; + int specla_top_k = 4; + bool specla_top_k_explicit = false; bool ddtree_mode = false; int ddtree_budget = 22; float ddtree_temp = 1.0f; bool ddtree_chain_seed = true; float ddtree_tau = std::numeric_limits::infinity(); + bool ddtree_tau_explicit = false; int verify_width = 0; // chain spec verify width; 0 = adaptive bool use_feature_mirror = false; + + // MoE backend requests. The server currently realizes these through + // environment variables, but admission still treats them as explicit + // operator input rather than server-owned context. + bool routing_stats_requested = false; + bool adaptive_experts_requested = false; }; } // namespace dflash::common diff --git a/server/src/common/backend_factory.cpp b/server/src/common/backend_factory.cpp index d462be899..b075bad6b 100644 --- a/server/src/common/backend_factory.cpp +++ b/server/src/common/backend_factory.cpp @@ -1,8 +1,10 @@ // Backend factory implementation. #include "backend_factory.h" -#include "feature_gate.h" +#include "backend_plan_internal.h" #include "gguf_inspect.h" +#include "model_capabilities.h" +#include "platform_env.h" #include "qwen35_backend.h" #include "qwen35moe_backend.h" @@ -111,137 +113,16 @@ DFLASH_CHECK_ARCH_OPTION("qwen35", Qwen35Config, Qwen35LayerSplitAdapterConfig, #undef DFLASH_CHECK_ARCH #undef DFLASH_CHECK_ARCH_OPTION -PlacementBackend resolve_target_backend( +std::unique_ptr construct_backend( const BackendArgs & args, - PlacementBackend compiled_backend) { - return args.device.backend == PlacementBackend::Auto - ? compiled_backend - : args.device.backend; -} - -} // namespace - -std::string detect_arch(const char * model_path) { - auto info = inspect_gguf_model_info(model_path); - return info.arch; -} - -BackendPreparation prepare_backend( - const BackendArgs & args, - const BackendFeatureConfig & features) { - BackendPreparation preparation; - if (!args.model_path) { - preparation.error = BackendPreparationError::InvalidRequest; - preparation.message = "model_path is null"; - return preparation; - } - - preparation.plan.model_path_ = args.model_path; - preparation.plan.features_ = features; - preparation.plan.compiled_backend_ = compiled_placement_backend(); - preparation.plan.target_backend_ = resolve_target_backend( - args, preparation.plan.compiled_backend_); - preparation.plan.model_ = inspect_gguf_model_info(args.model_path); - - if (preparation.plan.arch().empty()) { - preparation.error = BackendPreparationError::ModelInspection; - preparation.message = - "failed to detect architecture from " + - preparation.plan.model_path(); - return preparation; - } - - // A model this binary cannot construct is a property of the model, not of - // the requested feature set — same category (and same exit status) as an - // unreadable GGUF. Checking it here means the arch-dependent rules below - // only ever run against an architecture the capability table describes. - if (!arch_is_supported(preparation.plan.arch())) { - preparation.error = BackendPreparationError::ModelInspection; - preparation.message = - "unsupported model architecture '" + preparation.plan.arch() + - "' in " + preparation.plan.model_path(); - return preparation; - } - - preparation.message = check_feature_compatibility( - args, - preparation.plan.features(), - preparation.plan.arch(), - preparation.plan.target_backend(), - preparation.plan.compiled_backend()); - if (!preparation.message.empty()) { - preparation.error = BackendPreparationError::FeatureCompatibility; - return preparation; - } - - preparation.warnings = collect_feature_warnings( - args, preparation.plan.features(), preparation.plan.arch()); - return preparation; -} - -std::unique_ptr create_backend(const BackendArgs & args) { - const BackendPreparation preparation = prepare_backend(args); - if (!preparation.ok()) { - std::fprintf(stderr, "[backend_factory] %s\n", - preparation.message.c_str()); - return nullptr; - } - for (const std::string & warning : preparation.warnings) { - std::fprintf(stderr, "[backend_factory] warning: %s\n", - warning.c_str()); - } - return create_backend(args, preparation.plan); -} - -std::unique_ptr create_backend( - const BackendArgs & args, - const ResolvedBackendPlan & plan) { - if (!args.model_path) { - std::fprintf(stderr, "[backend_factory] model_path is null\n"); - return nullptr; - } - if (plan.model_path() != args.model_path) { - std::fprintf(stderr, - "[backend_factory] resolved plan does not match model_path %s\n", - args.model_path); - return nullptr; - } - if (plan.compiled_backend() != compiled_placement_backend() || - plan.target_backend() != - resolve_target_backend(args, plan.compiled_backend())) { - std::fprintf(stderr, - "[backend_factory] resolved plan does not match target placement\n"); - return nullptr; - } - - const std::string & arch = plan.arch(); - if (arch.empty()) { - std::fprintf(stderr, - "[backend_factory] failed to detect architecture from %s\n", - args.model_path); - return nullptr; - } - - std::fprintf(stderr, "[backend_factory] detected arch=%s\n", arch.c_str()); - - // Recheck at the construction boundary in case raw arguments changed - // after preparation. No entry point can dispatch an incoherent request. - const std::string incompatible = check_feature_compatibility( - args, - plan.features(), - arch, - plan.target_backend(), - plan.compiled_backend()); - if (!incompatible.empty()) { - std::fprintf(stderr, "[backend_factory] %s\n", incompatible.c_str()); - return nullptr; - } - + const std::string & arch) { if (arch == "qwen35") { if (args.device.is_layer_split()) { Qwen35LayerSplitAdapterConfig cfg; - cfg.target_path = args.model_path; - cfg.draft_path = args.draft_path; + cfg.target_path = args.model_path.c_str(); + cfg.draft_path = args.draft_path + ? args.draft_path->c_str() + : nullptr; cfg.device = args.device; cfg.draft_gpu = args.draft_device.gpu; cfg.remote_draft = args.remote_draft; @@ -254,7 +135,7 @@ std::unique_ptr create_backend( cfg.max_verify_tokens = args.ddtree_mode ? std::max(DFLASH27B_DRAFT_BLOCK_SIZE, args.ddtree_budget + 1) : DFLASH27B_DRAFT_BLOCK_SIZE; - cfg.run_dflash = args.draft_path != nullptr; + cfg.run_dflash = args.draft_path.has_value(); auto adapter = std::make_unique(cfg); auto backend = std::make_unique(std::move(adapter)); @@ -266,8 +147,10 @@ std::unique_ptr create_backend( } Qwen35Config cfg; - cfg.target_path = args.model_path; - cfg.draft_path = args.draft_path; + cfg.target_path = args.model_path.c_str(); + cfg.draft_path = args.draft_path + ? args.draft_path->c_str() + : nullptr; cfg.device = args.device; cfg.draft_gpu = args.draft_device.gpu; cfg.remote_draft = args.remote_draft; @@ -298,8 +181,10 @@ std::unique_ptr create_backend( } else if (arch == "qwen35moe") { Qwen35Config cfg; - cfg.target_path = args.model_path; - cfg.draft_path = args.draft_path; + cfg.target_path = args.model_path.c_str(); + cfg.draft_path = args.draft_path + ? args.draft_path->c_str() + : nullptr; cfg.device = args.device; cfg.draft_gpu = args.draft_device.gpu; cfg.stream_fd = args.stream_fd; @@ -325,7 +210,7 @@ std::unique_ptr create_backend( } else if (arch == "bailingmoe3") { BailingMoe3Config cfg; - cfg.model_path = args.model_path; + cfg.model_path = args.model_path.c_str(); cfg.device = args.device; cfg.stream_fd = args.stream_fd; @@ -339,7 +224,7 @@ std::unique_ptr create_backend( } else if (arch == "laguna") { if (args.device.is_layer_split()) { LagunaLayerSplitAdapterConfig cfg; - cfg.target_path = args.model_path; + cfg.target_path = args.model_path.c_str(); cfg.device = args.device; cfg.remote_target_shard = args.remote_target_shard; cfg.chunk = args.chunk; @@ -354,8 +239,10 @@ std::unique_ptr create_backend( } LagunaBackendArgs lcfg; - lcfg.target_path = args.model_path; - lcfg.draft_path = args.draft_path ? args.draft_path : ""; + lcfg.target_path = args.model_path.c_str(); + lcfg.draft_path = args.draft_path + ? args.draft_path->c_str() + : ""; lcfg.draft_gpu = args.draft_device.gpu; lcfg.draft_ctx_max = args.draft_ctx_max; lcfg.ddtree_mode = args.ddtree_mode; @@ -376,7 +263,7 @@ std::unique_ptr create_backend( } else if (arch == "qwen3") { Qwen3BackendConfig qcfg; - qcfg.model_path = args.model_path; + qcfg.model_path = args.model_path.c_str(); qcfg.device = args.device; qcfg.stream_fd = args.stream_fd; qcfg.chunk = args.chunk; @@ -391,7 +278,7 @@ std::unique_ptr create_backend( } else if (arch == "gemma4") { if (args.device.is_layer_split()) { Gemma4LayerSplitAdapterConfig cfg; - cfg.target_path = args.model_path; + cfg.target_path = args.model_path.c_str(); cfg.device = args.device; cfg.remote_target_shard = args.remote_target_shard; cfg.chunk = args.chunk; @@ -407,8 +294,10 @@ std::unique_ptr create_backend( } Gemma4BackendConfig gcfg; - gcfg.model_path = args.model_path; - gcfg.draft_path = args.draft_path; + gcfg.model_path = args.model_path.c_str(); + gcfg.draft_path = args.draft_path + ? args.draft_path->c_str() + : nullptr; gcfg.draft_gpu = args.draft_device.gpu; gcfg.draft_ctx_max = args.draft_ctx_max; gcfg.device = args.device; @@ -436,7 +325,7 @@ std::unique_ptr create_backend( if (!args.device.is_layer_split() && !args.remote_target_shard.enabled()) { DeepSeek4BackendConfig cfg; - cfg.model_path = args.model_path; + cfg.model_path = args.model_path.c_str(); cfg.device = args.device; cfg.stream_fd = args.stream_fd; cfg.max_ctx = args.device.max_ctx; @@ -456,7 +345,7 @@ std::unique_ptr create_backend( // Explicit local splits and CUDA/HIP remote splits use the adapter. DeepSeek4LayerSplitAdapterConfig cfg; - cfg.target_path = args.model_path; + cfg.target_path = args.model_path.c_str(); cfg.device = args.device; cfg.remote_target_shard = args.remote_target_shard; cfg.chunk = args.chunk; @@ -476,4 +365,62 @@ std::unique_ptr create_backend( } } +} // namespace + +BackendPreparation prepare_backend( + BackendArgs args, + BackendAdmissionContext admission) { + if (args.model_path.empty()) { + return BackendPreparationFailure{ + BackendPreparationError::InvalidRequest, + "model_path is empty", + {}}; + } + + GgufModelInfo model = inspect_gguf_model_info(args.model_path.c_str()); + return detail::BackendPlanBuilder::resolve( + std::move(args), + std::move(admission), + std::move(model), + compiled_placement_backend()); +} + +BackendRuntime::BackendRuntime(BackendPlan plan) + : plan_(std::move(plan)) {} + +BackendRuntime::~BackendRuntime() = default; + +std::unique_ptr create_backend(BackendPlan plan) { + auto runtime = std::unique_ptr( + new BackendRuntime(std::move(plan))); + + switch (runtime->plan_.specla_environment_) { + case BackendPlan::SpeclaEnvironmentAction::Preserve: + break; + case BackendPlan::SpeclaEnvironmentAction::Enable: + set_environment_variable("DFLASH_SPECLA", "1", true); + if (runtime->plan_.args_.specla_top_k_explicit) { + const std::string top_k = + std::to_string(runtime->plan_.args_.specla_top_k); + set_environment_variable( + "DFLASH_SPECLA_TOPK", top_k.c_str(), true); + } + break; + case BackendPlan::SpeclaEnvironmentAction::Disable: + unset_environment_variable("DFLASH_SPECLA"); + break; + } + + std::fprintf( + stderr, + "[backend_factory] detected arch=%s\n", + runtime->plan_.arch().c_str()); + runtime->backend_ = construct_backend( + runtime->plan_.args_, runtime->plan_.arch()); + if (!runtime->backend_) { + return nullptr; + } + return runtime; +} + } // namespace dflash::common diff --git a/server/src/common/backend_factory.h b/server/src/common/backend_factory.h index bb3d71164..7ac05a57d 100644 --- a/server/src/common/backend_factory.h +++ b/server/src/common/backend_factory.h @@ -1,91 +1,108 @@ -// Backend factory — arch-detecting ModelBackend construction. +// Backend planning and arch-detecting ModelBackend construction. // -// Given a GGUF model path and placement options, inspects the file's -// `general.architecture` key and constructs the appropriate ModelBackend -// subclass (Qwen35Backend, LagunaBackend, Qwen3Backend, Gemma4Backend). -// -// This decouples backend creation from the daemon binary's argv parsing -// and allows both the daemon (test_dflash) and the new native server to -// share the same construction logic. +// BackendArgs is mutable input. prepare_backend() consumes it, resolves model +// and placement facts, normalizes backend policy, and returns an immutable +// BackendPlan. create_backend() accepts only that plan. #pragma once #include "backend_args.h" #include "gguf_inspect.h" -#include "model_capabilities.h" -#include "model_backend.h" #include #include +#include #include namespace dflash::common { -struct BackendPreparation; +namespace detail { +class BackendPlanBuilder; +} + +class BackendPlan; +class BackendRuntime; +class ModelBackend; -// Runtime facts resolved once from BackendArgs and shared by server admission -// and backend construction. Fields are private so callers cannot substitute -// an architecture that disagrees with model_path. -class ResolvedBackendPlan { +// The sole construction entry point. It consumes a valid plan and returns a +// runtime that keeps the plan's owned path storage alive. +std::unique_ptr create_backend(BackendPlan plan); + +class BackendPlan final { public: + BackendPlan(BackendPlan &&) noexcept = default; + BackendPlan & operator=(BackendPlan &&) = delete; + BackendPlan(const BackendPlan &) = delete; + BackendPlan & operator=(const BackendPlan &) = delete; + + const BackendArgs & args() const { return args_; } const GgufModelInfo & model() const { return model_; } const std::string & arch() const { return model_.arch; } - const std::string & model_path() const { return model_path_; } - PlacementBackend target_backend() const { return target_backend_; } - PlacementBackend compiled_backend() const { return compiled_backend_; } - const BackendFeatureConfig & features() const { return features_; } + const std::vector & warnings() const { return warnings_; } private: - std::string model_path_; - GgufModelInfo model_; - BackendFeatureConfig features_; - PlacementBackend target_backend_ = PlacementBackend::Auto; - PlacementBackend compiled_backend_ = PlacementBackend::Auto; - - friend BackendPreparation prepare_backend( - const BackendArgs & args, - const BackendFeatureConfig & features); + enum class SpeclaEnvironmentAction { + Preserve, + Enable, + Disable, + }; + + BackendPlan() = default; + + BackendArgs args_; + GgufModelInfo model_; + std::vector warnings_; + SpeclaEnvironmentAction specla_environment_ = + SpeclaEnvironmentAction::Preserve; + + friend class detail::BackendPlanBuilder; + friend std::unique_ptr create_backend(BackendPlan plan); }; enum class BackendPreparationError { - None, InvalidRequest, ModelInspection, FeatureCompatibility, }; -struct BackendPreparation { - ResolvedBackendPlan plan; - BackendPreparationError error = BackendPreparationError::None; +struct BackendPreparationFailure { + BackendPreparationError error; std::string message; - - // Accepted-but-inert options for the resolved architecture and placement. - // Populated only when ok(); the caller decides how to surface them. std::vector warnings; - - bool ok() const { return error == BackendPreparationError::None; } }; -// Resolve model metadata and compiled placement, then apply all cross-feature -// compatibility policy. This is the server's fail-fast factory entry point: -// server_main forwards the raw request and only handles the categorized result. +using BackendPreparation = + std::variant; + +// Consumes the mutable request. Successful preparation performs one GGUF +// inspection and returns the only value accepted by backend construction. BackendPreparation prepare_backend( - const BackendArgs & args, - const BackendFeatureConfig & features = {}); - -// ─── Factory function ─────────────────────────────────────────────────── -// Inspects model_path GGUF metadata, constructs the correct backend, and -// calls init(). Returns nullptr on failure (diagnostic printed to stderr). -std::unique_ptr create_backend(const BackendArgs & args); - -// Uses facts already resolved from the same BackendArgs. The factory verifies -// that the plan belongs to args.model_path before dispatching. -std::unique_ptr create_backend( - const BackendArgs & args, - const ResolvedBackendPlan & plan); - -// Returns the detected architecture string without creating a backend. -// Useful for early dispatch (e.g. printing which backend will be used). -std::string detect_arch(const char * model_path); + BackendArgs args, + BackendAdmissionContext admission = {}); + +// Owns the immutable plan together with the backend. Several existing backend +// config structs retain c_str() pointers, so the plan must share their +// lifetime and be destroyed after the backend. +class BackendRuntime final { +public: + ~BackendRuntime(); + + BackendRuntime(const BackendRuntime &) = delete; + BackendRuntime & operator=(const BackendRuntime &) = delete; + BackendRuntime(BackendRuntime &&) = delete; + BackendRuntime & operator=(BackendRuntime &&) = delete; + + ModelBackend & backend() { return *backend_; } + const ModelBackend & backend() const { return *backend_; } + const BackendPlan & plan() const { return plan_; } + +private: + explicit BackendRuntime(BackendPlan plan); + + BackendPlan plan_; + std::unique_ptr backend_; + + friend std::unique_ptr create_backend(BackendPlan plan); +}; } // namespace dflash::common diff --git a/server/src/common/backend_plan.cpp b/server/src/common/backend_plan.cpp new file mode 100644 index 000000000..a9b59855b --- /dev/null +++ b/server/src/common/backend_plan.cpp @@ -0,0 +1,135 @@ +#include "backend_plan_internal.h" + +#include "feature_gate.h" +#include "model_capabilities.h" + +#include +#include + +namespace dflash::common::detail { + +namespace { + +BackendPreparationFailure failure( + BackendPreparationError error, + std::string message, + std::vector warnings = {}) { + return {error, std::move(message), std::move(warnings)}; +} + +PlacementBackend resolve_target_backend( + const BackendArgs & args, + PlacementBackend compiled_backend) { + return args.device.backend == PlacementBackend::Auto + ? compiled_backend + : args.device.backend; +} + +} // namespace + +BackendPreparation BackendPlanBuilder::resolve( + BackendArgs args, + BackendAdmissionContext admission, + GgufModelInfo model, + PlacementBackend compiled_backend) { + if (args.model_path.empty()) { + return failure( + BackendPreparationError::InvalidRequest, + "model_path is empty"); + } + + if (model.arch.empty()) { + return failure( + BackendPreparationError::ModelInspection, + "failed to detect architecture from " + args.model_path); + } + + if (!arch_is_supported(model.arch)) { + return failure( + BackendPreparationError::ModelInspection, + "unsupported model architecture '" + model.arch + "' in " + + args.model_path); + } + + const PlacementBackend target_backend = + resolve_target_backend(args, compiled_backend); + + if (args.specla_mode && !args.ddtree_tau_explicit) { + args.ddtree_tau = 6.0f; + } + + // Preserve the original admission order. Warnings describe what the + // operator requested, before model-specific SpecLA normalization changes + // the effective construction request. + std::string incompatible = check_feature_compatibility( + args, admission, model.arch, target_backend, compiled_backend); + if (!incompatible.empty()) { + return failure( + BackendPreparationError::FeatureCompatibility, + std::move(incompatible)); + } + + std::vector warnings = + collect_feature_warnings(args, admission, model.arch); + + BackendPlan::SpeclaEnvironmentAction specla_environment = + BackendPlan::SpeclaEnvironmentAction::Preserve; + + if (args.specla_mode) { + const bool supported = + model.arch == "qwen35" && !args.device.is_multi_device(); + if (supported) { + if (!args.draft_path.has_value()) { + return failure( + BackendPreparationError::FeatureCompatibility, + "Qwen3.6 SpecLA requires --draft ", + std::move(warnings)); + } + + args.ddtree_mode = true; + if (admission.kvflash_requested()) { + warnings.push_back( + "--specla is unavailable with KVFlash; using ordinary " + "DDTree verification"); + args.specla_mode = false; + if (!args.ddtree_tau_explicit) { + args.ddtree_tau = std::numeric_limits::infinity(); + } + specla_environment = + BackendPlan::SpeclaEnvironmentAction::Disable; + } else { + specla_environment = + BackendPlan::SpeclaEnvironmentAction::Enable; + } + } else { + warnings.push_back( + "--specla is unavailable for architecture '" + model.arch + + "' with placement " + placement_device_name(args.device) + + "; using the architecture's normal decode path"); + args.specla_mode = false; + if (!args.ddtree_tau_explicit) { + args.ddtree_tau = std::numeric_limits::infinity(); + } + } + } + + // Validate the exact snapshot construction will consume. This replaces + // the old factory recheck against a second mutable BackendArgs object. + incompatible = check_feature_compatibility( + args, admission, model.arch, target_backend, compiled_backend); + if (!incompatible.empty()) { + return failure( + BackendPreparationError::FeatureCompatibility, + std::move(incompatible), + std::move(warnings)); + } + + BackendPlan plan; + plan.args_ = std::move(args); + plan.model_ = std::move(model); + plan.warnings_ = std::move(warnings); + plan.specla_environment_ = specla_environment; + return plan; +} + +} // namespace dflash::common::detail diff --git a/server/src/common/backend_plan_internal.h b/server/src/common/backend_plan_internal.h new file mode 100644 index 000000000..98b4a47f7 --- /dev/null +++ b/server/src/common/backend_plan_internal.h @@ -0,0 +1,18 @@ +#pragma once + +#include "backend_factory.h" + +namespace dflash::common::detail { + +// Pure planning seam used by prepare_backend() after GGUF inspection and by +// model-free tests with supplied model facts. +class BackendPlanBuilder { +public: + static BackendPreparation resolve( + BackendArgs args, + BackendAdmissionContext admission, + GgufModelInfo model, + PlacementBackend compiled_backend); +}; + +} // namespace dflash::common::detail diff --git a/server/src/common/feature_gate.cpp b/server/src/common/feature_gate.cpp index cae310910..6657f1cb9 100644 --- a/server/src/common/feature_gate.cpp +++ b/server/src/common/feature_gate.cpp @@ -11,7 +11,7 @@ namespace dflash::common { std::string check_feature_compatibility( const BackendArgs & args, - const BackendFeatureConfig & features, + const BackendAdmissionContext & admission, const std::string & arch, PlacementBackend target_backend, PlacementBackend compiled_backend) @@ -32,7 +32,7 @@ std::string check_feature_compatibility( ? target_backend : args.draft_device.backend; const bool draft_placement_used = - features.pflash_enabled || args.draft_path != nullptr; + admission.pflash_enabled || args.draft_path.has_value(); const bool mixed_draft_placement = draft_placement_used && target_backend != draft_backend; @@ -48,8 +48,8 @@ std::string check_feature_compatibility( } // ── PFlash enablement × drafter model - if (features.pflash_enabled && - !features.pflash_drafter_configured) { + if (admission.pflash_enabled && + !admission.pflash_drafter_configured) { return "--prefill-compression requires --prefill-drafter"; } @@ -98,7 +98,7 @@ std::string check_feature_compatibility( if (args.remote_target_shard.enabled()) { return "tensor parallelism is incompatible with --target-shard-ipc-bin"; } - if (features.pflash_enabled) { + if (admission.pflash_enabled) { return "tensor parallelism does not yet support prefill compression"; } } @@ -150,14 +150,14 @@ std::string check_feature_compatibility( } // ── remote draft execution × architecture - if (args.remote_draft.enabled() && args.draft_path && + if (args.remote_draft.enabled() && args.draft_path.has_value() && !arch_supports_remote_draft(arch)) { return "model architecture '" + arch + "' does not support remote draft execution"; } // ── mixed-backend PFlash × architecture - if (features.pflash_enabled && mixed_draft_placement && + if (admission.pflash_enabled && mixed_draft_placement && !arch_supports_pflash_compression(arch)) { return "model architecture '" + arch + "' does not support PFlash compression"; @@ -166,7 +166,7 @@ std::string check_feature_compatibility( // A block-size override changes the local draft graph itself. Remote // drafters own that shape in the IPC process and cannot be resized here. if (args.draft_block_size != 0) { - if (args.draft_path == nullptr) { + if (!args.draft_path.has_value()) { return "--draft-block-size requires --draft"; } if (args.remote_draft.enabled()) { @@ -176,7 +176,7 @@ std::string check_feature_compatibility( const bool concurrent_local_chain = arch == "qwen35" && args.paged_attention && - args.max_concurrency > 1 && args.draft_path != nullptr && + args.max_concurrency > 1 && args.draft_path.has_value() && !args.ddtree_mode && !args.remote_draft.enabled() && !args.device.is_layer_split() && !args.device.is_tensor_parallel() && @@ -186,7 +186,7 @@ std::string check_feature_compatibility( args.fa_window == 0; if (concurrent_local_chain && - features.draft_residency == DraftResidencyPolicy::RequestScoped) { + admission.draft_residency == DraftResidencyPolicy::RequestScoped) { return "concurrent DFlash2 does not support " "--draft-residency=request-scoped"; } @@ -208,7 +208,7 @@ std::string check_feature_compatibility( args.remote_target_shard.enabled()) { return "--paged-attention requires one local target device"; } - if ((args.draft_path != nullptr || args.remote_draft.enabled()) && + if ((args.draft_path.has_value() || args.remote_draft.enabled()) && !concurrent_local_chain) { return "--paged-attention requires autoregressive decode without a " "draft, or concurrent local same-device DFlash2 chains"; @@ -219,11 +219,11 @@ std::string check_feature_compatibility( if (args.fa_window != 0) { return "--paged-attention requires full attention (--fa-window 0)"; } - if (features.pflash_enabled) { + if (admission.pflash_enabled) { return "--paged-attention cannot be combined with PFlash prefill " "compression"; } - if (features.kvflash_enabled) { + if (admission.fixed_kvflash_requested()) { return "--paged-attention cannot be combined with KVFlash"; } // The pool rounds max_ctx up to a whole number of blocks, so the top @@ -357,7 +357,7 @@ void warn_inert(std::vector & out, std::vector collect_feature_warnings( const BackendArgs & args, - const BackendFeatureConfig & features, + const BackendAdmissionContext & admission, const std::string & arch) { std::vector out; @@ -365,7 +365,7 @@ std::vector collect_feature_warnings( // Each entry pairs a requested option with the capability predicate for // the field create_backend() would have to forward for it to take effect. - warn_inert(out, args.draft_path != nullptr, + warn_inert(out, args.draft_path.has_value(), arch_supports_decode_draft(arch, split), arch_supports_decode_draft(arch, false), split, arch, "--draft", "speculative decode"); @@ -395,13 +395,13 @@ std::vector collect_feature_warnings( arch_supports_draft_swa(arch, false), split, arch, "--draft-swa", "draft sliding-window attention"); - // MoE-only server features. These drive the DFLASH_QWEN35MOE_* / + // MoE-only backend requests. These drive the DFLASH_QWEN35MOE_* / // DFLASH_LAGUNA_* env vars, which a dense backend never reads. - if (features.routing_stats_requested && !arch_has_expert_offload(arch)) { + if (args.routing_stats_requested && !arch_has_expert_offload(arch)) { out.push_back("--freq/--collect-routing ignored: architecture '" + arch + "' has no expert routing to record"); } - if (features.adaptive_experts_requested && !arch_has_expert_offload(arch)) { + if (args.adaptive_experts_requested && !arch_has_expert_offload(arch)) { out.push_back("--adaptive-experts ignored: architecture '" + arch + "' has no expert-count gating"); } diff --git a/server/src/common/feature_gate.h b/server/src/common/feature_gate.h index 13cbfe5af..b6d1aed01 100644 --- a/server/src/common/feature_gate.h +++ b/server/src/common/feature_gate.h @@ -41,18 +41,18 @@ namespace dflash::common { // Returns an empty string when the requested feature set is coherent, or a // description of the first violated rule. // -// `features` carries launch features owned above the backend factory. `arch`, +// `admission` carries launch facts owned above the backend factory. `arch`, // `target_backend`, and `compiled_backend` are resolved facts: the architecture // read from the GGUF, the requested target with PlacementBackend::Auto mapped // to the compiled default, and the binary's compiled placement. Passing these // in keeps the gate a pure function that unit tests can drive without a model // file or GPU. // -// prepare_backend() owns the public admission decision, and create_backend() -// checks the same function as a safety net before dispatch. +// prepare_backend() owns the public admission decision. Construction trusts +// the immutable BackendPlan produced by that boundary. std::string check_feature_compatibility( const BackendArgs & args, - const BackendFeatureConfig & features, + const BackendAdmissionContext & admission, const std::string & arch, PlacementBackend target_backend, PlacementBackend compiled_backend); @@ -69,7 +69,7 @@ std::string check_feature_compatibility( // configuration has no useful warnings to report. std::vector collect_feature_warnings( const BackendArgs & args, - const BackendFeatureConfig & features, + const BackendAdmissionContext & admission, const std::string & arch); } // namespace dflash::common diff --git a/server/src/server/server_main.cpp b/server/src/server/server_main.cpp index e2db605fa..9ffef9190 100644 --- a/server/src/server/server_main.cpp +++ b/server/src/server/server_main.cpp @@ -17,6 +17,7 @@ #include "common/backend_factory.h" #include "common/chain_rollback_policy.h" #include "common/layer_split_utils.h" +#include "common/model_capabilities.h" #include "common/spark_corpus.h" #include "common/moe_routing_collector.h" #include "common/moe_hybrid_routing_stats.h" @@ -249,10 +250,6 @@ int main(int argc, char ** argv) { bool target_devices_seen = false; bool fast_rollback_forced_off = false; bool target_split_fast_rollback_cli = false; - bool adaptive_experts_set = false; // --adaptive-experts (MoE architectures only) - bool ddtree_tau_set = false; - bool specla_top_k_set = false; - int specla_top_k = 4; // Track which thinking-budget tunables the operator set via CLI. // Those values win over the model card (spec §3.1: "Explicit CLI @@ -447,13 +444,15 @@ int main(int argc, char ** argv) { } else if (std::strcmp(argv[i], "--specla-top-k") == 0 && i + 1 < argc) { const char * value = argv[++i]; const char * end = value + std::strlen(value); - const auto parsed = std::from_chars(value, end, specla_top_k); - if (parsed.ec != std::errc{} || parsed.ptr != end || specla_top_k <= 0) { + const auto parsed = std::from_chars( + value, end, bargs.specla_top_k); + if (parsed.ec != std::errc{} || parsed.ptr != end || + bargs.specla_top_k <= 0) { std::fprintf(stderr, "--specla-top-k expects a positive integer, got '%s'\n", value); return 2; } - specla_top_k_set = true; + bargs.specla_top_k_explicit = true; } else if (std::strcmp(argv[i], "--ddtree") == 0) { bargs.ddtree_mode = true; bargs.fast_rollback = true; @@ -472,7 +471,7 @@ int main(int argc, char ** argv) { return 2; } bargs.ddtree_tau = tau; - ddtree_tau_set = true; + bargs.ddtree_tau_explicit = true; } else if (std::strcmp(argv[i], "--adaptive-experts") == 0) { const char * tau = "0.80"; if (i + 1 < argc && argv[i + 1][0] != '-') { @@ -486,7 +485,7 @@ int main(int argc, char ** argv) { return 1; } set_environment_variable("DFLASH_ADAPTIVE_K_TAU", tau, false); // explicit env still wins - adaptive_experts_set = true; + bargs.adaptive_experts_requested = true; } else if (std::strcmp(argv[i], "--verify-width") == 0 && i + 1 < argc) { bargs.verify_width = std::atoi(argv[++i]); } else if (std::strcmp(argv[i], "--no-fast-rollback") == 0) { @@ -677,7 +676,7 @@ int main(int argc, char ** argv) { return 2; } } - if (specla_top_k_set && !bargs.specla_mode) { + if (bargs.specla_top_k_explicit && !bargs.specla_mode) { std::fprintf(stderr, "[server] --specla-top-k requires --specla\n"); return 2; } @@ -686,8 +685,8 @@ int main(int argc, char ** argv) { "[server] --specla is incompatible with --no-fast-rollback\n"); return 2; } - if (bargs.specla_mode && !ddtree_tau_set) { - bargs.ddtree_tau = 6.0f; + if (bargs.specla_mode && !bargs.specla_top_k_explicit) { + bargs.specla_top_k = specla_tree_topk(); } if (fast_rollback_forced_off) { @@ -732,39 +731,49 @@ int main(int argc, char ** argv) { // Ask the factory to resolve model/placement facts and apply its feature // admission policy before any setup work. server_main only maps the // categorized result to the existing process exit convention. - BackendFeatureConfig backend_features; - backend_features.pflash_enabled = + bargs.routing_stats_requested = + sconfig.freq_tracking || !sconfig.collect_routing_path.empty(); + + BackendAdmissionContext backend_admission; + backend_admission.pflash_enabled = sconfig.pflash_mode != ServerConfig::PflashMode::OFF; - backend_features.pflash_drafter_configured = + backend_admission.pflash_drafter_configured = !sconfig.pflash_drafter_path.empty(); - backend_features.draft_residency = sconfig.draft_residency; - backend_features.routing_stats_requested = - sconfig.freq_tracking || !sconfig.collect_routing_path.empty(); - backend_features.adaptive_experts_requested = adaptive_experts_set; + backend_admission.draft_residency = sconfig.draft_residency; // Fixed pools are known incompatibilities before model setup. Automatic // sizing needs the backend's real VRAM budget; if it produces a live pool, // the backend rejects the pairing after sizing. - backend_features.kvflash_enabled = - kvflash_fixed_pool_requested(std::getenv("DFLASH_KVFLASH")); - const BackendPreparation backend_preparation = - prepare_backend(bargs, backend_features); - if (!backend_preparation.ok()) { + const char * kvflash_config = std::getenv("DFLASH_KVFLASH"); + backend_admission.kvflash = kvflash_fixed_pool_requested(kvflash_config) + ? KvFlashRequest::Fixed + : kvflash_pool_requested(kvflash_config) + ? KvFlashRequest::Auto + : KvFlashRequest::Off; + BackendPreparation backend_preparation = + prepare_backend(bargs, backend_admission); + if (const auto * failure = + std::get_if(&backend_preparation)) { + for (const std::string & warning : failure->warnings) { + std::fprintf(stderr, "[server] warning: %s\n", warning.c_str()); + } std::fprintf(stderr, "[server] %s\n", - backend_preparation.message.c_str()); - return backend_preparation.error == + failure->message.c_str()); + return failure->error == BackendPreparationError::FeatureCompatibility ? 2 : 1; } + BackendPlan backend_plan = + std::get(std::move(backend_preparation)); // Options that parsed cleanly but do nothing on this model. Reported up // front so they are visible before the backend's own startup chatter. - for (const std::string & warning : backend_preparation.warnings) { + for (const std::string & warning : backend_plan.warnings()) { std::fprintf(stderr, "[server] warning: %s\n", warning.c_str()); } - const ResolvedBackendPlan & backend_plan = backend_preparation.plan; - const std::string & arch = backend_plan.arch(); - const bool kvflash_requested = - kvflash_pool_requested(std::getenv("DFLASH_KVFLASH")); + // All later reporting and serving setup reads the normalized snapshot. + // Backend construction itself can only consume backend_plan. + bargs = backend_plan.args(); + const std::string arch = backend_plan.arch(); if (target_split_fast_rollback_cli && arch != "qwen35") { std::fprintf(stderr, "[server] --target-split-fast-rollback is only supported for " @@ -772,49 +781,6 @@ int main(int argc, char ** argv) { return 2; } - // SpecLA is the verification mode, not a proposal algorithm. Select the - // proposal adapter supported by this model. Qwen3.6 currently has a - // DDTree adapter; future model families may select DSpark here instead. - if (bargs.specla_mode) { - const bool supported = arch == "qwen35" && !bargs.device.is_multi_device(); - if (supported) { - if (!bargs.draft_path) { - std::fprintf(stderr, - "[server] Qwen3.6 SpecLA requires --draft \n"); - return 2; - } - bargs.ddtree_mode = true; - if (kvflash_requested) { - // KVFlash installs a paged attention-KV layout and therefore - // disables the factor-cache migration required by SpecLA. - // Keep the compatible proposal adapter, but report the - // effective verification mode accurately. - std::fprintf(stderr, - "[server] warning: --specla is unavailable with KVFlash; " - "using ordinary DDTree verification\n"); - bargs.specla_mode = false; - unset_environment_variable("DFLASH_SPECLA"); - } else { - set_environment_variable("DFLASH_SPECLA", "1", true); - if (specla_top_k_set) { - set_environment_variable( - "DFLASH_SPECLA_TOPK", std::to_string(specla_top_k).c_str(), true); - } else { - specla_top_k = specla_tree_topk(); - } - } - } else { - std::fprintf(stderr, - "[server] warning: --specla is unavailable for architecture '%s' " - "with placement %s; using the architecture's normal decode path\n", - arch.c_str(), placement_device_name(bargs.device).c_str()); - bargs.specla_mode = false; - if (!ddtree_tau_set) { - bargs.ddtree_tau = std::numeric_limits::infinity(); - } - } - } - // Paged decode owns its K/V through a block table that the snapshot format // cannot describe yet, so the caches it would restore into are turned off. // This rewrites ServerConfig rather than rejecting the launch, which is why @@ -903,9 +869,11 @@ int main(int argc, char ** argv) { } // Load tokenizer. - std::fprintf(stderr, "[server] loading tokenizer from %s\n", bargs.model_path); + std::fprintf( + stderr, "[server] loading tokenizer from %s\n", + bargs.model_path.c_str()); Tokenizer tokenizer; - if (!tokenizer.load_from_gguf(bargs.model_path)) { + if (!tokenizer.load_from_gguf(bargs.model_path.c_str())) { std::fprintf(stderr, "[server] tokenizer load failed\n"); return 1; } @@ -989,11 +957,12 @@ int main(int argc, char ** argv) { arch.c_str()); } } - auto backend = create_backend(bargs, backend_plan); - if (!backend) { + auto backend_runtime = create_backend(std::move(backend_plan)); + if (!backend_runtime) { std::fprintf(stderr, "[server] backend creation failed\n"); return 1; } + ModelBackend * backend = &backend_runtime->backend(); // Cross-check the capability table against the backend that was actually // built. arch_supports_remote_draft() admitted this launch from the arch // string alone; if the two ever disagree the table is stale, and failing @@ -1010,14 +979,14 @@ int main(int argc, char ** argv) { // ── Thinking-budget v2: resolve model card and apply to ServerConfig ── // Reuse the metadata captured during factory preparation instead of // opening the GGUF header again. - const std::string & general_name = backend_plan.model().name; - const std::string & general_arch = backend_plan.arch(); + const std::string & general_name = backend_runtime->plan().model().name; + const std::string & general_arch = backend_runtime->plan().arch(); std::fprintf(stderr, "[server] gguf meta: general.name='%s' general.architecture='%s'\n", general_name.c_str(), general_arch.c_str()); ModelCard card = resolve_model_card( - bargs.model_path ? bargs.model_path : "", + bargs.model_path, general_name, general_arch, /*repo_root_hint=*/""); @@ -1150,8 +1119,10 @@ int main(int argc, char ** argv) { std::fprintf(stderr, "[server] ╭─── Configuration ───────────────────────────────────╮\n"); std::fprintf(stderr, "[server] │ host = %s\n", sconfig.host.c_str()); std::fprintf(stderr, "[server] │ port = %d\n", sconfig.port); - std::fprintf(stderr, "[server] │ model = %s\n", bargs.model_path); - std::fprintf(stderr, "[server] │ draft = %s\n", bargs.draft_path ? bargs.draft_path : "(none)"); + std::fprintf(stderr, "[server] │ model = %s\n", bargs.model_path.c_str()); + std::fprintf( + stderr, "[server] │ draft = %s\n", + bargs.draft_path ? bargs.draft_path->c_str() : "(none)"); std::fprintf(stderr, "[server] │ model_name = %s\n", sconfig.model_name.c_str()); std::fprintf(stderr, "[server] │ max_ctx = %d\n", sconfig.max_ctx); // max_tokens default for requests that omit the field. The request @@ -1243,7 +1214,7 @@ int main(int argc, char ** argv) { std::fprintf(stderr, "[server] │ ddtree = %s\n", bargs.ddtree_mode ? "ON" : "off"); std::fprintf(stderr, "[server] │ specla = %s\n", bargs.specla_mode ? "ON" : "off"); if (bargs.specla_mode) { - std::fprintf(stderr, "[server] │ specla_top_k = %d\n", specla_top_k); + std::fprintf(stderr, "[server] │ specla_top_k = %d\n", bargs.specla_top_k); std::fprintf(stderr, "[server] │ ddtree_tau = %.3g\n", bargs.ddtree_tau); } std::fprintf(stderr, "[server] │ fast_rollback = %s\n", bargs.fast_rollback ? "ON" : "off"); @@ -1294,8 +1265,8 @@ int main(int argc, char ** argv) { // — the /props handler reads them lockless from config_ so they need to // be set BEFORE the HttpServer constructor copies sconfig. sconfig.arch = arch; - sconfig.model_path = bargs.model_path ? bargs.model_path : ""; - sconfig.draft_path = bargs.draft_path ? bargs.draft_path : ""; + sconfig.model_path = bargs.model_path; + sconfig.draft_path = bargs.draft_path.value_or(""); sconfig.fa_window = bargs.fa_window; sconfig.ddtree_budget = bargs.ddtree_budget; sconfig.speculative_enabled = bargs.ddtree_mode; diff --git a/server/test/test_backend_plan.cpp b/server/test/test_backend_plan.cpp new file mode 100644 index 000000000..33780584a --- /dev/null +++ b/server/test/test_backend_plan.cpp @@ -0,0 +1,176 @@ +// Unit tests for model-aware backend request normalization. +// +// These tests call the internal builder with already-inspected GGUF facts, so +// policy stays testable without a model file, a GPU, or backend construction. + +#include "CppUnitTestFramework.hpp" +#include "common/backend_plan_internal.h" + +#include +#include +#include +#include +#include + +using namespace CppUnitTestFramework; +using namespace dflash::common; + +namespace { + +template +struct CanCreateBackend : std::false_type {}; + +template +struct CanCreateBackend< + T, + std::void_t()))>> + : std::true_type {}; + +static_assert(CanCreateBackend::value); +static_assert(!CanCreateBackend::value); +static_assert(std::is_move_constructible_v); +static_assert(!std::is_move_assignable_v); + +struct BackendPlanFixture : CommonFixture { + using CommonFixture::CommonFixture; + +BackendPreparation resolve( + BackendArgs args, + const std::string & arch, + BackendAdmissionContext admission = {}) { + GgufModelInfo model; + model.arch = arch; + model.name = "test-model"; + return detail::BackendPlanBuilder::resolve( + std::move(args), + std::move(admission), + std::move(model), + compiled_placement_backend()); +} + +BackendArgs plain_args() { + BackendArgs args; + args.model_path = "/models/target.gguf"; + return args; +} + +void test_plan_owns_the_effective_request() { + std::string target = "/models/target.gguf"; + std::string draft = "/models/draft.gguf"; + BackendArgs args = plain_args(); + args.model_path = target; + args.draft_path = draft; + + BackendPreparation result = resolve(std::move(args), "qwen35"); + CHECK(std::holds_alternative(result)); + BackendPlan plan = std::get(std::move(result)); + + target.assign("changed"); + draft.assign("changed"); + CHECK(plan.args().model_path == "/models/target.gguf"); + CHECK(plan.args().draft_path == "/models/draft.gguf"); + CHECK(plan.arch() == "qwen35"); +} + +void test_supported_specla_selects_ddtree() { + BackendArgs args = plain_args(); + args.draft_path = "/models/draft.gguf"; + args.specla_mode = true; + + BackendPreparation result = resolve(std::move(args), "qwen35"); + CHECK(std::holds_alternative(result)); + const BackendPlan & plan = std::get(result); + CHECK(plan.args().specla_mode); + CHECK(plan.args().ddtree_mode); + CHECK(plan.args().ddtree_tau == 6.0f); + CHECK(plan.warnings().empty()); +} + +void test_explicit_specla_tau_is_preserved() { + BackendArgs args = plain_args(); + args.draft_path = "/models/draft.gguf"; + args.specla_mode = true; + args.ddtree_tau = 2.5f; + args.ddtree_tau_explicit = true; + + BackendPreparation result = resolve(std::move(args), "qwen35"); + CHECK(std::holds_alternative(result)); + const BackendPlan & plan = std::get(result); + CHECK(plan.args().specla_mode); + CHECK(plan.args().ddtree_mode); + CHECK(plan.args().ddtree_tau == 2.5f); +} + +void test_kvflash_falls_back_to_ordinary_ddtree() { + BackendArgs args = plain_args(); + args.draft_path = "/models/draft.gguf"; + args.specla_mode = true; + BackendAdmissionContext admission; + admission.kvflash = KvFlashRequest::Auto; + + BackendPreparation result = + resolve(std::move(args), "qwen35", admission); + CHECK(std::holds_alternative(result)); + const BackendPlan & plan = std::get(result); + CHECK(!plan.args().specla_mode); + CHECK(plan.args().ddtree_mode); + CHECK(std::isinf(plan.args().ddtree_tau)); + CHECK(plan.warnings().size() == 1); +} + +void test_kvflash_fallback_preserves_explicit_tau() { + BackendArgs args = plain_args(); + args.draft_path = "/models/draft.gguf"; + args.specla_mode = true; + args.ddtree_tau = 2.5f; + args.ddtree_tau_explicit = true; + BackendAdmissionContext admission; + admission.kvflash = KvFlashRequest::Auto; + + BackendPreparation result = + resolve(std::move(args), "qwen35", admission); + CHECK(std::holds_alternative(result)); + const BackendPlan & plan = std::get(result); + CHECK(!plan.args().specla_mode); + CHECK(plan.args().ddtree_mode); + CHECK(plan.args().ddtree_tau == 2.5f); +} + +void test_unsupported_specla_falls_back_without_hidden_tau() { + BackendArgs args = plain_args(); + args.specla_mode = true; + + BackendPreparation result = resolve(std::move(args), "qwen3"); + CHECK(std::holds_alternative(result)); + const BackendPlan & plan = std::get(result); + CHECK(!plan.args().specla_mode); + CHECK(!plan.args().ddtree_mode); + CHECK(std::isinf(plan.args().ddtree_tau)); + CHECK(plan.warnings().size() == 1); +} + +void test_supported_specla_requires_a_draft() { + BackendArgs args = plain_args(); + args.specla_mode = true; + + BackendPreparation result = resolve(std::move(args), "qwen35"); + CHECK(std::holds_alternative(result)); + const BackendPreparationFailure & failure = + std::get(result); + CHECK(failure.error == BackendPreparationError::FeatureCompatibility); + CHECK(failure.message == "Qwen3.6 SpecLA requires --draft "); +} + +}; + +} // namespace + +TEST_CASE(BackendPlanFixture, backend_plan_suite) { + test_plan_owns_the_effective_request(); + test_supported_specla_selects_ddtree(); + test_explicit_specla_tau_is_preserved(); + test_kvflash_falls_back_to_ordinary_ddtree(); + test_kvflash_fallback_preserves_explicit_tau(); + test_unsupported_specla_falls_back_without_hidden_tau(); + test_supported_specla_requires_a_draft(); +} diff --git a/server/test/test_feature_gate.cpp b/server/test/test_feature_gate.cpp index 787e0c6af..fdc4a111c 100644 --- a/server/test/test_feature_gate.cpp +++ b/server/test/test_feature_gate.cpp @@ -1,11 +1,9 @@ -// Unit tests for the backend feature/architecture gate. +// Unit tests for lightweight backend planning policy. // -// check_feature_compatibility(), collect_feature_warnings() and the -// model_capabilities.h table are pure functions over resolved facts, so this -// binary needs no model file, no GPU, and none of the backend stack — it -// compiles against feature_gate.cpp and placement_config.cpp alone. Keeping -// it separate from test_server_unit keeps that true: a gate rule stays -// testable in seconds rather than behind a full CUDA build. +// The plan builder, feature gate, and model_capabilities.h table operate on +// resolved facts. This binary needs no model file, GPU, or backend stack. +// Keeping it separate from test_server_unit keeps policy rules testable in +// seconds rather than behind a full CUDA build. // // Build: cmake --build . --target test_feature_gate // Run: ./test_feature_gate @@ -45,7 +43,7 @@ static std::string gate_result( const BackendArgs & args, const std::string & arch, PlacementBackend backend, - const BackendFeatureConfig & features = {}) { + const BackendAdmissionContext & features = {}) { return check_feature_compatibility( args, features, arch, backend, backend); } @@ -55,7 +53,7 @@ static std::string gate_result_for_binary( const std::string & arch, PlacementBackend target_backend, PlacementBackend compiled_backend, - const BackendFeatureConfig & features = {}) { + const BackendAdmissionContext & features = {}) { return check_feature_compatibility( args, features, arch, target_backend, compiled_backend); } @@ -150,7 +148,7 @@ void test_feature_gate_pflash_requires_drafter_and_supported_arch() { BackendArgs args; args.model_path = "/nonexistent/model.gguf"; - BackendFeatureConfig features; + BackendAdmissionContext features; features.pflash_enabled = true; CHECK(!gate_result( args, "qwen35", PlacementBackend::Cuda, features).empty()); @@ -238,7 +236,7 @@ void test_feature_gate_tensor_parallel_requirements() { CHECK(!gate_result( remote, "qwen35", PlacementBackend::Cuda).empty()); - BackendFeatureConfig pflash; + BackendAdmissionContext pflash; pflash.pflash_enabled = true; pflash.pflash_drafter_configured = true; CHECK(!gate_result( @@ -350,7 +348,7 @@ void test_feature_gate_remote_draft_requires_supported_arch() { // Without a draft model or PFlash, remote draft IPC is unnecessary. BackendArgs no_draft = args; - no_draft.draft_path = nullptr; + no_draft.draft_path.reset(); CHECK(!gate_result( no_draft, "gemma4", PlacementBackend::Cuda).empty()); } @@ -427,13 +425,13 @@ void test_feature_gate_paged_attention_allows_fixed_local_chains() { CHECK(gate_result( concurrent_chain, "qwen35", PlacementBackend::Hip).empty()); - BackendFeatureConfig request_scoped; + BackendAdmissionContext request_scoped; request_scoped.draft_residency = DraftResidencyPolicy::RequestScoped; CHECK(!gate_result( concurrent_chain, "qwen35", PlacementBackend::Cuda, request_scoped).empty()); - BackendFeatureConfig persistent; + BackendAdmissionContext persistent; persistent.draft_residency = DraftResidencyPolicy::Persistent; CHECK(gate_result( concurrent_chain, "qwen35", PlacementBackend::Cuda, @@ -453,14 +451,14 @@ void test_feature_gate_paged_attention_allows_fixed_local_chains() { CHECK(!gate_result( windowed, "qwen35", PlacementBackend::Cuda).empty()); - BackendFeatureConfig pflash; + BackendAdmissionContext pflash; pflash.pflash_enabled = true; pflash.pflash_drafter_configured = true; CHECK(!gate_result( base, "qwen35", PlacementBackend::Cuda, pflash).empty()); - BackendFeatureConfig kvflash; - kvflash.kvflash_enabled = true; + BackendAdmissionContext kvflash; + kvflash.kvflash = KvFlashRequest::Fixed; CHECK(!gate_result( base, "qwen35", PlacementBackend::Cuda, kvflash).empty()); @@ -581,7 +579,7 @@ void test_feature_gate_parallel_and_kv_pool_rules() { std::vector warn_result( const BackendArgs & args, const std::string & arch, - const BackendFeatureConfig & features = {}) { + const BackendAdmissionContext & features = {}) { CHECK(check_feature_compatibility( args, features, arch, compiled_placement_backend(), compiled_placement_backend()).empty()); @@ -666,14 +664,13 @@ void test_feature_warnings_report_inert_moe_options() { BackendArgs args; args.model_path = "/nonexistent/model.gguf"; - BackendFeatureConfig moe_opts; - moe_opts.routing_stats_requested = true; - moe_opts.adaptive_experts_requested = true; + args.routing_stats_requested = true; + args.adaptive_experts_requested = true; - CHECK(warn_result(args, "laguna", moe_opts).empty()); - CHECK(warn_result(args, "qwen35moe", moe_opts).empty()); - CHECK(warn_result(args, "qwen35", moe_opts).size() == 2); - CHECK(warn_result(args, "deepseek4", moe_opts).size() == 2); + CHECK(warn_result(args, "laguna").empty()); + CHECK(warn_result(args, "qwen35moe").empty()); + CHECK(warn_result(args, "qwen35").size() == 2); + CHECK(warn_result(args, "deepseek4").size() == 2); } void test_model_capability_tables() {