<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Ai on vishctl</title><link>https://vishctl.dev/tags/ai/</link><description>Recent content in Ai on vishctl</description><generator>Hugo</generator><language>en-us</language><lastBuildDate>Tue, 08 Sep 2026 13:30:00 +0800</lastBuildDate><atom:link href="https://vishctl.dev/tags/ai/index.xml" rel="self" type="application/rss+xml"/><item><title>Run vLLM on Kubernetes with Minikube, WSL2 and NVIDIA GPU</title><link>https://vishctl.dev/posts/vllm-on-wsl2-minikube/</link><pubDate>Tue, 08 Sep 2026 13:30:00 +0800</pubDate><guid>https://vishctl.dev/posts/vllm-on-wsl2-minikube/</guid><category>ai</category><category>local-llm</category><category>vllm</category><category>kubernetes</category><category>minikube</category><category>wsl2</category><category>gpu</category><category>nvidia</category><description>A practical guide to running vLLM on Kubernetes in WSL2 with Minikube, configuring NVIDIA GPU passthrough, and serving an OpenAI-compatible API.</description><content:encoded><![CDATA[<p>This is the <a href="https://docs.vllm.ai/">vLLM</a> entry in my local AI series. After testing <a href="https://vishctl.dev/posts/running-ollama-on-32gb-macbook-air/">Ollama</a>, <a href="https://vishctl.dev/posts/running-llama-cpp-on-32gb-macbook-air/">llama.cpp</a>, and <a href="https://vishctl.dev/posts/running-freetoken-on-8gb-laptop-gpu/">FreeToken</a>, I wanted to run vLLM as a Kubernetes Deployment on my Windows/WSL2 setup.</p>
<p>The plan was simple: deploy vLLM, request <code>nvidia.com/gpu: 1</code>, expose an OpenAI-compatible API endpoint through a Service, and tie it into the Kubernetes workflows I write about regularly.</p>
<p>Getting a GPU into Kubernetes on WSL2 turned into an investigation. Not because vLLM is hard, but because container runtimes and nested clusters handle GPU passthrough in non-obvious ways. Here is what happened, how the device plugin and node prerequisites work, and how to get a working GPU cluster running with Minikube.</p>
<hr>
<h2 id="what-youll-build">What You&rsquo;ll Build</h2>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Windows
</span></span><span class="line"><span class="cl">   │
</span></span><span class="line"><span class="cl">   ▼
</span></span><span class="line"><span class="cl">WSL2 Ubuntu
</span></span><span class="line"><span class="cl">   │
</span></span><span class="line"><span class="cl">   ▼
</span></span><span class="line"><span class="cl">Docker Engine + NVIDIA Container Toolkit
</span></span><span class="line"><span class="cl">   │
</span></span><span class="line"><span class="cl">   ▼
</span></span><span class="line"><span class="cl">Minikube
</span></span><span class="line"><span class="cl">   │
</span></span><span class="line"><span class="cl">   ▼
</span></span><span class="line"><span class="cl">Kubernetes
</span></span><span class="line"><span class="cl">   │
</span></span><span class="line"><span class="cl">   ├── NVIDIA GPU Operator
</span></span><span class="line"><span class="cl">   │
</span></span><span class="line"><span class="cl">   ├── NVIDIA Device Plugin
</span></span><span class="line"><span class="cl">   │
</span></span><span class="line"><span class="cl">   └── vLLM
</span></span><span class="line"><span class="cl">         │
</span></span><span class="line"><span class="cl">         ▼
</span></span><span class="line"><span class="cl">      NVIDIA GPU
</span></span></code></pre></div><p>By the end of this guide, you&rsquo;ll have vLLM running on Kubernetes with GPU acceleration and serving an OpenAI-compatible API.</p>
<hr>
<h2 id="the-test-rig">The Test Rig</h2>
<p>My test machine for this run:</p>
<ul>
<li><strong>Host Machine:</strong> Windows 11 Laptop</li>
<li><strong>GPU:</strong> NVIDIA GeForce RTX 4070 Laptop GPU (8.0 GiB VRAM)</li>
<li><strong>Host Memory:</strong> 32 GB DDR5</li>
<li><strong>WSL2 Environment:</strong> Ubuntu 24.04 LTS (noble) with systemd enabled</li>
<li><strong>NVIDIA Drivers:</strong> Driver version 616.56 / CUDA 13.4 user-mode driver</li>
<li><strong>Kubernetes:</strong> Minikube v1.39.0 provisioning Kubernetes v1.37.0</li>
<li><strong>Model:</strong> Qwen3.5-0.8B (<code>Qwen/Qwen3.5-0.8B</code>) served by vLLM v0.28.0</li>
</ul>
<hr>
<h2 id="troubleshooting-vllm-gpu-support-on-docker-desktop-wsl2-kubernetes">Troubleshooting vLLM GPU Support on Docker Desktop WSL2 Kubernetes</h2>
<p>Docker Desktop offers a one-click Kubernetes cluster in its settings. Turning it on provisions a single-node cluster named <code>desktop-control-plane</code> running on <code>containerd://2.3.4</code>.</p>
<p>On the WSL2 host, <code>nvidia-smi</code> works fine, and running standalone GPU containers (<code>docker run --gpus all</code>) works without issues. The next step was applying the standard NVIDIA Kubernetes device plugin DaemonSet to enable GPU scheduling:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">kubectl create -f https://raw.githubusercontent.com/NVIDIA/k8s-device-plugin/v0.16.2/deployments/static/nvidia-device-plugin.yml
</span></span></code></pre></div><p>The DaemonSet pod started, but immediately stalled in a retry loop:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">E... Incompatible strategy detected auto
</span></span><span class="line"><span class="cl">E... If this is a GPU node, did you configure the NVIDIA Container Toolkit?
</span></span><span class="line"><span class="cl">I... No devices found. Waiting indefinitely.
</span></span></code></pre></div><p>Checking the node capacity confirmed the issue:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">kubectl describe node desktop-control-plane <span class="p">|</span> grep -A5 <span class="s2">&#34;Capacity:\|Allocatable:&#34;</span>
</span></span></code></pre></div><figure class="post-screenshot">
    <img loading="lazy" src="https://vishctl.dev/images/posts/vllm-wsl2-minikube/01-docker-desktop-k8s-no-gpu.png"
         alt="Docker Desktop Kubernetes node missing GPU resources"/> <figcaption>
            <p>Checking Docker Desktop&rsquo;s desktop-control-plane: nvidia-smi finds the RTX 4070, but the node exposes zero GPU capacity.</p>
        </figcaption>
</figure>

<p>In this tested Docker Desktop Kubernetes setup, GPU resources were not exposed to the Kubernetes node. Despite GPU access working in WSL2 and standalone Docker containers, the Kubernetes node could not expose <code>nvidia.com/gpu</code> to the device plugin.</p>
<hr>
<h2 id="how-kubernetes-detects-nvidia-gpus-using-the-nvidia-device-plugin">How Kubernetes Detects NVIDIA GPUs Using the NVIDIA Device Plugin</h2>
<p>The <a href="https://github.com/NVIDIA/k8s-device-plugin">NVIDIA Kubernetes Device Plugin</a> is a DaemonSet that exposes GPU hardware to the Kubernetes control plane. It does not run inference, and it does not install drivers or container runtimes.</p>
<p>Under standard <a href="https://kubernetes.io/docs/tasks/manage-gpus/scheduling-gpus/">Kubernetes GPU scheduling</a>, pods request GPU resources by specifying limits for extended resources. The device plugin integrates with Kubelet through the Kubernetes Device Plugin API over gRPC, using a Unix domain socket at <code>/var/lib/kubelet/device-plugins/kubelet.sock</code>. Its responsibility comes down to three functions:</p>
<ol>
<li><strong>Discovery:</strong> The plugin queries the host system using NVML (NVIDIA Management Library) to check how many physical GPUs are present and verify their health status.</li>
<li><strong>Registration:</strong> It registers with Kubelet and advertises the discovered GPUs as an extended allocatable resource named <code>nvidia.com/gpu</code>.</li>
<li><strong>Allocation:</strong> When a pod requesting <code>nvidia.com/gpu: 1</code> gets scheduled to the node, Kubelet calls the plugin&rsquo;s <code>Allocate</code> gRPC endpoint. The plugin picks a healthy GPU and sends back the device IDs and environment variables (<code>NVIDIA_VISIBLE_DEVICES=&lt;UUID&gt;</code>) to Kubelet. Kubelet then passes those variables to the container runtime so the container gets access to <code>/dev/nvidia*</code>.</li>
</ol>
<h3 id="worker-node-prerequisites">Worker Node Prerequisites</h3>
<p>Because the device plugin only handles discovery and Kubelet registration, it assumes the worker node already has a functional GPU stack. For worker nodes in any Kubernetes cluster, three layers must be in place before the device plugin can run:</p>
<ol>
<li><strong>Host Kernel Drivers:</strong> The node OS must have the NVIDIA kernel modules loaded (<code>nvidia.ko</code>, <code>nvidia-uvm.ko</code>) and device nodes created in <code>/dev</code>. Running <code>nvidia-smi</code> on the host must return clean output.</li>
<li><strong>NVIDIA Container Toolkit:</strong> Packages such as <code>libnvidia-container</code> and <code>nvidia-container-toolkit</code> must be installed on the host. Refer to the <a href="https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html">NVIDIA Container Toolkit install guide</a> for distribution setup. This toolkit includes the OCI prestart hook that inspects container environment variables and mounts host GPU driver libraries into the target container.</li>
<li><strong>Runtime Configuration:</strong> The node container runtime (containerd or Docker) must have the NVIDIA runtime configured in its daemon settings (such as <code>/etc/containerd/config.toml</code> or <code>/etc/docker/daemon.json</code>) and set up to handle CDI (Container Device Interface) or NVIDIA runtime hooks.</li>
</ol>
<p>If any of those three pieces is missing or unconfigured, the device plugin pod will fail during startup or report zero allocatable GPUs.</p>
<hr>
<h2 id="why-docker-desktop-kubernetes-cannot-detect-my-nvidia-gpu">Why Docker Desktop Kubernetes Cannot Detect My NVIDIA GPU</h2>
<p>Based on the runtime architecture observed in this setup, the issue comes down to how Docker Desktop isolates its cluster node.</p>
<p><code>desktop-control-plane</code> runs inside Docker Desktop&rsquo;s private utility VM (<code>docker-desktop</code>), isolated using <strong><code>sysbox-runc</code></strong> rather than the standard OCI runtime <code>runc</code>. Sysbox provides nested container virtualization to spin up systemd, kubelet, and containerd within an unprivileged container environment.</p>
<p>In this setup, that isolation layer prevents GPU passthrough:</p>
<ul>
<li><strong>Missing OCI Passthrough:</strong> The node container was not launched with NVIDIA GPU passthrough flags (<code>--gpus all</code>). Inside the sandbox, containerd cannot access NVIDIA device nodes (<code>/dev/nvidia*</code>) or the WSL2 DirectX driver mapping (<code>/usr/lib/wsl/lib</code>).</li>
<li><strong>Locked-Down Lifecycle:</strong> You cannot configure the NVIDIA Container Toolkit inside the node&rsquo;s containerd because Docker Desktop internally supervises the container.</li>
<li><strong>Daemon Defaults Do Not Propagate:</strong> Setting <code>nvidia</code> as the default OCI runtime in <code>docker-desktop</code> WSL does not alter the <code>sysbox-runc</code> runtime used by the Kubernetes node container.</li>
</ul>
<p>Docker has an open roadmap issue requesting native GPU passthrough for Docker Desktop Kubernetes. It remains an architectural boundary in the current setup.</p>
<p>Because of these issues, I pivoted to running Minikube with WSL2 using native Docker.</p>
<hr>
<h2 id="installing-docker-ce-and-containerd-inside-wsl2">Installing Docker CE and Containerd Inside WSL2</h2>
<p>To satisfy those node prerequisites and bypass Docker Desktop&rsquo;s VM isolation, we install the native Docker Community Edition engine directly inside the Ubuntu 24.04 WSL2 environment:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># Add Docker&#39;s official GPG key</span>
</span></span><span class="line"><span class="cl">sudo mkdir -p /etc/apt/keyrings
</span></span><span class="line"><span class="cl">sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
</span></span><span class="line"><span class="cl">sudo chmod a+r /etc/apt/keyrings/docker.asc
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Set up the repository</span>
</span></span><span class="line"><span class="cl"><span class="nb">echo</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  <span class="s2">&#34;deb [arch=</span><span class="k">$(</span>dpkg --print-architecture<span class="k">)</span><span class="s2"> signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu noble stable&#34;</span> <span class="p">|</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  sudo tee /etc/apt/sources.list.d/docker.list &gt; /dev/null
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Install Docker CE and containerd</span>
</span></span><span class="line"><span class="cl">sudo apt update
</span></span><span class="line"><span class="cl">sudo apt install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
</span></span></code></pre></div><figure class="post-screenshot">
    <img loading="lazy" src="https://vishctl.dev/images/posts/vllm-wsl2-minikube/02-install-docker-ce-wsl2.png"
         alt="Installing Docker CE and containerd in Ubuntu WSL2"/> <figcaption>
            <p>Configuring the official Docker CE repository and installing containerd and the Docker daemon inside Ubuntu WSL2.</p>
        </figcaption>
