Native MLX concurrency and prefix caching
The MLX provider serves concurrent Qwen3.8-27B and Qwen3.8-Flash-Next requests inside your Python process with mlx_batching=True. Execution uses AbstractCore, MLX, mlx-lm and mlx-vlm; it does not import or run oMLX. See speculative decoding for checkpoints and MTP controls. For component ownership and request flow, see Architecture. The M5 Max measurements compare both models with oMLX, including vision and concurrent/staggered requests. Every MLX lane renders its prompt through the model's own chat template, so tool calls in history, tool results and the thinking opener match what the model was trained on; see tool calling.
Table of Contents
Vision with MTP off or on Scheduling and request controls Bounded RAM and optional SSD prefix reuse HTTP serving Live phase feedback (prefill vs generation) Supported boundariesFor a source checkout, install the Apple provider stack with
pip install -e ".[apple]". Released packages use pip install -U "abstractcore[apple]";
see the changelog for release availability.
The native runtime requires MLX ≥0.32.2, mlx-lm ≥0.31.3 and mlx-vlm ≥0.7.1,<0.8.0.
Download target and head weights explicitly before loading; AbstractCore's model
cache is offline-first. See model downloads.
from concurrent.futures import ThreadPoolExecutor
from abstractcore import create_llm
llm = create_llm(
"mlx", model="mlx-community/Qwen3.8-27B-4bit",
speculation={"mode": "native_mtp", "num_draft_tokens": 2,
"require_acceleration": True},
mlx_batching=True, mlx_max_batch_size=4,
)
def answer(question):
return llm.generate(question, thinking=False, temperature=0,
max_output_tokens=256)
with ThreadPoolExecutor(max_workers=2) as pool:
answers = list(pool.map(answer, ["Explain TTL caches.", "Explain LRU caches."]))
for answer in answers:
print(answer.content, answer.metadata["execution"])
llm.unload_model(llm.model)
For Flash-Next, use model="Jundot/Qwen3.8-Flash-Next-oQ4e-mtp"; its MTP head
is embedded. Keep the default PLE offload on 128 GiB machines. Both large models
should not be loaded simultaneously without checking available memory.
Vision with MTP off or on
Both documented checkpoints accept native image input with speculation disabled
or enabled. The same vision encoder supplies image embeddings to the target;
MTP changes token generation, not image delivery. The 27B target uses the matching
mlx-community/Qwen3.8-27B-MTP-4bit head; Flash-Next embeds its head.
from abstractcore import create_llm
llm = create_llm(
"mlx", model="mlx-community/Qwen3.8-27B-4bit", mlx_batching=True,
speculation={"mode": "native_mtp", "num_draft_tokens": 2},
)
# The target/head remain resident for both requests.
for setting in (False, {"mode": "native_mtp", "num_draft_tokens": 2,
"require_acceleration": True}):
response = llm.generate(
"Describe the left and right halves of this image.",
media=["halves.png"], speculation=setting,
thinking=False, temperature=0, max_output_tokens=64,
)
print(response.content)
print(response.metadata["speculation"]["used"])
llm.unload_model(llm.model)
Swapped red/blue image checks passed on both models with MTP off/on; see vision measurements. These establish image delivery and behavior for those inputs, not general OCR accuracy, image-history compatibility or concurrent multimodal throughput.
Scheduling and request controls
| Request | Execution policy |
|---|---|
Compatible greedy target-only requests (speculation=False) |
Continuous admission into available batch slots |
| Compatible greedy MTP requests with the same depth | Fixed cohort; later requests wait for another cohort |
| Sampled or incompatible requests | Exclusive execution with their requested controls |
num_draft_tokens counts proposals, excluding the target seed token, on both
models. Change depth or disable MTP per request without reloading. MTP does not
support non-neutral logits penalties or logit bias: strict acceleration refuses
them; optional acceleration reports an explicit unaccelerated outcome. Stop
strings work with the scheduled runtime.
Benchmark MTP against speculation=False for your workload. Speculative cohorts
can improve a single request while reducing aggregate throughput under load;
target-only continuous batching can be the better choice for concurrent clients.
Change the request control without unloading the resident target/head pair.
Batch compatibility also depends on prefix-cache manager identity. Requests using different cache managers are separate scheduling groups; concurrent submission does not guarantee a shared batch across independently keyed application sessions. Keep cache isolation intact and measure the session/key pattern your application uses.
AbstractRuntime integration
Use the existing mlx provider with scheduler/head options at construction.
AbstractRuntime's local client consults the loaded instance's concurrency capability;
ordinary MLX instances retain serialized generation. Per-call params.speculation
accepts False or a native-MTP control object, and params.thinking accepts boolean
on/off or a reasoning level. The remote client forwards these controls to Core HTTP
and preserves actual execution/speculation metadata. Head loading and scheduler
configuration remain load-time choices; forwarding a request does not load a head.
Applications can inherit the configured MTP policy or override it per run or node;
see the default policy below.
MTP defaults and application overrides
A fresh Core configuration seeds native MTP at draft depth 2 for compatible models. Existing configuration files are not reseeded. Depth 2 is a starting policy, not a guarantee of best performance for every model or workload.
The policy is stored at capability_defaults.routes.input.text.options.speculation;
the output.text route exposes the same setting. Core's console-TUI and Gateway's
web/TUI capability-default editors edit this Core-owned configuration. The CLI
also preserves other route options when changing only MTP:
abstractcore config set-default output.text --speculation 2
abstractcore config set-default output.text --speculation off
abstractcore config set-default output.text --speculation inherit
inherit removes the configured policy; it does not restore a fresh-install seed.
With no policy and no constructor/request setting, a model whose MTP support is
verified (registry drafter or embedded head) runs optional MTP
(require_acceleration: false, falling back to the target-only decoder when the
head is unavailable); other models run without speculation. Only off (false)
switches it off for a verified model (2.23.1; before, no policy meant off).
An inherited policy prepares an available matching cached head during model load;
it never downloads weights. Unsupported backends remain non-speculative. A model
already loaded without a head requires explicit provisioning/reloading before
an application can enable MTP.
Flow, Assistant and Code leave speculation unset when using the Gateway default.
Their Off choice sends false; an explicit depth sends a native-MTP object with
num_draft_tokens. Explicit call/node settings win over inherited run settings,
which win over the provider's default. An explicit constructor default wins over
the Core policy. Changes to depth or Off do not unload a prepared target/head.
Strict requests require MTP execution, not a measured speedup.
get_execution_capabilities(model_name, provider=..., instance=...) in
abstractcore.providers.speculation, and GET /v1/models/execution-capabilities,
describe backend support, instance readiness, available depth choices and the
effective default without loading a model. A missing readiness value is unknown,
not proof that the head is ready. Inspect each response's speculation.used for
what actually executed; unsupported explicit requests fail when strict or return
a named non-speculative outcome when fallback is allowed.
metadata["execution"] reports mode, observed peak_batch_size, queue time
and time to first backend token. Multi-request MTP counters are shared cohort
diagnostics, not independent per-request token totals; inspect accounting and
counter_semantics before aggregating them. Use response usage for actual
prompt/completion tokens. Client first-token latency also includes transport.
Defaults: four batch slots, 32 waiting requests, a 10 ms collection window,
a 120-second queue timeout and 256 buffered output records per request.
Controls: mlx_max_batch_size, mlx_max_queue_size, mlx_batch_wait_ms,
mlx_queue_timeout_s, and mlx_output_queue_size. Full queues, expired requests
and stalled stream consumers raise actionable errors. Close abandoned streams,
including await stream.aclose() for async streams. Cancellation suppresses
output immediately; GPU state is reclaimed at the next safe backend boundary,
which can follow an in-progress prefill.
Providers sharing a target/head pair share weights and one execution owner. They must agree on scheduler/cache settings. Releasing an idle provider does not unload another provider's model. Unloading an owner with active requests is refused. Direct and scheduled execution cannot share a resident native session. Garbage collection retires a scheduled provider's owner without blocking on an in-flight request; final cleanup occurs at a safe worker boundary. Prefer explicit unload after finishing or closing streams when you need deterministic SSD flush and error reporting. A live-worker shutdown timeout keeps resources owned for a later retry. A flush failure after the worker stops is reported, but does not leave a closed model permanently resident.
Bounded RAM and optional SSD prefix reuse
Use an explicit prompt_cache_key and the complete conversation in messages
(messages=[] for standalone prompts). Native prefix reuse does not append
hidden history. Requests without a key do not use the automatic cache.
llm = create_llm(
"mlx", model="mlx-community/Qwen3.8-27B-4bit",
speculation={"mode": "native_mtp"}, mlx_batching=True,
mlx_cache_disk_path="./private-mlx-cache",
mlx_cache_disk_max_gb=4, mlx_cache_memory_max_gb=8, # RAM cap is optional
)
result = llm.generate(long_prompt, messages=[], prompt_cache_key="conversation-1",
thinking=False, temperature=0, max_output_tokens=128)
print(result.usage["cached_input_tokens"])
print(llm.get_prompt_cache_stats())
llm.unload_model(llm.model) # Flushes SSD storage for compatible future processes.
The RAM budget is sized to the machine: min(8 GiB, recommended working set / 4)
(8 GiB on a 64 GB+ Mac, 5 GiB on a 24 GB Mac whose GPU limit is 20 GiB, about
2.7 GiB on 16 GB), with a fixed 512 MiB reserve on top of the measured free
memory (mlx-vlm evicts the oldest snapshot before a store would eat into it).
Sizing rule: a 27B hybrid snapshot measures about 98 KB per prompt token, so a
10k-token conversation needs about 1 GB and the budget holds several turns.
(Before 2.23.1 mlx-vlm's own shape applied: working_set / 10 plus a
max(1 GiB, working_set / 10) reserve, which on a 24 GB Mac running a 27B
model left no room for any snapshot.) The budget caps EACH retained snapshot: a
snapshot larger than the budget, or one the free memory cannot hold, is not
retained and the next turn re-prefills the whole
conversation, which the response reports as prompt_cache.degraded_reason
with the call's prompt_cache.apc store/skip counters. A hybrid model's
snapshot grows roughly 27-50 KB per prompt token, so set
mlx_cache_memory_max_gb only to a value larger than your longest
conversation's snapshot. Two recurrent-state checkpoints are kept. SSD storage
is opt-in, defaulting to a 4 GiB budget. One runtime shares budgets across keys and
depths. Cache identity includes weights, tokenizer/configuration, engine versions,
execution layout, MTP depth and image content; changes can prevent reuse.
A private namespace has one live writer; concurrent processes need separate
paths. mlx_cache_scope partitions local identity, not authenticated tenants.
Treat local checkpoint directories as immutable. Hub snapshots carry revision
identity; arbitrary local checkpoints use bounded file/header fingerprints,
not a full hash of every tensor value. Clear caches after manually editing weights.
prompt_cache_clear() invalidates owned RAM and SSD cache entries. Key-specific
clear also clears the whole shared native namespace and warns about its scope.
Weights and other namespaces are untouched. Native APC is separate from the
ordinary MLX durable append-cache artifact format: manual prefill, append, fork,
save and load are unsupported. See prompt caching and
model residency.
HTTP serving
abstractcore.endpoint.app.create_app(provider_instance=llm) serves the same
provider. The AbstractCore server accepts these constructor controls inside
options on /acore/models/load. Load before sending concurrent chat requests;
changing resident execution options requires unloading first. Both chat APIs
accept per-request speculation or false. Native chat responses expose
diagnostics under the additive abstractcore field, including terminal streaming
events. Cache and lifecycle controls remain serialized through the owner.
The single-model native endpoint accepts inline base64 PNG/JPEG/WebP/GIF
image_url parts in the final user message, up to 10 MiB per image, 32 MiB total
and 16 images. Remote/file image URLs, unsupported part types and image-bearing
history are rejected explicitly. Supply complete text history plus current-turn
images, or use the Python media= API for local files. Image detail metadata does
not override the native processor's resizing policy.
Use one server process per resident model session: web workers do not share an in-process scheduler or weight allocation. These controls do not enable batching or MTP for Hugging Face/GGUF providers.
Live phase feedback (prefill vs generation)
A 6,900-token prompt on this lane spends ~1.6 s in prefill before the first token, then decodes at ~150 tok/s. From outside, both look like "the call has not returned yet", so a host renders one undifferentiated spinner over two very different states. Pass a callback and the provider reports the boundary:
def on_phase(event: dict) -> None:
print(event["phase"], event.get("prompt_tokens"), event.get("generated_tokens"))
llm = create_llm("mlx", model="mlx-community/Qwen3.5-4B-4bit")
llm.generate("", messages=messages, system_prompt=system, tools=tools,
prompt_cache_key="session:chat", on_progress=on_phase)
on_progress=, progress_callback= and progress_event_callback= are accepted
(the first callable wins) and receive plain JSON-safe dicts:
| key | meaning |
|---|---|
kind |
always "llm" — the discriminator that separates these from generated-media progress, which shares the event name downstream |
phase |
"prefill", "generate" or "complete" |
prompt_tokens |
total prompt tokens for this call. Only ever grows: on a warm cache the backend reports the tokens IT processed (e.g. 1), which is a narrower measurement of the same prompt, not news |
cached_tokens / fed_tokens |
prompt tokens restored from KV vs actually prefilled |
generated_tokens |
tokens sampled so far |
ttft_s |
time to first token, set on the first-token event and stable afterwards |
tokens_per_second |
generation rate; absent until a second token exists (one token divided by a near-zero interval is not a rate) |
prefill_processed_tokens |
prefill events only: prompt tokens already in the KV cache — restored tokens count at once, then fed tokens as each chunk is evaluated. Cumulative, monotone, never above prompt_tokens. Absent when the lane cannot observe its prefill |
prefill_tokens_per_second |
prefill events only: rate over the tokens NEWLY processed since the first mid-prefill observation |
prompt_tokens_per_second, elapsed_s, event_index, final, finish_reason |
as named |
Keys with no known value are absent rather than zero: on the native APC lane the
cached/fed split is only known once prefill has started on the worker, so it
appears from the first mid-prefill event on (or the first-token event when the
prompt is too short to observe), while the mlx-lm key-mode lane already knows it
and puts it on the prefill event.
Mid-prefill progress. After the start event, prefill events keep arriving
at the same cadence while the prompt is processed, each carrying
prefill_processed_tokens — what a UI renders as
Prefill · 2,100 / 5,642 tokens (37%). Every lane reports from a real
per-chunk observation, never an estimate:
| lane | seam | granularity |
|---|---|---|
mlx-lm (mlx_batching=False, no speculation) |
mlx-lm's prompt_progress_callback(processed, total) |
per prefill_step_size chunk (2,048) |
| native runtime, batched (temperature 0) | upstream PromptProcessingBatch row counters, read after every BatchGenerator.next() |
per 256-token chunk |
| native runtime, exclusive (sampling) and in-process mlx-vlm (MTP included) | mlx-vlm's chunked-prefill "Prefill" bar, observed per thread by mlx_prefill_observer |
per chunk (256 native / 2,048 in-process) |
| HuggingFace transformers | the 2,048-token chunk loop (ABSTRACTCORE_TRANSFORMERS_PREFILL_STEP), device-synchronized per chunk |
per chunk; a prompt short enough for one forward reports no position |
| HuggingFace llama.cpp (control plane) | n_batch slices (512) |
per slice; the create_chat_completion fallback reports no position |
| HTTP providers (LM Studio, Ollama, llama.cpp server, …) | none — the server does not expose it | no callback, no event |
NativeRuntime.stream(request, on_prefill_progress=...) delivers
NativePrefillProgress observations on the iterating thread and never yields
them, so a consumer that did not ask sees the stream it saw before. No
prefill event is emitted after the first token.
Cost. Time-limited, never count-capped (ADR-0026). prefill, the
first-token event and the terminal complete always fire; every other event
(mid-prefill or decode cadence) is dropped unless at least
ABSTRACTCORE_PROGRESS_MIN_INTERVAL_S (default 0.5) seconds have passed since
the previous one, so events flow for the whole call at about two per second.
A 3.7 s answer costs 9 events; a 6 s prefill of 21,066 tokens adds 11. Subscribing also routes the call through stream_generate instead of
generate — byte-identical output, since mlx-lm's generate is exactly
"".join(r.text for r in stream_generate(...)) — and costs one extra tokenizer
encode to report the prompt size before the GPU work starts. With no subscriber
nothing is computed and nothing changes.
BaseProvider.supports_text_progress_events() gates this and defaults to
False. For a provider that answers False the host's callback is consumed at
the provider boundary and dropped — it never reaches provider kwargs, so no
strict SDK sees an unknown callable — and no phase is invented. AbstractRuntime
turns each event into one durable abstract.progress ledger record; see
AbstractRuntime's docs/integrations/abstractcore.md.
Supported boundaries
- MTP is fixed-cohort scheduling, not late-join continuous batching. Sampled execution is exclusive; larger draft depths need workload-specific measurement.
- Native prefix reuse requires full history and an explicit key. Legacy manual append/prefill/fork/save/load artifacts are not supported by this native cache.
- Prompted structured output is supported and may validate/retry. The Outlines adapter is not supported by this native runtime.
- One process owns a resident session; additional web workers do not share its tensors. Native SSD namespaces are private and single-writer.
- Model load/unload controls do not provide automatic multi-model memory-pressure LRU eviction; managed model TTL is advisory. See model residency.
- Upstream private batching/cache interfaces are version-sensitive. Use the documented dependency range and revalidate before changing the execution stack.