<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en"><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://sandy1618.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://sandy1618.github.io/" rel="alternate" type="text/html" hreflang="en" /><updated>2026-11-05T23:19:45+09:00</updated><id>https://sandy1618.github.io/feed.xml</id><title type="html">Sandeep Nayak</title><subtitle>Notes from building data pipelines and running LLMs on my own hardware, with the numbers I measured.</subtitle><author><name>Sandeep Nayak</name></author><entry><title type="html">llama.cpp vs BaseRT vs colibri: local LLM inference on an M1 Max, measured on a real workload</title><link href="https://sandy1618.github.io/2026/11/04/inference-engines-on-apple-silicon.html" rel="alternate" type="text/html" title="llama.cpp vs BaseRT vs colibri: local LLM inference on an M1 Max, measured on a real workload" /><published>2026-11-04T00:00:00+09:00</published><updated>2026-11-04T00:00:00+09:00</updated><id>https://sandy1618.github.io/2026/11/04/inference-engines-on-apple-silicon</id><content type="html" xml:base="https://sandy1618.github.io/2026/11/04/inference-engines-on-apple-silicon.html"><![CDATA[<p>If you follow local-LLM news, you have seen the claims.</p>

<ul>
  <li><strong>BaseRT</strong> says it beats llama.cpp on Apple Silicon, with faster decode and much faster prefill, plus continuous
batching built in. <!-- LINK TODO: exact BaseRT claim page; verify wording and which chip the numbers are for --></li>
  <li><strong>colibri</strong> says a pure-C engine can run frontier-size MoE models, far bigger than your RAM, on an ordinary machine by
streaming experts from SSD. <!-- LINK TODO: exact colibri README claim --></li>
</ul>

<p>Both claims are probably true for the workload each project measured. But a benchmark only tells you about the job it
ran, and I was not asking “which engine is fastest?” I was asking whether <em>my</em> job works on <em>my</em> laptop.</p>

<p>My job: I extract structured facts from websites. A model reads a site’s key pages and writes a JSON record, each item
with a quote from the page. Cloud models do it well, but I have hundreds of thousands of sites, and a MacBook Pro with
an M1 Max and 64 GB sitting next to the crawler. So: <strong>for this use case, how fast can that laptop do the job, and
which inference engine should run it?</strong></p>

<p>I tried the claims on this task, locally, with the same model and the same websites on four engines. Here is what I
measured, and where each claim did or did not hold for this use case:</p>

<ul>
  <li><strong>llama.cpp (<code class="language-plaintext highlighter-rouge">llama-server</code>) won</strong>: fastest overall, the only one that gained much from serving 8 requests at once,
and it never dropped a request or returned broken JSON.</li>
  <li><strong>BaseRT</strong>: the faster-decode claim held (it writes tokens a little faster than llama.cpp), but it reads prompts at
half the speed, and on my long prompts that decided it. The batching gain was small here (+12%).</li>
  <li><strong>colibri</strong>: built for a different problem (models bigger than your RAM). This model fits in RAM, so on this Mac it
ran ~100x slower. Not a flaw in the claim, just not my use case.</li>
  <li><strong>Ollama</strong> was the easiest to start and the first to break.</li>
</ul>

<p>The takeaway is not that those claims are wrong. It depends on your use case, and this post is what happened on mine.</p>

<p><img src="/assets/charts/throughput.svg" alt="Sites extracted per hour on the same 20 websites: llama.cpp 181 with 8 requests at once and 124 with one; BaseRT 126 and 113; colibri under 4" /></p>

<h2 id="the-workload-and-why-it-is-not-a-chat-benchmark">The workload, and why it is not a chat benchmark</h2>

<p>Most published numbers are “tokens per second” on a short prompt. My requests look different:</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>Per website</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Prompt (instructions + page text)</td>
      <td>~4,500 tokens on average, up to ~6,000</td>
    </tr>
    <tr>
      <td>Answer</td>
      <td>~1,100-1,400 tokens of JSON</td>
    </tr>
    <tr>
      <td>Output format</td>
      <td>must match a JSON schema (I ask the engine to enforce it)</td>
    </tr>
    <tr>
      <td>Settings</td>
      <td>temperature 0, “thinking” off, 24,576-token context per request</td>
    </tr>
  </tbody>
</table>

