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
6 changes: 5 additions & 1 deletion apps/mlxforge.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -78,13 +78,16 @@ void print_help() {
" --kv-pool <B> prefix-pool RAM budget in bytes, 0 = unbounded (default 1 GiB)\n"
" --kv-spill-dir <D> SSD spill dir for evicted prefix blocks (default off)\n"
" --kv-spill-bytes <B> spill-dir disk budget in bytes, 0 = unbounded (default 0)\n"
" --prefill-chunk <N> interleaved-prefill chunk size in tokens, 0 = monolithic\n"
" (default 256: decode keeps streaming during prefills)\n"
" -h, --help show this help and exit\n"
"\n"
"The model may be given via -m or the config file's \"model\" key.\n"
"Config precedence (low to high): defaults < config file < env vars < CLI flags.\n"
"Env vars: MLXFORGE_HOST, MLXFORGE_PORT, MLXFORGE_MAX_CTX, MLXFORGE_MAX_WAITING, "
"MLXFORGE_KV_BUDGET, MLXFORGE_KV_BITS, MLXFORGE_PREFIX_CACHE, MLXFORGE_KV_BLOCK, "
"MLXFORGE_KV_POOL, MLXFORGE_KV_SPILL_DIR, MLXFORGE_KV_SPILL_BYTES.");
"MLXFORGE_KV_POOL, MLXFORGE_KV_SPILL_DIR, MLXFORGE_KV_SPILL_BYTES, "
"MLXFORGE_PREFILL_CHUNK.");
std::fflush(stdout);
}

