GPU in a Linux VM on Apple Silicon

making Vulkan enumerate inside a krunkit VM

I have been running the pi coding agent (a CLI that drives an LLM to read code, edit files, run commands) in a Linux VM on Apple Silicon for a while now. For an agent, to write working software operarting in real world, the agent must have the freedom to navigate in said world. More complex tasks, for example browser applications or extensions, require the agent to operate the software, like we do. Writing a Kubernetes operator is the best done when the agent can run what it’s building in a real Kubernetes cluster. It needs to trip over the hurdles on its own, like we do.

From the coauthor: This was written by an LLM under my guidance.
Regarding emdash: any remaining, remain on purpose. Some did not survive, and some have been added.

§give ’em all

Macs come with capable GPUs. But GPUs in Linux VMs on macOS are the tough part. Most people, including me until recently, assume a Linux VM on macOS gets no graphics acceleration. There is no display attached, no XServer, no desktop. Not even Docker supports GPU in containers on macOS!

Whether it is a browser application, or local LLM inference, a working GPU supplements any development environment for an autonomous agent. When an agent needs to run a model—llama.cpp (the C/C++ inference engine most local LLM tools are built on), Ollama (a model registry and local API server that wraps llama.cpp), anything that does on-device inference—that runtime uses the GPU. For an agent on the macOS host, this is straightforward: same machine, same GPU.

I want to run the agent inside a VM, fully isolated, so the harness cannot reach back into the host. The only way to keep that isolation and still have GPU compute available to the agent is to pass the GPU through the VM. The place where I have seen the mention of a Linux VM on macOS + the GPU was in Colima[1], the colima model command. Colima is a lightweight tool for running container runtimes like Docker on macOS and Linux. Finally, Colima builds on top of Lima[2], and Lima is a tool for running Linux VMs on macOS, originally written for container-style workflows.

Since I knew it was possible, I wanted to figure out how to do this myself on Apple Silicon. This is the story of how that happened, and the chain of issues that made it possible.

As a bonus, anything Vulkan works inside the guest. That is useful in its own right. Building or testing front-end apps that use GPU paths, for example. But it is not the main reason this exists.

This is not a job for Lima alone because Lima by default offers no GPU passthrough on macOS. It is also not a job for QEMU[3] VM—the most common open-source machine emulator and virtualizer, because QEMU on macOS does not get access to the GPU either. However, there is a real gem out there. The one Colima uses: krunkit, a thin macOS frontend over libkrun. Likrun is the library that talks to Apple’s hypervisor.framework, the macOS API for creating and managing virtual machines. Krunkit is the only thing on this platform that exposes a virtio-gpu device (a virtual GPU that the guest sees as a virtio peripheral, with rendering offloaded to the host) backed by something useful on the host side.

I will need:

  • krunkit on the host (Apple Silicon macOS).
  • a custom-built Mesa guest (Mesa is the open-source graphics stack; here, built with only the virtio-vulkan driver enabled, the Vulkan driver that talks to the host over the virtio-gpu wire).
  • a slp/krun-pinned virglrenderer on the host (virglrenderer is the host-side renderer that turns the guest’s virtio-gpu commands into calls on the host’s actual GPU; the slp/krun tap is a Homebrew tap, i.e. a third-party package source, that ships a version known to work with krunkit).
  • a small, ugly patch. Without it, nothing enumerates.

That fourth item is the entire subject of this post.

I will cover:

  • the stack, end to end.
  • the symptom that started the investigation.
  • one GitHub comment that changed everything.
  • the root cause analysis that came after.
  • the patch that fixed it.
  • the upstream fix that will eventually make the patch unnecessary.

§the research process

This execercise was executed using on-premise inference with human supervision.