</figure>

<p>Verify that both the Docker daemon and containerd services are running cleanly under systemd:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">systemctl status docker
</span></span><span class="line"><span class="cl">systemctl status containerd
</span></span></code></pre></div><figure class="post-screenshot">
    <img loading="lazy" src="https://vishctl.dev/images/posts/vllm-wsl2-minikube/03-docker-containerd-service-status.png"
         alt="Verifying systemd status for Docker and containerd"/> <figcaption>
            <p>Confirming active (running) status for both docker.service and containerd.service under WSL2 systemd.</p>
        </figcaption>
</figure>

<p>Before moving to Kubernetes, sanity check that native Docker has full GPU access through the WSL2 NVIDIA container runtime:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker container run --gpus all --rm nvidia/cuda:13.3.1-base-ubuntu26.04 nvidia-smi -L
</span></span></code></pre></div><figure class="post-screenshot">
    <img loading="lazy" src="https://vishctl.dev/images/posts/vllm-wsl2-minikube/04-docker-gpu-passthrough-verification.png"
         alt="Docker container GPU passthrough test"/> <figcaption>
            <p>Direct GPU passthrough test: Docker successfully identifies GPU 0 as the NVIDIA GeForce RTX 4070 Laptop GPU.</p>
        </figcaption>
</figure>

<hr>
<h2 id="run-minikube-with-nvidia-gpu-support-on-wsl2">Run Minikube with NVIDIA GPU Support on WSL2</h2>
<p>Install the latest Minikube Debian package:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">curl -LO https://storage.googleapis.com/minikube/releases/latest/minikube_latest_amd64.deb
</span></span><span class="line"><span class="cl">sudo dpkg -i minikube_latest_amd64.deb
</span></span></code></pre></div><figure class="post-screenshot">
    <img loading="lazy" src="https://vishctl.dev/images/posts/vllm-wsl2-minikube/05-install-minikube-deb.png"
         alt="Installing Minikube package in WSL2"/> <figcaption>
            <p>Installing Minikube v1.39.0 via Debian package.</p>
        </figcaption>
</figure>

<p>Following the official <a href="https://minikube.sigs.k8s.io/docs/tutorials/nvidia/">Minikube GPU documentation</a>, starting Minikube with GPU acceleration requires specific driver and runtime parameters. Now start Minikube. Two flags matter here and both are easy to miss:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">minikube start <span class="se">\
</span></span></span><span class="line"><span class="cl">  --driver<span class="o">=</span>docker <span class="se">\
</span></span></span><span class="line"><span class="cl">  --container-runtime<span class="o">=</span>docker <span class="se">\
</span></span></span><span class="line"><span class="cl">  --gpus<span class="o">=</span>all <span class="se">\
</span></span></span><span class="line"><span class="cl">  --memory<span class="o">=</span>12g <span class="se">\
</span></span></span><span class="line"><span class="cl">  --cpus<span class="o">=</span><span class="m">10</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  --extra-config<span class="o">=</span>apiserver.service-account-issuer<span class="o">=</span>https://kubernetes.default.svc <span class="se">\
</span></span></span><span class="line"><span class="cl">  --extra-config<span class="o">=</span>apiserver.service-account-signing-key-file<span class="o">=</span>/var/lib/minikube/certs/sa.key
</span></span></code></pre></div><figure class="post-screenshot">
    <img loading="lazy" src="https://vishctl.dev/images/posts/vllm-wsl2-minikube/06-minikube-start-gpu-flags.png"
         alt="Starting Minikube with GPU support and Docker runtime"/> <figcaption>
            <p>Minikube starting Kubernetes v1.37.0 on Docker, automatically detecting and enabling the nvidia-device-plugin addon.</p>
        </figcaption>
</figure>

<blockquote>
<p>[!IMPORTANT]
<strong>Why <code>--container-runtime=docker</code> is required:</strong>
By default, even when using <code>--driver=docker</code>, Minikube configures containerd as its internal in-node runtime. However, Minikube&rsquo;s <code>--gpus=all</code> flag currently hooks into Docker&rsquo;s OCI runtime wrapper. If you omit <code>--container-runtime=docker</code>, Minikube fails during preflight checks with an invalid flag combination error.</p>
</blockquote>
<p>Notice the startup output: Minikube automatically detects the GPU and enables the <code>nvidia-device-plugin</code> addon for you.</p>
<hr>
<h2 id="verifying-the-cluster-and-gpu-allocation">Verifying the Cluster and GPU Allocation</h2>
<p>Install <code>kubectl</code> to talk to the cluster:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">curl -LO <span class="s2">&#34;https://dl.k8s.io/release/</span><span class="k">$(</span>curl -L -s https://dl.k8s.io/release/stable.txt<span class="k">)</span><span class="s2">/bin/linux/amd64/kubectl&#34;</span>
</span></span><span class="line"><span class="cl">sudo install -o root -g root -m <span class="m">0755</span> kubectl /usr/local/bin/kubectl
</span></span><span class="line"><span class="cl">kubectl version --client
</span></span><span class="line"><span class="cl">kubectl get nodes
</span></span><span class="line"><span class="cl">kubectl get pods -A
</span></span></code></pre></div><figure class="post-screenshot">
    <img loading="lazy" src="https://vishctl.dev/images/posts/vllm-wsl2-minikube/07-kubectl-install-and-cluster-verification.png"
         alt="Kubectl verification and nvidia-device-plugin DaemonSet"/> <figcaption>
            <p>Node minikube is Ready (v1.37.0) and the nvidia-device-plugin-daemonset is 1/1 Running in kube-system.</p>
        </figcaption>
</figure>

<p>Next, inspect the node&rsquo;s schedulable capacity:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">kubectl describe node minikube <span class="p">|</span> grep -A7 <span class="s2">&#34;Capacity:\|Allocatable:&#34;</span>
</span></span></code></pre></div><figure class="post-screenshot">
    <img loading="lazy" src="https://vishctl.dev/images/posts/vllm-wsl2-minikube/08-minikube-node-gpu-capacity.png"
         alt="Minikube node capacity showing GPU resource"/> <figcaption>
            <p>Cluster verification: nvidia.com/gpu: 1 is now officially registered in Capacity and Allocatable.</p>
        </figcaption>
</figure>

