-
Notifications
You must be signed in to change notification settings - Fork 256
feat(vision): native mmproj multimodal chat for Qwen35 #571
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
1f635e0
d451dc2
071fbaf
e30d26d
32abfd1
abbe46a
27b9a05
82d9a7d
317a1d7
1223936
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -203,6 +203,23 @@ endif() | |
| # Use only the ggml subtree of llama.cpp (skip libllama). | ||
| add_subdirectory(deps/llama.cpp/ggml EXCLUDE_FROM_ALL) | ||
|
|
||
| # Optional native mmproj vision (mtmd + llama for tokenization/vocab). | ||
| option(DFLASH27B_MMPROJ "Build native mmproj vision support via mtmd" OFF) | ||
| if(DFLASH27B_MMPROJ) | ||
| set(LLAMA_BUILD_COMMON OFF CACHE BOOL "" FORCE) | ||
| set(LLAMA_BUILD_TESTS OFF CACHE BOOL "" FORCE) | ||
| set(LLAMA_BUILD_TOOLS OFF CACHE BOOL "" FORCE) | ||
| set(LLAMA_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE) | ||
| set(LLAMA_BUILD_SERVER OFF CACHE BOOL "" FORCE) | ||
| add_subdirectory(deps/llama.cpp EXCLUDE_FROM_ALL) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: When Prompt for AI agents
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: The advertised mmproj build cannot configure because the vendored Prompt for AI agents |
||
| # mtmd is added as a sibling of llama.cpp (not via tools/) so | ||
| # LLAMA_INSTALL_VERSION must be visible in the parent scope. | ||
| if(NOT DEFINED LLAMA_INSTALL_VERSION) | ||
| set(LLAMA_INSTALL_VERSION "0.0.0") | ||
| endif() | ||
| add_subdirectory(deps/llama.cpp/tools/mtmd EXCLUDE_FROM_ALL) | ||
| endif() | ||
|
|
||
| if(DFLASH27B_GPU_BACKEND STREQUAL "hip") | ||
| # The vendored ggml HIP shim still uses a few CUDA spellings that are not | ||
| # mapped in this upstream snapshot. Keep the compatibility layer in this | ||
|
|
@@ -238,6 +255,7 @@ set(DFLASH27B_SRC_INCLUDE_DIRS | |
| ${CMAKE_CURRENT_SOURCE_DIR}/src/gemma4 | ||
| ${CMAKE_CURRENT_SOURCE_DIR}/src/deepseek4 | ||
| ${CMAKE_CURRENT_SOURCE_DIR}/src/server | ||
| ${CMAKE_CURRENT_SOURCE_DIR}/src/vision | ||
| ) | ||
|
|
||
| add_library(dflash_common STATIC | ||
|
|
@@ -319,6 +337,7 @@ add_library(dflash_common STATIC | |
| src/qwen35/qwen35_backend.cpp | ||
| src/qwen35/qwen35_tensor_parallel.cpp | ||
| src/qwen35/qwen35_layer_split_adapter.cpp | ||
| src/qwen35/qwen35_layer_split_vision.cpp | ||
| src/qwen35/qwen35_dflash_target.cpp | ||
| src/qwen35/qwen35_layer_split_dflash_target.cpp | ||
| src/qwen35/layer_split_daemon_loop.cpp | ||
|
|
@@ -344,6 +363,7 @@ add_library(dflash_common STATIC | |
| src/server/prefix_cache.cpp | ||
| src/server/disk_prefix_cache.cpp | ||
| src/server/freeze_history.cpp | ||
| src/vision/vision_input.cpp | ||
| # ── Jinja chat-template engine (vendored under deps/llama.cpp/common/) ── | ||
| # Used by render_chat_template_jinja() to support --chat-template-file | ||
| # in dflash_server. Mirrors llama.cpp's common_chat_template plumbing. | ||
|
|
@@ -357,6 +377,9 @@ add_library(dflash_common STATIC | |
| deps/llama.cpp/common/jinja/caps.cpp | ||
| deps/llama.cpp/common/unicode.cpp | ||
| ) | ||
| if(DFLASH27B_MMPROJ) | ||
| target_sources(dflash_common PRIVATE src/vision/vision_encoder.cpp) | ||
| endif() | ||
| # BSA (Block-Sparse Attention) backs the speculative-prefill drafter scoring | ||
| # path. Default ON so prefill is fast out of the box. Turn OFF if you don't | ||
| # run the spec-prefill stack or are building without CUDA BF16 WMMA support | ||
|
|
@@ -579,6 +602,16 @@ if(DFLASH27B_ENABLE_BSA) | |
| endif() | ||
| endif() | ||
|
|
||
| if(DFLASH27B_MMPROJ) | ||
| target_compile_definitions(dflash_common PRIVATE DFLASH_HAVE_MMPROJ=1) | ||
| set(DFLASH27B_MMPROJ_INCLUDE_DIRS | ||
| ${CMAKE_CURRENT_SOURCE_DIR}/deps/llama.cpp/include | ||
| ${CMAKE_CURRENT_SOURCE_DIR}/deps/llama.cpp/tools/mtmd | ||
| ${CMAKE_CURRENT_SOURCE_DIR}/deps/llama.cpp/vendor) | ||
| target_include_directories(dflash_common PRIVATE ${DFLASH27B_MMPROJ_INCLUDE_DIRS}) | ||
| target_link_libraries(dflash_common PRIVATE llama mtmd) | ||
| endif() | ||
|
|
||
| target_include_directories(dflash_common | ||
| PUBLIC | ||
| ${CMAKE_CURRENT_SOURCE_DIR}/include | ||
|
|
@@ -1133,6 +1166,10 @@ if(DFLASH27B_TESTS) | |
| else() | ||
| target_link_libraries(test_server_unit PRIVATE hip::host) | ||
| endif() | ||
| if(DFLASH27B_MMPROJ) | ||
| target_compile_definitions(test_server_unit PRIVATE DFLASH_HAVE_MMPROJ=1) | ||
| target_include_directories(test_server_unit PRIVATE ${DFLASH27B_MMPROJ_INCLUDE_DIRS}) | ||
| endif() | ||
| if(CMAKE_CROSSCOMPILING) | ||
| # Runtime discovery cannot execute a target binary on the build | ||
| # host. Register one aggregate test instead; CMake prepends the | ||
|
|
@@ -1312,6 +1349,10 @@ if(DFLASH27B_SERVER) | |
| DFLASH27B_BACKEND_CUDA=1 | ||
| DFLASH27B_CUDA_MIN_SM=${_dflash_cuda_min_sm}) | ||
| endif() | ||
| if(DFLASH27B_MMPROJ) | ||
| target_compile_definitions(dflash_server PRIVATE DFLASH_HAVE_MMPROJ=1) | ||
| target_include_directories(dflash_server PRIVATE ${DFLASH27B_MMPROJ_INCLUDE_DIRS}) | ||
| endif() | ||
| if(NOT WIN32) | ||
| target_link_libraries(dflash_server PRIVATE dflash_common ggml ${DFLASH27B_GGML_BACKEND_TARGET} pthread) | ||
| else() | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,108 @@ | ||
| # Native mmproj vision | ||
|
|
||
| Optional build (`-DDFLASH27B_MMPROJ=ON`) wires llama.cpp **mtmd** so | ||
| `dflash_server` can load a GGUF multimodal projector alongside the text model | ||
| and accept OpenAI-style `image_url` content in chat completions. | ||
|
|
||
| ## Quick start | ||
|
|
||
| **Need:** CUDA GPU, a Qwen3.5/3.6 GGUF + matching `mmproj-F16.gguf`, and a | ||
| **full** [`lucebox-ggml`](https://github.com/Luce-Org/lucebox-ggml) tree. | ||
| Hub only vendors the ggml subset — `tools/mtmd` is not in-tree — so a stock | ||
| configure with `-DDFLASH27B_MMPROJ=ON` will fail until mtmd sources are present. | ||
|
|
||
| ```bash | ||
| # 1) Checkout this PR | ||
| git fetch origin pull/571/head:pr-571 && git checkout pr-571 | ||
|
|
||
| # 2) Supply full llama.cpp (mtmd) for the build | ||
| cd server/deps | ||
| mv llama.cpp llama.cpp.vendored-ggml-only | ||
| git clone --depth 1 -b luce-dflash https://github.com/Luce-Org/lucebox-ggml.git llama.cpp | ||
| # Keep hub-local ggml patches (e.g. rocmfp4) on top of the full tree | ||
| cp -a llama.cpp.vendored-ggml-only/ggml/. llama.cpp/ggml/ | ||
|
|
||
| # 3) Build with vision | ||
| cd .. | ||
| cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Release \ | ||
| -DCMAKE_CUDA_ARCHITECTURES=<your_sm> \ | ||
| -DDFLASH27B_MMPROJ=ON -DDFLASH27B_SERVER=ON | ||
| cmake --build build --target dflash_server -j"$(nproc)" | ||
|
|
||
| # 4) Run (same flags you already use for Qwen35, plus mmproj) | ||
| ./build/dflash_server \ | ||
| --model /path/to/Qwen….gguf \ | ||
| --mmproj /path/to/mmproj-F16.gguf \ | ||
| # …draft / layer-split / port as usual… | ||
|
|
||
| # Container equivalent: | ||
| # DFLASH_MMPROJ=/path/to/mmproj-F16.gguf | ||
| # Optional: --no-mmproj-offload / DFLASH_MMPROJ_NO_OFFLOAD=1 | ||
| ``` | ||
|
|
||
| After the build you can restore the slim vendor so the tree stays pullable: | ||
|
|
||
| ```bash | ||
| cd server/deps | ||
| rm -rf llama.cpp | ||
| mv llama.cpp.vendored-ggml-only llama.cpp | ||
| ``` | ||
|
|
||
| ### Smoke | ||
|
|
||
| ```bash | ||
| # Capability flag | ||
| curl -s localhost:8080/props | jq '.capabilities.vision_supported' | ||
| # expect: true | ||
|
|
||
| # Multimodal chat (data URI only today) | ||
| IMG_B64=$(base64 -w0 /path/to/test.jpg) # macOS: base64 -i test.jpg | ||
| curl -s localhost:8080/v1/chat/completions \ | ||
| -H 'Content-Type: application/json' \ | ||
| -d "{ | ||
| \"model\": \"qwen\", | ||
| \"messages\": [{ | ||
| \"role\": \"user\", | ||
| \"content\": [ | ||
| {\"type\": \"text\", \"text\": \"What do you see?\"}, | ||
| {\"type\": \"image_url\", \"image_url\": { | ||
| \"url\": \"data:image/jpeg;base64,${IMG_B64}\" | ||
| }} | ||
| ] | ||
| }], | ||
| \"max_tokens\": 128 | ||
| }" | ||
| ``` | ||
|
|
||
| Also check a plain text turn still works (and still uses DFlash when a draft is | ||
| configured). Without `--mmproj`, image requests should 400 cleanly. | ||
|
|
||
| ## Runtime | ||
|
|
||
| Multimodal turns run AR decode; text-only turns keep DFlash speculative decode | ||
| when configured. `/props` reports `capabilities.vision_supported: true` when | ||
| the projector is loaded. | ||
|
|
||
| Supported on both monolithic Qwen35 and layer-split backends | ||
| (`supports_multimodal()` is delegated through `LayerSplitBackend`). | ||
|
|
||
| ## Example | ||
|
|
||
| Chat completion with an attached meme image — the model reads the visual | ||
| layout and answers in natural language: | ||
|
|
||
|  | ||
|
|
||
| Request shape (abbreviated): | ||
|
|
||
| ```json | ||
| { | ||
| "messages": [{ | ||
| "role": "user", | ||
| "content": [ | ||
| {"type": "text", "text": "What do you think this image means?"}, | ||
| {"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,..."}} | ||
| ] | ||
| }] | ||
| } | ||
| ``` |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -75,4 +75,24 @@ inline void build_tree_mask(const DDTree & tree, int past_length, | |
| } | ||
| } | ||
|
|
||
| // Bidirectional mask for vision image chunks (full attention within chunk + | ||
| // to all prior KV). Matches Qwen35Backend::build_bidirectional_mask. | ||
| inline void build_bidirectional_mask(std::vector<uint16_t> & out, | ||
| int kv_len, int n_tokens, int kv_pos, | ||
| int kq_stride_pad, | ||
| int kv_pad_override = 0) { | ||
| const int kv_pad = (kv_pad_override > 0) ? kv_pad_override | ||
| : align_up(kv_len, kq_stride_pad); | ||
| const int q_pad = align_up(n_tokens, KQ_MASK_PAD); | ||
| out.assign((size_t)kv_pad * q_pad, F16_NEG_INF); | ||
| for (int q = 0; q < n_tokens; q++) { | ||
| for (int k = 0; k < kv_pos; k++) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: Layer-split multimodal prefill can attend to padded KV rows when Prompt for AI agents |
||
| out[(size_t)q * kv_pad + k] = F16_ZERO; | ||
| } | ||
| for (int k = kv_pos; k < kv_pos + n_tokens; k++) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: Multimodal image chunks lose bidirectional attention when layer-split prefill subdivides them: queries in an earlier sub-batch cannot attend to image tokens in later sub-batches. Process a non-causal image chunk as one batch (or otherwise make all chunk K/V available before applying this mask) instead of limiting the visible range to the current Prompt for AI agents |
||
| out[(size_t)q * kv_pad + k] = F16_ZERO; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| } // namespace dflash::common | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -242,6 +242,11 @@ std::unique_ptr<ModelBackend> create_backend( | |||||||||||||||||||||||
| ? std::max<int>(DFLASH27B_DRAFT_BLOCK_SIZE, args.ddtree_budget + 1) | ||||||||||||||||||||||||
| : DFLASH27B_DRAFT_BLOCK_SIZE; | ||||||||||||||||||||||||
| cfg.run_dflash = args.draft_path != nullptr; | ||||||||||||||||||||||||
| cfg.mmproj_path = args.mmproj_path; | ||||||||||||||||||||||||
| cfg.mmproj_use_gpu = args.mmproj_use_gpu; | ||||||||||||||||||||||||
| if (const char * mt = std::getenv("DFLASH_MMPROJ_THREADS")) { | ||||||||||||||||||||||||
| cfg.mmproj_threads = std::max(1, std::atoi(mt)); | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
Comment on lines
+247
to
+249
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3: Layer-split projector loading treats empty, malformed, or non-positive Prompt for AI agents
Suggested change
|
||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| auto adapter = std::make_unique<Qwen35LayerSplitAdapter>(cfg); | ||||||||||||||||||||||||
| auto backend = std::make_unique<LayerSplitBackend>(std::move(adapter)); | ||||||||||||||||||||||||
|
|
@@ -270,6 +275,8 @@ std::unique_ptr<ModelBackend> create_backend( | |||||||||||||||||||||||
| cfg.ddtree_temp = args.ddtree_temp; | ||||||||||||||||||||||||
| cfg.ddtree_chain_seed = args.ddtree_chain_seed; | ||||||||||||||||||||||||
| cfg.use_feature_mirror = args.use_feature_mirror; | ||||||||||||||||||||||||
| cfg.mmproj_path = args.mmproj_path; | ||||||||||||||||||||||||
| cfg.mmproj_use_gpu = args.mmproj_use_gpu; | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| auto backend = std::make_unique<Qwen35Backend>(cfg); | ||||||||||||||||||||||||
| if (!backend->init()) { | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P1: Enabling
DFLASH27B_MMPROJcannot configure from this checkout because the referenced top-level llama.cpp project andtools/mtmdare not vendored. Adding the full llama.cpp/mtmd sources (or pointing these calls at a real supplied source tree) is needed before exposing this option.Prompt for AI agents