The context for subsequent actions—agent, or human—leading to the discovery:

  • Agent: Identify how Colima does the colima model command. Source code is available at https://github.com/abiosoft/colima, clone it to a temp directory and investigate.

    • Resulted in correct identification of libkrun and krunkit.
  • Agent: Clone krunkit sources from https://github.com/libkrun/krunkit to a temp directory and explain how it enables the GPU on macOS.

    • Provided new context for the build process. It has learned how krunkit works.
  • Agent: Provide the simplest Lima VM configuration to try the GPU using previous learnings.

    • The outcome was an initial version of the VM YAML configuration, and a set of commands to install all host tolls, and run the VM.
  • Human: The agent does not run code on the host.
    Corrected configuration problems.

    • Krunkit-powered VM could be started with all tools required to perform the test Vulkam test. Vulkan error was identified here.
  • In the VM:
    • Agent: you own this VM, you’re a sudoer, you have access to Chromium browser, you can perform GitHub searches, or navigate internet as you need. Run vulkaninfo --summary, it produces an error message. Use all means necessary to find a solution. You can install any software you need. There is no time limit.

    • The agent eventually came up with a working build.

The model is MiniMax-M3 Q8. Context windows size 256k tokens. Running over a single continuous session compacted a number of times at 95% fill-up rate. It took the agent a little bit over 15 hours to come up with the result.

§background

The stack looks like this:

┌──────────────────────────────────────────────────────────────────┐
│  Ubuntu 24.04 aarch64 guest                                      │
│                                                                  │
│   pi coding agent + Chromium, llamma.cpp, Ollama, ...            │
│                    │                                             │
│                    ▼                                             │
│   Vulkan apps   ──▶   Venus ICD (Mesa, virtio-vulkan only)       │
│                    │                                             │
└────────────────────│─────────────────────────────────────────────┘
                     │  virtio-gpu / Venus protocol over shared mem
                     ▼
┌──────────────────────────────────────────────────────────────────┐
│  macOS host (Apple Silicon)                                      │
│                                                                  │
│   krunkit / libkrun  ──▶  virglrenderer  ──▶  Metal              │
│                                       (slp/krun tap, pinned)     │
└──────────────────────────────────────────────────────────────────┘

On the guest side, krunkit exposes a virtio-gpu device with a Venus-compatible transport. Venus, in this context, is a protocol for running Vulkan across the virtio-gpu boundary: the guest translates Vulkan calls into a wire format the host can execute, and the host executes them on the real GPU. Memory for Vulkan buffers and command rings is shared between the guest and host via the RESOURCE_CREATE_BLOB ioctl, an ioctl being a syscall for sending a command to a device driver, here the virtio-gpu driver in the guest kernel. The guest Mesa is built from source with -Dvulkan-drivers=virtio only. No GL, no GLX (the two historical Linux OpenGL transports), no EGL (the interface between OpenGL/Vulkan and the windowing system), no Gallium drivers (Mesa’s internal driver framework). The build is installed to /opt/mesa/venus. The guest points VK_DRIVER_FILES. VK_DRIVER_FILES is the environment variable that tells the Vulkan loader which Installable Client Drivers to consider. IDCs are the per-driver JSON manifests the loader discovers and dlopens. The one created by this build is virtio_icd.aarch64.json and that is the only Vulkan ICD the loader sees.

On the host side, virglrenderer translates the guest’s Vulkan commands into Metal calls. Metal is Apple’s graphics API, the only way to talk to the GPU on macOS. This virglrenderer must be the version pinned by the slp/krun Homebrew tap. The upstream Homebrew virglrenderer 1.3.0 is incompatible with krunkit on Apple Silicon and produces the most unhelpful error imaginable. There is a fix-virglrenderer.sh script for exactly this situation. Use it. It removes the invalid virglrenderer and installs the slp/krun-pinned one.

So far, this is all ground that’s already been explored. But how did we get there?

§the symptom

Run a stock Ubuntu 24.04 cloud image with the system Mesa inside a krunkit VM and:

$ vulkaninfo
ERROR at ./vulkaninfo/./vulkaninfo.h:458:vkCreateInstance failed with ERROR_OUT_OF_HOST_MEMORY

vkCreateInstance is the first Vulkan call every program makes. It sets up the library and lets the loader pick an ICD. ERROR_OUT_OF_HOST_MEMORY is the loader’s way of saying “something inside the ICD bailed out”. Or, depending on the version:

ERROR: [Loader Message] Code 0 : setup_loader_term_phys_devs:  Failed to detect any valid GPUs in the current config
ERROR at ./vulkaninfo/./vulkaninfo.h:245:vkEnumeratePhysicalDevices failed with ERROR_INITIALIZATION_FAILED

vkEnumeratePhysicalDevices is the second Vulkan call. It lists the GPUs the loader can see. ERROR_INITIALIZATION_FAILED is the catch-all “the ICD is broken” error.

Either way, no GPU. Stock Debian, Ubuntu, Fedora. All the same. This is a real problem because if you actually want to do anything Vulkan in a krunkit VM, you cannot use a stock image.

The investigation that produced the answer is documented in two issues on the krunkit repository, and the chain between them is short enough to read in order.

§issue #102

libkrun/krunkit#102[4] is the symptom report. A user runs ghcr.io/ggml-org/llama.cpp:light-vulkan inside a krunkit VM. The container ships a recent Mesa. vulkaninfo fails. No GPU is detected. The error is vkCreateInstance failed with ERROR_OUT_OF_HOST_MEMORY. That’s exactly the error I have become intimate with.

The user does all the right debugging. They check the capset (the capability set the host advertises over virtio-gpu to say which features it supports), the parameters, the protocol version. Venus negotiates fine. VIRTGPU_PARAM_HOST_VISIBLE reports 1, i.e. yes, the host supports making blobs visible to both sides for sharing. The issue is closed by switching to a container image whose Mesa carries a downstream patch. The workaround exists. The root cause, at this point, is not pinned down.

This is where most investigations end. Someone finds a workaround, the issue is closed, life goes on.

§the comment that pointed the way

It did not end here because @stewartjeacocke left a one-line comment[5] on issue #102:

I believe the root cause of this may be libkrun/krunkit#114[6].

This single sentence changes everything.

§the root cause

Issue #114 is the diagnosis. The author did the work I would have wanted to do. The finding is:

On macOS on Apple Silicon, the VM page size—the granularity at which the hypervisor maps memory between guest and host—is 16384 bytes. Apple’s hv_vm_map requires page-aligned arguments. libkrun’s resource_map_blob passes the guest-requested blob size unchanged into hv_vm_map. The guest runs on 4 KiB pages. The guest kernel PAGE_ALIGNs blob sizes up to a multiple of 4096 (the kernel macro that rounds a size up to the nearest page boundary). But that is not a multiple of 16384. Remember, on macOS it must be a multiple of 16384 and 4096 is simply less. So the host-side mapping fails with EINVAL—the standard POSIX error code for “invalid argument”. The guest’s mmap (the syscall that maps a file or device region into the process’s address space) returns EINVAL. Mesa’s Venus ring shmem is 131268 bytes; the kernel rounds it to 135168; 135168 is 33 × 4 KiB and 8.25 × 16 KiB. The mapping fails. Venus reports the failure upward as VK_ERROR_OUT_OF_HOST_MEMORY, and the Vulkan loader aborts vkCreateInstance.

The author of #114 includes a self-contained C reproducer that walks a list of sizes and shows the exact failure pattern. Sizes that are multiples of 16384 work. Sizes that are not, do not. It does not matter what the guest’s page size is and what the protocol says. The host is imposing an undocumented requirement and reporting it as a generic ErrUnspec (the libkrun catch-all error variant), which surfaces to userspace as a bare EINVAL with no indication of the cause.

Not much to add to that analysis.

§the fix

The fix lives in slp’s downstream Mesa fork, in commit 761ef1ec[7]. It does exactly what the issue title suggests: it forces Venus allocations to be multiples of 16384. The change is in four call sites:

  • src/virtio/vulkan/vn_device_memory.c — vn_device_memory_alloc_simple aligns the allocation size up to 16384.
  • src/virtio/vulkan/vn_renderer_virtgpu.c — virtgpu_bo_create_from_device_memory and virtgpu_shmem_create align their sizes up.
  • src/virtio/vulkan/vn_ring.c — vn_ring_get_layout aligns the ring shmem size up.