<p>A quick test pod confirms the GPU is reachable from inside Kubernetes:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">cat <span class="s">&lt;&lt;EOF | kubectl apply -f -
</span></span></span><span class="line"><span class="cl"><span class="s">apiVersion: v1
</span></span></span><span class="line"><span class="cl"><span class="s">kind: Pod
</span></span></span><span class="line"><span class="cl"><span class="s">metadata:
</span></span></span><span class="line"><span class="cl"><span class="s">  name: gpu-test
</span></span></span><span class="line"><span class="cl"><span class="s">spec:
</span></span></span><span class="line"><span class="cl"><span class="s">  restartPolicy: Never
</span></span></span><span class="line"><span class="cl"><span class="s">  containers:
</span></span></span><span class="line"><span class="cl"><span class="s">    - name: cuda-test
</span></span></span><span class="line"><span class="cl"><span class="s">      image: nvidia/cuda:13.3.1-base-ubuntu26.04
</span></span></span><span class="line"><span class="cl"><span class="s">      command: [&#34;nvidia-smi&#34;, &#34;-L&#34;]
</span></span></span><span class="line"><span class="cl"><span class="s">      resources:
</span></span></span><span class="line"><span class="cl"><span class="s">        limits:
</span></span></span><span class="line"><span class="cl"><span class="s">          nvidia.com/gpu: 1
</span></span></span><span class="line"><span class="cl"><span class="s">EOF</span>
</span></span></code></pre></div><p>Check the test pod logs:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">kubectl logs gpu-test
</span></span></code></pre></div><figure class="post-screenshot">
    <img loading="lazy" src="https://vishctl.dev/images/posts/vllm-wsl2-minikube/09-k8s-cuda-gpu-test-pod.png"
         alt="CUDA test pod running on Minikube"/> <figcaption>
            <p>Test pod running nvidia-smi inside the Kubernetes cluster and successfully accessing the RTX 4070.</p>
        </figcaption>
</figure>

<p>Real GPU scheduling and <code>nvidia.com/gpu</code> resource accounting, without Docker Desktop getting in the way.</p>
<hr>
<h2 id="deploy-vllm-on-kubernetes-with-nvidia-gpu">Deploy vLLM on Kubernetes with NVIDIA GPU</h2>
<p>Model choice for 8 GB VRAM: <strong>Qwen3.5-0.8B</strong> (<code>Qwen/Qwen3.5-0.8B</code>). Small enough to leave real headroom for vLLM&rsquo;s KV cache, which is the main reason to use vLLM over llama.cpp or Ollama in the first place, and it is a capable model at that size. As detailed in the <a href="https://docs.vllm.ai/">vLLM documentation</a>, the server exposes an OpenAI-compatible API endpoint over HTTP.</p>
<h3 id="the-shared-memory-devshm-gotcha">The Shared Memory (<code>/dev/shm</code>) Gotcha</h3>
<p>When running vLLM via the Docker CLI, passing <code>--ipc=host</code> lets workers exchange tensors and state across processes using host shared memory. In Kubernetes, pods do not share the host IPC namespace by default, and Kubernetes provisions <code>/dev/shm</code> as a minimal 64 MB tmpfs mount.</p>
<p>Some PyTorch and vLLM multiprocessing workloads can require significantly more shared memory than Kubernetes&rsquo; default <code>/dev/shm</code> (which defaults to a minimal 64 MB tmpfs mount). If insufficient shared memory is available, initialization or worker processes may fail. The Kubernetes solution is mounting an <code>emptyDir</code> volume backed by host RAM (<code>medium: Memory</code>) with a dedicated <code>sizeLimit</code>:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="c"># vllm-qwen-k8s.yaml</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">apiVersion</span><span class="p">:</span><span class="w"> </span><span class="l">apps/v1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">kind</span><span class="p">:</span><span class="w"> </span><span class="l">Deployment</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">metadata</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">vllm-qwen</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">labels</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">app</span><span class="p">:</span><span class="w"> </span><span class="l">vllm-qwen</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">spec</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">replicas</span><span class="p">:</span><span class="w"> </span><span class="m">1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">selector</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">matchLabels</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">app</span><span class="p">:</span><span class="w"> </span><span class="l">vllm-qwen</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">template</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">metadata</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">labels</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">app</span><span class="p">:</span><span class="w"> </span><span class="l">vllm-qwen</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">spec</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">containers</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">vllm</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">vllm/vllm-openai:v0.28.0</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">args</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span>- <span class="s2">&#34;--model=Qwen/Qwen3.5-0.8B&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span>- <span class="s2">&#34;--gpu-memory-utilization=0.7&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span>- <span class="s2">&#34;--max-model-len=8192&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span>- <span class="nt">containerPort</span><span class="p">:</span><span class="w"> </span><span class="m">8000</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">resources</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="nt">limits</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">              </span><span class="nt">nvidia.com/gpu</span><span class="p">:</span><span class="w"> </span><span class="m">1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">volumeMounts</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">hf-cache</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">              </span><span class="nt">mountPath</span><span class="p">:</span><span class="w"> </span><span class="l">/root/.cache/huggingface</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">dshm</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">              </span><span class="nt">mountPath</span><span class="p">:</span><span class="w"> </span><span class="l">/dev/shm</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">hf-cache</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">emptyDir</span><span class="p">:</span><span class="w"> </span>{}<span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">dshm</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">emptyDir</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="nt">medium</span><span class="p">:</span><span class="w"> </span><span class="l">Memory</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="nt">sizeLimit</span><span class="p">:</span><span class="w"> </span><span class="l">2Gi</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nn">---</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">apiVersion</span><span class="p">:</span><span class="w"> </span><span class="l">v1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">kind</span><span class="p">:</span><span class="w"> </span><span class="l">Service</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">metadata</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">vllm-qwen</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">spec</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">selector</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">app</span><span class="p">:</span><span class="w"> </span><span class="l">vllm-qwen</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="nt">port</span><span class="p">:</span><span class="w"> </span><span class="m">8000</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">targetPort</span><span class="p">:</span><span class="w"> </span><span class="m">8000</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">type</span><span class="p">:</span><span class="w"> </span><span class="l">ClusterIP</span><span class="w">
</span></span></span></code></pre></div><p>Two practical notes on this manifest:</p>
<ul>
<li><strong>Image version pinning:</strong> I pin the image version (<code>v0.28.0</code>) here so the deployment remains reproducible. You can update the image tag after validating compatibility with your CUDA and model version.</li>
<li><strong>Hugging Face cache:</strong> <code>emptyDir</code> is used here for simplicity. The Hugging Face model cache is lost when the pod is recreated. For repeated testing or larger models, consider using a PersistentVolumeClaim (PVC) or a host-mounted path.</li>
</ul>
<p>Apply the manifest and watch the deployment roll out:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">kubectl apply -f vllm-qwen-k8s.yaml
</span></span><span class="line"><span class="cl">kubectl get pods,svc
</span></span></code></pre></div><figure class="post-screenshot">
    <img loading="lazy" src="https://vishctl.dev/images/posts/vllm-wsl2-minikube/10-vllm-deployment-and-service-running.png"
         alt="vLLM deployment and service running"/> <figcaption>
            <p>Applying vllm-qwen-k8s.yaml and confirming the vLLM pod reaches 1/1 Running status alongside the ClusterIP service.</p>
        </figcaption>
</figure>

<p>Stream the pod logs to inspect the vLLM initialization sequence:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">kubectl logs vllm-qwen-5c8bbc786f-gdpmj -f
</span></span></code></pre></div><figure class="post-screenshot">
    <img loading="lazy" src="https://vishctl.dev/images/posts/vllm-wsl2-minikube/11-vllm-pod-startup-logs.png"
         alt="vLLM pod engine initialization logs"/> <figcaption>
            <p>vLLM v0.28.0 engine initialization: resolving Qwen3_5ForConditionalGeneration, setting max model length to 8192, and warming up CUDA graphs.</p>
        </figcaption>
</figure>

<p>Once PyTorch compilation and CUDA graph capture finish, the ASGI application completes startup and begins listening on port 8000:</p>
<figure class="post-screenshot">
    <img loading="lazy" src="https://vishctl.dev/images/posts/vllm-wsl2-minikube/12-vllm-server-ready-and-metrics.png"
         alt="vLLM application startup complete and request logs"/> <figcaption>
            <p>vLLM application startup complete: exposing OpenAI-compatible endpoints (/v1/chat/completions) with live throughput metrics.</p>
        </figcaption>
</figure>

<hr>
<h2 id="testing-inference-and-hardware-telemetry">Testing Inference and Hardware Telemetry</h2>
<p>Forward port 8000 from the cluster service to the local machine:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">kubectl port-forward svc/vllm-qwen 8000:8000
</span></span></code></pre></div><p>Now issue an OpenAI-standard chat completion request using <code>curl</code>:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">curl http://localhost:8000/v1/chat/completions <span class="se">\
</span></span></span><span class="line"><span class="cl">  -H <span class="s2">&#34;Content-Type: application/json&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  -d <span class="s1">&#39;{
</span></span></span><span class="line"><span class="cl"><span class="s1">    &#34;model&#34;: &#34;Qwen/Qwen3.5-0.8B&#34;,
</span></span></span><span class="line"><span class="cl"><span class="s1">    &#34;messages&#34;: [{&#34;role&#34;: &#34;user&#34;, &#34;content&#34;: &#34;Say hello in one sentence.&#34;}]
</span></span></span><span class="line"><span class="cl"><span class="s1">  }&#39;</span>
</span></span></code></pre></div><figure class="post-screenshot">
    <img loading="lazy" src="https://vishctl.dev/images/posts/vllm-wsl2-minikube/13-vllm-curl-chat-completion-response.png"
         alt="Calling vLLM chat completions API with curl"/> <figcaption>
            <p>Inference test: 48 tokens generated cleanly through the OpenAI-compatible vLLM API running in Minikube.</p>
        </figcaption>
</figure>

<p>The response returned without issues, with server logs showing generation throughput averaging 23.5 tokens/s.</p>
<p>Finally, checking Windows Task Manager confirms the hardware utilization on the host:</p>
<figure class="post-screenshot">
    <img loading="lazy" src="https://vishctl.dev/images/posts/vllm-wsl2-minikube/14-windows-task-manager-gpu-vram-usage.png"
         alt="Windows Task Manager GPU memory usage"/> <figcaption>
            <p>Windows Task Manager telemetry: 4.4 GB of 8.0 GB dedicated VRAM allocated to the WSL2 vLLM pod at 45°C.</p>
        </figcaption>
</figure>