<p>So every request is <strong>prefill-heavy</strong> (reading a long prompt) <em>and</em> decode-heavy (writing a long answer), and the
engine has to support <strong>schema-constrained decoding</strong> (a grammar that only lets the model write valid JSON for my
schema). I measured what matters for a batch job: <strong>websites per hour</strong>, wall clock, start to end.</p>

<p><strong>Hardware.</strong> MacBook Pro, Apple M1 Max (10 CPU cores, 24-core GPU), 64 GB unified memory.</p>

<p><strong>Model.</strong> Qwen 3.6 35B-A3B: a mixture-of-experts (MoE) model with 35B parameters, of which ~3B are used per token.
In 4-bit it is ~20 GB, so it fits in memory with room for 8 parallel contexts. Each engine used its own 4-bit file
(GGUF Q4_K_M for llama.cpp and Ollama, BaseRT’s <code class="language-plaintext highlighter-rouge">default-q4</code>, colibri’s int4 container), so the four are not
bit-identical models.</p>

<p><strong>Method.</strong> The same 20 websites through each engine and setting, one engine at a time on an otherwise idle GPU. Each
configuration got one warm-up request first (kernel and grammar compilation), not counted.</p>

<h2 id="the-engines">The engines</h2>

<table>
  <thead>
    <tr>
      <th>Engine</th>
      <th>Version</th>
      <th>What it is</th>
      <th>How I ran it</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><a href="https://github.com/ggml-org/llama.cpp">llama.cpp</a> <code class="language-plaintext highlighter-rouge">llama-server</code></td>
      <td>0.6.0 (Homebrew)</td>
      <td>the C/C++ reference for GGUF models, Metal backend</td>
      <td><code class="language-plaintext highlighter-rouge">-np 1</code> or <code class="language-plaintext highlighter-rouge">-np 8</code>, flash attention on, schema via <code class="language-plaintext highlighter-rouge">response_format</code></td>
    </tr>
    <tr>
      <td><a href="https://basecompute.co">BaseRT</a></td>
      <td>0.3.0</td>
      <td>a newer engine for Apple Silicon (closed engine, open CLI), continuous batching</td>
      <td><code class="language-plaintext highlighter-rouge">basert serve --continuous-batching 8</code>, schema via <code class="language-plaintext highlighter-rouge">response_format</code></td>
    </tr>
    <tr>
      <td><a href="https://github.com/JustVugg/colibri">colibri</a></td>
      <td>2.0.0</td>
      <td>pure C, built to run huge MoE models by streaming experts from disk</td>
      <td><code class="language-plaintext highlighter-rouge">coli serve --ctx 24576 --cap 256</code> (all experts in RAM)</td>
    </tr>
    <tr>
      <td><a href="https://ollama.com">Ollama</a></td>
      <td>0.40.2</td>
      <td>the friendly wrapper (GGUF and MLX engines)</td>
      <td><code class="language-plaintext highlighter-rouge">/api/chat</code> with a JSON schema</td>
    </tr>
  </tbody>
</table>

<p>All of them speak an OpenAI-compatible (or Ollama) HTTP API, so one client script drove every run.</p>

<h2 id="results">Results</h2>

<h3 id="throughput">Throughput</h3>

<table>
  <thead>
    <tr>
      <th>Engine</th>
      <th style="text-align: right">Requests at once</th>
      <th style="text-align: right">Valid JSON</th>
      <th style="text-align: right">Seconds per site, median</th>
      <th style="text-align: right">Sites per hour</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>llama.cpp</strong></td>
      <td style="text-align: right"><strong>8</strong></td>
      <td style="text-align: right">20/20</td>
      <td style="text-align: right">141 (each request waits for the others)</td>
      <td style="text-align: right"><strong>181</strong></td>
    </tr>
    <tr>
      <td>llama.cpp</td>
      <td style="text-align: right">1</td>
      <td style="text-align: right">20/20</td>
      <td style="text-align: right">27</td>
      <td style="text-align: right">124</td>
    </tr>
    <tr>
      <td>BaseRT</td>
      <td style="text-align: right">8</td>
      <td style="text-align: right">20/20</td>
      <td style="text-align: right">183</td>
      <td style="text-align: right">126</td>
    </tr>
    <tr>
      <td>BaseRT</td>
      <td style="text-align: right">1</td>
      <td style="text-align: right">20/20</td>
      <td style="text-align: right">32</td>
      <td style="text-align: right">113</td>
    </tr>
    <tr>
      <td>colibri</td>
      <td style="text-align: right">1</td>
      <td style="text-align: right">n/a</td>
      <td style="text-align: right">one site did not finish in 15 minutes</td>
      <td style="text-align: right">&lt; 4</td>
    </tr>
    <tr>
      <td>Ollama (GGUF engine)</td>
      <td style="text-align: right">1 (it queues the rest)</td>
      <td style="text-align: right">ok</td>
      <td style="text-align: right">~27</td>
      <td style="text-align: right">~134 (different 50-site sample)</td>
    </tr>
  </tbody>