The author of #114 is explicit that all four hunks are needed. The vn_ring.c one alone gets past vkCreateInstance, then device memory allocation fails instead. Drop any one hunk and the failure moves somewhere else.

For Mesa 23.3.6:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
diff --git a/src/virtio/vulkan/vn_device_memory.c b/src/virtio/vulkan/vn_device_memory.c
index cfb32c7bee5..74ebf6f0f7e 100644
--- a/src/virtio/vulkan/vn_device_memory.c
+++ b/src/virtio/vulkan/vn_device_memory.c
@@ -28,6 +28,10 @@ vn_device_memory_alloc_simple(struct vn_device *dev,
 {
    VkDevice dev_handle = vn_device_to_handle(dev);
    VkDeviceMemory mem_handle = vn_device_memory_to_handle(mem);
+
+   ((VkMemoryAllocateInfo *)alloc_info)->allocationSize =
+      align64(alloc_info->allocationSize, 16384);
+
    if (VN_PERF(NO_ASYNC_MEM_ALLOC)) {
       return vn_call_vkAllocateMemory(dev->instance, dev_handle, alloc_info,
                                       NULL, &mem_handle);
diff --git a/src/virtio/vulkan/vn_renderer_virtgpu.c b/src/virtio/vulkan/vn_renderer_virtgpu.c
index 8c26d86f43e..e3cbed6e470 100644
--- a/src/virtio/vulkan/vn_renderer_virtgpu.c
+++ b/src/virtio/vulkan/vn_renderer_virtgpu.c
@@ -1235,6 +1235,8 @@ virtgpu_bo_create_from_device_memory(
    struct virtgpu *gpu = (struct virtgpu *)renderer;
    const uint32_t blob_flags = virtgpu_bo_blob_flags(flags, external_handles);
 
+   size = align64(size, 16384);
+
    uint32_t res_id;
    uint32_t gem_handle = virtgpu_ioctl_resource_create_blob(
       gpu, gpu->bo_blob_mem, blob_flags, size, mem_id, &res_id);
@@ -1285,6 +1287,8 @@ virtgpu_shmem_create(struct vn_renderer *renderer, size_t size)
 {
    struct virtgpu *gpu = (struct virtgpu *)renderer;
 
+   size = align64(size, 16384);
+
    struct vn_renderer_shmem *cached_shmem =
       vn_renderer_shmem_cache_get(&gpu->shmem_cache, size);
    if (cached_shmem) {
diff --git a/src/virtio/vulkan/vn_ring.c b/src/virtio/vulkan/vn_ring.c
index eec832ba1d5..7dc545e4894 100644
--- a/src/virtio/vulkan/vn_ring.c
+++ b/src/virtio/vulkan/vn_ring.c
@@ -170,7 +170,7 @@ vn_ring_get_layout(size_t buf_size,
    layout->extra_offset = layout->buffer_offset + layout->buffer_size;
    layout->extra_size = extra_size;
 
-   layout->shmem_size = layout->extra_offset + layout->extra_size;
+   layout->shmem_size = align64(layout->extra_offset + layout->extra_size, 16384);
 }
 
 void

For Mesa 25.0.7:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
diff --git a/src/virtio/vulkan/vn_device_memory.c b/src/virtio/vulkan/vn_device_memory.c
index e277ce68916..4c814949a67 100644
--- a/src/virtio/vulkan/vn_device_memory.c
+++ b/src/virtio/vulkan/vn_device_memory.c
@@ -30,6 +30,10 @@ vn_device_memory_alloc_simple(struct vn_device *dev,
 {
    VkDevice dev_handle = vn_device_to_handle(dev);
    VkDeviceMemory mem_handle = vn_device_memory_to_handle(mem);
+
+   ((VkMemoryAllocateInfo *)alloc_info)->allocationSize =
+      align64(alloc_info->allocationSize, 16384);
+
    if (VN_PERF(NO_ASYNC_MEM_ALLOC)) {
       return vn_call_vkAllocateMemory(dev->primary_ring, dev_handle,
                                       alloc_info, NULL, &mem_handle);
diff --git a/src/virtio/vulkan/vn_renderer_virtgpu.c b/src/virtio/vulkan/vn_renderer_virtgpu.c
index d829dca808a..78fce8ff711 100644
--- a/src/virtio/vulkan/vn_renderer_virtgpu.c
+++ b/src/virtio/vulkan/vn_renderer_virtgpu.c
@@ -1240,6 +1240,8 @@ virtgpu_bo_create_from_device_memory(
    struct virtgpu *gpu = (struct virtgpu *)renderer;
    const uint32_t blob_flags = virtgpu_bo_blob_flags(gpu, flags, external_handles);
 
+   size = align64(size, 16384);
+
    uint32_t res_id;
    uint32_t gem_handle = virtgpu_ioctl_resource_create_blob(
       gpu, gpu->bo_blob_mem, blob_flags, size, mem_id, &res_id);
@@ -1290,6 +1292,8 @@ virtgpu_shmem_create(struct vn_renderer *renderer, size_t size)
 {
    struct virtgpu *gpu = (struct virtgpu *)renderer;
 
+   size = align64(size, 16384);
+
    struct vn_renderer_shmem *cached_shmem =
       vn_renderer_shmem_cache_get(&gpu->shmem_cache, size);
    if (cached_shmem) {
diff --git a/src/virtio/vulkan/vn_ring.c b/src/virtio/vulkan/vn_ring.c
index 4e0cff9e9cd..c340c91ebb0 100644
--- a/src/virtio/vulkan/vn_ring.c
+++ b/src/virtio/vulkan/vn_ring.c
@@ -264,7 +264,7 @@ vn_ring_get_layout(size_t buf_size,
    layout->extra_offset = layout->buffer_offset + layout->buffer_size;
    layout->extra_size = extra_size;
 
-   layout->shmem_size = layout->extra_offset + layout->extra_size;
+   layout->shmem_size = align64(layout->extra_offset + layout->extra_size, 16384);
 }
 
 struct vn_ring *

Whitespace is critical.

The two patches are derived directly from this commit. They reside in the pi-lima.bootstrap directory and they must not be edited. The reason: small but important, they are the only thing standing between the guest and a vkCreateInstance that returns VK_ERROR_OUT_OF_HOST_MEMORY.

1
sudo apt-get install --install-recommends linux-generic-hwe-24.04-edge

§what this enables

Once the four-hunk patch is in place, the guest Mesa enumerates a real Vulkan physical device. In my case, on an M-series host, that device reports itself as Virtio-GPU Venus (Apple M5 Pro). From that point on, anything that talks to Metal on the host has a Vulkan-backed implementation in the guest.

The main thing this enables is local LLM inference from inside the agent. llama.cpp, Ollama, vLLM (a high-throughput inference server for serving LLMs to many clients), MLX (Apple’s array framework for running ML models on Apple Silicon), or WebGPU, anything that talks to the GPU on macOS works as if it were running on the bare host, except it is running in the VM, alongside the agent. This is what makes it possible to test “computer + LLM” deployments end to end on macOS without ever leaving the host machine: the agent is fully sandboxed in a VM, the GPU is passed through, and any local-inference runtime the agent wants to use just works.

A second thing is that the guest is not a server-class VM. It is a desktop-class VM with a real GPU. Anything Vulkan-based runs inside it. This is handy for front-end work—building or testing apps that use GPU paths (WebGPU, Skia/Vulkan, canvas-heavy UIs)—and Chromium picks up hardware acceleration as a side effect.

A third thing, and this one surprised me, is that nested virtualization is enabled in the image. KVM (the Linux in-kernel hypervisor, normally requires hardware virtualization extensions on the host CPU) is available inside the guest. So the guest can itself host additional VMs or run workloads that need /dev/kvm. This is unusual for a krunkit VM and I am not sure why it works on this combination, only that it does. I was able to run Firecracker microVM inside. I am certain it is possible to further pass the GPU to Firecracker itself. But that’s a bed time story for another day.

§build mesa

Finally, build Mesa:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
export MESA_VERSION="25.0.7" # 22.3.6
mkdir -p /opt/mesa/build
cd /opt/mesa/build

apt-get install -y \
  bison \
  cmake \
  flex \
  meson \
  ninja-build \
  pkg-config

git clone -v https://gitlab.freedesktop.org/mesa/mesa.git .
git checkout "mesa-$MESA_VERSION"
git apply /path/to/the/mesa-$MESA_VERSION.patch"

meson setup "build-krunkit-$MESA_VERSION" \
  --prefix="/opt/mesa/venus" \
  --libdir=lib \
  --buildtype=debugoptimized \
  -Dvulkan-drivers=virtio \
  -Dgallium-drivers=[] \
  -Dplatforms=[] \
  -Dglx=disabled \
  -Degl=disabled \
  -Dgles1=disabled \
  -Dgles2=disabled
meson compile -C "build-krunkit-$MESA_VERSION"
meson install -C "build-krunkit-$MESA_VERSION"

To have the GPU working, put this in your ~/.bashrc, ~/.zshrc, or another place where it becomes globally available.

1
export VK_DRIVER_FILES=/opt/mesa/venus/share/vulkan/icd.d/virtio_icd.aarch64.json

Now vulkaninfo --summary simply works.

§the recipe, in summary

The recipe that works, on Apple Silicon, today:

  • install krunkit, libkrun, libkrunfw (a firmware bundle used by libkrun), gvproxy (the network plumbing used by Lima to give the guest a routable network), and virglrenderer from the slp/krun Homebrew tap. Trust the formulae (Homebrew 4.x removed automatic trust for third-party formulae; brew trust opts back in). Do not use the upstream Homebrew virglrenderer.
  • build a guest Mesa with -Dvulkan-drivers=virtio only.
  • apply the four-hunk 16384-alignment patch before building.
  • install the result to /opt/mesa/venus and point VK_DRIVER_FILES at the Venus ICD.
  • inside the guest, run vulkaninfo --summary and see Virtio-GPU Venus (Apple Mx Pro).

The recipe is a bit fragile, it’s probably wise to hold forks of all dependencies. There is the Mesa patch that is not in upstream Mesa. And there is the virglrenderer version that is not in upstream Homebrew.

§the upstream solution

Hardcoding 16384 is a workaround for an undocumented host-side requirement. The proper fix landed in upstream Mesa as a merge request MR #44783 “venus: honor the virtio-gpu blob alignment”[8], merged on 2026-09-28 by Matthew Periut.

Instead of hardcoding 16384, MR #44783 queries the kernel for VIRTGPU_PARAM_BLOB_ALIGNMENT—a new parameter added in Linux 7.2—and rounds blob sizes up to whatever the kernel reports. On hosts without that parameter it falls back to 1, i.e. no rounding. The change is a no-op on every other platform.

The implementation is a single hunk in vn_renderer_virtgpu.c. It is much smaller than the slp downstream patch because it does not hardcode anything. It just listens to the kernel.

This is the kind of fix that makes the slp downstream patch obsolete eventually. The downstream patch exists because, at the time, the kernel did not advertise its alignment requirement. Once both Mesa and the kernel are new enough, the patches for those new versions aren’t required anymore.

The conditions for no patch needed:

  • a Mesa release that contains MR #44783,
  • a Linux 7.2+ guest kernel,
  • Ubuntu 24.04’s HWE kernel with kernel 7.2 support. At the time of writing it is 24.04.5 LTS (HWE) with kernel version 7.0.

The third of these is the bottleneck. Installing an edge kernel could be an option.

§closing words

The investigation that produced it was carried out, almost entirely, by two people in two GitHub issues and one commit. That is how good open-source debugging actually happens: small, focused, well-documented. The fact that I can run a Linux VM with a working GPU on Apple Silicon is a direct consequence of those two issues and one patch.

Once the upstream fix rolls through Ubuntu’s HWE kernel, the hardcoded patch becomes unnecessary.