<p>Dedicated GPU memory held at <strong>4.4 / 8.0 GB</strong> (in line with the <code>--gpu-memory-utilization 0.7</code> setting plus desktop display overhead), with the GPU temperature at 45°C.</p>
<hr>
<h2 id="beyond-local-dev-why-production-clusters-use-the-nvidia-gpu-operator">Beyond Local Dev: Why Production Clusters Use the NVIDIA GPU Operator</h2>
<p>For a single-node laptop or a Minikube sandbox, managing drivers on the host and running a standalone device plugin DaemonSet gets the job done.</p>
<p>In production Kubernetes clusters, manual node configuration does not scale. When you operate multi-node clusters across cloud providers or bare metal, nodes get provisioned dynamically by autoscalers, Linux kernel patch levels drift, and different GPU architectures (such as A100, H100, or L40S) coexist in the same cluster.</p>
<p>This is why production setups deploy the <a href="https://docs.nvidia.com/datacenter/cloud-native/gpu-operator/latest/">NVIDIA GPU Operator</a> instead of managing the device plugin directly.</p>
<p>The GPU Operator automates the deployment and lifecycle management of the NVIDIA software stack required for GPU-enabled Kubernetes nodes. Driven by the <code>ClusterPolicy</code> Custom Resource Definition, it manages several components depending on your cluster configuration:</p>
<ol>
<li><strong>Driver Management:</strong> Depending on the operating environment, the Operator can compile and load the NVIDIA driver container matched to the node kernel, or use pre-installed host drivers (common in managed Kubernetes environments like GKE, EKS, or AKS).</li>
<li><strong>Container Toolkit and CDI Configuration:</strong> Depending on the Operator version and runtime configuration, GPU injection can be configured through direct NVIDIA runtime integration or CDI-based device injection (Container Device Interface), which modern releases enable by default.</li>
<li><strong>Device Plugin Supervision:</strong> It deploys and manages the lifecycle of the NVIDIA Kubernetes Device Plugin DaemonSet, keeping it in sync with driver and runtime availability.</li>
<li><strong>Node Feature Discovery (GFD):</strong> It automatically detects physical GPU capabilities and labels each node (such as <code>nvidia.com/gpu.product: NVIDIA-A100-SXM4-80GB</code> or <code>nvidia.com/gpu.family: ampere</code>). Workloads can then target specific GPU models using standard Kubernetes <code>nodeSelector</code> or affinity rules.</li>
<li><strong>Cluster Metrics with DCGM Exporter:</strong> It runs the Data Center GPU Manager (DCGM) exporter to collect metrics (GPU compute utilization, VRAM usage, temperature, power draw, and memory bandwidth) and expose them to Prometheus for cluster monitoring and alerting.</li>
<li><strong>MIG Management:</strong> On enterprise GPUs that support Multi-Instance GPU (MIG), the Operator can dynamically partition a single physical GPU into isolated hardware instances, allowing smaller workloads to share an A100 or H100 without memory interference.</li>
</ol>
<p>In short: the device plugin provides basic GPU scheduling for pods. The GPU Operator manages the operational lifecycle, monitoring, and driver stack required to run GPU infrastructure reliably in production.</p>
<hr>
<h2 id="key-takeaways">Key Takeaways</h2>
<ol>
<li><strong>Docker Desktop Kubernetes isolates nodes without GPU passthrough:</strong> In tested setups, <code>desktop-control-plane</code> runs in a nested <code>sysbox-runc</code> sandbox without GPU device passthrough flags.</li>
<li><strong>Minikube on native Docker CE in WSL2 works reliably:</strong> Launching Minikube with <code>--driver=docker --container-runtime=docker --gpus=all</code> automatically activates the NVIDIA device plugin addon.</li>
<li><strong>Understand device plugin vs node prerequisites:</strong> The device plugin only manages discovery and Kubelet allocation. Host drivers, Container Toolkit, and runtime configuration must already exist on the worker node.</li>
<li><strong>Always size <code>/dev/shm</code> in Kubernetes:</strong> Multiprocessing in PyTorch and vLLM quickly exceeds the default 64 MB tmpfs mount. Mount an <code>emptyDir</code> memory volume backed by host RAM.</li>
<li><strong>Budget VRAM carefully:</strong> Setting <code>--gpu-memory-utilization 0.7</code> on a compact model like Qwen3.5-0.8B preserves headroom for the KV cache on an 8 GB GPU.</li>
<li><strong>Deploy the NVIDIA GPU Operator in production:</strong> Production multi-node clusters rely on the GPU Operator to manage drivers, CDI device injection, node feature discovery, and DCGM monitoring.</li>
</ol>
<hr>
<p>Thanks for reading! If you run into issues setting up GPU passthrough on your machine, have questions, or have suggestions for other local AI setups to explore, leave a comment below.</p>
]]></content:encoded></item><item><title>Running a 35B MoE Model on an 8 GB Laptop GPU: Testing FreeToken</title><link>https://vishctl.dev/posts/running-freetoken-on-8gb-laptop-gpu/</link><pubDate>Mon, 07 Sep 2026 14:30:00 +0800</pubDate><guid>https://vishctl.dev/posts/running-freetoken-on-8gb-laptop-gpu/</guid><category>ai</category><category>local-llm</category><category>freetoken</category><category>gpu</category><category>homelab</category><category>nvidia</category><description>Testing FreeToken on an RTX 4070 Laptop GPU: running Qwen 3.6 35B-A3B NVFP4 across 8 GB VRAM and 32 GB RAM with hierarchical memory offloading.</description><content:encoded><![CDATA[<p>Running large language models locally usually comes down to a hard hardware boundary: video RAM. If a model does not fit into your GPU&rsquo;s VRAM, performance usually falls off a cliff as soon as standard runtimes fall back to system memory over the PCIe bus.</p>
<p>Mixture of Experts (MoE) architectures offer an attractive theoretical escape hatch. While the total parameter count can be large (30B to 70B+), only a sparse subset of expert layers activates for any given token. However, standard local runtimes still require loading the entire weight footprint into memory, which puts 30B+ models out of reach for everyday consumer laptops with 8 GB of VRAM.</p>
<p>Enter <a href="https://github.com/FlashML-org/FreeToken">FreeToken</a> by FlashML, backed by their research paper (<a href="https://arxiv.org/abs/2608.16157">arXiv:2608.16157</a>). FreeToken is an edge inference runtime designed specifically to run frontier MoE models on consumer hardware by dynamically managing a hierarchical cache between GPU VRAM and host RAM.</p>
<p>I recently downloaded FreeToken to put their claims to the test on my modest laptop GPU. Here is what the setup looked like, how FreeToken handles memory under the hood, and the real-world generation numbers I observed.</p>
<h3 id="the-test-rig">The Test Rig</h3>
<p>My test machine is a portable laptop, not a high-end multi-GPU workstation:</p>
<ul>
<li><strong>CPU:</strong> 13th Gen Intel Core i9-13900H (14 cores, 20 threads)</li>
<li><strong>GPU:</strong> NVIDIA GeForce RTX 4070 Laptop GPU (8.0 GiB VRAM)</li>
<li><strong>Host RAM:</strong> 32 GB DDR5 (31.7 GiB usable)</li>
<li><strong>Operating System:</strong> Windows 11</li>
<li><strong>Runtime:</strong> FreeToken Desktop (v0.2.0-beta.17)</li>
</ul>
<figure class="post-screenshot">
    <img loading="lazy" src="https://vishctl.dev/images/posts/freetoken-8gb-laptop-gpu/01-freetoken-website-download.png"
         alt="FreeToken website download page"/> <figcaption>
            <p>The FreeToken landing page highlights bringing frontier models to consumer edge hardware.</p>
        </figcaption>
</figure>

<p>On paper, an 8 GB VRAM budget makes running a 35-billion parameter model look impossible. In traditional setups, 8 GB VRAM limits you to 7B or 8B parameter models in 4-bit quantizations (such as Q4_K_M). Attempting to load a 35B model typically triggers out-of-memory errors or slows inference to a crawl.</p>
<h3 id="installing-freetoken-and-exploring-the-library">Installing FreeToken and Exploring the Library</h3>
<p>The FreeToken desktop app is available to download directly from the <a href="https://www.flashml.ai/">FlashML website</a>. It provides a self-contained installer that is quick to set up on Windows. When you launch it, the interface automatically detects your hardware specs, available VRAM, and system RAM, and presents a curated library of models optimized for edge offloading.</p>
<figure class="post-screenshot">
    <img loading="lazy" src="https://vishctl.dev/images/posts/freetoken-8gb-laptop-gpu/02-hardware-specs-and-model-library.png"
         alt="FreeToken hardware detection and model library"/> <figcaption>
            <p>FreeToken accurately detects the RTX 4070 Laptop GPU (8 GB) and 32 GB RAM, recommending compatible models.</p>
        </figcaption>
</figure>

<p>For this test, I selected <code>Qwen3.6-35B-A3B NVFP4</code> (<code>nvidia/Qwen3.6-35B-A3B-NVFP4</code>). This is a 35-billion parameter MoE model using NVIDIA&rsquo;s 4-bit floating point (NVFP4) format, with an initial download size of 21.9 GiB.</p>
<p>Before loading the model, the console dashboard showed clean baseline resource usage:</p>
<ul>
<li><strong>GPU VRAM:</strong> 0.4 / 8.0 GiB</li>
<li><strong>Host RAM:</strong> 11.0 / 31.7 GiB</li>
<li><strong>GPU Temp:</strong> 42°C at idle</li>
</ul>
<figure class="post-screenshot">
    <img loading="lazy" src="https://vishctl.dev/images/posts/freetoken-8gb-laptop-gpu/03-system-resources-idle.png"
         alt="System resources at idle before model loading"/> <figcaption>
            <p>Idle console telemetry: 0.4 GiB VRAM used, ready for model weights.</p>
        </figcaption>
</figure>

<h3 id="weight-conversion-the-ftw-format">Weight Conversion: The FTW Format</h3>
<p>Once the 21.9 GiB download finished, FreeToken flagged the model as needing conversion before it could be launched.</p>
<figure class="post-screenshot">
    <img loading="lazy" src="https://vishctl.dev/images/posts/freetoken-8gb-laptop-gpu/05-download-complete-needs-conversion.png"
         alt="Download complete prompt indicating conversion is required"/> <figcaption>
            <p>Raw model download complete (21.9 GiB), prompting for weight conversion.</p>
        </figcaption>
</figure>

<p>FreeToken converts raw Hugging Face weights into its proprietary format called <strong>FTW</strong> (FreeToken Weight format). During this process, the engine repacks and organizes the tensors into memory-mapped structures optimized for rapid streaming between host RAM and GPU memory.</p>
<figure class="post-screenshot">
    <img loading="lazy" src="https://vishctl.dev/images/posts/freetoken-8gb-laptop-gpu/06-converting-weights-to-ftw.png"
         alt="Converting raw weights into FTW format"/> <figcaption>
            <p>Repacking raw weights into FTW format for fast memory streaming.</p>
        </figcaption>
</figure>

<p>The conversion took just over a minute on the i9-13900H and NVMe storage, producing a compact 19.5 GiB weight package.</p>
<figure class="post-screenshot">
    <img loading="lazy" src="https://vishctl.dev/images/posts/freetoken-8gb-laptop-gpu/07-conversion-complete-ftw.png"
         alt="Conversion complete showing 19.5 GiB repacked size"/> <figcaption>
            <p>Conversion complete: 19.5 GiB ready for execution.</p>
        </figcaption>
</figure>

<h3 id="loading-the-model-and-memory-allocation">Loading the Model and Memory Allocation</h3>
<p>Starting the model triggers the weight allocation phase. Instead of attempting to cram the entire 19.5 GiB into the 8 GB VRAM, FreeToken partitions the workload across both memory tiers.</p>
<figure class="post-screenshot">
    <img loading="lazy" src="https://vishctl.dev/images/posts/freetoken-8gb-laptop-gpu/08-loading-weights-into-vram.png"
         alt="Loading model weights into VRAM"/> <figcaption>
            <p>Starting model and populating active weights into VRAM.</p>
        </figcaption>
</figure>

<p>Once loaded, the memory profile was eye-opening:</p>
<ul>
<li><strong>VRAM Usage:</strong> 6.8 GiB / 8.0 GiB (85% utilization, leaving a healthy buffer for OS display compositing)</li>
<li><strong>Host RAM Usage:</strong> 30.2 GiB / 31.7 GiB (95% utilization)</li>
<li><strong>GPU Status:</strong> Running at 45°C, drawing only 7W at idle</li>
</ul>
<figure class="post-screenshot">
    <img loading="lazy" src="https://vishctl.dev/images/posts/freetoken-8gb-laptop-gpu/09-model-running-vram-ram-usage.png"
         alt="Model running with VRAM and RAM utilization"/> <figcaption>
            <p>Model running: 6.8 GiB in VRAM and 30.2 GiB in system RAM.</p>
        </figcaption>
</figure>

<h3 id="the-cache-architecture-under-the-hood">The Cache Architecture Under the Hood</h3>
<p>The console view provides detailed insight into how FreeToken manages this memory footprint:</p>
<ol>
<li><strong>MoE Expert Cache:</strong> FreeToken allocated 916 active expert slots in VRAM out of a total pool of 10,240 slots, consuming 1.51 GiB of VRAM. As different experts are needed during generation, they are dynamically paged in and out from the host RAM cache.</li>
<li><strong>KV Cache:</strong> 8K tokens of context were allocated in VRAM, consuming just 0.16 GiB.</li>
<li><strong>Mamba State Slots:</strong> 24 state slots were reserved, using 1.44 GiB of VRAM.</li>
</ol>
<figure class="post-screenshot">
    <img loading="lazy" src="https://vishctl.dev/images/posts/freetoken-8gb-laptop-gpu/10-cache-architecture-console.png"
         alt="Cache architecture telemetry in console"/> <figcaption>
            <p>Detailed cache breakdown: MoE expert cache, KV cache, and Mamba state slots in VRAM.</p>
        </figcaption>