Expand Down Expand Up @@ -142,6 +145,7 @@ int main(int argc, char** argv) {
ec.kv_pool_bytes = sc.kv_pool_bytes;
ec.kv_spill_dir = sc.kv_spill_dir;
ec.kv_spill_bytes = sc.kv_spill_bytes;
ec.prefill_chunk = sc.prefill_chunk;
engine = std::make_unique<mlxforge::Engine>(std::move(ec));
} catch (const std::exception& e) {
mlxforge::log::error("model error: {}", e.what());
Expand Down
6 changes: 6 additions & 0 deletions bindings/node/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ export interface EngineOptions {
kvSpillDir?: string;
/** Spill-directory disk budget in bytes (0/unset = unbounded). */
kvSpillBytes?: number;
/**
* Chunked-prefill interleaving: tokens prefilled per engine step, with a
* decode step in between so in-flight requests keep streaming during long
* or queued prefills. Default 256 (on); 0 = monolithic prefill (off).
*/
prefillChunk?: number;
}

export interface SamplingOptions {
Expand Down
5 changes: 5 additions & 0 deletions bindings/node/src/addon.cc
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,11 @@ class EngineWrap : public Napi::ObjectWrap<EngineWrap> {
}
if (o.Has("kvSpillBytes") && o.Get("kvSpillBytes").IsNumber())
opts.kv_spill_bytes = o.Get("kvSpillBytes").As<Napi::Number>().Int64Value();
if (o.Has("prefillChunk") && o.Get("prefillChunk").IsNumber()) {
// JS 0 means "off"; the ABI uses 0 for "engine default", < 0 for off.
const int chunk = o.Get("prefillChunk").As<Napi::Number>().Int32Value();
opts.prefill_chunk = chunk <= 0 ? -1 : chunk;
}
}

char* err = nullptr;
Expand Down
1 change: 1 addition & 0 deletions doc/applications.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ with environment-variable fallbacks (`server/config`):
| `--kv-pool` | `MLXFORGE_KV_POOL` | `1 GiB` | Prefix-pool RAM budget in bytes (LRU beyond it); `0` = unbounded. |
| `--kv-spill-dir` | `MLXFORGE_KV_SPILL_DIR` | off | SSD spill directory: RAM-evicted prefix blocks persist here and survive restarts. |
| `--kv-spill-bytes` | `MLXFORGE_KV_SPILL_BYTES` | `0` (unbounded) | Disk budget for the spill directory. |
| `--prefill-chunk` | `MLXFORGE_PREFILL_CHUNK` | `256` (on) | Chunked-prefill interleaving: admissions prefill this many tokens per engine step with a decode step in between, so in-flight requests keep streaming during long or queued prefills (+25–35% batched throughput, up to 60% lower TTFT under load). `0` = monolithic prefill per admission. |

### Logging

Expand Down
30 changes: 22 additions & 8 deletions doc/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,19 +118,33 @@ The scheduler keeps a single persistent decode batch and continually admits new
work into it and evicts finished work from it — rather than running fixed batches
to completion. The three moving parts:

### Prefill is a separate pass, then joined
### Prefill is a separate pass, then joined — and interleaved with decode

Prefill shape `(B_prefill, P_max, …)` and decode shape `(B_decode, 1, …)` are
different, so they are kept as two regular-shaped passes rather than
chunk-interleaved. Prefill (`runtime/batching`):
different, so they are kept as two regular-shaped passes (never mixed into one
ragged batch). Prefill:

- Left-pads all prompts in the batch to a common `P_max` (left-padding so every
row's last real token sits at the same physical column, `P_max - 1`).
- Runs the forward in chunks of `kPrefillStepSize` (2048) for long prompts,
calling `cache.eval_state()` at each chunk boundary to bound graph/memory
growth.
- Returns a populated `BatchKVCache`, the last-position logits per row, and the
left-padding vector. The worker `merge`s this cache into the live decode cache.
- Runs the forward in chunks, calling `cache.eval_state()` at each chunk
boundary to bound graph/memory growth.
- Produces a populated `BatchKVCache` and the last-position logits per row. The
worker `merge`s this cache into the live decode cache.

**Chunked-prefill interleaving** (`prefill_chunk`, default 256; `0` =
monolithic): rather than running an admission's whole prefill before the next
decode step, the worker queues it as a *pending unit* and advances it one
`prefill_chunk`-token chunk per loop iteration, with a decode step in between —
so in-flight rows keep streaming while new prompts prefill (+25–35% batched
throughput, up to 60% lower TTFT under load; chunking also pipelines better
through Metal than one monolithic forward). Cold admissions share one batched
unit; a prefix-cache hit becomes its own single-row unit (seeded cache +
uncached suffix). Units complete FIFO; chunk boundaries change only *when*
`eval_state()` runs, not what is computed, so the greedy stream is gated
token-identical across chunk sizes (`tests/scheduler/worker_test.cpp`). At
shutdown the loop drains pending units before exiting. The monolithic path
(`prefill(...)` in `runtime/batching`, internal chunking at `kPrefillStepSize`
2048) remains for `prefill_chunk = 0`.

### The steady-state decode step

Expand Down
4 changes: 4 additions & 0 deletions src/capi/mlxforge.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,10 @@ mlxforge_engine* mlxforge_engine_create2(const char* model_spec,
cfg.kv_spill_dir = opts->kv_spill_dir;
if (covered(&opts->kv_spill_bytes + 1) && opts->kv_spill_bytes > 0)
cfg.kv_spill_bytes = static_cast<std::size_t>(opts->kv_spill_bytes);
/* v8: chunked-prefill interleaving. Zero-init keeps the engine default
* (256, on); negative explicitly disables (monolithic prefill). */
if (covered(&opts->prefill_chunk + 1) && opts->prefill_chunk != 0)
cfg.prefill_chunk = opts->prefill_chunk < 0 ? 0 : opts->prefill_chunk;

auto handle = std::make_unique<mlxforge_engine>();
handle->model_name = model_spec;
Expand Down
17 changes: 14 additions & 3 deletions src/capi/mlxforge.h
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,10 @@ extern "C" {
* opts2 carries struct_size so future fields append without a create3).
* v7: mlxforge_engine_opts2 prefix-cache fields (prefix_cache, kv_block_size,
* kv_pool_bytes, kv_spill_dir, kv_spill_bytes) — appended, struct_size-
* gated; no new symbols. */
#define MLXFORGE_ABI_VERSION 7
* gated; no new symbols.
* v8: mlxforge_engine_opts2.prefill_chunk (chunked-prefill interleaving,
* default-on) — appended, struct_size-gated; no new symbols. */
#define MLXFORGE_ABI_VERSION 8

typedef struct mlxforge_engine mlxforge_engine;
typedef struct mlxforge_request mlxforge_request;
Expand Down Expand Up @@ -135,7 +137,13 @@ mlxforge_engine* mlxforge_engine_create(const char* model_spec,
* prompt sharing a token prefix skips that part of prefill (same greedy
* tokens, much lower time-to-first-token). kv_spill_dir adds an SSD tier:
* RAM-evicted blocks persist there and survive engine restarts. Vision-
* language and hybrid (Qwen3.5) models reject the option at creation. */
* language and hybrid (Qwen3.5) models reject the option at creation.
*
* prefill_chunk (v8+) tunes chunked-prefill interleaving: admissions prefill
* this many tokens per worker iteration with a decode step in between, so
* in-flight requests keep streaming during long or queued prefills. On by
* default (256). 0 keeps the default; < 0 disables it (monolithic prefill
* per admission, the pre-v8 behavior). */
typedef struct {
size_t struct_size; /* caller sets sizeof(mlxforge_engine_opts2) */
int max_waiting; /* max queued requests; <= 0 => default (256) */
Expand All @@ -147,6 +155,9 @@ typedef struct {
long long kv_pool_bytes; /* pool RAM budget; 0 => default (1 GiB); < 0 => unbounded */
const char* kv_spill_dir; /* SSD spill directory; NULL/empty => no spill */
long long kv_spill_bytes; /* spill disk budget; <= 0 => unbounded */
/* ---- v8 ---- */
int prefill_chunk; /* tokens per interleaved prefill chunk; 0 => default
(256); < 0 => monolithic prefill (off) */
} mlxforge_engine_opts2;

/* Create an engine with extended options (v6+). Identical contract to
Expand Down
12 changes: 11 additions & 1 deletion src/runtime/engine.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,15 @@ PrefixCacheConfig validate_prefix_cache(const EngineConfig& ec, const ModelConfi
return pc;
}

// Validate the chunked-prefill setting. 0 (monolithic) and any positive chunk
// are valid; negative values are a caller error, not a silent default.
int validate_prefill_chunk(const EngineConfig& ec) {
if (ec.prefill_chunk < 0)
throw std::runtime_error("prefill_chunk must be >= 0 (0 = monolithic); got " +
std::to_string(ec.prefill_chunk));
return ec.prefill_chunk;
}

} // namespace

// Loads the model directory, config, and tokenizer metadata, but not weights.
Expand Down Expand Up @@ -176,7 +185,8 @@ Engine::Engine(EngineConfig cfg, Loaded loaded)
// constrained decoding. tok_ is initialized above and outlives worker_.
// cfg_ is initialized above, so the KV-quant validation sees the model.
worker_(make_factory(std::move(loaded.dir), loaded.is_gguf), &scheduler_, &tok_,
validate_kv_quant(cfg, cfg_), validate_prefix_cache(cfg, cfg_, model_name_)) {
validate_kv_quant(cfg, cfg_), validate_prefix_cache(cfg, cfg_, model_name_),
validate_prefill_chunk(cfg)) {
// Configure the max waiting requests for the batch scheduler.
scheduler_.set_max_waiting(cfg.max_waiting);

Expand Down
6 changes: 6 additions & 0 deletions src/runtime/engine.h
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@ struct EngineConfig {
// this directory and survive engine restarts. Empty = no spill.
std::string kv_spill_dir;
std::size_t kv_spill_bytes = 0; // disk budget; 0 = unbounded
// Chunked-prefill interleaving: admissions prefill `prefill_chunk` tokens per
// worker iteration with a decode step in between, so in-flight rows keep
// streaming during long or queued prefills. On by default (256 tokens —
// benchmarked sweet spot); 0 = monolithic prefill per admission. Negative
// values are rejected at construction.
int prefill_chunk = 256;
};

// Per-call embedding options. The two int fields are tri-state: -1 means "use
Expand Down
92 changes: 56 additions & 36 deletions src/runtime/worker.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
#include <limits>

#include "cache/block_store.h"
#include "core/env.h"
#include "core/logging.h"
#include "model/qwen3_vl.h"
#include "model/vision/vit.h"
Expand Down Expand Up @@ -60,13 +59,9 @@ bool consume(Request& req, int& produced, int id, const TokenLogprob* lp) {
} // namespace

Worker::Worker(ModelFactory factory, Scheduler* scheduler, const Tokenizer* tok,
KVQuantConfig kv_quant, PrefixCacheConfig prefix)
KVQuantConfig kv_quant, PrefixCacheConfig prefix, int prefill_chunk)
: factory_(std::move(factory)), sched_(scheduler), tok_(tok), kv_quant_(kv_quant),
prefix_cfg_(prefix),
prefill_chunk_(static_cast<int>(env_long("MLXFORGE_PREFILL_CHUNK", 0))) {
if (prefill_chunk_ > 0)
log::info("worker: EXPERIMENTAL interleaved prefill on (chunk={} tokens)", prefill_chunk_);
}
prefix_cfg_(prefix), prefill_chunk_(prefill_chunk) {}

Worker::~Worker() { stop(); }

Expand Down Expand Up @@ -231,13 +226,13 @@ void Worker::run() {

while (true) {
std::vector<std::shared_ptr<Request>> incoming;
if (reqs_.empty() && !pending_) {
if (reqs_.empty() && pending_.empty()) {
auto r = sched_->next_waiting(); // block until work or stop+drained
if (!r) break;
incoming.push_back(r);
auto more = sched_->take_waiting(kPrefillBatchSize - 1);
incoming.insert(incoming.end(), more.begin(), more.end());
} else if (!pending_) {
} else if (pending_.empty()) {
incoming = sched_->take_waiting(kPrefillBatchSize); // non-blocking top-up
}
// With a prefill in flight, no new admissions are taken: one chunk advances
Expand All @@ -256,15 +251,14 @@ void Worker::run() {
else if (r->is_multimodal()) admit_multimodal(r);
else gen.push_back(std::move(r));
}
// Interleaved mode hands cold admissions to the chunked state machine;
// with the prefix cache on (heterogeneous warm suffixes) it falls back
// to the monolithic path.
// Interleaved mode (the default) queues admissions as chunked-prefill
// units; prefill_chunk = 0 restores the monolithic path.
if (!gen.empty()) {
if (prefill_chunk_ > 0 && !prefix_) start_chunked_prefill(gen);
if (prefill_chunk_ > 0) enqueue_admissions(gen);
else admit(gen);
}
}
if (pending_) advance_chunked_prefill();
if (!pending_.empty()) advance_chunked_prefill();
evict_finished(); // a row may finish on its very first token
if (reqs_.empty()) continue;

Expand All @@ -283,22 +277,28 @@ void Worker::run() {
log::info("worker: stopped after {} decode steps", decode_steps_.load());
}

void Worker::admit(const std::vector<std::shared_ptr<Request>>& incoming) {
// Split on the prefix cache: matched requests prefill only their suffix
// (one by one — their cached lengths are heterogeneous), the rest share the
// batched cold prefill.
std::vector<std::shared_ptr<Request>> cold;
std::vector<std::pair<std::shared_ptr<Request>, PrefixCache::Match>> warm;
Worker::PrefixSplit Worker::split_prefix(
const std::vector<std::shared_ptr<Request>>& incoming) {
PrefixSplit out;
for (const auto& r : incoming) {
if (prefix_) {
PrefixCache::Match m = prefix_->match(r->prompt_ids);
if (m.tokens > 0) {
warm.emplace_back(r, std::move(m));
out.warm.emplace_back(r, std::move(m));
continue;
}
}
cold.push_back(r);
out.cold.push_back(r);
}
return out;
}

void Worker::admit(const std::vector<std::shared_ptr<Request>>& incoming) {
// Matched requests prefill only their suffix (one by one — their cached
// lengths are heterogeneous), the rest share the batched cold prefill.
PrefixSplit split = split_prefix(incoming);
std::vector<std::shared_ptr<Request>>& cold = split.cold;
auto& warm = split.warm;

log::debug("worker: admitting {} request(s), {} prefix-warm (batch {} -> {})", incoming.size(),
warm.size(), reqs_.size(), reqs_.size() + incoming.size());
Expand Down Expand Up @@ -331,9 +331,25 @@ void Worker::admit(const std::vector<std::shared_ptr<Request>>& incoming) {
}
}

void Worker::start_chunked_prefill(const std::vector<std::shared_ptr<Request>>& cold) {
// Same left-padding as prefill() (batching.cpp); only the chunk loop is
// spread across worker iterations so decode steps can interleave.
void Worker::enqueue_admissions(const std::vector<std::shared_ptr<Request>>& incoming) {
// Same warm/cold split as admit(); only the prefill is spread across worker
// iterations. A prefix hit becomes its own single-row unit: a cache seeded
// from the pooled blocks (as prefill_with_prefix does) plus the uncached
// suffix as its token stream. Cold requests share one left-padded batched
// unit, padded exactly like prefill() (batching.cpp).
PrefixSplit split = split_prefix(incoming);
for (auto& [r, m] : split.warm) {
auto cache = std::make_unique<BatchKVCache>(BatchKVCache::from_prefix(
model_->config().n_layers, m.blocks, m.tokens, kv_quant_));
cache->eval_state(); // materialize the seeded storage before the forward
const int suffix = static_cast<int>(r->prompt_ids.size()) - m.tokens;
mx::array toks(r->prompt_ids.data() + m.tokens, {1, suffix}, mx::int32);
pending_.push_back(PendingPrefill{{r}, std::move(toks), std::move(cache), suffix,
/*pos=*/0, /*reused_tokens=*/m.tokens});
}
std::vector<std::shared_ptr<Request>>& cold = split.cold;
if (cold.empty()) return;

const int B = static_cast<int>(cold.size());
int p_max = 0;
for (const auto& r : cold) p_max = std::max(p_max, static_cast<int>(r->prompt_ids.size()));
Expand All @@ -346,27 +362,25 @@ void Worker::start_chunked_prefill(const std::vector<std::shared_ptr<Request>>&
left_padding[b] = pad;
for (size_t j = 0; j < ids.size(); ++j) padded[b * p_max + pad + j] = ids[j];
}

auto pending = std::make_unique<PendingPrefill>(PendingPrefill{
pending_.push_back(PendingPrefill{
cold, mx::array(padded.data(), {B, p_max}, mx::int32),
std::make_unique<BatchKVCache>(model_->config().n_layers, left_padding, kv_quant_),
p_max, /*pos=*/0});
pending_ = std::move(pending);
log::debug("worker: chunked prefill started ({} rows, {} tokens, chunk={})", B, p_max,
prefill_chunk_);
p_max, /*pos=*/0, /*reused_tokens=*/0});
log::debug("worker: chunked prefill queued ({} rows, {} tokens, chunk={}, queue={})", B, p_max,
prefill_chunk_, pending_.size());
}

void Worker::advance_chunked_prefill() {
PendingPrefill& p = *pending_;
PendingPrefill& p = pending_.front();
const int B = static_cast<int>(p.reqs.size());
const int n = std::min(prefill_chunk_, p.p_max - p.pos);
const int n = std::min(prefill_chunk_, p.n_total - p.pos);
mx::array chunk = mx::slice(p.tokens, {0, p.pos}, {B, p.pos + n});
mx::array logits = model_->forward(chunk, *p.cache);
p.cache->eval_state(); // same per-chunk materialization as prefill()
p.pos += n;
if (p.pos < p.p_max) return;
if (p.pos < p.n_total) return;

// Final chunk: every row's last real token is at p_max-1, the last column.
// Final chunk: every row's last real token sits in the last column.
const int n_last = logits.shape()[1];
const int vocab = logits.shape()[2];
mx::array last =
Expand All @@ -376,7 +390,13 @@ void Worker::advance_chunked_prefill() {
if (!cache_) cache_ = std::move(p.cache);
else cache_->merge(*p.cache);
register_rows(p.reqs, last);
pending_.reset();
if (p.reused_tokens > 0) {
++prefix_hits_;
prefix_tokens_reused_ += p.reused_tokens;
log::debug("worker: prefix hit ({} of {} prompt tokens reused)", p.reused_tokens,
p.reqs[0]->prompt_ids.size());
}
pending_.pop_front();
}

void Worker::register_rows(const std::vector<std::shared_ptr<Request>>& incoming,
Expand Down
Loading
Loading