</table>

<p>On a longer run of 200 websites, llama.cpp with 8 slots held <strong>~206 sites/hour</strong> with this model; the 20-site run ends
with a tail where only a few slots are still busy, which pulls its average down.</p>

<p>Two things stand out. <strong>Batching pays on llama.cpp</strong> (124 → 181, +46%) <strong>and barely on BaseRT</strong> (113 → 126, +12%).
And one at a time, llama.cpp is already ahead despite BaseRT’s faster decode. The next chart shows why.</p>

<h3 id="prefill-vs-decode">Prefill vs decode</h3>

<p><img src="/assets/charts/prefill_decode.svg" alt="Prefill: llama.cpp about 570 tokens per second, BaseRT 300, colibri 4.5. Decode: llama.cpp 56, BaseRT 66, colibri about 5" /></p>

<p>From each engine’s own logs, one request at a time:</p>

<table>
  <thead>
    <tr>
      <th>Engine</th>
      <th style="text-align: right">Prefill (reading the prompt)</th>
      <th style="text-align: right">Decode (writing the answer)</th>
      <th style="text-align: right">Decode per request, 8 at once</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>llama.cpp</td>
      <td style="text-align: right">~570 tok/s</td>
      <td style="text-align: right">56 tok/s</td>
      <td style="text-align: right">13 tok/s (~100 tok/s in total)</td>
    </tr>
    <tr>
      <td>BaseRT</td>
      <td style="text-align: right">~300 tok/s</td>
      <td style="text-align: right">66 tok/s</td>
      <td style="text-align: right">14 tok/s, but prefill drops to ~80 tok/s</td>
    </tr>
    <tr>
      <td>colibri</td>
      <td style="text-align: right">~4.5 tok/s</td>
      <td style="text-align: right">~5 tok/s (estimated)</td>
      <td style="text-align: right">one request at a time only</td>
    </tr>
  </tbody>
</table>

<p>With a 4,500-token prompt, llama.cpp spends ~8 s reading and BaseRT ~15 s. BaseRT wins back ~3 s while writing
1,300 tokens. Net: llama.cpp is faster per site. <strong>On long-prompt workloads, look at prefill speed first</strong>; a decode
benchmark on a one-line prompt will pick the wrong engine.</p>

<p>A synthetic probe tells the same story at the extremes: a 1,029-token prompt took <strong>1.8 s</strong> on llama.cpp and
<strong>228 s</strong> on colibri; a 5,029-token prompt took 7.1 s on llama.cpp.</p>

<h3 id="the-model-matters-as-much-as-the-engine">The model matters as much as the engine</h3>

<p><img src="/assets/charts/models.svg" alt="On llama.cpp with 8 requests at once: Gemma 4 26B-A4B 247 sites per hour, Qwen 3.6 35B-A3B 206, dense Qwen 3.8 27B 37" /></p>

<p>Same engine, same 8 slots, 200 websites. Gemma 4 26B-A4B (MoE, ~4B active) ran at <strong>247 sites/hour</strong>, mostly because it
writes ~20% shorter answers. The dense Qwen 3.8 27B, a smaller file than Qwen 3.6 (19 vs 21 GB), ran at <strong>37 sites/hour</strong>: every one of
its 27B parameters works on every token (~40 tok/s prefill, ~2.8 tok/s decode per slot). On a laptop GPU, <strong>active
parameters per token</strong> set the speed far more than total size.</p>

<h2 id="engine-by-engine-what-i-learned">Engine by engine: what I learned</h2>

<h3 id="llamacpp-the-boring-winner">llama.cpp: the boring winner</h3>

<ul>
  <li><code class="language-plaintext highlighter-rouge">llama-server -np 8 -c &lt;8 × context&gt;</code> serves 8 requests from one copy of the weights. Going from 4 to 8 slots gave a