</figure>

<p>This hierarchical design is the core reason the model can run without crashing. Inactive experts reside in system RAM (filling ~30 GB of system memory), while the active attention layers, KV cache, and currently triggered experts reside in the 8 GB VRAM.</p>
<h3 id="real-world-performance--generation-speed">Real-World Performance &amp; Generation Speed</h3>
<p>To evaluate real-world performance, I tested a conversational prompt with reasoning enabled:</p>
<blockquote>
<p><strong>User:</strong> &ldquo;hi, how are you ?&rdquo;</p>
</blockquote>
<p>The model engaged its full thinking process, taking 1 minute and 9 seconds to plan and structure its response, and then generated 393 tokens at <strong>4.3 tokens per second</strong>.</p>
<figure class="post-screenshot">
    <img loading="lazy" src="https://vishctl.dev/images/posts/freetoken-8gb-laptop-gpu/11-chat-inference-test.png"
         alt="Chat inference test output and token speed"/> <figcaption>
            <p>Chat generation benchmark: 393 tokens generated at 4.3 tok/s with full chain-of-thought thinking.</p>
        </figcaption>
</figure>

<p>While 4.3 tokens per second is not real-time voice conversational speed, it is remarkably steady for running a <strong>35-billion parameter model on an 8 GB laptop GPU</strong>. For tasks like code review, background agent execution, document analysis, or local drafting, 4.3 tok/s is completely practical.</p>
<p>The model detail sheet confirms the architecture and licensing details:</p>
<ul>
<li><strong>Base Repository:</strong> <code>nvidia/Qwen3.6-35B-A3B-NVFP4</code></li>
<li><strong>Context Length:</strong> 262,144 tokens (256K context window)</li>
<li><strong>License:</strong> Apache 2.0</li>
</ul>
<figure class="post-screenshot">
    <img loading="lazy" src="https://vishctl.dev/images/posts/freetoken-8gb-laptop-gpu/12-model-details-huggingface.png"
         alt="Model detail card on FreeToken"/> <figcaption>
            <p>Model details: 262K context window, NVFP4 quantization, and Apache 2.0 license.</p>
        </figcaption>
</figure>

<h3 id="built-in-agentic-tooling--local-endpoints">Built-in Agentic Tooling &amp; Local Endpoints</h3>
<p>FreeToken also includes built-in support for developer tools and coding agents.</p>
<p>The <strong>Apps</strong> tab exposes both OpenAI-compatible and Anthropic-compatible local HTTP endpoints:</p>
<ul>
<li><strong>OpenAI Endpoint:</strong> <code>http://127.0.0.1:1919/v1</code></li>
<li><strong>Anthropic Messages Endpoint:</strong> <code>http://127.0.0.1:1919/v1/messages</code></li>
</ul>
<figure class="post-screenshot">
    <img loading="lazy" src="https://vishctl.dev/images/posts/freetoken-8gb-laptop-gpu/04-local-api-endpoints-and-apps.png"
         alt="FreeToken Apps tab showing local endpoints and integrations"/> <figcaption>
            <p>Local OpenAI and Anthropic compatible endpoints with one-click configurations for Claude Code, Codex, opencode, and more.</p>
        </figcaption>
</figure>

<p>FreeToken provides quick configuration presets for popular coding assistants:</p>
<ul>
<li><strong>Claude Code:</strong> Configurable via local Anthropic endpoint routing</li>
<li><strong>Codex / opencode / openclaw:</strong> One-click environment setups</li>
<li><strong>Hermes / DeepSeek Harness:</strong> Direct command-line integration</li>
</ul>
<p>Having drop-in compatibility with both OpenAI and Anthropic API schemas means you can point tools like Claude Code directly to your localhost port without running a reverse proxy.</p>
<h3 id="where-freetoken-fits">Where FreeToken Fits</h3>
<p>FreeToken is a relatively young product, but its approach solves a real constraint for local inference.</p>
<h4 id="strengths">Strengths</h4>
<ul>
<li><strong>Bypasses the VRAM limit:</strong> Running a 35B MoE model on an 8 GB laptop GPU without manual layer-by-layer offload tuning works out of the box.</li>
<li><strong>Hierarchical caching:</strong> The dynamic MoE expert cache keeps VRAM usage around 6.8 GiB, preventing driver resets and CUDA out-of-memory crashes.</li>
<li><strong>Local developer endpoints:</strong> Dual support for OpenAI and Anthropic endpoints makes integration with modern coding agents straightforward.</li>
<li><strong>Simple setup on Windows:</strong> No manual compilation, no CUDA toolkit troubleshooting, and a clean desktop GUI.</li>
</ul>
<h4 id="limitations">Limitations</h4>
<ul>
<li><strong>System RAM is the real requirement:</strong> While VRAM requirements drop significantly, your host RAM must be large enough to hold the repacked weights. On my machine, the model took 30.2 GB of system RAM. If your laptop only has 16 GB of RAM, you will not be able to run 35B models this way.</li>
<li><strong>Initial conversion step:</strong> Each new model requires a one-time repacking step into the FTW format before the first run.</li>
<li><strong>Generation throughput:</strong> At roughly 4.3 tokens per second, it is well suited for asynchronous workloads, coding agents, and complex reasoning queries, but not for instant conversational back-and-forth.</li>
</ul>
<h3 id="wrap-up">Wrap-up</h3>
<p>If you have a laptop with 32 GB of system RAM and a modest 8 GB NVIDIA GPU, FreeToken is worth checking out. It makes 35B-class MoE models runnable on consumer hardware that would otherwise choke on them, keeping your data completely local.</p>
<p>You can download the desktop app directly from <a href="https://www.flashml.ai/">FlashML</a>, check out the project on <a href="https://github.com/FlashML-org/FreeToken">GitHub</a>, and read their research paper on <a href="https://arxiv.org/abs/2608.16157">arXiv:2608.16157</a>.</p>
]]></content:encoded></item><item><title>Running llama.cpp on a 32 GB MacBook Air: A Direct Comparison with Ollama</title><link>https://vishctl.dev/posts/running-llama-cpp-on-32gb-macbook-air/</link><pubDate>Thu, 03 Sep 2026 10:30:00 +0800</pubDate><guid>https://vishctl.dev/posts/running-llama-cpp-on-32gb-macbook-air/</guid><category>ai</category><category>local-llm</category><category>llama-cpp</category><category>ollama</category><category>apple-silicon</category><category>benchmarks</category><description>Compile llama.cpp with Metal support on Apple Silicon, serve local GGUF models, and benchmark prompt eval and generation speeds head-to-head against Ollama.</description><content:encoded><![CDATA[<p>In the <a href="https://vishctl.dev/posts/running-ollama-on-32gb-macbook-air/">previous post</a>, I ran Ornith 1.5 9B on my 32 GB MacBook Air using Ollama and recorded baseline token-generation speeds on short prompts. Ollama is great for getting up and running quickly, but under the hood, its inference engine is built on <a href="https://github.com/ggml-org/llama.cpp">llama.cpp</a>.</p>
<p>In this post, we go one level down: building and running llama.cpp directly, offloading inference to Apple Silicon&rsquo;s Metal GPU, and comparing performance numbers side by side with Ollama on the exact same model and quantization level.</p>
<h3 id="step-1-build-llamacpp-from-source">Step 1: Build llama.cpp from Source</h3>
<p>Building llama.cpp from source on macOS is fast and straightforward. On Apple Silicon, CMake automatically enables Metal support (<code>GGML_METAL=ON</code>) and compiles GPU compute kernels tailored for Apple&rsquo;s unified memory architecture.</p>
<p>Clone the repository and compile the release binaries using all available CPU cores:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">git clone https://github.com/ggml-org/llama.cpp
</span></span><span class="line"><span class="cl"><span class="nb">cd</span> llama.cpp
</span></span><span class="line"><span class="cl">cmake -B build -DCMAKE_BUILD_TYPE<span class="o">=</span>Release
</span></span><span class="line"><span class="cl">cmake --build build --config Release -j<span class="k">$(</span>sysctl -n hw.logicalcpu<span class="k">)</span>
</span></span></code></pre></div><p>Once the build finishes, the binaries live in <code>build/bin</code>. Add them to your current session&rsquo;s <code>$PATH</code>:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nb">export</span> <span class="nv">PATH</span><span class="o">=</span><span class="s2">&#34;</span><span class="k">$(</span><span class="nb">pwd</span><span class="k">)</span><span class="s2">/build/bin:</span><span class="nv">$PATH</span><span class="s2">&#34;</span>
</span></span></code></pre></div><p>To make this permanent across terminal sessions, append it to your shell configuration:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nb">echo</span> <span class="s2">&#34;export PATH=\&#34;</span><span class="k">$(</span><span class="nb">pwd</span><span class="k">)</span><span class="s2">/build/bin:\$PATH\&#34;&#34;</span> &gt;&gt; ~/.zshrc
</span></span><span class="line"><span class="cl"><span class="nb">source</span> ~/.zshrc
</span></span></code></pre></div><p>Confirm that the build succeeded:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">llama-server --version
</span></span></code></pre></div><h3 id="step-2-serve-the-model-out-of-the-box-baseline">Step 2: Serve the Model (Out-of-the-Box Baseline)</h3>
<p>llama.cpp can download GGUF models directly from Hugging Face Hub using the <code>-hf</code> flag, caching weights under <code>~/.cache/huggingface/hub</code>:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">llama-server -hf ornith-ai/Ornith-1.5-9B-GGUF --port <span class="m">8080</span> -ngl <span class="m">99</span>
</span></span></code></pre></div><p>Here is what the flags do:</p>
<ul>
<li><code>-hf ornith-ai/Ornith-1.5-9B-GGUF</code>: Resolves the model on Hugging Face and downloads the default <code>Q4_K_M</code> quant.</li>
<li><code>-ngl 99</code>: Offloads all 99 model layers to the GPU (Metal on Apple Silicon unified memory).</li>
<li><code>--port 8080</code>: Binds the OpenAI-compatible HTTP server to port 8080.</li>
</ul>
<figure class="post-screenshot">
    <img loading="lazy" src="https://vishctl.dev/images/posts/llama-cpp-32gb-macbook-air/01-llama-server-start.png"
         alt="Starting llama-server and downloading Ornith 1.5 9B from Hugging Face"/> <figcaption>
            <p>Starting llama-server with automatic Hugging Face download and full Metal GPU offload.</p>
        </figcaption>
</figure>

