From e42adbffb598b13ecd5389a48e6018b8d6bb81bc Mon Sep 17 00:00:00 2001 From: Graffioh Date: Sat, 25 Jul 2026 07:40:11 +0000 Subject: [PATCH] refactor(common): centralize cross-feature and architecture gating Cross-feature rules were split between server_main and backend_factory, with each layer seeing only part of the launch configuration. Centralize those rules in the pure check_feature_compatibility() helper and make the backend factory the single admission owner. prepare_backend() now resolves GGUF model metadata and the compiled target placement once into an opaque ResolvedBackendPlan, applies compatibility policy, and returns a categorized result. server_main forwards BackendArgs to that factory preflight and only maps its result to the existing exit-code convention. create_backend(args) remains source-compatible, while the prepared overload verifies the model path and target placement and rechecks compatibility at the dispatch boundary. Keep raw requested configuration in backend_args.h, architecture capabilities in model_capabilities.h, and the pure gate independent of the factory header. Reuse the same GGUF metadata read for backend dispatch, PFlash/Spark decisions, and model-card resolution. Architecture capabilities become one constexpr table whose rows mirror create_backend()'s dispatch chain, so a row states what a model can do and a column states which architectures honor a flag. Placement-sensitive entries carry a FeatureSupport value because laguna and gemma4 forward a draft model only when monolithic. The table stays in the binary deliberately: it can only ever describe the dispatch chain, since a new architecture needs a backend before it needs a row. Two layers of static_assert keep the table honest. model_capabilities.h checks it is internally coherent: rows are named and unique, an architecture without a layer-split adapter cannot claim support on 'Both' placements, and remote draft execution requires an architecture that accepts a draft model. backend_factory.cpp then checks it against the structs it actually feeds, detecting whether each backend config carries the field an option arrives through, so a row claiming support an architecture has nowhere to store fails the build. Neither can see whether a dispatch branch assigns a field it has; that remains the job of the tests and of the table's dispatch-matching row order. Admission gains one rule from that table. qwen35moe and qwen3 have no layer-split adapter, so --target-devices previously handed a multi-device placement to a monolithic backend that reads only the primary GPU and failed later as an out-of-memory; it is now rejected up front. Options that parse cleanly but never reach the backend are reported through collect_feature_warnings() instead, which never blocks a launch. Errors still describe why a configuration cannot run; warnings describe what silently will not happen. Fix --fa-window being dropped on single-device gemma4: Gemma4BackendConfig carries the field and Gemma4Backend reads it, but the factory only populated it on the layer-split path. Restore the post-init supports_remote_draft() cross-check so a stale capability table fails loudly rather than routing draft work to a backend that cannot serve it. Move the gate tests to their own test_feature_gate target. The gate and the capability table are pure functions over resolved facts, so the target compiles feature_gate.cpp and placement_config.cpp alone, without dflash_common, ggml or a GPU toolkit; a gate rule is testable in under a second instead of behind a full backend build. Build the check dependency list per target so that target still runs when the server unit tests are skipped for a missing CURL. Behavior is otherwise preserved. A 3M-configuration differential against the previous scattered rules, run for both compiled backends, reports no other admission change; an undetectable or unbuildable architecture is now rejected consistently on every launch rather than only when remote draft is enabled. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GeWuszoJoUnaUYmTxXE62Z --- server/CMakeLists.txt | 63 +++- server/src/common/backend_args.h | 74 +++++ server/src/common/backend_factory.cpp | 214 ++++++++++-- server/src/common/backend_factory.h | 109 ++++--- server/src/common/feature_gate.cpp | 249 ++++++++++++++ server/src/common/feature_gate.h | 75 +++++ server/src/common/gguf_inspect.cpp | 6 + server/src/common/gguf_inspect.h | 3 +- server/src/common/model_capabilities.h | 238 ++++++++++++++ server/src/server/server_main.cpp | 274 +++------------- server/test/test_feature_gate.cpp | 435 +++++++++++++++++++++++++ 11 files changed, 1428 insertions(+), 312 deletions(-) create mode 100644 server/src/common/backend_args.h create mode 100644 server/src/common/feature_gate.cpp create mode 100644 server/src/common/feature_gate.h create mode 100644 server/src/common/model_capabilities.h create mode 100644 server/test/test_feature_gate.cpp diff --git a/server/CMakeLists.txt b/server/CMakeLists.txt index a07701382..7183b5651 100644 --- a/server/CMakeLists.txt +++ b/server/CMakeLists.txt @@ -318,6 +318,7 @@ add_library(dflash_common STATIC src/common/daemon_loop.cpp src/common/gguf_inspect.cpp src/common/backend_factory.cpp + src/common/feature_gate.cpp src/placement/placement_config.cpp src/common/layer_split_utils.cpp src/common/ddtree.cpp @@ -1114,23 +1115,57 @@ if(DFLASH27B_TESTS) add_test(NAME server_unit COMMAND test_server_unit) endif() - # 'make check' — builds test targets then runs ctest - if(TARGET test_server_unit) - set(_check_deps test_server_unit) - if(TARGET test_deepseek4_unit) - list(APPEND _check_deps test_deepseek4_unit) + # 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 + # 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 + src/common/feature_gate.cpp + src/placement/placement_config.cpp) + target_include_directories(test_feature_gate PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/src + ${CMAKE_CURRENT_SOURCE_DIR}/src/common) + # compiled_placement_backend() is decided by these defines, so the + # gate must be tested with the same ones the server is built with. + if(DFLASH27B_GPU_BACKEND STREQUAL "hip") + target_compile_definitions(test_feature_gate PRIVATE + DFLASH27B_BACKEND_HIP=1 GGML_USE_HIP) + else() + target_compile_definitions(test_feature_gate PRIVATE + DFLASH27B_BACKEND_CUDA=1) endif() - add_custom_target(check - COMMAND ${CMAKE_CTEST_COMMAND} --output-on-failure - DEPENDS ${_check_deps} - COMMENT "Building and running unit tests" - ) + add_test(NAME feature_gate COMMAND test_feature_gate) + endif() + + # 'make check' — builds test targets then runs ctest. + # Collect deps per target rather than gating the whole list on + # test_server_unit: test_feature_gate has no CURL dependency, so it must + # still build when the server unit tests are skipped. + set(_check_deps "") + if(TARGET test_server_unit) + list(APPEND _check_deps test_server_unit) + endif() + if(TARGET test_feature_gate) + list(APPEND _check_deps test_feature_gate) + endif() + if(TARGET test_deepseek4_unit) + list(APPEND _check_deps test_deepseek4_unit) + endif() + if(TARGET test_server_unit) + set(_check_comment "Building and running unit tests") else() - add_custom_target(check - COMMAND ${CMAKE_CTEST_COMMAND} --output-on-failure - COMMENT "Building and running unit tests (server unit tests skipped — CURL not found)" - ) + set(_check_comment "Building and running unit tests (server unit tests skipped — CURL not found)") endif() + add_custom_target(check + COMMAND ${CMAKE_CTEST_COMMAND} --output-on-failure + DEPENDS ${_check_deps} + COMMENT "${_check_comment}" + ) if(DFLASH27B_GPU_BACKEND STREQUAL "cuda") # internal.h includes when GGML_USE_CUDA is set; link diff --git a/server/src/common/backend_args.h b/server/src/common/backend_args.h new file mode 100644 index 000000000..1607b1495 --- /dev/null +++ b/server/src/common/backend_args.h @@ -0,0 +1,74 @@ +// Raw backend construction arguments. +// +// This contains only caller-requested configuration. Runtime facts derived +// from the model or compiled binary belong in ResolvedBackendPlan instead. + +#pragma once + +#include "placement/placement_config.h" +#include "placement/remote_draft_config.h" +#include "placement/remote_target_shard_config.h" +#include "prefill_attention_mode.h" + +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 { + bool pflash_enabled = false; + bool pflash_drafter_configured = false; + + // 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 +}; + +// 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 + + // Optional: speculative decode draft model (qwen35 only) + const char * draft_path = nullptr; + + // Device placement + DevicePlacement device; + DevicePlacement draft_device; + RemoteDraftConfig remote_draft; + RemoteTargetShardConfig remote_target_shard; + + // I/O — only used when running under daemon_loop (legacy). The new + // server passes -1 and uses on_token callbacks instead. + int stream_fd = -1; + + // Chunked prefill + int chunk = 512; + PrefillAttentionMode ds4_prefill_mode = PrefillAttentionMode::Exact; + bool ds4_prefill_mode_set = false; + + // deepseek4-specific decode options + int ds4_expert_top_k = 0; // 0 = model default + bool ds4_fused_decode = false; + + // Attention and speculative-decode options. Individual backends consume + // only the fields they support. + int fa_window = 0; // 0 = full attention. qwen3.6 full-attn layers must see the whole context; a finite window drops the system prompt/tools -> breaks tool calls. + int kq_stride_pad = 32; + int draft_swa_window = 0; + int draft_ctx_max = 4096; + bool fast_rollback = true; + bool seq_verify = false; + bool ddtree_mode = false; + int ddtree_budget = 22; + float ddtree_temp = 1.0f; + bool ddtree_chain_seed = true; + int verify_width = 0; // chain spec verify width; 0 = adaptive + bool use_feature_mirror = false; +}; + +} // namespace dflash::common diff --git a/server/src/common/backend_factory.cpp b/server/src/common/backend_factory.cpp index 9348cd8e0..ab34f2a19 100644 --- a/server/src/common/backend_factory.cpp +++ b/server/src/common/backend_factory.cpp @@ -1,6 +1,7 @@ // Backend factory implementation. #include "backend_factory.h" +#include "feature_gate.h" #include "gguf_inspect.h" #include "qwen35_backend.h" @@ -17,41 +18,209 @@ #include #include +#include namespace dflash::common { +namespace { + +// ─── Capability table ↔ dispatch cross-check ──────────────────────────── +// Every option an architecture can receive arrives through a named field on +// its backend config struct. Detecting whether that field exists lets the +// compiler verify model_capabilities.h against the structs this file feeds: +// a row claiming support an architecture has nowhere to store, or a config +// carrying a field the table calls unsupported, fails the build here. +// +// This is the strongest check the language allows. It cannot see whether the +// dispatch below actually *assigns* a field it has — that is what the unit +// tests and the table's dispatch-matching row order are for. + +#define DFLASH_ARCH_FIELD_TRAIT(trait_name, field_name) \ + template \ + struct trait_name : std::false_type {}; \ + template \ + struct trait_name> \ + : std::true_type {} + +DFLASH_ARCH_FIELD_TRAIT(has_draft_path, draft_path); +DFLASH_ARCH_FIELD_TRAIT(has_fa_window, fa_window); +DFLASH_ARCH_FIELD_TRAIT(has_verify_width, verify_width); +DFLASH_ARCH_FIELD_TRAIT(has_draft_swa, draft_swa_window); +DFLASH_ARCH_FIELD_TRAIT(has_ddtree_mode, ddtree_mode); +DFLASH_ARCH_FIELD_TRAIT(has_max_verify_tokens, max_verify_tokens); + +#undef DFLASH_ARCH_FIELD_TRAIT + +// DDTree reaches qwen35's layer-split path as a max_verify_tokens budget +// rather than a ddtree_mode flag, so either field counts as a carrier. +template +struct has_ddtree : std::bool_constant::value || + has_max_verify_tokens::value> {}; + +// Architectures with no layer-split adapter. Pairing one of these with the +// split half of a check requires the row to be Monolithic or Never, which +// table_split_coherent() already guarantees. +struct NoLayerSplitConfig {}; + +constexpr bool monolithic_carries(FeatureSupport support) { + return support != FeatureSupport::Never; +} +constexpr bool layer_split_carries(FeatureSupport support) { + return support == FeatureSupport::Both; +} + +#define DFLASH_CHECK_ARCH_OPTION(arch_name, Mono, Split, trait, field) \ + static_assert( \ + trait::value == \ + monolithic_carries(arch_capabilities(arch_name).field), \ + arch_name ": monolithic config and capability table disagree on " \ + #field); \ + static_assert( \ + trait::value == \ + layer_split_carries(arch_capabilities(arch_name).field), \ + arch_name ": layer-split config and capability table disagree on " \ + #field) + +#define DFLASH_CHECK_ARCH(arch_name, Mono, Split) \ + DFLASH_CHECK_ARCH_OPTION(arch_name, Mono, Split, has_draft_path, decode_draft); \ + DFLASH_CHECK_ARCH_OPTION(arch_name, Mono, Split, has_ddtree, ddtree); \ + DFLASH_CHECK_ARCH_OPTION(arch_name, Mono, Split, has_verify_width, verify_width); \ + DFLASH_CHECK_ARCH_OPTION(arch_name, Mono, Split, has_fa_window, fa_window); \ + DFLASH_CHECK_ARCH_OPTION(arch_name, Mono, Split, has_draft_swa, draft_swa) + +DFLASH_CHECK_ARCH("qwen35", Qwen35Config, Qwen35LayerSplitAdapterConfig); +DFLASH_CHECK_ARCH("qwen35moe", Qwen35Config, NoLayerSplitConfig); +DFLASH_CHECK_ARCH("laguna", LagunaBackendArgs, LagunaLayerSplitAdapterConfig); +DFLASH_CHECK_ARCH("qwen3", Qwen3BackendConfig, NoLayerSplitConfig); +DFLASH_CHECK_ARCH("gemma4", Gemma4BackendConfig, Gemma4LayerSplitAdapterConfig); +DFLASH_CHECK_ARCH("deepseek4", DeepSeek4BackendConfig, DeepSeek4LayerSplitAdapterConfig); + +#undef DFLASH_CHECK_ARCH +#undef DFLASH_CHECK_ARCH_OPTION + +PlacementBackend resolve_target_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; } -bool arch_supports_remote_draft(const std::string & arch) { - return arch == "qwen35"; -} +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; + } -bool arch_supports_pflash_compression(const std::string & arch) { - return arch == "qwen35" || arch == "qwen3"; + 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 = detect_arch(args.model_path); + const std::string & arch = plan.arch(); if (arch.empty()) { - std::fprintf(stderr, "[backend_factory] failed to detect architecture from %s\n", - args.model_path); + 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()); - if (args.ds4_prefill_mode_set && arch != "deepseek4") { - std::fprintf(stderr, - "[backend_factory] --ds4-prefill is only valid for deepseek4 models " - "(detected %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; } @@ -213,6 +382,10 @@ std::unique_ptr create_backend(const BackendArgs & args) { gcfg.device = args.device; gcfg.stream_fd = args.stream_fd; gcfg.chunk = args.chunk; + // Gemma4Backend reads this into its cache (gemma4_backend.cpp) exactly + // as the layer-split adapter does; leaving it unset silently dropped + // --fa-window on single-device gemma4. + gcfg.fa_window = args.fa_window; auto backend = std::make_unique(gcfg); if (!backend->init()) { @@ -222,21 +395,8 @@ std::unique_ptr create_backend(const BackendArgs & args) { return backend; } else if (arch == "deepseek4") { - const PlacementBackend target_backend = - args.device.backend == PlacementBackend::Auto - ? compiled_placement_backend() - : args.device.backend; - if (prefill_attention_mode_is_approximate(args.ds4_prefill_mode) && - (target_backend != PlacementBackend::Hip || - args.device.is_layer_split() || - args.remote_target_shard.enabled())) { - std::fprintf(stderr, - "[backend_factory] DS4 %s prefill requires a single local HIP " - "target; use --ds4-prefill exact for split, remote, or CUDA " - "placement\n", - prefill_attention_mode_name(args.ds4_prefill_mode)); - return nullptr; - } + // Approximate prefill and the fused decode options are gated against + // non-monolithic-HIP placement in check_feature_compatibility(). // A single local device uses the monolithic backend. Reserve the // layer-split adapter for explicit multi-device placement or remote diff --git a/server/src/common/backend_factory.h b/server/src/common/backend_factory.h index 7b468000c..bb3d71164 100644 --- a/server/src/common/backend_factory.h +++ b/server/src/common/backend_factory.h @@ -10,12 +10,10 @@ #pragma once +#include "backend_args.h" +#include "gguf_inspect.h" +#include "model_capabilities.h" #include "model_backend.h" -#include "internal.h" -#include "placement/placement_config.h" -#include "placement/remote_draft_config.h" -#include "placement/remote_target_shard_config.h" -#include "prefill_attention_mode.h" #include #include @@ -23,60 +21,71 @@ namespace dflash::common { -// ─── Backend creation arguments ───────────────────────────────────────── -// A superset of all per-arch config fields. The factory reads only those -// relevant to the detected arch; unused fields are silently ignored. -struct BackendArgs { - // Required - const char * model_path = nullptr; // target .gguf - - // Optional: speculative decode draft model (qwen35 only) - const char * draft_path = nullptr; - - // Device placement - DevicePlacement device; - DevicePlacement draft_device; - RemoteDraftConfig remote_draft; - RemoteTargetShardConfig remote_target_shard; - - // I/O — only used when running under daemon_loop (legacy). The new - // server passes -1 and uses on_token callbacks instead. - int stream_fd = -1; - - // Chunked prefill - int chunk = 512; - PrefillAttentionMode ds4_prefill_mode = PrefillAttentionMode::Exact; - bool ds4_prefill_mode_set = false; - - // deepseek4-specific decode options - int ds4_expert_top_k = 0; // 0 = model default - bool ds4_fused_decode = false; - - // qwen35-specific speculative decode options - int fa_window = 0; // 0 = full attention. qwen3.6 full-attn layers must see the whole context; a finite window drops the system prompt/tools -> breaks tool calls. - int kq_stride_pad = 32; - int draft_swa_window = 0; - int draft_ctx_max = 4096; - bool fast_rollback = true; - bool seq_verify = false; - bool ddtree_mode = false; - int ddtree_budget = 22; - float ddtree_temp = 1.0f; - bool ddtree_chain_seed = true; - int verify_width = 0; // chain spec verify width; 0 = adaptive - bool use_feature_mirror = false; +struct BackendPreparation; + +// 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 { +public: + 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_; } + +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 BackendPreparationError { + None, + InvalidRequest, + ModelInspection, + FeatureCompatibility, +}; + +struct BackendPreparation { + ResolvedBackendPlan plan; + BackendPreparationError error = BackendPreparationError::None; + 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. +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); -bool arch_supports_remote_draft(const std::string & arch); -bool arch_supports_pflash_compression(const std::string & arch); - } // namespace dflash::common diff --git a/server/src/common/feature_gate.cpp b/server/src/common/feature_gate.cpp new file mode 100644 index 000000000..6ab697b45 --- /dev/null +++ b/server/src/common/feature_gate.cpp @@ -0,0 +1,249 @@ +// Cross-feature compatibility gate — see feature_gate.h for what belongs here. + +#include "feature_gate.h" + +#include "model_capabilities.h" + +namespace dflash::common { + +std::string check_feature_compatibility( + const BackendArgs & args, + const BackendFeatureConfig & features, + const std::string & arch, + PlacementBackend target_backend, + PlacementBackend compiled_backend) +{ + if (arch.empty()) { + return "failed to detect model architecture"; + } + + // ── target placement × compiled backend + if (target_backend != compiled_backend) { + return "--target-device=" + placement_device_name(args.device) + + " is unsupported in this binary (compiled backend: " + + placement_backend_name(compiled_backend) + ")"; + } + + const PlacementBackend draft_backend = + args.draft_device.backend == PlacementBackend::Auto + ? target_backend + : args.draft_device.backend; + const bool draft_placement_used = + features.pflash_enabled || args.draft_path != nullptr; + const bool mixed_draft_placement = + draft_placement_used && target_backend != draft_backend; + + // ── IPC auxiliary options × IPC enablement + if (!args.remote_draft.enabled() && + args.remote_draft.has_aux_options()) { + return "--draft-ipc-work-dir and --draft-ipc-ring-cap require " + "--draft-ipc-bin"; + } + if (!args.remote_target_shard.enabled() && + args.remote_target_shard.has_aux_options()) { + return "--target-shard-ipc-work-dir requires --target-shard-ipc-bin"; + } + + // ── PFlash enablement × drafter model + if (features.pflash_enabled && + !features.pflash_drafter_configured) { + return "--prefill-compression requires --prefill-drafter"; + } + + // ── target/draft backend mixing × remote draft IPC + if (mixed_draft_placement && !args.remote_draft.enabled()) { + return "mixed target/draft backends require --draft-ipc-bin " + "(target=" + std::string(placement_backend_name(target_backend)) + + " draft=" + placement_backend_name(draft_backend) + ")"; + } + if (!mixed_draft_placement && args.remote_draft.enabled()) { + return "--draft-ipc-bin is only needed for mixed target/draft " + "backends (target=" + + std::string(placement_backend_name(target_backend)) + + " draft=" + placement_backend_name(draft_backend) + ")"; + } + // ── target layer split structure and remote backend topology + if (!args.device.is_layer_split() && + !args.device.layer_split_weights.empty()) { + return "--target-layer-split requires --target-devices"; + } + if (args.device.is_layer_split()) { + const std::string placement_error = + validate_device_placement(args.device, /*device_count=*/-1); + if (!placement_error.empty()) { + return "bad target layer split: " + placement_error; + } + } + + const bool mixed_target_split = + args.device.is_layer_split() && + args.device.is_mixed_layer_split(); + if (mixed_target_split) { + if (!args.remote_target_shard.enabled()) { + return "mixed-backend target layer split requires " + "--target-shard-ipc-bin"; + } + + size_t remote_begin = 0; + while (remote_begin < args.device.layer_split_gpus.size() && + args.device.layer_split_backend(remote_begin) == + compiled_backend) { + ++remote_begin; + } + if (remote_begin == 0 || + remote_begin >= args.device.layer_split_gpus.size()) { + return "mixed-backend target layer split currently supports " + "one local backend group followed by one remote backend " + "group"; + } + + const PlacementBackend remote_backend = + args.device.layer_split_backend(remote_begin); + for (size_t i = remote_begin; + i < args.device.layer_split_gpus.size(); + ++i) { + if (args.device.layer_split_backend(i) != remote_backend) { + return "mixed-backend target layer split currently supports " + "only one backend boundary"; + } + } + } + + // ── layer split × architecture + // qwen35moe and qwen3 have no layer-split adapter. Their factory cases + // hand the split DevicePlacement to a monolithic backend, which reads + // only the primary GPU — the extra devices are silently unused. Reject + // instead: a multi-device placement that quietly becomes single-device + // fails later as an out-of-memory, far from its cause. + if (args.device.is_layer_split() && !arch_supports_layer_split(arch)) { + return "model architecture '" + arch + + "' has no layer-split path; --target-devices would run on " + + placement_device_name(args.device) + " alone"; + } + + // ── remote draft execution × architecture + if (args.remote_draft.enabled() && args.draft_path && + !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 && + !arch_supports_pflash_compression(arch)) { + return "model architecture '" + arch + + "' does not support PFlash compression"; + } + + // ── --ds4-prefill × architecture + if (args.ds4_prefill_mode_set && arch != "deepseek4") { + return "--ds4-prefill is only valid for deepseek4 models (detected '" + + arch + "')"; + } + + // Approximate prefill and the fused decode options are implemented only + // in the monolithic HIP DeepSeek4 backend; the layer-split adapter and + // the CUDA path have no equivalent. + const bool monolithic_ds4 = + arch == "deepseek4" && + target_backend == PlacementBackend::Hip && + !args.device.is_layer_split() && + !args.remote_target_shard.enabled(); + + // ── approximate --ds4-prefill × placement + if (arch == "deepseek4" && + prefill_attention_mode_is_approximate(args.ds4_prefill_mode) && + !monolithic_ds4) { + return std::string("DS4 ") + + prefill_attention_mode_name(args.ds4_prefill_mode) + + " prefill requires a single local HIP target; use " + "--ds4-prefill exact for split, remote, or CUDA placement"; + } + + // ── --ds4-fused-decode / --ds4-expert-top-k × placement + if ((args.ds4_fused_decode || args.ds4_expert_top_k != 0) && + !monolithic_ds4) { + return "--ds4-fused-decode and --ds4-expert-top-k currently require " + "single-device HIP DeepSeek4"; + } + + return {}; +} + +namespace { + +// Emit " ignored: ..." when a requested option does not reach the +// backend for this architecture and placement. `supported_monolithic` lets +// the message distinguish "this architecture never supports it" from "this +// architecture supports it, but not when layer-split". +void warn_inert(std::vector & out, + bool requested, + bool supported_here, + bool supported_monolithic, + bool is_layer_split, + const std::string & arch, + const char * flag, + const char * feature) { + if (!requested || supported_here) return; + if (is_layer_split && supported_monolithic) { + out.push_back(std::string(flag) + " ignored: architecture '" + arch + + "' provides " + feature + + " only on single-device placement"); + } else { + out.push_back(std::string(flag) + " ignored: architecture '" + arch + + "' has no " + feature + " support"); + } +} + +} // namespace + +std::vector collect_feature_warnings( + const BackendArgs & args, + const BackendFeatureConfig & features, + const std::string & arch) +{ + std::vector out; + const bool split = args.device.is_layer_split(); + + // 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, + arch_supports_decode_draft(arch, split), + arch_supports_decode_draft(arch, false), + split, arch, "--draft", "speculative decode"); + + warn_inert(out, args.ddtree_mode, + arch_supports_ddtree(arch, split), + arch_supports_ddtree(arch, false), + split, arch, "--ddtree", "DDTree speculative decode"); + + warn_inert(out, args.verify_width != 0, + arch_supports_verify_width(arch, split), + arch_supports_verify_width(arch, false), + split, arch, "--verify-width", "chain-spec verify width"); + + warn_inert(out, args.fa_window != 0, + arch_supports_fa_window(arch, split), + arch_supports_fa_window(arch, false), + split, arch, "--fa-window", "flash-attention sliding window"); + + warn_inert(out, args.draft_swa_window != 0, + arch_supports_draft_swa(arch, split), + arch_supports_draft_swa(arch, false), + split, arch, "--draft-swa", "draft sliding-window attention"); + + // MoE-only server features. 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)) { + 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)) { + out.push_back("--adaptive-experts ignored: architecture '" + arch + + "' has no expert-count gating"); + } + + return out; +} + +} // namespace dflash::common diff --git a/server/src/common/feature_gate.h b/server/src/common/feature_gate.h new file mode 100644 index 000000000..13cbfe5af --- /dev/null +++ b/server/src/common/feature_gate.h @@ -0,0 +1,75 @@ +// Cross-feature compatibility gate. +// +// One place for admission rules that combine a requested feature with facts +// only known after model and placement resolution. Before this existed the +// same rules were spelled out at two or three layers, each seeing a different +// slice of the config, so no single site was authoritative. +// +// Syntax and value parsing stay at the parse site. Once raw arguments have +// been parsed, every launch-compatibility rule belongs here: cross-flag +// requirements, compiled-backend placement, layer-split topology, and +// architecture-specific feature support. +// +// This is policy; model_capabilities.h is the fact it reasons over. The two +// stay separate because they change for different reasons — the table changes +// when create_backend()'s dispatch changes, these rules change when a +// combination is judged inadmissible — and because the table is a leaf header +// that callers with no interest in BackendArgs can include cheaply. It is +// pulled in here rather than left to each caller because every rule below is +// phrased in its vocabulary; keep the include even though no declaration in +// this header names a capability type. +// +// Warnings are a separate return channel, not a separate layer. +// collect_feature_warnings() reports flags that are accepted but inert on the +// resolved architecture and placement — the request still runs, just without +// that feature, so it must not fail admission. Degradations that mutate config +// on the way through ("--draft-residency=request-scoped ignored", "--spark +// ignored") stay with the setting they rewrite; a pure function returning +// strings cannot express those. + +#pragma once + +#include "backend_args.h" +#include "model_capabilities.h" +#include "placement/placement_backend.h" + +#include +#include + +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`, +// `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. +std::string check_feature_compatibility( + const BackendArgs & args, + const BackendFeatureConfig & features, + const std::string & arch, + PlacementBackend target_backend, + PlacementBackend compiled_backend); + +// Flags that were accepted but do nothing on this architecture and placement. +// Each entry is a complete operator-facing sentence, without a log prefix. +// +// These never block a launch: the server runs exactly as it would have, so +// turning them into errors would reject configurations that work today. They +// exist because the alternative — a flag that is silently dropped in the +// factory's dispatch — is indistinguishable from a flag that took effect. +// +// Only meaningful once check_feature_compatibility() has passed; a rejected +// configuration has no useful warnings to report. +std::vector collect_feature_warnings( + const BackendArgs & args, + const BackendFeatureConfig & features, + const std::string & arch); + +} // namespace dflash::common diff --git a/server/src/common/gguf_inspect.cpp b/server/src/common/gguf_inspect.cpp index 4d8fd20ef..614b6d3f3 100644 --- a/server/src/common/gguf_inspect.cpp +++ b/server/src/common/gguf_inspect.cpp @@ -29,6 +29,12 @@ GgufModelInfo inspect_gguf_model_info(const char * path) { if (v) info.arch = v; } + int64_t name_id = gguf_find_key(gctx, "general.name"); + if (name_id >= 0) { + const char * v = gguf_get_val_str(gctx, name_id); + if (v) info.name = v; + } + // Read layer count: .block_count if (!info.arch.empty()) { std::string key = info.arch + ".block_count"; diff --git a/server/src/common/gguf_inspect.h b/server/src/common/gguf_inspect.h index 29ffb91a5..3685900c0 100644 --- a/server/src/common/gguf_inspect.h +++ b/server/src/common/gguf_inspect.h @@ -12,10 +12,11 @@ namespace dflash::common { struct GgufModelInfo { std::string arch; // e.g. "qwen35", "laguna", "qwen3", "gemma4" + std::string name; // optional general.name display string int n_layer = -1; }; -// Read architecture and layer count from a GGUF file. +// Read architecture, display name, and layer count from a GGUF file. // Returns info with arch="" and n_layer=-1 on failure. GgufModelInfo inspect_gguf_model_info(const char * path); diff --git a/server/src/common/model_capabilities.h b/server/src/common/model_capabilities.h new file mode 100644 index 000000000..2189fe312 --- /dev/null +++ b/server/src/common/model_capabilities.h @@ -0,0 +1,238 @@ +// Architecture-level feature capabilities. +// +// One table describing what each architecture supports, plus predicates that +// read it. This is not configuration: every row states what create_backend() +// actually plumbs through to that architecture's backend, so the table can +// only ever describe the dispatch chain — it cannot lead it. A new +// architecture needs a backend, a factory case, and a row here, in that order. +// Keeping it in the binary is deliberate; an external file would add a third +// artifact to keep in sync and turn a compile-time constant into a parse that +// can fail at startup. +// +// Read a row to see what a model can do; read a column to see which +// architectures honor a flag. The row order matches create_backend()'s +// dispatch chain so the two can be diffed by eye. +// +// Placement matters as much as architecture: laguna and gemma4 forward a +// draft model only when monolithic, so those entries are FeatureSupport +// rather than bool. +// +// Note on "qwen36": it is not a dispatchable architecture. model_card.cpp's +// family fallback has a branch for it, but there is no factory case, so a +// GGUF declaring general.architecture=qwen36 is rejected by +// arch_is_supported() before any model card is resolved. If that arch string +// ever ships it needs a backend, a factory case, and a row below. + +#pragma once + +#include +#include + +namespace dflash::common { + +// Whether an option reaches the backend, as a function of placement. +enum class FeatureSupport { + Never, // the architecture has no path for it at all + Monolithic, // single-device only; the layer-split adapter drops it + Both, // forwarded on both the monolithic and layer-split paths +}; + +struct ArchCapabilities { + const char * arch; + + // Placement-independent. + bool layer_split; // has a LayerSplitAdapter (--target-devices) + bool remote_draft; // draft over IPC (--draft-ipc-bin) + bool pflash_compression; // PFlash prefill compression (--prefill-compression) + bool expert_offload; // hot/cold MoE expert residency (--spark, --freq, + // --collect-routing, --adaptive-experts). Note + // deepseek4 is mixture-of-experts but has no such + // path, so this is narrower than "is MoE". + + // Placement-dependent. + FeatureSupport decode_draft; // --draft + FeatureSupport ddtree; // --ddtree, --ddtree-budget, --ddtree-temp + FeatureSupport verify_width; // --verify-width + FeatureSupport fa_window; // --fa-window + FeatureSupport draft_swa; // --draft-swa +}; + +inline constexpr FeatureSupport kNever = FeatureSupport::Never; +inline constexpr FeatureSupport kMono = FeatureSupport::Monolithic; +inline constexpr FeatureSupport kBoth = FeatureSupport::Both; + +inline constexpr ArchCapabilities kArchCapabilities[] = { +// arch split rdraft pflash offload draft ddtree vwidth fa_win dswa + {"qwen35", true, true, true, false, kBoth, kBoth, kNever, kBoth, kBoth}, + {"qwen35moe", false, false, false, true, kMono, kMono, kNever, kMono, kMono}, + {"laguna", true, false, false, true, kMono, kMono, kMono, kNever, kNever}, + {"qwen3", false, false, true, false, kNever, kNever, kNever, kNever, kNever}, + {"gemma4", true, false, false, false, kMono, kNever, kNever, kBoth, kNever}, + {"deepseek4", true, false, false, false, kNever, kNever, kNever, kNever, kNever}, +}; + +inline constexpr std::size_t kArchCount = + sizeof(kArchCapabilities) / sizeof(kArchCapabilities[0]); + +constexpr bool arch_name_equal(const char * a, const char * b) { + while (*a != '\0' && *a == *b) { ++a; ++b; } + return *a == *b; +} + +// constexpr so the table can be checked against the factory's config structs +// at compile time; see the static_asserts in backend_factory.cpp. +constexpr const ArchCapabilities * find_arch_capabilities(const char * arch) { + for (const ArchCapabilities & caps : kArchCapabilities) { + if (arch_name_equal(arch, caps.arch)) return ∩︀ + } + return nullptr; +} + +inline const ArchCapabilities * find_arch_capabilities(const std::string & arch) { + return find_arch_capabilities(arch.c_str()); +} + +// Row for an architecture known to be in the table. Calling this with an +// unlisted name is a compile error in a constant expression (null deref), +// which is what makes the cross-checks in backend_factory.cpp safe. +constexpr const ArchCapabilities & arch_capabilities(const char * arch) { + return *find_arch_capabilities(arch); +} + +namespace detail { + +constexpr bool row_has_both(const ArchCapabilities & c) { + return c.decode_draft == FeatureSupport::Both || + c.ddtree == FeatureSupport::Both || + c.verify_width == FeatureSupport::Both || + c.fa_window == FeatureSupport::Both || + c.draft_swa == FeatureSupport::Both; +} + +constexpr bool table_rows_named() { + for (const ArchCapabilities & c : kArchCapabilities) { + if (c.arch == nullptr || c.arch[0] == '\0') return false; + } + return true; +} + +constexpr bool table_rows_unique() { + for (std::size_t i = 0; i < kArchCount; ++i) { + for (std::size_t j = i + 1; j < kArchCount; ++j) { + if (arch_name_equal(kArchCapabilities[i].arch, + kArchCapabilities[j].arch)) { + return false; + } + } + } + return true; +} + +// "Both" means "and also on the layer-split path", which an architecture +// without a layer-split adapter does not have. Such a row must say +// Monolithic, so that the split half of every cross-check below is false. +constexpr bool table_split_coherent() { + for (const ArchCapabilities & c : kArchCapabilities) { + if (!c.layer_split && row_has_both(c)) return false; + } + return true; +} + +// Remote draft execution runs a draft model over IPC. An architecture that +// never accepts a draft model cannot run one remotely. +constexpr bool table_remote_draft_coherent() { + for (const ArchCapabilities & c : kArchCapabilities) { + if (c.remote_draft && c.decode_draft == FeatureSupport::Never) { + return false; + } + } + return true; +} + +} // namespace detail + +static_assert(detail::table_rows_named(), + "every capability row needs a non-empty architecture name"); +static_assert(detail::table_rows_unique(), + "duplicate architecture row in kArchCapabilities"); +static_assert(detail::table_split_coherent(), + "an architecture with no layer-split adapter cannot support an " + "option on 'Both' placements; use Monolithic"); +static_assert(detail::table_remote_draft_coherent(), + "remote draft execution requires an architecture that accepts a " + "draft model"); + +namespace detail { + +inline bool supported_at(FeatureSupport support, bool is_layer_split) { + switch (support) { + case FeatureSupport::Never: return false; + case FeatureSupport::Monolithic: return !is_layer_split; + case FeatureSupport::Both: return true; + } + return false; +} + +inline bool arch_has(const std::string & arch, + bool ArchCapabilities::* field) { + const ArchCapabilities * caps = find_arch_capabilities(arch); + return caps && caps->*field; +} + +inline bool arch_has(const std::string & arch, + FeatureSupport ArchCapabilities::* field, + bool is_layer_split) { + const ArchCapabilities * caps = find_arch_capabilities(arch); + return caps && supported_at(caps->*field, is_layer_split); +} + +} // namespace detail + +// An unknown architecture answers false to every predicate below, so no rule +// can admit a model the factory cannot build. +inline bool arch_is_supported(const std::string & arch) { + return find_arch_capabilities(arch) != nullptr; +} + +inline bool arch_supports_layer_split(const std::string & arch) { + return detail::arch_has(arch, &ArchCapabilities::layer_split); +} + +inline bool arch_supports_remote_draft(const std::string & arch) { + return detail::arch_has(arch, &ArchCapabilities::remote_draft); +} + +inline bool arch_supports_pflash_compression(const std::string & arch) { + return detail::arch_has(arch, &ArchCapabilities::pflash_compression); +} + +inline bool arch_has_expert_offload(const std::string & arch) { + return detail::arch_has(arch, &ArchCapabilities::expert_offload); +} + +inline bool arch_supports_decode_draft(const std::string & arch, + bool is_layer_split) { + return detail::arch_has(arch, &ArchCapabilities::decode_draft, is_layer_split); +} + +inline bool arch_supports_ddtree(const std::string & arch, + bool is_layer_split) { + return detail::arch_has(arch, &ArchCapabilities::ddtree, is_layer_split); +} + +inline bool arch_supports_verify_width(const std::string & arch, + bool is_layer_split) { + return detail::arch_has(arch, &ArchCapabilities::verify_width, is_layer_split); +} + +inline bool arch_supports_fa_window(const std::string & arch, + bool is_layer_split) { + return detail::arch_has(arch, &ArchCapabilities::fa_window, is_layer_split); +} + +inline bool arch_supports_draft_swa(const std::string & arch, + bool is_layer_split) { + return detail::arch_has(arch, &ArchCapabilities::draft_swa, is_layer_split); +} + +} // namespace dflash::common diff --git a/server/src/server/server_main.cpp b/server/src/server/server_main.cpp index bc5dfe33b..063b20f11 100644 --- a/server/src/server/server_main.cpp +++ b/server/src/server/server_main.cpp @@ -15,7 +15,6 @@ #include "chat_template.h" #include "model_card.h" #include "common/backend_factory.h" -#include "common/gguf_inspect.h" #include "common/layer_split_utils.h" #include "common/spark_corpus.h" #include "common/moe_routing_collector.h" @@ -24,8 +23,6 @@ #include "placement/pflash_placement.h" #include "placement/draft_residency.h" -#include "gguf.h" - #include #include #include @@ -70,127 +67,6 @@ static bool parse_double_list(const char * value, std::vector & out) { return !out.empty(); } -static bool validate_server_placement(const BackendArgs & bargs, - const ServerConfig & sconfig) { - const PlacementBackend compiled = compiled_placement_backend(); - if (!placement_backend_supported(bargs.device.backend)) { - std::fprintf(stderr, - "[server] --target-device=%s is unsupported in this binary " - "(compiled backend: %s)\n", - placement_device_name(bargs.device).c_str(), - placement_backend_name(compiled)); - return false; - } - const bool pflash_enabled = - sconfig.pflash_mode != ServerConfig::PflashMode::OFF; - const PFlashDrafterPlacement pflash_placement = - resolve_pflash_drafter_placement( - bargs.device, bargs.draft_device, bargs.remote_draft, - pflash_enabled); - const PlacementBackend target = pflash_placement.target_backend; - const PlacementBackend draft = pflash_placement.drafter_backend; - const bool draft_placement_used = - pflash_drafter_placement_used(pflash_enabled, bargs.draft_path != nullptr); - if (!bargs.remote_draft.enabled() && bargs.remote_draft.has_aux_options()) { - std::fprintf(stderr, - "[server] --draft-ipc-work-dir and --draft-ipc-ring-cap require " - "--draft-ipc-bin\n"); - return false; - } - if (!bargs.remote_target_shard.enabled() && - bargs.remote_target_shard.has_aux_options()) { - std::fprintf(stderr, - "[server] --target-shard-ipc-work-dir requires --target-shard-ipc-bin\n"); - return false; - } - if (draft_placement_used && target != draft) { - if (!bargs.remote_draft.enabled()) { - std::fprintf(stderr, - "[server] mixed target/draft backends require --draft-ipc-bin " - "(target=%s draft=%s)\n", - placement_backend_name(target), placement_backend_name(draft)); - return false; - } - if (!bargs.draft_path && !pflash_enabled) { - std::fprintf(stderr, - "[server] mixed target/draft backends require --draft " - "or --prefill-compression with --prefill-drafter\n"); - return false; - } - } else if (bargs.remote_draft.enabled()) { - std::fprintf(stderr, - "[server] --draft-ipc-bin is only needed for mixed target/draft " - "backends (target=%s draft=%s)\n", - placement_backend_name(target), placement_backend_name(draft)); - return false; - } else if (draft_placement_used && - !placement_backend_supported(bargs.draft_device.backend)) { - std::fprintf(stderr, - "[server] --draft-device=%s is unsupported in this binary " - "(compiled backend: %s)\n", - placement_device_name(bargs.draft_device).c_str(), - placement_backend_name(compiled)); - return false; - } - if (!bargs.device.is_layer_split() && !bargs.device.layer_split_weights.empty()) { - std::fprintf(stderr, - "[server] --target-layer-split requires --target-devices\n"); - return false; - } - if (bargs.device.is_layer_split()) { - const std::string placement_error = - validate_device_placement(bargs.device, /*device_count=*/-1); - if (!placement_error.empty()) { - std::fprintf(stderr, "[server] bad target layer split: %s\n", - placement_error.c_str()); - return false; - } - } - const bool mixed_target_split = - bargs.device.is_layer_split() && bargs.device.is_mixed_layer_split(); - if (mixed_target_split) { - if (!bargs.remote_target_shard.enabled()) { - std::fprintf(stderr, - "[server] mixed-backend target layer split requires " - "--target-shard-ipc-bin\n"); - return false; - } - size_t remote_begin = 0; - while (remote_begin < bargs.device.layer_split_gpus.size() && - bargs.device.layer_split_backend(remote_begin) == compiled) { - ++remote_begin; - } - if (remote_begin == 0 || remote_begin >= bargs.device.layer_split_gpus.size()) { - std::fprintf(stderr, - "[server] mixed-backend target layer split currently supports " - "one local backend group followed by one remote backend group\n"); - return false; - } - const PlacementBackend remote_backend = - bargs.device.layer_split_backend(remote_begin); - bool one_boundary = true; - for (size_t i = remote_begin; i < bargs.device.layer_split_gpus.size(); ++i) { - if (bargs.device.layer_split_backend(i) != remote_backend) { - one_boundary = false; - break; - } - } - if (!one_boundary) { - std::fprintf(stderr, - "[server] mixed-backend target layer split currently supports " - "only one backend boundary\n"); - return false; - } - } else if (bargs.device.is_layer_split() && target != compiled) { - std::fprintf(stderr, - "[server] target layer split must use this binary's compiled " - "backend (target=%s compiled=%s)\n", - placement_backend_name(target), placement_backend_name(compiled)); - return false; - } - return true; -} - static void print_usage(const char * prog) { std::fprintf(stderr, "Usage: %s [options]\n" @@ -338,6 +214,7 @@ int main(int argc, char ** argv) { bool target_device_seen = false; bool target_devices_seen = false; bool fast_rollback_forced_off = false; + bool adaptive_experts_set = false; // --adaptive-experts (MoE architectures only) // Track which thinking-budget tunables the operator set via CLI. // Those values win over the model card (spec §3.1: "Explicit CLI @@ -480,6 +357,7 @@ int main(int argc, char ** argv) { return 1; } setenv("DFLASH_ADAPTIVE_K_TAU", tau, 0); // explicit env still wins + adaptive_experts_set = 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) { @@ -672,23 +550,44 @@ int main(int argc, char ** argv) { } if (fast_rollback_forced_off) bargs.fast_rollback = false; - if (!validate_server_placement(bargs, sconfig)) return 2; - - if (bargs.remote_draft.enabled() && bargs.draft_path) { - const std::string arch = detect_arch(bargs.model_path); - if (arch.empty()) { - std::fprintf(stderr, - "[server] failed to detect model architecture for remote draft validation\n"); - return 1; - } - if (!arch_supports_remote_draft(arch)) { - std::fprintf(stderr, - "[server] model architecture '%s' does not support remote draft execution\n", - arch.c_str()); - return 2; + // Resolve documented environment defaults before factory preparation so + // compatibility warnings describe the effective backend configuration. + // An explicit --draft-swa value continues to take precedence. + if (bargs.draft_swa_window == 0) { + if (const char * e = std::getenv("DFLASH27B_DRAFT_SWA")) { + bargs.draft_swa_window = std::atoi(e); } } + // 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 = + sconfig.pflash_mode != ServerConfig::PflashMode::OFF; + backend_features.pflash_drafter_configured = + !sconfig.pflash_drafter_path.empty(); + backend_features.routing_stats_requested = + sconfig.freq_tracking || !sconfig.collect_routing_path.empty(); + backend_features.adaptive_experts_requested = adaptive_experts_set; + const BackendPreparation backend_preparation = + prepare_backend(bargs, backend_features); + if (!backend_preparation.ok()) { + std::fprintf(stderr, "[server] %s\n", + backend_preparation.message.c_str()); + return backend_preparation.error == + BackendPreparationError::FeatureCompatibility + ? 2 + : 1; + } + // 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) { + std::fprintf(stderr, "[server] warning: %s\n", warning.c_str()); + } + const ResolvedBackendPlan & backend_plan = backend_preparation.plan; + const std::string & arch = backend_plan.arch(); + // Sync max_ctx: if --max-ctx was not provided, use the backend's default. // This prevents the HTTP server from accepting prompts larger than the // KV cache the backend actually allocates. @@ -762,30 +661,12 @@ int main(int argc, char ** argv) { // Load pflash drafter tokenizer (if pflash enabled). Tokenizer drafter_tokenizer; if (pflash_enabled) { - if (sconfig.pflash_drafter_path.empty()) { - std::fprintf(stderr, "[server] --prefill-compression requires --prefill-drafter\n"); - return 1; - } std::fprintf(stderr, "[server] loading pflash drafter tokenizer from %s\n", sconfig.pflash_drafter_path.c_str()); if (!drafter_tokenizer.load_from_gguf(sconfig.pflash_drafter_path.c_str())) { std::fprintf(stderr, "[server] drafter tokenizer load failed\n"); return 1; } - if (sconfig.pflash_remote_drafter) { - if (!bargs.remote_draft.enabled()) { - std::fprintf(stderr, - "[server] mixed-backend PFlash requires --draft-ipc-bin\n"); - return 2; - } - const std::string arch = detect_arch(bargs.model_path); - if (!arch_supports_pflash_compression(arch)) { - std::fprintf(stderr, - "[server] model architecture '%s' does not support PFlash compression\n", - arch.c_str()); - return 2; - } - } std::fprintf(stderr, "[server] pflash: mode=%s threshold=%d keep=%.3f drafter_gpu=%d skip_park=%d\n", sconfig.pflash_mode == ServerConfig::PflashMode::AUTO ? "auto" : "always", sconfig.pflash_threshold, sconfig.pflash_keep_ratio, @@ -804,48 +685,17 @@ int main(int argc, char ** argv) { } } - // Honor DFLASH27B_DRAFT_SWA env (documented in server/README.md) when --draft-swa is absent. - if (bargs.draft_swa_window == 0) { - if (const char * e = std::getenv("DFLASH27B_DRAFT_SWA")) { - bargs.draft_swa_window = std::atoi(e); - } - } - // Create backend. g_peer_access_opt_in = bargs.device.peer_access; std::fprintf(stderr, "[server] creating backend...\n"); - const std::string arch = detect_arch(bargs.model_path); - if (bargs.ds4_prefill_mode_set && arch != "deepseek4") { - std::fprintf(stderr, - "[server] --ds4-prefill is only valid for deepseek4 models " - "(detected '%s')\n", arch.c_str()); - return 2; - } - const PlacementBackend target_backend = - bargs.device.backend == PlacementBackend::Auto - ? compiled_placement_backend() - : bargs.device.backend; - const bool monolithic_ds4 = - arch == "deepseek4" && - target_backend == PlacementBackend::Hip && - !bargs.device.is_layer_split() && - !bargs.remote_target_shard.enabled(); - if ((bargs.ds4_fused_decode || bargs.ds4_expert_top_k != 0) && - !monolithic_ds4) { - std::fprintf(stderr, - "[server] --ds4-fused-decode and --ds4-expert-top-k " - "currently require single-device HIP DeepSeek4\n"); - return 2; - } if (spark_autotune) { // Self-tuning hot/cold MoE residency: enable the bounded expert cache // (auto-tunes the working set at serve time), auto-load a learned // placement profile next to the model if present, and keep persisting it // from live traffic. One command; improves across restarts. Both laguna // and qwen35moe. - const bool is_laguna = (arch == "laguna"); - const bool is_qwenmoe = (arch == "qwen35moe"); - if (is_laguna || is_qwenmoe) { + const bool is_laguna = (arch == "laguna"); + if (arch_has_expert_offload(arch)) { const std::string pfx = is_laguna ? "DFLASH_LAGUNA_" : "DFLASH_QWEN35MOE_"; const std::string profile = std::string(bargs.model_path) + ".spark.csv"; std::FILE * pf = std::fopen(profile.c_str(), "rb"); @@ -880,48 +730,32 @@ int main(int argc, char ** argv) { arch.c_str()); } } - auto backend = create_backend(bargs); + auto backend = create_backend(bargs, backend_plan); if (!backend) { std::fprintf(stderr, "[server] backend creation failed\n"); return 1; } + // 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 + // here beats routing draft work to a backend that cannot serve it. if (bargs.remote_draft.enabled() && bargs.draft_path && !backend->supports_remote_draft()) { std::fprintf(stderr, - "[server] detected model backend does not support remote draft execution\n"); + "[server] internal: architecture '%s' is listed as supporting " + "remote draft execution but the constructed backend does not\n", + arch.c_str()); backend->shutdown(); return 2; } - // ── Thinking-budget v2: resolve model card and apply to ServerConfig ── - // Read general.name + general.architecture directly from the GGUF. - // This is best-effort; if the file can't be opened (corruption, removed - // after backend init) we fall through to hard-fallback defaults via - // resolve_model_card(...). - std::string general_name; - std::string general_arch = arch; // fall back to detect_arch() result - { - gguf_init_params gip{}; - gip.no_alloc = true; - gip.ctx = nullptr; - gguf_context * gctx = gguf_init_from_file(bargs.model_path, gip); - if (gctx) { - int64_t name_id = gguf_find_key(gctx, "general.name"); - if (name_id >= 0) { - const char * v = gguf_get_val_str(gctx, name_id); - if (v) general_name = v; - } - int64_t arch_id = gguf_find_key(gctx, "general.architecture"); - if (arch_id >= 0) { - const char * v = gguf_get_val_str(gctx, arch_id); - if (v) general_arch = v; - } - gguf_free(gctx); - } - std::fprintf(stderr, - "[server] gguf meta: general.name='%s' general.architecture='%s'\n", - general_name.c_str(), general_arch.c_str()); - } + // 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(); + 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 : "", diff --git a/server/test/test_feature_gate.cpp b/server/test/test_feature_gate.cpp new file mode 100644 index 000000000..b75eee35e --- /dev/null +++ b/server/test/test_feature_gate.cpp @@ -0,0 +1,435 @@ +// Unit tests for the backend feature/architecture gate. +// +// 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. +// +// Build: cmake --build . --target test_feature_gate +// Run: ./test_feature_gate + +#include "common/feature_gate.h" +#include "common/model_capabilities.h" +#include "placement/placement_config.h" + +#include +#include +#include + +using namespace dflash::common; + +static int test_failures = 0; +static int test_count = 0; + +#define TEST_ASSERT(expr) do { \ + test_count++; \ + if (!(expr)) { \ + test_failures++; \ + std::fprintf(stderr, " FAIL: %s:%d: %s\n", __FILE__, __LINE__, #expr); \ + } \ +} while (0) + +#define RUN_TEST(fn) do { \ + std::fprintf(stderr, " %s ...", #fn); \ + int before = test_failures; \ + fn(); \ + if (test_failures == before) std::fprintf(stderr, " ok\n"); \ + else std::fprintf(stderr, "\n"); \ +} while (0) + +// ── Backend compatibility gate ────────────────────────────────────────── +// One case per rule cluster in check_feature_compatibility(). All resolved +// facts are parameters, so none of this needs a model file or GPU. + +static BackendArgs gate_args_hip_deepseek4() { + BackendArgs args; + args.model_path = "/nonexistent/model.gguf"; + args.device.backend = PlacementBackend::Hip; + args.device.gpu = 0; + return args; +} + +static std::string gate_result( + const BackendArgs & args, + const std::string & arch, + PlacementBackend backend, + const BackendFeatureConfig & features = {}) { + return check_feature_compatibility( + args, features, arch, backend, backend); +} + +static std::string gate_result_for_binary( + const BackendArgs & args, + const std::string & arch, + PlacementBackend target_backend, + PlacementBackend compiled_backend, + const BackendFeatureConfig & features = {}) { + return check_feature_compatibility( + args, features, arch, target_backend, compiled_backend); +} + +static void test_feature_gate_accepts_plain_launch() { + BackendArgs args; + args.model_path = "/nonexistent/model.gguf"; + TEST_ASSERT(gate_result( + args, "qwen35", PlacementBackend::Cuda).empty()); +} + +static void test_feature_gate_rejects_undetected_arch() { + BackendArgs args; + args.model_path = "/nonexistent/model.gguf"; + TEST_ASSERT(!gate_result( + args, "", PlacementBackend::Cuda).empty()); +} + +static void test_feature_gate_requires_compiled_target_backend() { + BackendArgs args; + args.model_path = "/nonexistent/model.gguf"; + args.device.backend = PlacementBackend::Hip; + TEST_ASSERT(!gate_result_for_binary( + args, "qwen35", PlacementBackend::Hip, + PlacementBackend::Cuda).empty()); +} + +static void test_feature_gate_ipc_options_require_ipc_binary() { + BackendArgs draft; + draft.model_path = "/nonexistent/model.gguf"; + draft.remote_draft.work_dir = "/tmp/draft"; + TEST_ASSERT(!gate_result( + draft, "qwen35", PlacementBackend::Cuda).empty()); + + BackendArgs target; + target.model_path = "/nonexistent/model.gguf"; + target.remote_target_shard.work_dir = "/tmp/target"; + TEST_ASSERT(!gate_result( + target, "qwen35", PlacementBackend::Cuda).empty()); +} + +static void test_feature_gate_mixed_draft_placement_requires_ipc() { + BackendArgs args; + args.model_path = "/nonexistent/model.gguf"; + args.draft_path = "/nonexistent/draft.gguf"; + args.device.backend = PlacementBackend::Cuda; + args.draft_device.backend = PlacementBackend::Hip; + + TEST_ASSERT(!gate_result( + args, "qwen35", PlacementBackend::Cuda).empty()); + + args.remote_draft.ipc_bin = "/usr/bin/draft-ipc"; + TEST_ASSERT(gate_result( + args, "qwen35", PlacementBackend::Cuda).empty()); + + args.draft_device.backend = PlacementBackend::Cuda; + TEST_ASSERT(!gate_result( + args, "qwen35", PlacementBackend::Cuda).empty()); +} + +static void test_feature_gate_pflash_requires_drafter_and_supported_arch() { + BackendArgs args; + args.model_path = "/nonexistent/model.gguf"; + + BackendFeatureConfig features; + features.pflash_enabled = true; + TEST_ASSERT(!gate_result( + args, "qwen35", PlacementBackend::Cuda, features).empty()); + + features.pflash_drafter_configured = true; + TEST_ASSERT(gate_result( + args, "gemma4", PlacementBackend::Cuda, features).empty()); + + args.device.backend = PlacementBackend::Cuda; + args.draft_device.backend = PlacementBackend::Hip; + args.remote_draft.ipc_bin = "/usr/bin/draft-ipc"; + TEST_ASSERT(!gate_result( + args, "gemma4", PlacementBackend::Cuda, features).empty()); + TEST_ASSERT(gate_result( + args, "qwen35", PlacementBackend::Cuda, features).empty()); +} + +static void test_feature_gate_validates_target_split_topology() { + BackendArgs weights; + weights.model_path = "/nonexistent/model.gguf"; + weights.device.layer_split_weights = {1.0, 1.0}; + TEST_ASSERT(!gate_result( + weights, "qwen35", PlacementBackend::Cuda).empty()); + + BackendArgs mixed; + mixed.model_path = "/nonexistent/model.gguf"; + TEST_ASSERT(parse_placement_device_list( + "cuda:0,hip:0", mixed.device)); + TEST_ASSERT(!gate_result( + mixed, "qwen35", PlacementBackend::Cuda).empty()); + + mixed.remote_target_shard.ipc_bin = "/usr/bin/target-shard"; + TEST_ASSERT(gate_result( + mixed, "qwen35", PlacementBackend::Cuda).empty()); + + BackendArgs two_boundaries; + two_boundaries.model_path = "/nonexistent/model.gguf"; + TEST_ASSERT(parse_placement_device_list( + "cuda:0,hip:0,cuda:1", two_boundaries.device)); + two_boundaries.remote_target_shard.ipc_bin = + "/usr/bin/target-shard"; + TEST_ASSERT(!gate_result( + two_boundaries, "qwen35", PlacementBackend::Cuda).empty()); +} + +static void test_feature_gate_ds4_prefill_requires_deepseek4() { + BackendArgs args = gate_args_hip_deepseek4(); + args.ds4_prefill_mode_set = true; + args.ds4_prefill_mode = PrefillAttentionMode::Dense; + + TEST_ASSERT(!gate_result( + args, "qwen35", PlacementBackend::Hip).empty()); + TEST_ASSERT(gate_result( + args, "deepseek4", PlacementBackend::Hip).empty()); +} + +static void test_feature_gate_approximate_ds4_prefill_requires_local_hip() { + BackendArgs args = gate_args_hip_deepseek4(); + args.ds4_prefill_mode_set = true; + args.ds4_prefill_mode = PrefillAttentionMode::Sparse; + + // CUDA has no approximate prefill path. + TEST_ASSERT(!gate_result( + args, "deepseek4", PlacementBackend::Cuda).empty()); + + // Neither does the layer-split adapter, even on HIP. + BackendArgs split = args; + TEST_ASSERT(parse_placement_device_list("hip:0,hip:1", split.device)); + TEST_ASSERT(!gate_result( + split, "deepseek4", PlacementBackend::Hip).empty()); + + // Nor a remote target shard. + BackendArgs remote = args; + remote.remote_target_shard.ipc_bin = "/usr/bin/shard"; + TEST_ASSERT(!gate_result( + remote, "deepseek4", PlacementBackend::Hip).empty()); + + // Single local HIP device is the supported placement. + TEST_ASSERT(gate_result( + args, "deepseek4", PlacementBackend::Hip).empty()); + + // Exact prefill is unrestricted. + BackendArgs exact = gate_args_hip_deepseek4(); + exact.ds4_prefill_mode_set = true; + exact.ds4_prefill_mode = PrefillAttentionMode::Exact; + TEST_ASSERT(gate_result( + exact, "deepseek4", PlacementBackend::Cuda).empty()); +} + +static void test_feature_gate_ds4_decode_options_require_monolithic_hip() { + BackendArgs fused = gate_args_hip_deepseek4(); + fused.ds4_fused_decode = true; + TEST_ASSERT(!gate_result( + fused, "deepseek4", PlacementBackend::Cuda).empty()); + TEST_ASSERT(gate_result( + fused, "deepseek4", PlacementBackend::Hip).empty()); + + BackendArgs topk = gate_args_hip_deepseek4(); + topk.ds4_expert_top_k = 4; + TEST_ASSERT(!gate_result( + topk, "qwen35", PlacementBackend::Hip).empty()); + TEST_ASSERT(gate_result( + topk, "deepseek4", PlacementBackend::Hip).empty()); +} + +static void test_feature_gate_remote_draft_requires_supported_arch() { + BackendArgs args; + args.model_path = "/nonexistent/model.gguf"; + args.draft_path = "/nonexistent/draft.gguf"; + args.device.backend = PlacementBackend::Cuda; + args.draft_device.backend = PlacementBackend::Hip; + args.remote_draft.ipc_bin = "/usr/bin/draft-ipc"; + + TEST_ASSERT(!gate_result( + args, "gemma4", PlacementBackend::Cuda).empty()); + TEST_ASSERT(gate_result( + args, "qwen35", PlacementBackend::Cuda).empty()); + + // Without a draft model or PFlash, remote draft IPC is unnecessary. + BackendArgs no_draft = args; + no_draft.draft_path = nullptr; + TEST_ASSERT(!gate_result( + no_draft, "gemma4", PlacementBackend::Cuda).empty()); +} + +static void test_feature_gate_layer_split_requires_supported_arch() { + BackendArgs args; + args.model_path = "/nonexistent/model.gguf"; + TEST_ASSERT(parse_placement_device_list("cuda:0,cuda:1", args.device)); + + // These four have a layer-split adapter. + for (const char * arch : {"qwen35", "laguna", "gemma4", "deepseek4"}) { + TEST_ASSERT(gate_result(args, arch, PlacementBackend::Cuda).empty()); + } + // These two do not: the factory would hand the split placement to a + // monolithic backend, which reads only the primary GPU. + for (const char * arch : {"qwen35moe", "qwen3"}) { + TEST_ASSERT(!gate_result(args, arch, PlacementBackend::Cuda).empty()); + } + + // Single-device placement is unaffected for the same architectures. + BackendArgs single; + single.model_path = "/nonexistent/model.gguf"; + TEST_ASSERT(gate_result(single, "qwen35moe", PlacementBackend::Cuda).empty()); + TEST_ASSERT(gate_result(single, "qwen3", PlacementBackend::Cuda).empty()); +} + +// ── Inert-flag warnings ───────────────────────────────────────────────── +// Warnings must never gate admission, so each case also asserts the same +// configuration passes check_feature_compatibility(). + +static std::vector warn_result( + const BackendArgs & args, + const std::string & arch, + const BackendFeatureConfig & features = {}) { + TEST_ASSERT(check_feature_compatibility( + args, features, arch, compiled_placement_backend(), + compiled_placement_backend()).empty()); + return collect_feature_warnings(args, features, arch); +} + +static bool warns_about(const std::vector & warnings, + const std::string & flag) { + for (const std::string & w : warnings) { + if (w.rfind(flag + " ignored:", 0) == 0) return true; + } + return false; +} + +static void test_feature_warnings_silent_when_supported() { + BackendArgs args; + args.model_path = "/nonexistent/model.gguf"; + args.draft_path = "/nonexistent/draft.gguf"; + args.ddtree_mode = true; + args.fa_window = 512; + args.draft_swa_window = 2048; + // qwen35 forwards every one of these. + TEST_ASSERT(warn_result(args, "qwen35").empty()); +} + +static void test_feature_warnings_report_inert_draft() { + BackendArgs args; + args.model_path = "/nonexistent/model.gguf"; + args.draft_path = "/nonexistent/draft.gguf"; + + // qwen3 and deepseek4 never forward a draft model. + TEST_ASSERT(warns_about(warn_result(args, "qwen3"), "--draft")); + TEST_ASSERT(warns_about(warn_result(args, "deepseek4"), "--draft")); + // laguna and gemma4 forward it only when monolithic. + TEST_ASSERT(!warns_about(warn_result(args, "laguna"), "--draft")); + TEST_ASSERT(!warns_about(warn_result(args, "gemma4"), "--draft")); + + BackendArgs split = args; + TEST_ASSERT(parse_placement_device_list("cuda:0,cuda:1", split.device)); + const std::vector w = collect_feature_warnings(split, {}, "laguna"); + TEST_ASSERT(warns_about(w, "--draft")); + TEST_ASSERT(w[0].find("single-device placement") != std::string::npos); +} + +static void test_feature_warnings_report_inert_decode_tunables() { + BackendArgs ddtree; + ddtree.model_path = "/nonexistent/model.gguf"; + ddtree.ddtree_mode = true; + TEST_ASSERT(warns_about(warn_result(ddtree, "gemma4"), "--ddtree")); + TEST_ASSERT(!warns_about(warn_result(ddtree, "laguna"), "--ddtree")); + + BackendArgs vw; + vw.model_path = "/nonexistent/model.gguf"; + vw.verify_width = 8; + TEST_ASSERT(!warns_about(warn_result(vw, "laguna"), "--verify-width")); + TEST_ASSERT(warns_about(warn_result(vw, "qwen35"), "--verify-width")); + + BackendArgs fa; + fa.model_path = "/nonexistent/model.gguf"; + fa.fa_window = 4096; + // gemma4 honors --fa-window on both paths; laguna has no such option. + TEST_ASSERT(!warns_about(warn_result(fa, "gemma4"), "--fa-window")); + TEST_ASSERT(warns_about(warn_result(fa, "laguna"), "--fa-window")); + + BackendArgs swa; + swa.model_path = "/nonexistent/model.gguf"; + swa.draft_swa_window = 2048; + TEST_ASSERT(!warns_about(warn_result(swa, "qwen35moe"), "--draft-swa")); + TEST_ASSERT(warns_about(warn_result(swa, "gemma4"), "--draft-swa")); +} + +static 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; + + TEST_ASSERT(warn_result(args, "laguna", moe_opts).empty()); + TEST_ASSERT(warn_result(args, "qwen35moe", moe_opts).empty()); + TEST_ASSERT(warn_result(args, "qwen35", moe_opts).size() == 2); + TEST_ASSERT(warn_result(args, "deepseek4", moe_opts).size() == 2); +} + +static void test_model_capability_tables() { + // Table integrity: one row per architecture, no blanks, no duplicates. + for (const ArchCapabilities & row : kArchCapabilities) { + TEST_ASSERT(row.arch != nullptr && row.arch[0] != '\0'); + TEST_ASSERT(find_arch_capabilities(row.arch) == &row); + } + + // arch_is_supported() must match create_backend()'s dispatch chain. + for (const char * arch : {"qwen35", "qwen35moe", "laguna", + "qwen3", "gemma4", "deepseek4"}) { + TEST_ASSERT(arch_is_supported(arch)); + } + TEST_ASSERT(!arch_is_supported("")); + TEST_ASSERT(!arch_is_supported("qwen36")); // model_card has a branch; the factory does not + TEST_ASSERT(!arch_is_supported("llama")); + + TEST_ASSERT(arch_has_expert_offload("laguna")); + TEST_ASSERT(arch_has_expert_offload("qwen35moe")); + TEST_ASSERT(!arch_has_expert_offload("qwen35")); + // deepseek4 is mixture-of-experts but has no hot/cold offload path. + TEST_ASSERT(!arch_has_expert_offload("deepseek4")); + + // Every capability predicate must be false for an architecture the + // factory cannot build, so no rule can admit an unbuildable model. + TEST_ASSERT(!arch_supports_layer_split("qwen36")); + TEST_ASSERT(!arch_supports_remote_draft("qwen36")); + TEST_ASSERT(!arch_supports_pflash_compression("qwen36")); + TEST_ASSERT(!arch_supports_decode_draft("qwen36", false)); + TEST_ASSERT(!arch_supports_ddtree("qwen36", false)); + TEST_ASSERT(!arch_supports_verify_width("qwen36", false)); + TEST_ASSERT(!arch_supports_fa_window("qwen36", false)); + TEST_ASSERT(!arch_supports_draft_swa("qwen36", false)); +} + +int main() { + std::fprintf(stderr, "\n\u2500\u2500 Backend feature/architecture gate \u2500\u2500\n"); + RUN_TEST(test_feature_gate_accepts_plain_launch); + RUN_TEST(test_feature_gate_rejects_undetected_arch); + RUN_TEST(test_feature_gate_requires_compiled_target_backend); + RUN_TEST(test_feature_gate_ipc_options_require_ipc_binary); + RUN_TEST(test_feature_gate_mixed_draft_placement_requires_ipc); + RUN_TEST(test_feature_gate_pflash_requires_drafter_and_supported_arch); + RUN_TEST(test_feature_gate_validates_target_split_topology); + RUN_TEST(test_feature_gate_ds4_prefill_requires_deepseek4); + RUN_TEST(test_feature_gate_approximate_ds4_prefill_requires_local_hip); + RUN_TEST(test_feature_gate_ds4_decode_options_require_monolithic_hip); + RUN_TEST(test_feature_gate_remote_draft_requires_supported_arch); + RUN_TEST(test_feature_gate_layer_split_requires_supported_arch); + RUN_TEST(test_feature_warnings_silent_when_supported); + RUN_TEST(test_feature_warnings_report_inert_draft); + RUN_TEST(test_feature_warnings_report_inert_decode_tunables); + RUN_TEST(test_feature_warnings_report_inert_moe_options); + RUN_TEST(test_model_capability_tables); + + std::fprintf(stderr, + "\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n" + " Results: %d assertions, %d failures\n" + "\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n", + test_count, test_failures); + if (test_failures == 0) std::fprintf(stderr, "ALL PASSED\n"); + return test_failures == 0 ? 0 : 1; +}