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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 30 additions & 7 deletions docs/image-input.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,9 +136,31 @@ concurrent sequence scheduling (`--paged-attention --max-concurrency N`): each
image request is encoded when it is admitted and then prefills and decodes in
the shared batch like text, with the drafter. On one R9700, four concurrent
256-token image answers finish in 6.9 s (149 tok/s in total) against 13.3 s
(77 tok/s) one at a time. DeepSeek V4 image requests still need one request
at a time. `/props` reports the effective capability in
`capabilities.image_input_supported` after backend initialization.
(77 tok/s) one at a time.

DeepSeek V4 Flash Vision batches too, with the batched launch from the DeepSeek
guide plus `--mmproj` (and `--mmproj-device` for an R9700 encoder):

```
luce_server models/DeepSeek-V4-Flash-Vision-Exp-ROCMFPX-MIX-STRIX.gguf \
--target-device hip:1 --mmproj-device hip:0 \
--paged-attention --max-concurrency 4 --kv-pool-tokens 24576 --max-ctx 8192 \
--ds4-prefill exact --prefix-cache-slots 0 --ds4-expert-top-k 6 \
--mmproj models/DeepSeek-V4-Flash-Vision-Exp-mmproj-BF16.gguf
```

Its image blocks need whole-block bidirectional prefill, which the batched
engine's 16-row step cannot run. Image requests admitted since the last step
are therefore prefilled up to their last token together, in shared
layer-major sparse passes into per-request staging caches (each layer's
experts are read once for all of them); that state is copied into each
request's paged slot and the last token prefills in the batch, so the answers
decode alongside everyone else. On the Strix Halo with the encoder on the
R9700, four concurrent image answers of 256 tokens finish in 35 s (29 tok/s in
total), two images plus two text requests at 31 tok/s; four text requests
reach 38 tok/s. Image requests beyond the free slots wait in the queue. `/props` reports the
effective capability in `capabilities.image_input_supported` after backend
initialization.

## Qwen3.5 / Qwen3.8

Expand Down Expand Up @@ -238,10 +260,11 @@ per-expert layout is used expert by expert; the community publishes one for
this model) or `--absmax-only`. The converter uses every core: about 40 minutes
for this checkpoint on 32 cores.

One image request may be outstanding per backend. Its admission lease remains
with the immutable payload through queueing and generation; another image
request is rejected until that payload is released. This bounds simultaneous
preprocessing and prepared-image memory. Text requests retain the normal queue.
Image requests wait in the same queue as text requests. A waiting request
holds only its preprocessed patches, a few MB per image; its encoded rows
exist only while it runs, so the number of slots bounds them. When host
memory is too short to prepare another image request, the server answers
HTTP 503 and the client should retry.

The server expands image markers after final rendering and tokenization.
Expanded image tokens count toward context and usage. Image blocks remain
Expand Down
6 changes: 3 additions & 3 deletions server/src/common/feature_gate.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,9 @@ std::string check_feature_compatibility(
if (args.mmproj_path.has_value()) {
if ((arch != "deepseek4" && arch != "qwen35") || args.device.is_layer_split() ||
args.device.is_tensor_parallel() || args.remote_target_shard.enabled() ||
(arch == "deepseek4" && args.max_concurrency != 1)) {
return "--mmproj requires a local DeepSeek4 (one request at a time) or Qwen3.5 "
"backend that is not split across GPUs by layer or tensor";
(arch == "deepseek4" && args.max_concurrency != 1 && !args.paged_attention)) {
return "--mmproj requires a local DeepSeek4 or Qwen3.5 backend that is not split "
"across GPUs by layer or tensor (DeepSeek4 batching needs --paged-attention)";
}
if (arch == "deepseek4" && target_backend != PlacementBackend::Hip) {
return "--mmproj with DeepSeek4 requires a HIP backend";
Expand Down
5 changes: 5 additions & 0 deletions server/src/common/image_prompt.h
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ namespace luce::common {
// transport. Each image still has its own token and byte bounds.
inline constexpr size_t MAX_REQUEST_IMAGES = 16;

// Outcome of binding a request's images to its prompt. `busy` means the
// request is valid but the backend already holds as many image requests as it
// serves at once; the server answers 503 so the client retries.
enum class ImagePrepareStatus { ok, invalid, busy };

struct EncodedImage {
std::string mime_type;
std::vector<uint8_t> bytes;
Expand Down
16 changes: 8 additions & 8 deletions server/src/common/model_backend.h
Original file line number Diff line number Diff line change
Expand Up @@ -138,21 +138,21 @@ struct ModelBackend {
// turns into the image marker, and binds decoded images to a rendered prompt.
virtual bool supports_images() const { return false; }
virtual std::string image_placeholder() const { return {}; }
virtual bool prepare_images(std::vector<int32_t> & tokens,
std::vector<EncodedImage> images,
uint64_t context_capacity,
uint64_t output_reserve,
ImagePromptHandle & payload,
std::string & error) const {
virtual ImagePrepareStatus prepare_images(std::vector<int32_t> & tokens,
std::vector<EncodedImage> images,
uint64_t context_capacity,
uint64_t output_reserve,
ImagePromptHandle & payload,
std::string & error) const {
(void) tokens;
(void) context_capacity;
(void) output_reserve;
if (!images.empty()) {
error = "this backend does not support image input";
return false;
return ImagePrepareStatus::invalid;
}
payload.reset();
return true;
return ImagePrepareStatus::ok;
}

// Print the "[<arch>-daemon] ready ..." banner on stdout.
Expand Down
Loading
Loading