GPT-OSS 120B & 20B on AMD Ryzen AI Max+ 395 (Strix Halo) via Vulkan with Kubernetes

In order to run bigger Machine Learning / LLM Models, I've wanted to upgrade the RAM of my computer. Unfortunately the rising DDR5 prices made that prohibitively expensive.

Therefore I've pivoted to using a Minisforum MS-S1 which I could get for a reasonable price. I have to say, I'm positively surprised of the capabilities of this device, especially seen as compared to it's power consumption.

Here is how I've integrated it into my kubernetes cluster and how I'm running the GPT-OSS 20B and the GPT-OSS 120B models on the node, as well as the performance I'm getting on my setup.

I had a few challenges to solve:

  • Network cards not working properly
  • How to increase shared memory size
  • How to configure kubernetes correctly to allow hardware access

Setup and Configuration

BIOS Config

  • Limited CPU temperature to 77 degrees.
  • 96GB VRAM allocated.

The limited temperature is the easiest way to limit power consumption without tuning many values in the BIOS. I'll take the performance loss for efficiency.

The maximum VRAM allocation solved my performance issues on context sizes greater than 8k.

Installing Debian Testing and K3S

Before installing Debian on the MS-S1, I knew from reviews and Reddit discussions that the wired chips are requiring a cutting edge kernel. So instead of installing Debian stable (as I usually would), I setup a bootable Debian testing USB stick.