small gain only: the GPU, not the slot count, is the limit.</li>
  <li>Schema-constrained JSON via <code class="language-plaintext highlighter-rouge">response_format: {type: json_schema}</code> worked every time: 440 of 440 answers across my Qwen 3.6
and Gemma 4 runs parsed and matched the schema.</li>
  <li>It never crashed, hung or leaked memory over multi-hour runs.</li>
  <li>One gotcha: Ollama’s Gemma 4 GGUF does not load in llama.cpp (“wrong number of tensors”); the <code class="language-plaintext highlighter-rouge">ggml-org</code> GGUF on
Hugging Face does.</li>
</ul>

<h3 id="basert-fast-decode-slow-prefill-and-an-m1-gap">BaseRT: fast decode, slow prefill, and an M1 gap</h3>

<ul>
  <li>Installs as a single CLI with a Metal library; <code class="language-plaintext highlighter-rouge">basert pull</code> fetches pre-converted models (<code class="language-plaintext highlighter-rouge">basert pull id:variant</code>
returned HTTP 401 for me; pulling without the variant worked).</li>
  <li>Its OpenAI-compatible server <strong>rejects any model name other than the one it serves</strong> (HTTP 404, which looks like a
wrong URL). Send <code class="language-plaintext highlighter-rouge">basecompute/Qwen3.6-35B-A3B</code>, not your own label.</li>
  <li><code class="language-plaintext highlighter-rouge">response_format</code> with a JSON schema is compiled to a grammar: 40/40 valid, and the grammar did not slow decoding.</li>
  <li>It <strong>caches prompt prefixes</strong>: sending the same site twice took 16 s instead of 55 s. Great for chat and agents,
useless when every prompt is new.</li>
  <li><strong>Gemma 4 does not run properly on an M1.</strong> Its Metal library has no <code class="language-plaintext highlighter-rouge">moe_simd_gelu_gate_up_q4_sbf16</code> kernel, so each
step falls back to a slow path: 24 tok/s prefill, 4.7 tok/s decode, 491 s for one site, where llama.cpp runs the
same model at ~247 sites/hour. Likely a bf16 kernel aimed at newer Apple chips. I stopped that run.</li>
  <li>With the same model, BaseRT and llama.cpp agreed on 15 to 17 of 20 sites depending on the field: two different
4-bit quantisations are not quite the same model.</li>
</ul>

<h3 id="colibri-the-right-tool-for-a-different-job">colibri: the right tool for a different job</h3>

<p>colibri runs mixture-of-experts models far larger than RAM (hundreds of billions to trillions of parameters): it keeps
the dense part in memory and streams experts from disk. That is a remarkable trick, and not my situation, since my
model fits in RAM. Still, its setup recommended exactly this model for my machine, so I measured it:</p>

<ul>
  <li>The one-step setup builds a CPU engine. <code class="language-plaintext highlighter-rouge">make qwen36 METAL=1</code> builds with Metal, <strong>but the Qwen 3.6 engine uses Metal
only for its “qpack” weight format</strong>, not for the int4 file the setup downloads. So it ran on the CPU: ~750% CPU,
12.7 GB resident, ~4.5 tok/s prefill.</li>
  <li>That is in line with colibri’s own CPU numbers for this model (6 tok/s decode on a Ryzen 7 desktop): not a broken
install, just CPU inference against a GPU engine.</li>
  <li>The server runs <strong>one generation at a time</strong>, has <strong>no JSON grammar</strong> for this model family (<code class="language-plaintext highlighter-rouge">response_format</code>
returns 400), and, like BaseRT, answers 404 unless you send its <code class="language-plaintext highlighter-rouge">--model-id</code>.</li>
  <li>Defaults to change for long prompts: context is 8,192 tokens and the expert cache 8 per layer; I used
<code class="language-plaintext highlighter-rouge">--ctx 24576 --cap 256</code>.</li>
</ul>

<p>Where colibri should shine on this Mac is a 284B-744B model streamed from a fast SSD at 0.5-2 tok/s: too slow for a
batch job, interesting for a hard case that no smaller model gets right. I kept it installed for that.</p>

<h3 id="ollama-easy-to-start-first-to-break">Ollama: easy to start, first to break</h3>