<p>During startup, llama.cpp downloads both the multimodal vision projector (<code>mmproj-Ornith-1.5-9B-BF16.gguf</code>) and the main model weights (<code>Ornith-1.5-9B-Q4_K_M.gguf</code>), offloads the layers into Metal, initializes inference slots, and listens on port 8080.</p>
<p>We test this default setup with a short greeting prompt through the OpenAI-compatible API:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">curl http://localhost:8080/v1/chat/completions <span class="se">\
</span></span></span><span class="line"><span class="cl">  -d <span class="s1">&#39;{
</span></span></span><span class="line"><span class="cl"><span class="s1">    &#34;model&#34;: &#34;ornith-1.5-9b&#34;,
</span></span></span><span class="line"><span class="cl"><span class="s1">    &#34;messages&#34;: [{&#34;role&#34;: &#34;user&#34;, &#34;content&#34;: &#34;Hello!&#34;}]
</span></span></span><span class="line"><span class="cl"><span class="s1">  }&#39;</span>
</span></span></code></pre></div><figure class="post-screenshot">
    <img loading="lazy" src="https://vishctl.dev/images/posts/llama-cpp-32gb-macbook-air/02-llama-server-first-request.png"
         alt="Calling the llama-server chat completions API"/> <figcaption>
            <p>First chat completion request via curl, returning detailed server-side timing breakdowns.</p>
        </figcaption>
</figure>

<p>The response returns a complete timing breakdown in the <code>timings</code> object:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-fallback" data-lang="fallback"><span class="line"><span class="cl">prompt eval time = 496.21 ms / 12 tokens (24.18 tokens per second)
</span></span><span class="line"><span class="cl">eval time        = 5527.17 ms / 96 tokens (17.19 tokens per second)
</span></span></code></pre></div><p>Compare that to Ollama&rsquo;s numbers on the same model and quant from the <a href="https://vishctl.dev/posts/running-ollama-on-32gb-macbook-air/">earlier post</a>:</p>
<table>
	<thead>
			<tr>
					<th>Metric</th>
					<th>Ollama (Default)</th>
					<th>llama.cpp (Default, no <code>-fa</code>)</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><strong>Prompt Eval Speed</strong></td>
					<td><strong>37.13 tokens/s</strong></td>
					<td>24.18 tokens/s</td>
			</tr>
			<tr>
					<td><strong>Generation Speed</strong></td>
					<td>16.68 tokens/s</td>
					<td><strong>17.19 tokens/s</strong></td>
			</tr>
	</tbody>
</table>
<p>Generation speed is practically neck-and-neck (~17 tokens/s). However, prompt evaluation (prefill latency) was noticeably slower on vanilla llama.cpp out of the box (24.18 tokens/s vs. Ollama&rsquo;s 37.13 tokens/s).</p>
<p>Ollama enables FlashAttention and sensible batch sizes by default, while raw llama.cpp keeps conservative baseline settings unless configured explicitly.</p>
<h3 id="step-3-verifying-the-quants">Step 3: Verifying the Quants</h3>
<p>Before comparing benchmark numbers, it is critical to confirm that both engines are executing the exact same quantization format. Comparing different quants (e.g. Q4_K_M vs Q8_0) would invalidate any performance conclusions.</p>
<p>Check the quant llama.cpp loaded:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">curl -s http://localhost:8080/v1/models <span class="p">|</span> jq
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl"><span class="s2">&#34;ftype&#34;</span><span class="err">:</span> <span class="s2">&#34;Q4_K - Medium&#34;</span>
</span></span></code></pre></div><p>Check the quant Ollama is using:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">curl -s http://localhost:11434/api/show -d <span class="s1">&#39;{&#34;model&#34;: &#34;ornith-1.5:9b&#34;}&#39;</span> <span class="p">|</span> jq <span class="s1">&#39;.details.quantization_level&#39;</span>
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl"><span class="s2">&#34;Q4_K_M&#34;</span>
</span></span></code></pre></div><p>Both runtimes are confirmed to be executing <code>Q4_K_M</code>. Precision and model size are strictly identical.</p>
<h3 id="step-4-leveling-the-playing-field-with-flash-attention--batch-tuning">Step 4: Leveling the Playing Field with Flash Attention &amp; Batch Tuning</h3>
<p>To give llama.cpp parity with Ollama&rsquo;s runtime optimizations, we restart <code>llama-server</code> with FlashAttention enabled and explicit batch sizes configured:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">llama-server -hf ornith-ai/Ornith-1.5-9B-GGUF --port <span class="m">8080</span> -ngl <span class="m">99</span> -fa on -b <span class="m">2048</span> -ub <span class="m">512</span>
</span></span></code></pre></div><p>Here is what these tuning flags configure:</p>
<ul>
<li><code>-fa on</code> (or <code>--flash-attn on</code>): Enables FlashAttention kernels for Apple Silicon Metal. This significantly accelerates prompt evaluation (prefill) and reduces memory bandwidth overhead.</li>
<li><code>-b 2048</code> (<code>--batch-size</code>): Sets the logical batch size for prompt evaluation.</li>
<li><code>-ub 512</code> (<code>--ubatch-size</code>): Sets the physical micro-batch size dispatched to Metal compute passes, keeping the GPU pipelines fully saturated.</li>
</ul>
<h3 id="step-5-side-by-side-benchmark-on-longer-generation">Step 5: Side-by-Side Benchmark on Longer Generation</h3>
<p>Both engines were given the same prompt:</p>
<blockquote>
<p><code>&quot;give a 25 line story&quot;</code></p>
</blockquote>
<p>A longer generation produces several hundred tokens, giving a much more reliable measurement of steady-state generation speed than short one-liners.</p>
<p>Here are the side-by-side results on the 32 GB MacBook Air:</p>
<table>
	<thead>
			<tr>
					<th>Metric</th>
					<th>Ollama (<code>ornith-1.5:9b</code>)</th>
					<th>llama.cpp (<code>-fa on -b 2048 -ub 512</code>)</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><strong>Prompt Eval Count</strong></td>
					<td>17 tokens</td>
					<td>18 tokens</td>
			</tr>
			<tr>
					<td><strong>Prompt Eval Speed</strong></td>
					<td>46.01 tokens/s</td>
					<td><strong>48.68 tokens/s</strong></td>
			</tr>
			<tr>
					<td><strong>Generation Count</strong></td>
					<td>496 tokens</td>
					<td>436 tokens</td>
			</tr>
			<tr>
					<td><strong>Generation Speed</strong></td>
					<td>16.42 tokens/s</td>
					<td><strong>17.07 tokens/s</strong></td>
			</tr>
			<tr>
					<td><strong>Total Duration</strong></td>
					<td>30.58 s</td>
					<td><strong>25.85 s</strong></td>
			</tr>
	</tbody>