I also replaced the NVMe SSD of the MS-S1 with a bigger SSD before installing Linux (I'm keeping the other SSD in case I want to use Windows, or another OS).

During setup, I just followed the wizard, setup my partition layout (everything in one partition), and only installed the SSH server (no GUI).

Then I've installed the basic programs I require on my kubernetes hosts (including docker and other cluster prerequisites), some software for creating backups and some for seeing the GPU state (such as radeontop).

The full install command is:

apt install sudo docker.io docker-compose htop rsync nfs-common curl open-iscsi earlyoom git git-lfs smartmontools lm-sensors imagemagick restic rear radeontop vulkan-tools

Then I could join my existing k3s cluster:

sudo curl -sfL https://get.k3s.io | K3S_TOKEN=ABCXYZ sh -s - agent --server https://192.168.0.1:6443 --docker

Afterwards I needed to change some kernel parameters in order to configure how much memory is accessible as shared memory (for the CPU and GPU). The easiest way of verifying the shared memory is checking the GTT size with radeontop.

To achieve that, first, run sudo nano /etc/default/grub, then edit the file with the values below, and then enable it by running sudo update-grub and sudo reboot.

GRUB_CMDLINE_LINUX_DEFAULT="quiet amdttm.pages_limit=32768 amdttm.page_pool_size=32768 amdgpu.gttsize=32768"

After setting up the OS and joining the cluster, I was ready of running workloads on the machine via kubernetes.

But before I could run the workloads, I had to create the docker images I wanted to run on the machine.

Dockerfile

I'm using the following Dockerfile to run both llama.cpp and llama.swap with a Vulkan backend on my computer. I choose Vulkan for my tests, because the ROCm docker images are very big.

The images and the code can be seen here: https://git.akehir.com/infra/llama-swap-llama-cpp-vulkan .

FROM debian:testing
ARG NODE_VERSION=24

## Container
RUN mkdir /models
RUN mkdir /conf

## Install dependencies
RUN apt update \
    && apt upgrade -y \
    && apt install -y \
        build-essential \
        git \
        python3 \
        python3-pip \
        python3-wheel \
        cmake \
        wget \
        xz-utils \
        npm \
        nodejs \
        curl \
        libcurl4-openssl-dev \
        libcpp-httplib-dev \
        libminiaudio-dev \
        glslc \
        libxcb-xinput0 \
        libxcb-xinerama0 \
        libxcb-cursor-dev \
        libvulkan-dev \
        vulkan-tools \
        radeontop \
        spirv-headers \
    && apt autoremove -y \
    && apt clean -y \
    && rm -rf /tmp/* /var/tmp/* \
    && find /var/cache/apt/archives /var/lib/apt/lists -not -name lock -type f -delete \
    && find /var/cache -type f -delete

RUN curl --output go-linux-amd64.tar.gz --location https://go.dev/dl/go1.26.1.linux-amd64.tar.gz \
    && rm -rf /usr/local/go && tar -C /usr/local -xzf go-linux-amd64.tar.gz \
    && rm go-linux-amd64.tar.gz
ENV PATH=$PATH:/usr/local/go/bin

## Clone repositories
WORKDIR /app
RUN git clone https://github.com/ggerganov/llama.cpp.git
RUN git clone https://github.com/mostlygeek/llama-swap

## Build llama.cpp
WORKDIR /app/llama.cpp
RUN cmake -B build -DGGML_NATIVE=OFF -DGGML_VULKAN=ON -DLLAMA_BUILD_TESTS=OFF -DGGML_BACKEND_DL=ON -DGGML_CPU_ALL_VARIANTS=ON && \
    cmake --build build --config Release -j$(nproc)
RUN pip install --break-system-packages --upgrade setuptools
RUN pip install --break-system-packages -r requirements.txt
WORKDIR /app/llama.cpp/build/bin

## Build llama-swap
WORKDIR /app/llama-swap
RUN make clean all
WORKDIR /app/llama-swap/build

CMD ["/bin/bash"]

Kubernetes Config

I've deployed both openwebui and llama-swap / llama.cpp (in one container as seen above) to the cluster.

To re-use the config you probably want to remove the traefik.ingress.kubernetes.io/router.middlewares: default-redirect-https@kubernetescrd, depending on your ingress controller (or you'll need to create that redirect rule).

Of interest is mainly the llama config, the openwebui just provides an easy chat ui. To use my config of openwebui, you will need to install openebs, or provide a different volume (the easiest probably being hostpath).

I use flux to synchronize the config to my cluster, but you can also just create the files below and run kubectl apply -f ....

llama-swap & llama.cpp

---
apiVersion: v1
kind: Namespace
metadata:
  name: llm
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: llm
  namespace: llm
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt
    traefik.ingress.kubernetes.io/router.middlewares: default-redirect-https@kubernetescrd
spec:
  ingressClassName: traefik
  rules:
    - host: llm.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: llm
                port:
                  number: 8080
  tls:
    - hosts:
        - llm.example.com
      secretName: llm-example-com
---
kind: Service
apiVersion: v1
metadata:
  name: llm
  namespace: llm
spec:
  selector:
    app: llm
  ports:
    - protocol: TCP
      port: 8080
      targetPort: 8080
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: llm-v1
  namespace: llm
spec:
  selector:
    matchLabels:
      app: llm
  replicas: 1
  template:
    metadata:
      labels:
        app: llm
    spec:
      securityContext:
        seccompProfile:
          type: Unconfined
        fsGroup: 0
        runAsUser: 0
        runAsGroup: 0
        runAsNonRoot: false
        supplementalGroups:
          - 44
          - 991
      hostIPC: true
      containers:
        - name: llm
          securityContext:
            privileged: true
            capabilities:
              add:
                - SYS_PTRACE
          image: registry.example.com/infra/llama-swap-llama-cpp-vulkan/llama-release:latest
          command: ['/app/llama-swap/build/llama-swap-linux-amd64']
          ports:
            - containerPort: 8080
          volumeMounts:
            - name: llama-swap-config-v1
              mountPath: /app/llama-swap/build/config.yaml
              subPath: config.yaml
              readOnly: true
            - name: dev-kfd
              mountPath: /dev/kfd
              securityContext:
                privileged: true
            - name: dev-dri
              mountPath: /dev/dri
              securityContext:
                privileged: true
            - name: models
              mountPath: /models
      volumes:
        - name: llama-swap-config
          configMap:
            name: llama-swap-config
            items:
              - key: config.yaml
                path: config.yaml
        - name: dev-kfd
          hostPath:
            path: /dev/kfd
        - name: dev-dri
          hostPath:
            path: /dev/dri
        - name: models
          hostPath:
            path: /models
      affinity:
        nodeAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
            nodeSelectorTerms:
              - matchExpressions:
                  - key: kubernetes.io/arch
                    operator: In
                    values:
                      - amd64
                  - key: kubernetes.io/hostname
                    operator: In
                    values:
                      - srv
        podAntiAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
            - labelSelector:
                matchExpressions:
                  - key: module
                    operator: In
                    values:
                      - llm
              topologyKey: 'kubernetes.io/hostname'
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: llama-swap-config-v1
  namespace: llm
data:
  config.yaml: |
    healthCheckTimeout: 600
    startPort: 14001

    models:
      gpt-oss-20b:
        cmd: |
          /app/llama.cpp/build/bin/llama-server --port ${PORT} --host 0.0.0.0 --model /models/chat/gpt-oss-20b/gpt-oss-20B-F16.gguf --jinja

      gpt-oss-120b:
        cmd: /app/llama.cpp/build/bin/llama-server --port ${PORT} --host 0.0.0.0 --model /models/chat/gpt-oss-120b/gpt-oss-120b-UD-Q8_K_XL-00001-of-00002.gguf --jinja --temp 1.0 --top-p 1.0 --top-k 0 -c 111111

    groups:
       default:
        swap: false
        members:
          - "gpt-oss-20b"
          - "gpt-oss-120b"

    hooks:
      on_startup:
        preload:
          - "gpt-oss-20b"
          - "gpt-oss-120b"
    ---
---

OpenWebUI

---
apiVersion: v1
kind: Namespace
metadata:
  name: openwebui
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: openwebui
  namespace: openwebui
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt
    traefik.ingress.kubernetes.io/router.middlewares: default-redirect-https@kubernetescrd
spec:
  ingressClassName: traefik
  rules:
    - host: chat.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: openwebui
                port:
                  number: 8080
  tls:
    - hosts:
        - chat.example.com
      secretName: chat-example-com
---
kind: Service
apiVersion: v1
metadata:
  name: openwebui
  namespace: openwebui
spec:
  selector:
    app: openwebui
  ports:
    - protocol: TCP
      port: 8080
      targetPort: 8080
---
kind: PersistentVolumeClaim
apiVersion: v1
metadata:
  name: openwebui
  namespace: openwebui
spec:
  storageClassName: openebs-hostpath
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 7G
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: openwebui
  namespace: openwebui
spec:
  selector:
    matchLabels:
      app: openwebui
  replicas: 1
  template:
    metadata:
      labels:
        app: openwebui
    spec:
      volumes:
        - name: openwebui
          persistentVolumeClaim:
            claimName: openwebui
      containers:
        - name: openwebui
          image: ghcr.io/open-webui/open-webui:v0.6.43
          ports:
            - containerPort: 8080
          volumeMounts:
            - mountPath: '/app/backend/data'
              name: openwebui
---

Docker-Compose

If kubernetes is too heavy, the same Dockerfile I provided can be run via a simple docker-compose file (created in the same directory). You just need to create a correct config file for llama-swap. You can find an example in my blog post about llama-swap here.

services:
  server:
    build: .
    ports:
      - "12345:8080"
    volumes:
      - /models:/models
      - /app/llama-swap/config.yaml:/conf/config.yaml
    devices:
      - '/dev/kfd:/dev/kfd'
      - '/dev/dri:/dev/dri'
    security_opt:
      - seccomp:unconfined
    group_add:
      - video
    cap_add:
      - SYS_PTRACE
    ipc: "host"
    command: /app/llama-swap/build/llama-swap-linux-amd64 --listen 0.0.0.0:8080 --config /conf/config.yaml

Conclusion & Results

Issues

No Network after Installing the OS

While the network cards were working fine during the installation, after removing the USB drive and rebooting the computer, they didn't work anymore. Even rebooting didn't help. Thanks to a Google search, I figured out a solution: The computer needs to be completely shut off - and not just rebooted. So I turned it off, removed the power cable, and waited a bit before turning it on. Afterwards it worked perfectly.

Low Performance on Bigger Context Sizes (>8k)

The default allocation of 2GB VRAM didn't work out for me, because as soon as context sizes were greater than 8k and the context didn't fit into the GPU RAM, the performance was horrible.

First I've increased the shared memory, but that didn't seem to help immediately.

In the end I've just changed the VRAM to a fixed allocation of 96GB, and left the remaining 32GB as shared RAM.

How to increase shared memory size

The default shared memory for the GPU was set to ~64GB in my kernel.

I've changed it by changing the defaults in, sudo nano /etc/default/grub, with the values below, and then enable it by running sudo update-grub and sudo reboot.

GRUB_CMDLINE_LINUX_DEFAULT="quiet amdttm.pages_limit=126000 amdttm.page_pool_size=126000 amdgpu.gttsize=126000"

In the end, I've chosen to allocate the GPU memory in the BIOS and not gone the route of shared memory for the moment, due to performance issues with larger context sizes; this is something I'd like to revisit in the future though.

How to configure kubernetes correctly to allow hardware access

This had me stumped for a while. You can see the working config above. But I didn't have the right approach to enabling access to the host devices inside the container.

One (safer) approach would be to use device plugins for kubernetes - for instance the AMD GPU device plugin for Kubernetes.

However, I'm not particularly concerned about the safety, since the cluster isn't used by untrusted applications and this particular container isn't accessible from outside my network. I'm also building the applications by myself.

And since I've chosen to run the Vulkan backend, I can get away with running the container as privileged: true, which allows the device access to work. The complete config is already posted above, there are quite a few flags I had to set in order to run the workload I wanted.

In the future I do want to look into the AMD device plugin, but that's a story for another time. For another year, I dare say.

Performance

I've chosen the GPT-OSS models because they're popular, performant, and provide a baseline performance as a means to compare this to other devices.

The results are with GPT-OSS-20B and GPT-OSS-120B preloaded. This takes around ~80GB of the VRAM assigned to the card.

GPT-OSS-20B

Firstly, I've checked the performance I'm getting on some requests as captured by llama-swap in real-world usage.

Prompt processing: ~1000 - 1500 tokens / second. Generation: ~45 tokens / second.

And then I've used llama-bench to run a few benchmarks to have a comparable result to those posted on gpt-oss discussions on the llama.cpp here.

The command was /app/llama.cpp/build/bin# ./llama-bench -m /models/chat/gpt-oss-20b/gpt-oss-20B-F16.gguf -t 1 -fa 1 -b 2048 -ub 2048 -p 2048,8192,16384,32768

root@llm-v15-76d5bf569c-jdgr9:/app/llama.cpp/build/bin# ./llama-bench -m /models/chat/gpt-oss-20b/gpt-oss-20B-F16.gguf  -t 1 -fa 1 -b 2048 -ub 2048 -p 2048,8192,16384,32768
ggml_vulkan: Found 1 Vulkan devices:
ggml_vulkan: 0 = Radeon 8060S Graphics (RADV GFX1151) (radv) | uma: 1 | fp16: 1 | bf16: 0 | warp size: 64 | shared memory: 65536 | int dot: 1 | matrix cores: KHR_coopmat
load_backend: loaded Vulkan backend from /app/llama.cpp/build/bin/libggml-vulkan.so
load_backend: loaded CPU backend from /app/llama.cpp/build/bin/libggml-cpu-icelake.so
model size params backend ngl threads n_ubatch fa test t/s
gpt-oss 20B F16 12.83 GiB 20.91 B Vulkan 99 1 2048 1 pp2048 1274.95 ± 1.29
gpt-oss 20B F16 12.83 GiB 20.91 B Vulkan 99 1 2048 1 pp8192 1055.64 ± 1.35
gpt-oss 20B F16 12.83 GiB 20.91 B Vulkan 99 1 2048 1 pp16384 828.95 ± 0.55
gpt-oss 20B F16 12.83 GiB 20.91 B Vulkan 99 1 2048 1 pp32768 546.66 ± 1.35
gpt-oss 20B F16 12.83 GiB 20.91 B Vulkan 99 1 2048 1 tg128 46.45 ± 0.14

GPT-OSS-120B

Again, I've run some real usage benchmarks to get a feeling for the performance of the model on my setup:

Prompt processing: ~400 tokens / second. Generation: ~30 - 33 tokens / second.

And then I've run llama-bench from within the container (via kubectl -n llm exec -it llm-v15-76d5bf569c-jdgr9 -- /bin/bash) to get benchmark results comparable to those posted on the gpt-oss discussions on the llama.cpp here.

root@llm-v15-76d5bf569c-jdgr9:/app/llama.cpp/build/bin# ./llama-bench -m /models/chat/gpt-oss-120b/gpt-oss-120B-F16.gguf  -t 1 -fa 1 -b 2048 -ub 2048 -p 2048,8192,16384,32768
ggml_vulkan: Found 1 Vulkan devices:
ggml_vulkan: 0 = Radeon 8060S Graphics (RADV GFX1151) (radv) | uma: 1 | fp16: 1 | bf16: 0 | warp size: 64 | shared memory: 65536 | int dot: 1 | matrix cores: KHR_coopmat
load_backend: loaded Vulkan backend from /app/llama.cpp/build/bin/libggml-vulkan.so
load_backend: loaded CPU backend from /app/llama.cpp/build/bin/libggml-cpu-icelake.so
model size params backend ngl threads n_ubatch fa test t/s
gpt-oss 120B F16 60.87 GiB 116.83 B Vulkan 99 1 2048 1 pp2048 447.08 ± 6.63
gpt-oss 120B F16 60.87 GiB 116.83 B Vulkan 99 1 2048 1 pp8192 412.02 ± 0.79
gpt-oss 120B F16 60.87 GiB 116.83 B Vulkan 99 1 2048 1 pp16384 354.80 ± 0.55
gpt-oss 120B F16 60.87 GiB 116.83 B Vulkan 99 1 2048 1 pp32768 265.68 ± 1.11
gpt-oss 120B F16 60.87 GiB 116.83 B Vulkan 99 1 2048 1 tg128 34.12 ± 0.12

Final Words

As you can see, prompt processing performance falls off as compared to dedicated GPUs, but the generation speed is quite solid comparatively.

For power efficiency, it's a good result for ~100 Watts of sustained use (and ~10W idle).

I'm particularly happy to see that the 120B model performs almost as good as the 20B model while being a much more powerful model. For my usage, this is basically making the smaller model redundant.

This is a stark contrast to running both models on my discrete GPU with 24GB of VRAM (an AMD RX 7900XTX), where I've gotten more than 100 tokens per second on the smaller model, but less than 10 tokens per second for the bigger model when generating text.

Where the dedicated GPU can really outshine the APU here is prompt processing, which is significantly faster.

For me, the conclusion is, that a model that fits on the GPU will be much faster running on the dedicated GPU, however big trade-offs have to be made with context size and model size / quantization.

The performance of the APU is good enough, and the higher GPU memory affords bigger context sizes and models. In particular for GPT-OSS, the difference between the 20B and the 120B models is huge. For my tests, the 20B model is palatable for chatting; however it falls apart in more complex tasks, such as coding - for one the output it provides is more error prone, and for two it gets easily confused.

From this point of view, the investment into the MS-S1 was absolutely worth it, because it allows me to run larger LLM models with better performance and less power consumption than on my desktop computer.