<ul>
  <li>Its <strong>MLX engine crashed</strong> on an M1 with prompts this long (“Maximum threads per threadgroup is 896 but requested
1024”, then “cache: offset exceeds stored keys”).</li>
  <li>Its <strong>GGUF engine</strong> works but serves this model architecture one request at a time (“architecture does not currently
support parallel requests”): ~27 s per site, ~134 sites/hour, with no way to batch.</li>
</ul>

<h3 id="not-yet-tested-vllm">Not yet tested: vLLM</h3>

<p>vLLM is the engine I trust most on Linux GPUs; its Apple plugin (vllm-metal, MLX-based) is next on my list as a
reference point.</p>

<h2 id="lessons-for-running-llm-batch-jobs-on-apple-silicon">Lessons for running LLM batch jobs on Apple Silicon</h2>

<ol>
  <li><strong>Benchmark your real prompt length.</strong> Prefill speed differed 2x between the two working engines and decided the
result; short-prompt “tok/s” charts would have picked the slower one.</li>
  <li><strong>Measure sites (or documents) per hour, wall clock.</strong> Per-request latency goes <em>up</em> with batching while throughput
goes up too; only the end-to-end rate tells you what you get.</li>
  <li><strong>Check that batching really batches.</strong> llama.cpp gained 46% from 8 slots, BaseRT 12%, colibri and Ollama none.</li>
  <li><strong>Pick a mixture-of-experts model.</strong> On the same engine a dense 27B model was 5-7x slower than 26-35B MoE models.</li>
  <li><strong>Use schema-constrained decoding</strong> when you need JSON: llama.cpp and BaseRT both enforce a JSON schema; with it, I
never had to repair an answer.</li>
  <li><strong>Expect sharp edges in newer engines:</strong> model-name 404s, missing kernels for older chips, engines that only use the
GPU for some weight formats. Read the server log before trusting a number.</li>
  <li><strong>Run one engine at a time</strong> and warm it up first; two engines sharing the GPU make both numbers meaningless.</li>
</ol>

<h2 id="and-the-quality-question">And the quality question</h2>

<p>Speed was only half of my question. On a hand-labelled set, the local models’ answers were good but not good enough
for data I publish: after my cleaning rules, 87-98% of the extracted facts they kept were correct across my key fields
(Qwen 3.6: 91.7-95.5%), against 98-100% for the cloud models I use. The errors are the subtle kind—pulling a related
entity’s detail instead of the target’s, or mistaking a passing mention for a primary fact. So for now the laptop stays
a test bench, and llama.cpp is the engine on it.</p>

<h2 id="appendix-how-to-reproduce">Appendix: how to reproduce</h2>

<ul>
  <li>Client: one script sends each website to an OpenAI-compatible <code class="language-plaintext highlighter-rouge">/v1/chat/completions</code> with the JSON schema in
<code class="language-plaintext highlighter-rouge">response_format</code>, temperature 0, <code class="language-plaintext highlighter-rouge">chat_template_kwargs: {enable_thinking: false}</code>, and records seconds and token
counts per site.</li>
  <li>Servers, as run:</li>
</ul>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>llama-server <span class="nt">-m</span> qwen3.6-35b-a3b-q4_K_M.gguf <span class="nt">-np</span> 8 <span class="nt">-c</span> 196608 <span class="nt">-fa</span> on <span class="nt">--jinja</span> <span class="nt">--port</span> 8081
basert serve basecompute/Qwen3.6-35B-A3B <span class="nt">--port</span> 8453 <span class="nt">--continuous-batching</span> 8 <span class="nt">--max-context</span> 24576
coli serve <span class="nt">--model</span> qwen36-35b <span class="nt">--model-id</span> qwen36 <span class="nt">--ctx</span> 24576 <span class="nt">--cap</span> 256 <span class="nt">--port</span> 8000
</code></pre></div></div>

<ul>
  <li>Caveats: one machine, one run per configuration, 20 websites for the engine comparison (200 for the model chart), a
different 4-bit file per engine, and colibri’s decode rate estimated from a short request (prompt time subtracted).
The Qwen 3.8 27B number was measured in a separate run with the same llama.cpp setup.</li>
</ul>]]></content><author><name>Sandeep Nayak</name></author><summary type="html"><![CDATA[Four inference engines, one MoE model, one MacBook: websites per hour on a real long-prompt JSON extraction job.]]></summary></entry></feed>