</table>
<p>With FlashAttention and batch tuning enabled, llama.cpp matches or slightly edges out Ollama across both prompt evaluation (<strong>48.68 vs. 46.01 tokens/s</strong>) and token generation speed (<strong>17.07 vs. 16.42 tokens/s</strong>).</p>
<h4 id="ollama-command--stats">Ollama Command &amp; Stats</h4>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">ollama run ornith-1.5:9b --verbose
</span></span><span class="line"><span class="cl">&gt;&gt;&gt; give a <span class="m">25</span> line story
</span></span><span class="line"><span class="cl">Thinking...
</span></span><span class="line"><span class="cl">...done thinking.
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="o">[</span>story output omitted<span class="o">]</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">total duration:       30.582007541s
</span></span><span class="line"><span class="cl">load duration:        2.821208ms
</span></span><span class="line"><span class="cl">prompt <span class="nb">eval</span> count:    <span class="m">17</span> token<span class="o">(</span>s<span class="o">)</span>
</span></span><span class="line"><span class="cl">prompt <span class="nb">eval</span> duration: 369.481ms
</span></span><span class="line"><span class="cl">prompt <span class="nb">eval</span> rate:     46.01 tokens/s
</span></span><span class="line"><span class="cl"><span class="nb">eval</span> count:           <span class="m">496</span> token<span class="o">(</span>s<span class="o">)</span>
</span></span><span class="line"><span class="cl"><span class="nb">eval</span> duration:        30.204706s
</span></span><span class="line"><span class="cl"><span class="nb">eval</span> rate:            16.42 tokens/s
</span></span></code></pre></div><h4 id="llamacpp-server-startup--timing-logs">llama.cpp Server Startup &amp; Timing Logs</h4>
<p>Server startup and live slot execution log:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">➜  ~ llama-server -hf ornith-ai/Ornith-1.5-9B-GGUF --port 8080 -ngl 99 -fa on -b 2048 -ub 512
</span></span><span class="line"><span class="cl">0.00.729.018 I cmn  common_param: common_params_print_info: verbosity = 3 (adjust with the `-lv N` CLI arg)
</span></span><span class="line"><span class="cl">0.00.729.664 W srv  llama_server: -----------------
</span></span><span class="line"><span class="cl">0.00.729.667 W srv  llama_server: CORS is set to allow all origins (&#39;*&#39;) and no API key is set
</span></span><span class="line"><span class="cl">0.00.729.668 W srv  llama_server: this can be a security risk (cross-origin attacks)
</span></span><span class="line"><span class="cl">0.00.729.668 W srv  llama_server: more info: https://github.com/ggml-org/llama.cpp/pull/25655
</span></span><span class="line"><span class="cl">0.00.729.669 W srv  llama_server: -----------------
</span></span><span class="line"><span class="cl">0.00.731.435 I srv    load_model: loading model &#39;ornith-ai/Ornith-1.5-9B-GGUF&#39;
</span></span><span class="line"><span class="cl">0.01.177.317 W model has unused tensor blk.32.attn_norm.weight (size = 16384 bytes) -- ignoring
</span></span><span class="line"><span class="cl">0.01.177.324 W model has unused tensor blk.32.post_attention_norm.weight (size = 16384 bytes) -- ignoring
</span></span><span class="line"><span class="cl">0.01.177.329 W model has unused tensor blk.32.attn_q.weight (size = 18874368 bytes) -- ignoring
</span></span><span class="line"><span class="cl">0.01.177.331 W model has unused tensor blk.32.attn_k.weight (size = 2359296 bytes) -- ignoring
</span></span><span class="line"><span class="cl">0.01.177.333 W model has unused tensor blk.32.attn_v.weight (size = 3440640 bytes) -- ignoring
</span></span><span class="line"><span class="cl">0.01.177.338 W model has unused tensor blk.32.attn_output.weight (size = 9437184 bytes) -- ignoring
</span></span><span class="line"><span class="cl">0.01.177.340 W model has unused tensor blk.32.attn_q_norm.weight (size = 1024 bytes) -- ignoring
</span></span><span class="line"><span class="cl">0.01.177.341 W model has unused tensor blk.32.attn_k_norm.weight (size = 1024 bytes) -- ignoring
</span></span><span class="line"><span class="cl">0.01.177.343 W model has unused tensor blk.32.ffn_gate.weight (size = 28311552 bytes) -- ignoring
</span></span><span class="line"><span class="cl">0.01.177.345 W model has unused tensor blk.32.ffn_down.weight (size = 41287680 bytes) -- ignoring
</span></span><span class="line"><span class="cl">0.01.177.347 W model has unused tensor blk.32.ffn_up.weight (size = 28311552 bytes) -- ignoring
</span></span><span class="line"><span class="cl">0.01.177.350 W model has unused tensor blk.32.nextn.eh_proj.weight (size = 18874368 bytes) -- ignoring
</span></span><span class="line"><span class="cl">0.01.177.353 W model has unused tensor blk.32.nextn.enorm.weight (size = 16384 bytes) -- ignoring
</span></span><span class="line"><span class="cl">0.01.177.354 W model has unused tensor blk.32.nextn.hnorm.weight (size = 16384 bytes) -- ignoring
</span></span><span class="line"><span class="cl">0.01.177.359 W model has unused tensor blk.32.nextn.shared_head_norm.weight (size = 16384 bytes) -- ignoring
</span></span><span class="line"><span class="cl">0.01.799.972 I cmn          init: llama threadpool init, n_threads = 4
</span></span><span class="line"><span class="cl">0.01.959.375 W load_hparams: Qwen-VL models require at minimum 1024 image tokens to function correctly on grounding tasks
</span></span><span class="line"><span class="cl">0.01.959.377 W load_hparams: if you encounter problems with accuracy, try adding --image-min-tokens 1024
</span></span><span class="line"><span class="cl">0.01.959.377 W load_hparams: more info: https://github.com/ggml-org/llama.cpp/issues/16842
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">0.02.172.437 I srv    load_model: loaded multimodal model, &#39;/Users/vishnuhd/.cache/huggingface/hub/models--ornith-ai--Ornith-1.5-9B-GGUF/snapshots/abdd624b12ebf020b767fff532ff44fe552b28c3/mmproj-Ornith-1.5-9B-BF16.gguf&#39;
</span></span><span class="line"><span class="cl">0.02.399.797 I srv    load_model: initializing, n_slots = 4, n_ctx_slot = 262144, kv_unified = &#39;true&#39;
</span></span><span class="line"><span class="cl">0.02.402.615 I srv          init: chat template supports preserving reasoning, consider enabling it via --reasoning-preserve
</span></span><span class="line"><span class="cl">0.02.402.621 I srv  llama_server: model loaded
</span></span><span class="line"><span class="cl">0.02.402.623 I srv  llama_server: listening on http://127.0.0.1:8080
</span></span><span class="line"><span class="cl">0.02.402.623 W srv  llama_server: NOTICE: server default port will be changed to :9931 in a future release
</span></span><span class="line"><span class="cl">0.02.402.623 W srv  llama_server:         ref: https://github.com/ggml-org/llama.cpp/pull/26508
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">0.36.386.802 I slot get_availabl: id  3 | task -1 | selected slot by LRU, t_last = -1
</span></span><span class="line"><span class="cl">0.36.386.826 I slot launch_slot_: id  3 | task 0 | processing task, is_child = 0
</span></span><span class="line"><span class="cl">0.42.586.497 I slot print_timing: id  3 | task 0 | n_gen =    100, tg =  16.98 t/s, tg_3s =  17.15 t/s
</span></span><span class="line"><span class="cl">0.45.633.605 I slot print_timing: id  3 | task 0 | n_gen =    152, tg =  17.01 t/s, tg_3s =  17.07 t/s
</span></span><span class="line"><span class="cl">0.48.679.586 I slot print_timing: id  3 | task 0 | n_gen =    204, tg =  17.03 t/s, tg_3s =  17.07 t/s
</span></span><span class="line"><span class="cl">0.51.718.658 I slot print_timing: id  3 | task 0 | n_gen =    256, tg =  17.04 t/s, tg_3s =  17.11 t/s
</span></span><span class="line"><span class="cl">0.54.746.036 I slot print_timing: id  3 | task 0 | n_gen =    308, tg =  17.07 t/s, tg_3s =  17.18 t/s
</span></span><span class="line"><span class="cl">0.57.778.672 I slot print_timing: id  3 | task 0 | n_gen =    360, tg =  17.08 t/s, tg_3s =  17.15 t/s
</span></span><span class="line"><span class="cl">1.00.817.109 I slot print_timing: id  3 | task 0 | n_gen =    412, tg =  17.08 t/s, tg_3s =  17.11 t/s
</span></span><span class="line"><span class="cl">1.02.233.148 I slot print_timing: id  3 | task 0 | prompt eval time =     369.74 ms /    18 tokens (   20.54 ms per token,    48.68 tokens per second)
</span></span><span class="line"><span class="cl">1.02.233.155 I slot print_timing: id  3 | task 0 |        eval time =   25476.17 ms /   436 tokens (   58.57 ms per token,    17.07 tokens per second)
</span></span><span class="line"><span class="cl">1.02.233.157 I slot print_timing: id  3 | task 0 |       total time =   25845.92 ms /   454 tokens
</span></span><span class="line"><span class="cl">1.02.233.159 I slot print_timing: id  3 | task 0 |    graphs reused =        434
</span></span><span class="line"><span class="cl">1.02.233.187 I slot      release: id  3 | task 0 | stop processing: n_tokens = 453, truncated = 0
</span></span></code></pre></div><p>And the raw curl request (with story output trimmed):</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">curl http://localhost:8080/v1/chat/completions <span class="se">\
</span></span></span><span class="line"><span class="cl">  -d <span class="s1">&#39;{
</span></span></span><span class="line"><span class="cl"><span class="s1">    &#34;model&#34;: &#34;ornith-1.5-9b&#34;,
</span></span></span><span class="line"><span class="cl"><span class="s1">    &#34;messages&#34;: [{&#34;role&#34;: &#34;user&#34;, &#34;content&#34;: &#34;Give me a 25 line story&#34;}]
</span></span></span><span class="line"><span class="cl"><span class="s1">  }&#39;</span>
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;choices&#34;</span><span class="p">:</span> <span class="p">[{</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;finish_reason&#34;</span><span class="p">:</span> <span class="s2">&#34;stop&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;index&#34;</span><span class="p">:</span> <span class="mi">0</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;message&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;role&#34;</span><span class="p">:</span> <span class="s2">&#34;assistant&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;content&#34;</span><span class="p">:</span> <span class="s2">&#34;[story text omitted]&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;reasoning_content&#34;</span><span class="p">:</span> <span class="s2">&#34;[thinking omitted]&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="p">}],</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;created&#34;</span><span class="p">:</span> <span class="mi">1788423730</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;model&#34;</span><span class="p">:</span> <span class="s2">&#34;ornith-ai/Ornith-1.5-9B-GGUF&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;system_fingerprint&#34;</span><span class="p">:</span> <span class="s2">&#34;b10712-daef7b687&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;object&#34;</span><span class="p">:</span> <span class="s2">&#34;chat.completion&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;usage&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;completion_tokens&#34;</span><span class="p">:</span> <span class="mi">436</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;prompt_tokens&#34;</span><span class="p">:</span> <span class="mi">18</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;total_tokens&#34;</span><span class="p">:</span> <span class="mi">454</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;prompt_tokens_details&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;cached_tokens&#34;</span><span class="p">:</span> <span class="mi">0</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="p">},</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;id&#34;</span><span class="p">:</span> <span class="s2">&#34;chatcmpl-dLoeYkd6rvuFieOvUSr9dBxB798twvZi&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;timings&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;cache_n&#34;</span><span class="p">:</span> <span class="mi">0</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;prompt_n&#34;</span><span class="p">:</span> <span class="mi">18</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;prompt_ms&#34;</span><span class="p">:</span> <span class="mf">369.742</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;prompt_per_token_ms&#34;</span><span class="p">:</span> <span class="mf">20.54</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;prompt_per_second&#34;</span><span class="p">:</span> <span class="mf">48.68</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;predicted_n&#34;</span><span class="p">:</span> <span class="mi">436</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;predicted_ms&#34;</span><span class="p">:</span> <span class="mf">25476.174</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;predicted_per_token_ms&#34;</span><span class="p">:</span> <span class="mf">58.57</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;predicted_per_second&#34;</span><span class="p">:</span> <span class="mf">17.07</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><h3 id="key-takeaways">Key Takeaways</h3>
<ol>
<li><strong>Ollama is not faster than llama.cpp</strong>: Under the hood, Ollama is llama.cpp. Its initial out-of-the-box advantage in prompt evaluation comes entirely from default runtime tuning (FlashAttention and batch sizes), not a secret runtime or different quantization.</li>
<li><strong>FlashAttention is essential on Apple Silicon</strong>: Enabling <code>-fa on</code> doubled prompt evaluation speed from <strong>24.18 tokens/s to 48.68 tokens/s</strong>, drastically cutting time-to-first-token.</li>
<li><strong>Consistent throughput</strong>: At 9B parameters with Q4_K_M quantization, Apple Silicon unified memory sustains a rock-solid <strong>~17.1 tokens/sec</strong>.</li>
<li><strong>Why run llama.cpp directly?</strong>: Ollama provides exceptional developer ergonomics for local apps and testing. But direct llama.cpp gives you full control over context allocation, slot limits, KV cache quantization (<code>-ctk</code>, <code>-ctv</code>), and immediate access to upstream features and bugfixes.</li>
</ol>
<p>In the next post, we will explore KV cache quantization and memory profiling to see how far we can stretch long-context windows on a 32 GB machine.</p>
]]></content:encoded></item><item><title>Running Ollama on a 32 GB MacBook Air: A Practical First Setup</title><link>https://vishctl.dev/posts/running-ollama-on-32gb-macbook-air/</link><pubDate>Thu, 03 Sep 2026 09:30:00 +0800</pubDate><guid>https://vishctl.dev/posts/running-ollama-on-32gb-macbook-air/</guid><category>ai</category><category>local-llm</category><category>ollama</category><category>apple-silicon</category><category>homelab</category><description>Install Ollama, pull and run local models, and call the local API on a 32 GB Apple-silicon MacBook Air.</description><content:encoded><![CDATA[<p>I have a 32 GB MacBook Air. It is not a workstation GPU box, but its unified memory makes it a surprisingly capable machine for local models, provided I choose models that fit and keep expectations sensible.</p>
<p>This is the first post in a small, practical series about running models locally. I am starting with Ollama because it gets a model running quickly without building a runtime from source or hand-managing dependencies.</p>
<h3 id="why-ollama">Why Ollama</h3>
<p><a href="https://ollama.com/">Ollama</a> manages model downloads, exposes a straightforward CLI, and starts a local HTTP API. On Apple silicon, it supports the Apple GPU; Ollama&rsquo;s current macOS requirement is Sonoma (14) or newer. The app stores models and configuration under <code>~/.ollama</code>. <a href="https://docs.ollama.com/macos">The macOS documentation</a> is the useful reference for install, storage, and logs.</p>
<p>Under the hood, Ollama packages model management and an API around inference backends including <a href="https://github.com/ggml-org/llama.cpp">llama.cpp</a>. That distinction is useful: Ollama is the convenient front door; llama.cpp is a lower-level route I can use later when I want to compare runtimes directly.</p>
<h3 id="step-1-install-ollama">Step 1: Install Ollama</h3>
<p>Download the macOS app from <a href="https://ollama.com/download">ollama.com/download</a>, mount the DMG, and drag Ollama to <code>/Applications</code>. Start it once. If the CLI is not already available, the app will offer to add it to your path.</p>
<p>Confirm that both the CLI and the local server are available:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">ollama --version
</span></span><span class="line"><span class="cl">ollama list
</span></span><span class="line"><span class="cl">ollama ps
</span></span></code></pre></div><p>On a new installation, <code>ollama list</code> and <code>ollama ps</code> should be empty. The first reports downloaded models; the second reports models currently loaded into memory.</p>
<h3 id="step-2-pull-a-model">Step 2: Pull a Model</h3>
<p>For this machine, I started with Ornith 1.5 9B:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">ollama pull ornith-1.5:9b
</span></span></code></pre></div><p>This feels deliberately familiar if you work with containers: <code>ollama pull</code> downloads the model and its layers, while <code>ollama run</code> starts an interactive session. The current Ollama build of <code>ornith-1.5:9b</code> is 6.6 GB with a 256K context window, which is a comfortable starting point on a 32 GB laptop. <a href="https://ollama.com/library/ornith-1.5">Ollama&rsquo;s model page</a> lists the available tags; the 35B download is 23 GB, so I would not make that the default on an Air.</p>
<figure class="post-screenshot">
    <img loading="lazy" src="https://vishctl.dev/images/posts/ollama-32gb-macbook-air/01-pull-ornith-1-5-9b.png"
         alt="Pulling Ornith 1.5 9B, then confirming the local model"/> <figcaption>
            <p>Pulling Ornith 1.5 9B, then confirming it is available locally.</p>
        </figcaption>
