Sybil AI
‹ Apple M5 Pro

Qwen3.8-27B

MLX 3-bit (g64), 8-bit vision, MTP drafter · mlx-vlm · 200K context · Apple M5 Pro · 24 GB

Alibaba's 27B dense model from August 2026, and the strongest model that fits one consumer card. It thinks before it answers, calls tools reliably and reads images, which makes it the default for coding agents and long projects.

17 tok/sdecode
176 tok/sprefill
200Kcontext window
Tested on a siblingOct 6, 2026
Run it

Follow the steps below to download and run this recipe. Omarchy Local AI lists the recipes it can launch on your hardware. The server then answers on http://localhost:8080/v1.

  1. 1 Install the engine
    python3 -m pip install mlx-vlm==0.7.3 mlx==0.32.2 transformers==5.17.0 huggingface_hub==1.33.0
  2. 2 Download the weights
    hf download leonsarmiento/Qwen3.8-27B-3bit-mlx \
      --revision 5fc234d9e6080b8388a11286380e801b7c9f535c \
      --local-dir models/Qwen3.8-27B-3bit-mlx-5fc234d9
    
    hf download lukaskremla/Qwen3.8-27B-MTP-3bit-MLX \
      --revision 9d061a0661258e75b401a11ac9fa22fc648e039d \
      --local-dir models/Qwen3.8-27B-MTP-3bit-MLX-9d061a06
  3. 3 Write the launcher
    cat > qwen38_mlx_serve.py <<'EOF'
    """mlx_vlm.server for Qwen3.8-27B with two long-prefill memory fixes (mlx-vlm 0.7.3).
    
    Usage: python3 qwen38_mlx_serve.py <mlx_vlm.server arguments>
    
    1. Fused prefill attention. Qwen3.8's full-attention layers use head_dim 256, for which MLX
       picks its unfused SDPA during prefill, and mlx-vlm's quantized-KV path always computes
       Q·Kᵀ with quantized_matmul. Both materialise a chunk × context score matrix. Prefill
       chunks (more than 8 query tokens) dequantize the layer's keys/values and run MLX's fused
       kernel with force_fused; decode keeps the quantized path.
    2. Segmented prompt embeddings. The full-prompt inputs_embeds (200k x 5120 x bf16 ≈ 2 GB)
       stays resident through prefill: the request kwargs keep a reference and prompt_step's
       `[:, n:]` views keep the buffer alive until the KV cache peaks. The batch takes sole
       ownership and holds it as 8192-token segments that are freed as prefill consumes them.
    
    It also serves POST /v1/token/encode ({"text"} -> {"tokens", "length"}) from the model's own tokenizer, which
    lab.py uses to count decoded tokens; lists only the served model on /v1/models (mlx-vlm lists every model in the
    Hugging Face cache, and a client that picks the first one makes the server swap models); and applies the model's generation_config sampling (temperature, top_k,
    top_p) to requests that leave those fields out, as vLLM and SGLang do by default. mlx-vlm otherwise samples with
    top_k 0 and top_p 1.0, and at temperature 0.6 a long thinking answer drifts off the prompt. Both patches fail
    loudly (assert) if mlx-vlm's internals no longer match.
    """
    
    import json
    import sys
    from pathlib import Path
    
    import mlx.core as mx
    from mlx_vlm.generate import ar
    from mlx_vlm.models import base
    
    PREFILL_MIN_QUERY_TOKENS = 8
    HEAD_DIM = 256
    SEGMENT_TOKENS = 8192
    
    _quantized_sdpa = base.quantized_scaled_dot_product_attention
    _fast_sdpa = mx.fast.scaled_dot_product_attention
    
    
    def fused_fast_sdpa(q, k, v, **kwargs):
        if q.shape[-2] > PREFILL_MIN_QUERY_TOKENS and q.shape[-1] == HEAD_DIM and kwargs.get("sinks") is None:
            kwargs["force_fused"] = True
        return _fast_sdpa(q, k, v, **kwargs)
    
    
    def fused_prefill_quantized_sdpa(queries, q_keys, q_values, scale, mask, group_size=64, bits=8):
        if queries.shape[-2] <= PREFILL_MIN_QUERY_TOKENS:
            return _quantized_sdpa(queries, q_keys, q_values, scale=scale, mask=mask, group_size=group_size, bits=bits)
        keys = mx.dequantize(*q_keys, group_size=group_size, bits=bits).astype(queries.dtype)
        values = mx.dequantize(*q_values, group_size=group_size, bits=bits).astype(queries.dtype)
        return mx.fast.scaled_dot_product_attention(queries, keys, values, scale=scale, mask=mask)
    
    
    class SegmentedEmbeds:
        """Only what PromptProcessingBatch does to `_inputs_embeds`: `.shape`, `[:, :n]`, `[:, n:]`."""
    
        def __init__(self, embeds):
            self.segments = [mx.contiguous(embeds[:, i:i + SEGMENT_TOKENS]) for i in range(0, embeds.shape[1], SEGMENT_TOKENS)]
            mx.eval(self.segments)
            self.dtype, self.ndim = embeds.dtype, embeds.ndim
    
        @property
        def shape(self):
            first = self.segments[0].shape
            return (first[0], sum(segment.shape[1] for segment in self.segments), first[2])
    
        def __getitem__(self, key):
            rows, cols = key
            assert rows == slice(None) and cols.step is None
            if cols.start is None:
                parts, need = [], cols.stop
                for segment in self.segments:
                    parts.append(segment[:, :need])
                    need -= parts[-1].shape[1]
                    if need <= 0:
                        break
                return parts[0] if len(parts) == 1 else mx.concatenate(parts, axis=1)
            drop = cols.start
            while self.segments and drop >= self.segments[0].shape[1]:
                drop -= self.segments.pop(0).shape[1]
            if drop:
                self.segments[0] = self.segments[0][:, drop:]
            # The last segment goes back as a plain array for the final generate() call.
            return self.segments[0] if len(self.segments) == 1 else self
    
    
    def install():
        assert hasattr(base, "quantized_scaled_dot_product_attention") and hasattr(ar, "_merge_prefill_prompt_kwargs")
        base.quantized_scaled_dot_product_attention = fused_prefill_quantized_sdpa
        mx.fast.scaled_dot_product_attention = fused_fast_sdpa
    
        merge = ar._merge_prefill_prompt_kwargs
    
        def merge_and_release(prompt_kwargs_list, input_ids):
            inputs_embeds, merged = merge(prompt_kwargs_list, input_ids)
            for kwargs in prompt_kwargs_list:
                if kwargs:
                    kwargs.pop("inputs_embeds", None)
            return inputs_embeds, merged
    
        ar._merge_prefill_prompt_kwargs = merge_and_release
        init = ar.PromptProcessingBatch.__init__
    
        def init_segmented(self, *args, **kwargs):
            init(self, *args, **kwargs)
            embeds = self._inputs_embeds
            if embeds is not None and self.prefill_step_size and embeds.shape[1] > 2 * SEGMENT_TOKENS:
                self._inputs_embeds = SegmentedEmbeds(embeds)
    
        ar.PromptProcessingBatch.__init__ = init_segmented
    
    
    class ServedModelOnly:
        """ASGI middleware: GET /v1/models lists the served model only."""
    
        def __init__(self, app, model_id):
            self.app = app
            self.body = json.dumps({"object": "list", "data": [{"id": model_id, "object": "model", "owned_by": "local"}]}).encode()
    
        async def __call__(self, scope, receive, send):
            if scope["type"] != "http" or scope["method"] != "GET" or scope["path"] not in ("/v1/models", "/models"):
                return await self.app(scope, receive, send)
            await send({"type": "http.response.start", "status": 200,
                        "headers": [(b"content-type", b"application/json"), (b"content-length", str(len(self.body)).encode())]})
            await send({"type": "http.response.body", "body": self.body})
    
    
    def add_lab_routes(model_path):
        from mlx_vlm.server import app
        from transformers import AutoTokenizer
    
        tokenizer = AutoTokenizer.from_pretrained(model_path)
        app.add_middleware(ServedModelOnly, model_id=model_path)
    
        @app.post("/v1/token/encode")
        async def encode(body: dict):
            ids = tokenizer.encode(body.get("text") or "", add_special_tokens=False)
            return {"tokens": ids, "length": len(ids)}
    
    
    SAMPLING_KEYS = ("temperature", "top_k", "top_p")
    GENERATION_PATHS = {"/v1/chat/completions", "/chat/completions", "/v1/responses", "/responses", "/v1/messages", "/messages"}
    
    
    class SamplingDefaults:
        """ASGI middleware: fill in the model's sampling settings a request body leaves out; never override one it sets."""
    
        def __init__(self, app, defaults):
            self.app, self.defaults = app, defaults
    
        async def __call__(self, scope, receive, send):
            if scope["type"] != "http" or scope["method"] != "POST" or scope["path"] not in GENERATION_PATHS:
                return await self.app(scope, receive, send)
            body, more = b"", True
            while more:
                message = await receive()
                body += message.get("body", b"")
                more = message.get("more_body", False)
            try:
                data = json.loads(body)
                if isinstance(data, dict):
                    body = json.dumps({**self.defaults, **data}).encode()
            except ValueError:
                pass
            delivered = False
    
            async def replay():
                nonlocal delivered
                if not delivered:
                    delivered = True
                    return {"type": "http.request", "body": body, "more_body": False}
                return await receive()
    
            headers = [(k, v) for k, v in scope["headers"] if k != b"content-length"] + [(b"content-length", str(len(body)).encode())]
            return await self.app({**scope, "headers": headers}, replay, send)
    
    
    def add_sampling_defaults(model_path):
        config = Path(model_path) / "generation_config.json"
        defaults = {k: v for k, v in json.loads(config.read_text()).items() if k in SAMPLING_KEYS} if config.exists() else {}
        if defaults:
            from mlx_vlm.server import app
    
            app.add_middleware(SamplingDefaults, defaults=defaults)
    
    
    if __name__ == "__main__":
        install()
        model_path = sys.argv[sys.argv.index("--model") + 1]
        add_lab_routes(model_path)
        add_sampling_defaults(model_path)
        from mlx_vlm.server.cli import main
    
        sys.argv[0] = "mlx_vlm.server"
        main()
    EOF
  4. 4 Raise the GPU memory limit (resets on reboot)
    sudo sysctl iogpu.wired_limit_mb=20480
  5. 5 Start the server
    HF_HUB_OFFLINE=1 \
      MLX_VLM_TOKEN_QUEUE_TIMEOUT=7200 \
      python3 \
      qwen38_mlx_serve.py \
      --model models/Qwen3.8-27B-3bit-mlx-5fc234d9 \
      --draft-model models/Qwen3.8-27B-MTP-3bit-MLX-9d061a06 \
      --draft-kind mtp \
      --draft-block-size 2 \
      --kv-bits 4 \
      --kv-quant-scheme uniform \
      --kv-group-size 64 \
      --max-kv-size 204800 \
      --max-tokens 8192 \
      --enable-thinking \
      --max-num-seqs 1 \
      --prefill-step-size 128 \
      --host 127.0.0.1 \
      --port 8080
What it passed
✓ loadLoads and serves within an hour
✓ chatAnswers a plain question and stops on its own
✓ reasoningThinks separately and gets 17 × 23 right
✓ toolsCalls a tool with the right arguments and uses the result
✓ contextRecalls a code buried in a prompt that fills 85% of the window
✓ speedDecodes at 15 tok/s or more

No Apple M5 Pro is rentable; this ran on the Apple M5 Pro 48GB (16-core GPU, same chip and 307 GB/s as the card), not memory-capped; context-gate peak 17.72 GiB of the card's 20 GiB GPU budget, the same chip family.

Details
Weights
leonsarmiento/Qwen3.8-27B-3bit-mlx @ 5fc234d9e6 ›
Image
none: a program on the host
Launch
mlx-vlm-qwen3.8-27b-3bit-mtp-200k ›
Recipe file
registry/recipes/apple/apple-m5-pro-24gb/qwen3.8-27b.mlx-vlm.200k.json ›
Model released
Aug 5, 2026
Tested
owner, Apple M5 Pro 48GB (16-core GPU) (sibling: Apple M5 Pro 48GB (16-core GPU, same chip and 307 GB/s as the card), not memory-capped; context-gate peak 17.72 GiB of the card's 20 GiB GPU budget)