</figure>

<p>After the pull completes, confirm it is available:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">ollama list
</span></span></code></pre></div><h3 id="step-3-run-it-interactively">Step 3: Run It Interactively</h3>
<p>Start a chat session with the model:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">ollama run ornith-1.5:9b
</span></span></code></pre></div><p>Use a question that resembles the work you actually do. I tested a simple greeting first, then moved on to infrastructure questions. Exit the interactive prompt with <code>/exit</code> or <code>Ctrl-D</code>.</p>
<figure class="post-screenshot">
    <img loading="lazy" src="https://vishctl.dev/images/posts/ollama-32gb-macbook-air/02-run-ornith-1-5-9b.png"
         alt="An interactive Ornith 1.5 9B session in the terminal"/> <figcaption>
            <p>A first interactive conversation with the locally running model.</p>
        </figcaption>
</figure>

<p>For an initial sanity check, the model was responsive and produced a natural answer. That is useful confirmation that the model loads and runs locally, but it is not a benchmark. A real comparison needs the same prompt, context length, generation settings, and output length.</p>
<h3 id="step-4-inspect-performance-with---verbose">Step 4: Inspect Performance with <code>--verbose</code></h3>
<p>Ollama&rsquo;s <code>--verbose</code> flag is a quick way to see timings after every response:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">ollama run ornith-1.5:9b --verbose
</span></span></code></pre></div><p>On this MacBook Air, my short greeting test produced 114 output tokens at <strong>16.68 tokens/sec</strong>, with a total duration of <strong>7.22 seconds</strong>. The model&rsquo;s thinking trace was visible before its answer.</p>
<p>I ran the same kind of test with Gemma 4 E4B:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">ollama run gemma4:e4b --verbose
</span></span></code></pre></div><p>That run produced 228 output tokens at <strong>27.62 tokens/sec</strong>, with a total duration of <strong>8.49 seconds</strong>. It is faster in this small test, but it also generated a different and longer response. These figures are useful as a personal baseline, not as an apples-to-apples model ranking.</p>
<h3 id="ornith-15-9b-vs-gemma-4-e4b">Ornith 1.5 9B vs. Gemma 4 E4B</h3>
<p>Both models fit well on a 32 GB MacBook Air, but they are aimed at slightly different trade-offs.</p>
<table>
	<thead>
			<tr>
					<th>Model</th>
					<th>What I observed</th>
					<th>Practical fit</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><code>ornith-1.5:9b</code></td>
					<td>6.6 GB download; 16.68 tokens/sec in my short verbose run</td>
					<td>A capable 9B-class, text-and-image model with plenty of memory headroom</td>
			</tr>
			<tr>
					<td><code>gemma4:e4b</code></td>
					<td>9.6 GB download; 27.62 tokens/sec in my different short verbose run</td>
					<td>An efficient edge model for local chat, reasoning, coding, and multimodal work</td>
			</tr>
	</tbody>
</table>
<p>The <code>E</code> in Gemma 4 E4B means <strong>effective</strong> parameters. Ollama describes E4B as a 4.5B-effective-parameter edge model (8B including embeddings), with a 128K context window and text, image, and audio support. It is designed to do useful local work without the memory cost of the larger Gemma 4 workstation models. <a href="https://ollama.com/library/gemma4">The Gemma 4 library page</a> has the current tags, sizes, and capabilities.</p>
<p>In practice, I would start with <code>ornith-1.5:9b</code> if I want the smaller download and a roomy 256K context window, or <code>gemma4:e4b</code> if I want the efficient Gemma 4 feature set. Neither of these two quick runs says which model is universally better; use the prompts you care about and record the result.</p>
<h3 id="step-5-call-the-local-api">Step 5: Call the Local API</h3>
<p>The terminal chat is only the first test. Ollama exposes an API locally at <code>http://localhost:11434/api</code>, so the model can be part of a script or an application.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">curl http://localhost:11434/api/chat <span class="se">\
</span></span></span><span class="line"><span class="cl">  -d <span class="s1">&#39;{
</span></span></span><span class="line"><span class="cl"><span class="s1">    &#34;model&#34;: &#34;ornith-1.5:9b&#34;,
</span></span></span><span class="line"><span class="cl"><span class="s1">    &#34;messages&#34;: [{&#34;role&#34;: &#34;user&#34;, &#34;content&#34;: &#34;Hello!&#34;}],
</span></span></span><span class="line"><span class="cl"><span class="s1">    &#34;stream&#34;: false
</span></span></span><span class="line"><span class="cl"><span class="s1">  }&#39;</span>
</span></span></code></pre></div><figure class="post-screenshot">
    <img loading="lazy" src="https://vishctl.dev/images/posts/ollama-32gb-macbook-air/03-ornith-local-api.png"
         alt="Calling the local Ollama chat API with curl"/> <figcaption>
            <p>Calling the same model through Ollama&rsquo;s local chat API.</p>
        </figcaption>
</figure>

<p>The response includes the answer and useful timing fields. Keep this endpoint local by default. If I later expose it to another device, I will put authentication and a proper reverse proxy in front of it. I will not publish port 11434 directly. <a href="https://docs.ollama.com/api/introduction">Ollama&rsquo;s API documentation</a> covers the local base URL and client libraries.</p>
<h3 id="step-6-try-the-app-ui">Step 6: Try the App UI</h3>
<p>The CLI is great for testing and scripts, but the Ollama app also gives me a simple chat interface. Here, Ornith is selected in the model picker and used for a weather question.</p>
<figure class="post-screenshot">
    <img loading="lazy" src="https://vishctl.dev/images/posts/ollama-32gb-macbook-air/04-ollama-app-ornith.png"
         alt="The Ollama app with Ornith 1.5 9B selected"/> <figcaption>
            <p>The Ollama app with Ornith 1.5 9B selected for a web-assisted prompt.</p>
        </figcaption>
</figure>

<p>The app can also give a model access to web tools. In this example, Ornith searched for current Singapore weather before answering. That extends the model with fresh online information when the tool is enabled, but it does not replace the model&rsquo;s built-in training knowledge. Treat retrieved results as sources to verify, especially for technical or time-sensitive answers.</p>
<p>The UI is useful when I want to compare prompts casually. The local API is the path I will use when I want to integrate models into tooling.</p>
<h3 id="commands-worth-remembering">Commands Worth Remembering</h3>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># Download without entering an interactive chat</span>
</span></span><span class="line"><span class="cl">ollama pull ornith-1.5:9b
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Start an interactive chat</span>
</span></span><span class="line"><span class="cl">ollama run ornith-1.5:9b
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Show downloaded models and their disk usage</span>
</span></span><span class="line"><span class="cl">ollama list
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Show models currently loaded by the runner</span>
</span></span><span class="line"><span class="cl">ollama ps
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Remove a model I no longer need</span>
</span></span><span class="line"><span class="cl">ollama rm ornith-1.5:9b
</span></span></code></pre></div><p>Models are not small. Treat <code>ollama pull</code> the same way you would a sizeable <code>docker pull</code>: check disk space before collecting a pile of models &ldquo;just in case.&rdquo;</p>
<h3 id="what-this-macbook-air-is-good-at">What This MacBook Air Is Good At</h3>
<p>An 8B to 9B class model is a good fit for private note summarisation, explaining logs, drafting YAML, lightweight coding help, and experimenting with local integrations. This is not where I expect a 70B-class model to be effortless or where I would host production inference.</p>
<p>There are many ways to run models locally, but Ollama is an excellent Apple-silicon starting point. It gets the plumbing out of the way so I can spend time evaluating the models themselves.</p>
<p>Next, I will run llama.cpp directly on the same machine, then see whether a vLLM setup is worth comparing. Stay tuned.</p>
]]></content:encoded></item><item><title>Hello World</title><link>https://vishctl.dev/posts/hello-world/</link><pubDate>Wed, 02 Sep 2026 15:17:58 +0800</pubDate><guid>https://vishctl.dev/posts/hello-world/</guid><category>devops</category><category>kubernetes</category><category>infrastructure</category><category>ai</category><category>homelab</category><description>A quick intro to vishctl, my background in Kubernetes and AI infra, and why this blog exists.</description><content:encoded><![CDATA[<p>Hey, I&rsquo;m Vishnu. Welcome to my little corner of the internet.</p>
<h3 id="what-i-do">What I Do</h3>
<p>Professionally, I have about 9 years of overall experience under my belt. I actually started out writing code as a .NET developer before pivoting hard into DevOps and infrastructure engineering. Since 2019, I have been deep in the Kubernetes ecosystem, architecting and managing infrastructure across physical data centers and public clouds.</p>
<p>Over the years, that has meant running everything from standard high-traffic web platforms to mission-critical telco workloads, airgapped enterprise environments, and AI-native GPU clusters. Nowadays, my work revolves heavily around container orchestration, SUSE and Rancher setups, vLLM inference deployments, and homelab GPU testing.</p>
<h3 id="who-i-am">Who I Am</h3>
<p>At my core, I am a tech guy through and through. Whether it is tackling an infrastructure puzzle at work, geeking out over the latest gadgets, or dreaming of walking the floor at CES, technology is simply what makes the most sense to me.</p>
<p>Outside of terminal windows and YAML files, you will usually find me playing FPS PC games, binging a good web series, or unwinding with music. I also have a massive bucket list goal: to travel the entire world before I hit my deathbed.</p>
<h3 id="why-this-blog-exists">Why This Blog Exists</h3>
<p>This site is where I will document the problems I run into and the solutions that worked. Expect practical write-ups on Kubernetes quirks, AI infrastructure experiments, airgapped deployments, and whatever interesting challenge I am currently knee-deep in.</p>
<p>No fixed publishing schedule. No corporate polish guarantee. Just authentic notes from the trenches, mostly so future-me (and maybe you) does not have to re-solve the same headache twice.</p>
<p>More soon.</p>
]]></content:encoded></item></channel></rss>