# Kubesimplify Blog — full content > Complete text of all published articles. Canonical URL precedes each > article; cite those URLs. Attribution: the author named in each header. --- # Kubernetes observability in 2026 with OpenObserve 1.0 as the backend - Canonical: https://blog.kubesimplify.com/kubernetes-observability-in-2026-with-openobserve - Published: 2026-09-15 - Summary: A 31 ms full-text search over 4.2 million Kubernetes log rows, and a whole cluster on 43m CPU under 600 MiB: a hands-on run of OpenObserve 1.0 as the backend. **TL;DR:** A full-text search over 4.2 million Kubernetes log rows came back in 31 ms after touching 27 MB of the 4,159 MB in range, and the whole three node cluster's telemetry ran through one [OpenObserve](https://openobserve.ai) 1.0 pod at 43m CPU and under 600 MiB. Collecting telemetry from Kubernetes is solved, paying to store and search it is not, and this post is about why the backend is where the cost lives, what a backend built on object storage and columnar files does differently, and what that looks like on a real cluster. I ran it on a kiac cluster on my Mac, read the parts of the source that matter, and hit one real bug on the way. Every Kubernetes cluster you run is quietly producing four kinds of evidence about itself: container logs on every node, metrics from the kubelet and kube-state-metrics, traces if your apps are instrumented, and Kubernetes events, which most clusters throw away after an hour. When a pod restarts at 3 am you usually do have the data somewhere, the real question is where it went and whether you can afford to keep it there. That second question is what we are after, so let's look at the problem, what people run today, one backend built differently, and then run it. Where a number comes from the vendor, I say so. ## Why the backend is where the money goes ![What a Kubernetes cluster emits, and where it goes](/img/blog/kubernetes-observability-in-2026-with-openobserve/01-k8s-signals-two-paths.png) The collection side is done. In 2026 you run the OpenTelemetry Collector as a DaemonSet on every node, it reads container stdout, scrapes the kubelet, watches the API server for events and receives OTLP from your apps. The Grafana Labs Observability Survey 2026 (1,363 respondents) shows how settled that is, and where the pain moved: | What the survey found | Share | |---|---| | Use OpenTelemetry for metrics / traces / logs | 57% / 50% / 48% | | Name complexity and overhead as the biggest observability concern | 38% | | Name cost as a top-three concern | 31% | | Say cost is a priority when picking new tools | 65% | So why is the backend the expensive part? Because of how the two classic designs store data. ![Where the money goes: index-heavy vs columnar on object storage](/img/blog/kubernetes-observability-in-2026-with-openobserve/02-index-heavy-vs-columnar.png) **Index-heavy stores** like Elasticsearch build an inverted index over every field at ingest and keep hot data on replicated SSD. You pay three times: CPU to build the index, disk for index plus data plus replicas, and RAM to keep the index hot. Every new high-cardinality field makes it worse. **Label-based stores** like Loki went the other way. They index a handful of labels and scan the rest. That is cheap until you need to query by something with many values. A pod name, a request id, a trace id: the moment you want those as query dimensions you are told to keep cardinality down, and the thing you most want to search by becomes the thing you cannot index. **SaaS per-GB pricing** adds a third pressure. Every debug log line is a line item, so teams sample and drop, which defeats the point of collecting. And the data is getting wider: LLM traces carry tokens, prompts and cost, GPU nodes emit per-process metrics, agents make dozens of model calls per user action. ## What we run today, and what we should ask for The default backend most of us know is the LGTM stack: Loki, Prometheus or Mimir, Tempo, Grafana. It works, and it is what I learned on. It is also four systems with four data models and four retention configurations, and correlation mostly happens by copying a trace id from one screen into another. Elastic gives you full-text search on every field and the hardware bill that comes with it. Datadog gives you everything and charges per host, per custom metric and per indexed log. The data warehousing world solved a similar problem a few years ago. Think of your phone: you do not keep every photo you ever took on the fast internal storage, you keep them in cheap cloud storage and pull down the ones you need. Object storage is cheap and built for eleven nines of durability, columnar file formats compress well, and modern query engines scan them fast. Iceberg, DuckDB and the cloud warehouses are all built on this. An observability backend built the same way shrinks the expensive tier to only what you search, and puts everything else in a bucket. That gives us a bar to hold any backend to: ![The bar: eight things a Kubernetes observability backend should do](/img/blog/kubernetes-observability-in-2026-with-openobserve/03-eight-point-bar.png) 1. OpenTelemetry-native ingest, plus compatibility endpoints so existing agents keep working. 2. One process on a laptop, roles on a cluster, same binary. 3. Object storage as the durable tier, in an open file format, so the data outlives the tool. 4. High cardinality as a feature: index where you search, columnar scan everywhere else. 5. SQL for logs and traces, PromQL for metrics. 6. Correlation built in: trace to logs in one click, alerts that understand SLOs. 7. Understands LLM traces coming in, and exposes itself to agents over MCP going out. 8. A clear open-source core. ## How OpenObserve is built OpenObserve is a single Rust binary, licensed AGPL-3.0, and [Kubernetes observability](https://openobserve.ai/kubernetes-monitoring/) is the job it is most often put to. It ingests logs, metrics and traces (LLM traces included) over OTLP, RUM from its browser SDK, and keeps compatibility endpoints for Elasticsearch bulk, Loki push, Prometheus remote write and Splunk HEC. It stores everything as Parquet or Vortex files in S3, GCS, Azure Blob, MinIO or a local disk, indexes only the fields you search, and answers SQL through Apache DataFusion and PromQL with its own evaluator over the same files. Its first 1.0 release candidate landed on 28 August 2026 and 1.0.0 went GA on 11 September. The walkthrough below was run on rc1, before GA, so I went back afterwards and re-checked the two parts most likely to have moved at GA: the MCP step and a bug I hit in the SLO step. The README claims a 2 PB per day deployment and "140x lower storage cost than Elasticsearch", and the closest thing to public evidence is the vendor's own [one billion log records benchmark against ClickHouse](https://openobserve.ai/blog/openobserve-vs-clickhouse-one-billion-logs-benchmark/). These are vendor claims, so let's look at what is underneath. Before we go inside, let's put it against the bar we set above. Read the table as OpenObserve vs Loki, OpenObserve vs Elasticsearch and OpenObserve vs the full LGTM stack in one place, because those are the backends it replaces in practice: | | Most stacks today | OpenObserve | |---|---|---| | Signals | Loki, Prometheus or Mimir, Tempo, one system each | Logs, metrics, traces, RUM and LLM traces in one binary, one data model, retention set per stream in one place | | Durable tier | Each system's own storage, its own format | Object storage holds open Parquet or Vortex files, so the data outlives the tool | | Index | Elastic indexes every field, Loki indexes only labels | A full-text index only on the fields you search, kept as a small sidecar next to each data file, columnar scan for the rest | | Query | LogQL, PromQL, TraceQL | SQL for logs and traces, PromQL for metrics | | Shape | Several deployments to keep healthy | One process on a laptop, the same binary split into ingester, querier, compactor, router and scheduler roles on a cluster | It is not a drop-in replacement for Prometheus, though. For metrics it takes the seat Thanos or Mimir take, long-term storage behind Prometheus with remote write in and PromQL out, and its PromQL engine has gaps that I list in the sharp edges section. Compare it with the whole LGTM stack, Elastic, or Datadog. ### A log line goes in OpenObserve appends every incoming batch to a write-ahead log and an in-memory Arrow table, turns the frozen tables into Parquet or Vortex files, uploads each file to object storage with a `.ttv` full-text index beside it, and only then records the file in its `file_list` catalog. ![Write path](/img/blog/kubernetes-observability-in-2026-with-openobserve/write-path.gif) A batch lands over HTTP, its JSON is flattened (`k8s.namespace.name` becomes `k8s_namespace_name`, which is why every screenshot has those long field names) and its schema is checked against the stream. It is appended to a write-ahead log and an in-memory Arrow table at the same time. Every 2 seconds the frozen tables become Parquet, the upload job merges the dumps of the same stream, hour and schema into one file per round, writes it to the bucket under `files/{org}/{type}/{stream}/YYYY/MM/DD/HH/`, builds a full-text index for that file as a `.ttv` object, and only then records the file in the `file_list` catalog. If the catalog database is unreachable, nothing is uploaded, and I like that a lot: a database outage cannot litter the bucket. A failure between the upload and the catalog write can still leave an object behind, so the gate closes the common case rather than every case. Two things you should know: the WAL is flushed but not fsynced per batch by default (`ZO_WAL_FSYNC_DISABLED=true`), a fair trade for a system whose durable tier is the bucket, and the defaults in the code differ from the docs in several places (the WAL rotates at 512 MB, the docs say 64), so go by the binary you run and not the docs page. ### The index is a file next to the data OpenObserve stores its full-text index as a separate `.ttv` file next to each Parquet or Vortex data file, a single tantivy segment inside an Apache Iceberg Puffin container. ![Anatomy of a .ttv index file](/img/blog/kubernetes-observability-in-2026-with-openobserve/06-ttv-anatomy.png) Puffin is Iceberg's simple container format for index and statistics blobs, and tantivy is the Rust full-text search library. All configured full-text fields (`message`, `body`, `log` and friends) are concatenated into one indexed column, fields like `trace_id` are indexed whole for exact match, and `_timestamp` is a fast field. Because there is exactly one segment per data file, a document id in the index equals a row number in the data file. That one fact is what makes the query side cheap, as we will see next. ### A query comes out OpenObserve answers a query by asking the `file_list` catalog for the files in the time range, pruning them with partition keys, bloom filters and the `.ttv` index down to a bitmap of matching rows, and handing only those rows to Apache DataFusion to read out of Parquet or Vortex. ![How a query finds your rows](/img/blog/kubernetes-observability-in-2026-with-openobserve/query-funnel.gif) A query asks the catalog for the files that overlap the time range, splits them across queriers, and then throws away as much as it can before reading anything: files that fail the partition keys, files the bloom filters rule out, and then, using the index, everything but the matching rows. The matched row ids become a row bitmap, and the bitmap becomes a Parquet or Vortex access plan that DataFusion reads. Counts, histograms and top-N over indexed fields never open a data file at all when the file sits fully inside the query window. A background compactor merges each finished hour's small files into files of up to 2 GB and rebuilds the index, and a result cache serves repeated dashboard queries. ### What 1.0 adds **Vortex as a file format.** `ZO_FILE_FORMAT=parquet,logs=vortex` writes logs as Vortex, a columnar format from SpiralDB that is now a Linux Foundation project, built for random access, which is exactly the shape of a "show me these 100 log lines" query. [OpenObserve's own August 2026 comparison](https://openobserve.ai/blog/openobserve-vs-clickhouse-one-billion-logs-benchmark/), one billion log records with everything but the format identical, vendor-run but public: | Workload | Parquet | Vortex | |---|---|---| | Row fetch with LIMIT 100 (8 queries) | 1,114 ms | 436 ms | | Indexed counts (8 queries) | 215 ms | 232 ms | | Storage for 1 billion rows | 673.5 GB | 710.7 GB | Faster on the query that hurts, a tie on counts, about 5 percent more disk. The vendor's [metrics benchmark against Prometheus and Mimir](https://openobserve.ai/blog/openobserve-vs-prometheus-mimir-metrics-benchmark/) ran the same two formats side by side as well, and there Vortex was about 3x faster on filtered histogram queries with the two formats within a gigabyte of each other on disk. OpenObserve's Vortex support only left the enterprise build in July 2026 and the crate is pinned to a git revision, so I would call it new and promising, and not the default for a reason. **An [MCP server](https://openobserve.ai/docs/integration/ai/mcp/) that does not flood the context window.** The tool catalog is generated from the OpenAPI spec, a couple of hundred tools, but `tools/list` returns only seven: a `tool_search` over the descriptions, a `tools_call` that returns summarised responses, and five pinned tools. Authentication is your own token, so the model inherits your permissions and nothing more. This is the part of the release I was most keen to try. Also new, and all open source: [SLOs with burn-rate alerts](https://openobserve.ai/docs/user-guide/analytics/slos/), a time index for traces so a bare trace id no longer scans everything, and LLM traces from the OpenTelemetry GenAI conventions plus Vercel AI SDK, OpenInference, Langfuse and TraceLoop-style attributes, priced at ingest from a built-in price table (custom pricing is enterprise). SSO and fine-grained RBAC, incidents, anomaly detection, the AI assistant and the service graph UI are enterprise. ![Open source vs enterprise in 1.0](/img/blog/kubernetes-observability-in-2026-with-openobserve/08-oss-vs-enterprise.png) ## Running it: a whole cluster into one binary Let's run it. I used kiac (Kubernetes in Apple Containers), where every node is its own lightweight VM on macOS. It works the same on kind or k3d. You need kubectl, helm, jq and curl on your machine, plus Claude Code if you want the MCP step in your editor. Versions: Kubernetes v1.36.1 via kiac v0.5.1, Helm v4.1.4, and OpenObserve v1.0.0-rc1 for the original runs, though the values file in the repo now pins 1.0.0, which is what you will get. I ran the whole thing twice, on 2 and 4 September, and the step 8 numbers are from the second run, which I left up for 15 hours. Step 6 I then re-ran in full on 15 September against 1.0.0 GA, so every number in it is from the shipped release. In step 7 I re-checked only the SLO bug against GA, so that step shows the rc1 run first and the GA re-check after it, each labelled. Everything the demo uses is in one repo: ```bash git clone https://github.com/saiyam1814/openobserve-k8s-demo cd openobserve-k8s-demo ``` ### 1. Cluster and OpenObserve ```bash kiac create cluster --name o2 --workers 2 --memory 4G --cp-memory 4G helm repo add openobserve https://charts.openobserve.ai helm upgrade -i o2 openobserve/openobserve-standalone -n openobserve --create-namespace \ -f manifests/o2-values.yaml kubectl -n openobserve get pods,svc ``` ```text NAME READY STATUS RESTARTS AGE pod/o2-openobserve-standalone-0 1/1 Running 0 58s NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) service/o2-openobserve-standalone LoadBalancer 10.100.30.54 192.168.64.10 5080:30148/TCP,5081:32552/TCP ``` The chart comes from [charts.openobserve.ai](https://charts.openobserve.ai), and the values file pins the image at 1.0.0, asks for a LoadBalancer Service, and sets three things worth knowing: ```yaml config: ZO_FILE_FORMAT: "parquet,logs=vortex" # the 1.0 feature under test ZO_MAX_FILE_RETENTION_TIME: "60" # demo pacing: rotate every 60s instead of 600s ZO_COMPACT_DELETE_FILES_DELAY_MINUTES: "10" # demo pacing: drop compacted-away files after 10 min ``` That EXTERNAL-IP and the chart's default root user are all the later steps need, so let's put them in two variables. Change the password the moment this is more than a demo. ```bash export O2=http://192.168.64.10:5080 # your LoadBalancer IP will differ export AUTH='root@example.com:Complexpass#123' ``` Log in at `$O2` with that user. The home page is empty. Let's fix that. ### 2. Collect everything the cluster emits The official collector chart installs an OpenTelemetry Collector agent as a DaemonSet and a gateway, both managed by the OpenTelemetry Operator, so cert-manager and the operator go first: ```bash kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.19.1/cert-manager.yaml kubectl apply -f https://github.com/open-telemetry/opentelemetry-operator/releases/download/v0.158.0/opentelemetry-operator.yaml helm upgrade -i o2c openobserve/openobserve-collector -n openobserve-collector --create-namespace \ -f manifests/collector-values.yaml curl -s -u $AUTH "$O2/api/default/streams?type=logs" | jq -r '.list[].name' curl -s -u $AUTH "$O2/api/default/streams?type=metrics" | jq '.list | length' ``` ```text default k8s_events 450 ``` Container logs, Kubernetes events and 450 metric streams within a minute, from the kubelet, cAdvisor, kube-state-metrics and the API server. A few hours into the run, long before the fifteen-hour totals in step 8, the streams page summed up the storage story so far (the `slo_slices` and `triggers` streams come from the SLO step further down, and `checkout_archive` is a 40,000 row backfill I used to test compaction, not covered here): ![Streams page: 470 streams, 1.51 GB ingested, 102.56 MB compressed](/img/blog/kubernetes-observability-in-2026-with-openobserve/12-streams-compression.jpg) | Streams page | Whole cluster | |---|---| | Ingested | 1.51 GB | | Compressed on disk | 102.56 MB (15.1x) | | Index | 43.41 MB | | Container logs alone | 267.62 MB in, 11.55 MB out (23.2x) | | OpenObserve pod, all roles | 43m CPU, 597Mi memory (`kubectl top pod`) | ### 3. An application with traces and logs Cluster telemetry is half the picture. The other half is your own app, so I wrote a small stand-in: `checkout`, a Go HTTP service that takes an order, reserves inventory, charges a card and fails a configurable share of payments. It is under 200 lines in `app/`, instrumented with the standard OpenTelemetry Go SDK with nothing vendor-specific in the code, and it logs JSON to stdout with the trace id on every line. A load generator sends five checkouts a second. ```bash container build -t docker.io/library/checkout:demo app # docker build works too kiac load image docker.io/library/checkout:demo --name o2 # kind load docker-image on kind kubectl apply -f manifests/10-shop.yaml kubectl -n shop logs deploy/checkout --tail=1 ``` ```text {"time":"2026-09-04T05:53:06.35924046Z","level":"ERROR","msg":"payment failed","service":"checkout","version":"1.0.0","order_id":"ord-846683","amount":64.99,"gateway":"stripe-sandbox","error":"payment gateway timeout","trace_id":"a309a2bc90a055def047fb770fc2d00e","span_id":"40f0e133e5d9c8cc"} ``` In the UI, traces arrived immediately, three spans per request. From a trace, "View Logs" opens the logs page filtered on that trace id, and the three log lines of that request are right there, including the failed payment. ![A checkout trace: three spans, two errors](/img/blog/kubernetes-observability-in-2026-with-openobserve/05-trace-detail-3-spans.jpg) ![Trace to logs: the three log lines of one failed checkout](/img/blog/kubernetes-observability-in-2026-with-openobserve/06-trace-to-logs.jpg) ### 4. Parse the log body at ingest That link needs `trace_id` to be a column, and the collector delivers each log line as one `body` string. Rather than reconfigure the collector, a realtime pipeline parses it at ingest: source stream `default`, a VRL function (Vector Remap Language, the transform language from Vector), destination stream `default`. Both objects are JSON files you POST: ```bash curl -s -u $AUTH -H 'Content-Type: application/json' -X POST "$O2/api/default/functions" \ -d @manifests/function-parse-checkout-json.json curl -s -u $AUTH -H 'Content-Type: application/json' -X POST "$O2/api/default/pipelines" \ -d @manifests/pipeline-parse-shop-logs.json ``` ```text {"code":200,"message":"Function saved successfully"} {"code":200,"message":"Pipeline created successfully","id":"7500791427660513280","name":"parse-shop-logs"} ``` The function is the interesting part (abridged here, the version in the repo also copies `span_id`, `order_id`, `amount`, `gateway`, `error` and `sku`): ```text if .k8s_namespace_name == "shop" && exists(.body) { parsed, err = parse_json(string!(.body)) if err == null && is_object(parsed) { .level = downcase(string!(parsed.level)) .msg = parsed.msg .trace_id = parsed.trace_id } } . ``` A minute later the new fields are columns, and a search over the API shows the join key sitting right there: ```bash curl -s -u $AUTH -H 'Content-Type: application/json' "$O2/api/default/_search?type=logs" -d '{"query":{ "sql":"SELECT level, msg, order_id, trace_id FROM \"default\" WHERE k8s_namespace_name='"'"'shop'"'"' AND level='"'"'error'"'"' ORDER BY _timestamp DESC", "start_time":'$(( $(date +%s) - 600 ))000000',"end_time":'$(date +%s)000000',"size":2}}' | jq -c '.hits[] | del(._timestamp)' ``` ```text {"level":"error","msg":"payment failed","order_id":"ord-648398","trace_id":"e826c2d59bc16d05923d1d654bafcd4c"} {"level":"error","msg":"payment failed","order_id":"ord-311027","trace_id":"512dea2672432a9e4b827d48af5e1e1b"} ``` In the UI the same fields show up as facets on the left, which is what turns "grep the shop namespace" into clicking `k8s_namespace_name`, then `level`. This is 2.2K error rows in 116 ms: ![Logs page: shop namespace errors with the k8s field facets](/img/blog/kubernetes-observability-in-2026-with-openobserve/02-logs-shop-errors.jpg) And the jump works in both directions. Expand any of those rows and there is a View Trace button on it, because the trace id is now a field: ![An expanded log row after the pipeline: parsed fields such as level, amount and error, with the View Trace button](/img/blog/kubernetes-observability-in-2026-with-openobserve/03-log-row-trace-id.jpg) ### 5. Look at the files Now for my favourite part: the write path from earlier, in a real data directory. The image has no shell, so an ephemeral debug container that shares the process namespace gets you the filesystem through `/proc/1/root`: ```bash kubectl -n openobserve debug o2-openobserve-standalone-0 --image=busybox:1.36 \ --target=openobserve-standalone --container=toolbox --profile=general -- sleep 86400 kubectl -n openobserve exec o2-openobserve-standalone-0 -c toolbox -- sh -c \ 'cd /proc/1/root/data/stream && find files/default -type f | sed "s/.*\.//" | sort | uniq -c' ``` ```text 3989 parquet <- metrics 3674 ttv <- an index per data file, give or take the open hour 19 vortex <- logs, in Vortex, written by the ingester ``` These are binary columnar files, so `cat` shows nothing useful. What identifies them is the first four bytes. Copy one file of each type out of the pod (the loop picks whatever file `find` sees first, so your names will differ) and look at those bytes: ```bash for ext in parquet ttv vortex; do F=$(kubectl -n openobserve exec o2-openobserve-standalone-0 -c toolbox -- sh -c \ "cd /proc/1/root/data/stream && find files/default -name '*.$ext' 2>/dev/null | head -1") kubectl -n openobserve exec o2-openobserve-standalone-0 -c toolbox -- cat "/proc/1/root/data/stream/$F" > sample.$ext done for f in sample.parquet sample.ttv sample.vortex; do printf '%-16s ' "$f"; head -c 4 "$f" | xxd | cut -c10-; done ``` ```text sample.parquet 5041 5231 PAR1 sample.ttv 5046 4131 PFA1 sample.vortex 5654 5846 VTXF ``` Parquet, a Puffin index container, Vortex. To look inside an index, OpenObserve ships `ttv-inspect`. With no shell in the image, that runs as a Job on the same volume (`manifests/20-ttv-inspect-job.yaml`). The Job needs two things filled in: the node that holds the volume, because a local-path volume only exists on one node, and the index file to read. Both come from `kubectl`: ```bash NODE=$(kubectl -n openobserve get pod o2-openobserve-standalone-0 -o jsonpath='{.spec.nodeName}') TTV=$(kubectl -n openobserve exec o2-openobserve-standalone-0 -c toolbox -- sh -c \ "cd /proc/1/root/data/stream && find files/default/index/default_logs -name '*.ttv' 2>/dev/null | head -1") kubectl -n openobserve delete job ttv-inspect --ignore-not-found # a Job's template is immutable, so re-runs need this sed -e "s#NODE_NAME#$NODE#" -e "s#TTV_PATH#/data/stream/$TTV#" manifests/20-ttv-inspect-job.yaml | kubectl apply -f - kubectl -n openobserve wait --for=condition=complete job/ttv-inspect --timeout=180s kubectl -n openobserve logs job/ttv-inspect ``` ```text blob_count : 6 row_group_size : 131072 segments : 1 total_docs : 248318 (deleted: 0) _all text [indexed, tokenizer=o2] service_name text [indexed,fast, tokenizer=raw] trace_id text [indexed,fast, tokenizer=raw] _timestamp i64 [fast] ``` One segment, 248,318 documents, and the fields `_all`, `service_name` and `trace_id`. One index file sitting beside one data file, which is the shape the write path promised. That leaves bar item 3, and with the index accounted for, the magic bytes above are what the case rests on. `PAR1` and `VTXF` say these are Parquet and Vortex containers, `PFA1` says the index is an Iceberg Puffin blob, and four bytes are enough to rule out a private format wearing a borrowed extension. They are not enough to certify every page inside, so take it as a strong hint rather than a proof. The two formats also travel differently. Parquet is read by Spark, pandas and every warehouse you can name, while Vortex is young enough that its reader list is still short, which is one more reason the sharp edges below say to keep it on a test cluster. What holds for both is that your bucket ends up holding open formats rather than a private one, which is what you want from an archive and what you need on the day you migrate. Worth saying plainly, because reading files out of a bucket yourself is the obvious wrong turn here: open formats are a property of the storage, not a way to work. An engine pointed straight at these files skips the catalog, the index, the bloom filters and the compactor, which is to say it skips everything that makes a query fast. To search this cluster you use OpenObserve's own query path, and the next step points an agent at exactly that. ### 6. Ask it questions over MCP The MCP server is in the open-source build and needed no enabling on this install, so the other way into this cluster is to register it with an agent and ask in English. The setup page under IAM writes the command for you, one tab per client, and nudges you toward a read-only credential, which is good advice: ![MCP Server setup page with the claude mcp add command](/img/blog/kubernetes-observability-in-2026-with-openobserve/11-mcp-setup-page.jpg) The Claude Code tab is a one-liner: your organisation's MCP endpoint, plus a token the page mints. Copy it, or build the same header from the credentials we exported in step 1: ```bash claude mcp add openobserve "$O2/api/default/mcp" -t http \ --header "Authorization: Basic $(printf %s "$AUTH" | base64)" ``` After that the question goes in the editor rather than into a SELECT. I asked it "which checkout operations threw errors in the last hour, and how many?" and it listed the streams, read the traces schema, then wrote and ran five queries of its own. This was the one that gave it the error rate: ```sql SELECT operation_name, count(*) AS total, sum(case when span_status='ERROR' then 1 else 0 end) AS errors FROM "default" WHERE service_name = 'checkout' GROUP BY operation_name ORDER BY total DESC ``` And this is what came back, trimmed. The failures in it are mine, `checkout` drops 2 percent of payments on purpose, and this cluster had only been ingesting for a few minutes: ```text Two operations threw errors, 52 failures each, the same 52 checkout requests. service operation spans errors error msg checkout payment.charge 2,412 52 payment gateway timeout checkout POST /checkout 2,412 52 (empty, propagated) checkout inventory.reserve 2,412 0 - 104 error spans across 52 distinct trace IDs, so it is 52 failed checkouts, not 104. payment.charge fails with a gateway timeout and the parent POST /checkout span inherits the ERROR status with no message of its own. inventory.reserve is clean, which puts the fault squarely at the payment gateway boundary rather than anywhere upstream in checkout. Failure rate: 52 / 2,412 = 2.15% of checkout requests. One caveat on the window: the traces stream only has data from 06:51:49Z onward, so the full-hour query is really covering ~8.5 minutes. ``` Two things in there I did not ask for and would have had to work out myself: that the 104 error spans are 52 requests rather than 104 incidents, and that my "last hour" was really about eight minutes, because the cluster was that young. Back on the setup page, the other tabs wire the same server into Cursor, VS Code and the rest. The endpoint speaks streamable HTTP, so a curl loop is a client too, and it shows the path an agent takes. `mcp.sh` in the repo wraps one JSON-RPC call and reads `O2` and `AUTH` from step 1, so export them again if you are in a new terminal: ```bash ./mcp.sh tools/list | jq -r '.result.tools[].name' ./mcp.sh tools/call '{"name":"tool_search","arguments":{"query":"list traces with errors","limit":1}}' \ | jq -r '.result.content[0].text | fromjson | .tools[0].name' ./mcp.sh tools/call '{"name":"tools_call","arguments":{"tool":"SearchSQL","detail":"summary","args":{"org_id":"default","type":"traces", "request_body":{"query":{"sql":"SELECT service_name, operation_name, count(*) AS errors FROM \"default\" WHERE span_status='"'"'ERROR'"'"' GROUP BY service_name, operation_name","start_time":'$(( $(date +%s) - 3600 ))000000',"end_time":'$(date +%s)000000',"size":10}}}}}' \ | jq -c '.result.structuredContent.hits' ``` ```text tool_search tools_call GetLatestTraces PrometheusRangeQuery SearchSQL StreamList StreamSchema GetLatestTraces [{"service_name":"checkout","operation_name":"payment.charge","errors":61},{"service_name":"checkout","operation_name":"POST /checkout","errors":61}] ``` Three calls: the tool list, a search that turns plain intent into the right tool, and the answer. The count has moved on from the agent's 52 because the load generator kept running between the two, which is its own small reminder that "the last hour" is a moving window. I wrote that SQL by hand to show the path. The registration above is so that you do not have to. ### 7. Break an SLO Define an SLO on the checkout traces (a good event is a `POST /checkout` span that did not end in `ERROR`, target 99 percent over 7 days), deploy a webhook echo server to receive alerts, and create the alert objects. An alert in OpenObserve is three things: a template (the payload body), a destination (where it goes) and the alert itself, so that is three POSTs, all from `manifests/alert-burn-rate.json`. A second, plain scheduled alert on error spans goes in alongside it, you will see why in a moment. Then push the failure rate to 60 percent: ```bash kubectl apply -f manifests/30-alert-sink.yaml curl -s -u $AUTH -H 'Content-Type: application/json' -X POST "$O2/api/default/slos" \ -d @manifests/slo-checkout-availability.json SLO=$(curl -s -u $AUTH "$O2/api/default/slos" | jq -r '.list[0].id') jq '.template' manifests/alert-burn-rate.json | curl -s -u $AUTH -H 'Content-Type: application/json' -X POST "$O2/api/default/alerts/templates" -d @- jq '.destination' manifests/alert-burn-rate.json | curl -s -u $AUTH -H 'Content-Type: application/json' -X POST "$O2/api/default/alerts/destinations" -d @- jq --arg id "$SLO" '.alert | .query_condition.slo_condition.slo_id = $id' manifests/alert-burn-rate.json \ | curl -s -u $AUTH -H 'Content-Type: application/json' -X POST "$O2/api/v2/default/alerts" -d @- curl -s -u $AUTH -H 'Content-Type: application/json' -X POST "$O2/api/v2/default/alerts" -d @manifests/alert-error-spans.json kubectl -n shop exec deploy/loadgen -- curl -s "http://checkout.shop.svc/chaos?rate=60" ``` ```text {"code":200,"message":"SLO saved","id":"7500794280517042176","name":"checkout-availability"} {"code":200,"message":"Template saved","id":"3IlAageUwczuTkc0FuyqEEP1dd4","name":"burn-rate-json"} {"code":200,"message":"Destination saved","id":"3IlB8cmyOkm43oPUBBduELWjUlI","name":"alert-sink"} {"code":200,"message":"Alert saved","id":"3IlBHI5FXraQCVx9pCYEJRwOBuX","name":"checkout-burn-rate"} {"code":200,"message":"Alert saved","id":"3IlEZgA5TSGAwDC0Rl3spdfsGut","name":"checkout-error-spans"} {"fail_rate_percent":60} ``` One catch you will hit: the echo server has a private cluster IP and OpenObserve blocks those as webhook destinations (SSRF protection, so a webhook cannot be pointed at internal services), so the values file sets `ZO_SKIP_SSRF_CHECKS=true`. Fine for a demo, wrong for anything internet-facing. Within a minute the SLO page, still rc1 at this point, showed 97.907 percent against 99 and "Budget blown": ![SLO page: checkout-availability, budget blown](/img/blog/kubernetes-observability-in-2026-with-openobserve/09-slo-budget-blown.jpg) The burn-rate alert stayed quiet, and its evaluations were logged as "frozen (unobserved)". That freeze is deliberate: an SLO alert never fires or resolves while its windows are unmeasured. But the measurements were being written, and the status row the alert reads was written once at creation and never advanced afterwards. Debug logging gave the reason in one line: ```bash kubectl -n openobserve logs o2-openobserve-standalone-0 | grep "\[slo\] pass failed" ``` ```text ERROR [slo] pass failed for 7500794280517042176 org=default: DbError# SeaORMError# Execution Error: error returned from database: (code: 8) attempt to write a readonly database ``` The SLO pass opens the read-only database client and then writes through it. On PostgreSQL the read-only pool falls back to the normal connection unless you point it at a replica, so most cluster deployments are fine. On SQLite, which every single-node install uses, the write fails. I expected this to be gone by now, because 1.0.0's release notes say the storage layer "split into separate ORM read/write clients (retiring the sqlite write lock)". So I upgraded this cluster to 1.0.0 GA and ran the step again. On GA the SLO backfilled its slices, reached full coverage, computed an SLI of 98.009 percent against the 99 target, and then stopped. Twenty minutes later, with the load generator still failing 60 percent of payments, `computed_at` had not moved, `stale_watermark` was still true, the burn rate was still reading 1.99, and the same `attempt to write a readonly database` line was back in the log. The burn-rate alert never fired, because the row it reads never advanced. So this one survived GA on the single-node path, and the reason turned out to be more interesting than the bug. The fix exists. I reported this during the rc1 run, and [the patch that closed it](https://github.com/openobserve/openobserve/pull/14192) moved that write onto the read-write client and merged into `main` on 9 September. 1.0.0 was tagged on the 11th without it: at the tag, `commit_status` still takes the read-only handle, and on `main` it does not. The backport onto the release branch landed on the 12th, one day too late for the tag, and there is no patch release yet, so the image you can pull today still has it. A release-timing miss rather than an unsolved problem. If you want working SLO alerts on a single node before that patch ships, point the metadata store at PostgreSQL instead of SQLite and keep `ZO_LOCAL_MODE=true`, because the read-only Postgres client falls back to the read-write connection and the failing write succeeds. None of it affects a cluster deployment on PostgreSQL. Plain alerts are unaffected, which is why we created the second one. The scheduled alert on the same failed spans evaluates once at creation, where it usually reports Normal, and fires on the next run a minute later, so give it that minute before reading the echo server: ```bash kubectl -n shop logs deploy/alert-sink | jq -R -c 'fromjson? | select(.path=="/alerts") | .body | fromjson' ``` ```text {"alert":"checkout-error-spans","stream":"traces/default","org":"default","type":"scheduled","level":"critical","fired_at":"2026-09-02T06:34:44","url":"/web/short/f0a29971a7f5701d?org_identifier=default"} ``` The timestamp gives it away as the rc1 run, and plain alerts behave the same on GA. The alerts page tells the same story in one row each: the SLO-backed alert with no outcome yet, the scheduled one showing its last result. That screenshot is from the rc1 run, and 1.0.0 behaves the same way. ![Alerts list: the SLO-backed alert with no outcome, and the scheduled alert after its first evaluation](/img/blog/kubernetes-observability-in-2026-with-openobserve/08-alerts-list.jpg) Heal the app with `chaos?rate=2` when you are done. ### 8. Compaction, the bill, and how fast it answers Every hour the ingester leaves behind a pile of small files, and once the hour closes the compactor merges them, rebuilds the index and writes a bloom filter sidecar. You can see both states on disk at once, the open hour and the one before it: ```bash kubectl -n openobserve exec o2-openobserve-standalone-0 -c toolbox -- sh -c ' cd /proc/1/root/data/stream PREV=$(date -u -d @$(( $(date +%s) - 3600 )) +%Y/%m/%d/%H); CUR=$(date -u +%Y/%m/%d/%H) echo "closed hour $PREV (KB, file)" du -ak files/default/logs/default/$PREV files/default/index/default_logs/$PREV files/default/bloom/default_logs/$PREV | grep "\." echo "current hour $CUR: $(ls files/default/logs/default/$CUR | wc -l) files"' ``` ```text closed hour 2026/09/04/04 (KB, file) 14924 files/default/logs/default/2026/09/04/04/75015051248633118722d6f.vortex 11228 files/default/index/default_logs/2026/09/04/04/75015051248633118722d6f.ttv 516 files/default/bloom/default_logs/2026/09/04/04/1788498194507969.bf current hour 2026/09/04/05: 13 files ``` Thirteen small files in the open hour, one 15 MB Vortex file with one index and one bloom filter for the closed one, and the originals were deleted after the delay we set. Nobody ran anything, this is the background job doing its rounds. (The compactor also logs each merge, but at this log volume the pod log only holds a few minutes, so you have to look right after an hour closes.) Now for the question we started with: what does it cost to keep? The stream stats API reports, per stream, the bytes that came in, the bytes on disk, and the size of the index: ```bash for t in logs metrics traces; do curl -s -u $AUTH "$O2/api/default/streams?type=$t" | jq -r --arg t $t \ '[.list[].stats] | "\($t): \(length) streams, \(map(.doc_num)|add) rows, \(map(.storage_size)|add|round) MB in, \(map(.compressed_size)|add|round) MB on disk, \(map(.index_size)|add|round) MB index"' done ``` ```text logs: 4 streams, 5067137 rows, 5173 MB in, 228 MB on disk, 164 MB index metrics: 463 streams, 21040362 rows, 17329 MB in, 161 MB on disk, 64 MB index traces: 1 streams, 727109 rows, 674 MB in, 44 MB on disk, 13 MB index ``` | | Came in | On disk | Index | Smaller by | |---|---|---|---|---| | Logs | 5,173 MB | 228 MB | 164 MB | 13x with index, 23x without | | Metrics | 17,329 MB | 161 MB | 64 MB | 77x with index, 108x without | | Traces | 674 MB | 44 MB | 13 MB | 12x with index, 15x without | | Whole cluster, 15 hours | 23,176 MB | 433 MB | 241 MB | 34x with index, 54x without | So 23 GB of telemetry from a three node cluster over fifteen hours is 674 MB in the bucket, index included. At S3 standard pricing of 2.3 cents per GB-month, a full month of this cluster is around 32 GB and under a dollar of storage. So the storage bill rounds to zero here, and the real cost of running this is the pod's CPU and memory. One detail in that table to notice: the logs index is not small, 164 MB against 228 MB of data, because every log body is tokenised into the full-text index. Metrics and traces have no full-text fields and their index is a fraction of the data. If you want logs cheaper still, take fields out of the full-text list. Is it fast, though? Three questions over the last 12 hours of logs, 4.2 million rows, on this one pod: ```bash NOW=$(date +%s); FROM=$((NOW-43200)) q() { curl -s -u $AUTH -H 'Content-Type: application/json' -X POST "$O2/api/default/_search?type=logs" \ -d "{\"query\":{\"sql\":\"$1\",\"start_time\":${FROM}000000,\"end_time\":${NOW}000000,\"size\":5}}" \ | jq -c '{took, total, scan_records, scan_size, idx_scan_size}'; } q "SELECT count(*) AS rows FROM \\\"default\\\"" q "SELECT k8s_namespace_name, count(*) AS rows FROM \\\"default\\\" GROUP BY k8s_namespace_name" q "SELECT _timestamp, k8s_namespace_name, body FROM \\\"default\\\" WHERE match_all('readonly database')" ``` ```text {"took":66,"total":1,"scan_records":4219547,"scan_size":4159,"idx_scan_size":135} {"took":61,"total":4,"scan_records":4219547,"scan_size":4159,"idx_scan_size":135} {"took":31,"total":5,"scan_records":28135,"scan_size":27,"idx_scan_size":0} ``` `took` is milliseconds, `scan_size` is the uncompressed size in MB of what the query touched. The count comes from file metadata and took 66 ms, which is the slowest of the three and a fair reminder that at this size everything is fast enough that the ordering is mostly noise. The group-by is the columnar scan reading one column across all 4.2 million rows, 61 ms. The full-text search is the query funnel from earlier in one line: 28,135 rows in the files it had to open, out of 4.2 million, 27 MB out of 4,159, in 31 ms, because the index threw away every file without a hit and then narrowed the rest to the matching rows. Do not read `idx_scan_size` as the proof of that, by the way: it reports 0 on the very row where the index did the most work, and 135 on the two that scanned everything. I reproduced the same inversion on 1.0.0, so take the drop in `scan_records` as the number that matters. This is one pod in a VM on a laptop with 12 hours of data, so treat these milliseconds as a rough shape rather than a benchmark. Watching it skip 99 percent of the data on my own laptop was really fun, though. One last thing I wanted to see was a restart. A Helm upgrade mid-run restarted the pod for me (`kubectl -n openobserve rollout restart statefulset/o2-openobserve-standalone` does the same), and the startup log walked through the WAL replay we saw in the write path: ```text INFO ingester::wal: Scanning lock files from "./data/wal/logs" INFO ingester::wal: Clean orphan par files done INFO ingester: Found 5 wal files to replay WARN ingester::wal: replay wal file: ".../logs/1788326948372735.wal" done, batch_num: 6, took: 4 ms ``` Nothing was lost. Two things that cost me time, neither about OpenObserve: `kiac load image checkout:demo` stores the bare name while the kubelet looks for `docker.io/library/checkout:demo`, so tag with the full name. And if you wrap Go's `slog.Handler` to inject trace ids, implement `WithAttrs` and `WithGroup` too, or `logger.With(...)` silently drops your wrapper. ## Sharp edges **Laptop to cluster works.** One Helm install, and the same binary that runs on a laptop was ingesting a whole cluster at under 600 MiB of memory. Cluster mode is a bigger commitment: PostgreSQL, NATS, object storage and the roles chart. **Know what is off by default.** The memory cache, the circuit breakers and synthetics are off, the WAL is not fsynced per batch, and the docs lag the code on several defaults. **Vortex is young here.** Faster on row fetch and larger on disk in the vendor's own numbers, in the open-source build only since July, pinned to a git revision. Try it on a test cluster, watch the release notes before production. **SLO alerts do not fire on a single node.** The SLO measures and the page updates, but the status row the alert reads stops advancing the first time a pass tries to write it, because that write goes through the read-only database client and SQLite refuses it. I hit this on rc1 and again on 1.0.0 GA, where it is a missed backport rather than an open bug: the fix merged to `main` two days before the tag. Cluster deployments on PostgreSQL are not affected, and plain alerts work fine. **PromQL has gaps.** OpenObserve does not run Prometheus's engine, it has its own PromQL evaluator, and `histogram_count`, `histogram_sum`, `histogram_fraction`, `sort`, `sort_desc` and the `@` modifier are not implemented in it yet. Point an existing Grafana dashboard at it and test before you switch. ## Wrapping up We followed a pod log line into a Vortex file and its index, watched queries prune down to the rows they needed, and saw fifteen hours of a whole cluster's telemetry, 23 GB of it, sit in 674 MB on disk with every field queryable. Give it a try on a test cluster and tell me how it goes, I am @SaiyamPathak on X and LinkedIn, and I would especially like to hear whether the SLO alerts advance for you on PostgreSQL, since the SQLite path still has this bug in 1.0.0. If you hit the same sharp edges I did, the notes above should save you an evening. ## Links - Companion repo with the values files, demo app, manifests and step-by-step README: https://github.com/saiyam1814/openobserve-k8s-demo - OpenObserve docs: https://openobserve.ai/docs - Helm charts (standalone and collector): https://github.com/openobserve/openobserve-helm-chart - 1.0.0 release: https://github.com/openobserve/openobserve/releases/tag/v1.0.0 - OpenObserve vs ClickHouse benchmark (vendor-run): https://openobserve.ai/blog/openobserve-vs-clickhouse-one-billion-logs-benchmark/ - Vortex file format: https://vortex.dev - Grafana Labs Observability Survey 2026: https://grafana.com/observability-survey/ - kiac: https://github.com/saiyam1814/kiac --- # Running a big LLM across multiple GPUs with vLLM - Canonical: https://blog.kubesimplify.com/running-a-big-llm-across-multiple-gpus-with-vllm - Published: 2026-09-01 - Summary: A runbook for serving a model too big for one GPU: download to serving in seven steps, with every vLLM flag, startup log line and real error explained, plus tensor, pipeline and expert parallelism benchmarked head to head on a 235B model across four RTX PRO 6000 cards. Sooner or later everyone running models locally hits the same wall. You find a model you want, you look at the download size, and it is bigger than the GPU you own. A 235B model needs roughly 236 GB just for its weights. The card we have holds 96 GB. A handful of current data-centre parts do carry more, but nothing on our machine does, and no amount of clever flags will make 236 GB squeeze into 96 GB. The answer is to use more than one GPU. That part everybody knows. The part that is genuinely confusing is what "use more than one GPU" actually means. Does each GPU get a copy of the model? Does the model get cut in half? Do the GPUs take turns? Which of those is happening, and what does it cost you? Let's answer that properly, with a real model on real hardware. ## What this post covers This is the runbook. Seven steps, from downloading a 236 GB model to serving it across four GPUs, with every command, flag, startup log line and real error explained. It is written for the person with root on the box, and it assumes no prior knowledge of distributed computing: if you know what a GPU is and you have run a model locally once, you are qualified. The theory arrives where you need it to make a decision, not before. Step 3 explains what a tensor-parallel split actually costs, because that is where you pick one, and Step 6 explains why the three options trade against each other, because that is where you read the numbers. Nothing here is theory for its own sake. ## The machine and the model Numbers mean nothing without the hardware attached, so here it is once. **The machine:** a server with 8x NVIDIA RTX PRO 6000 Blackwell Server Edition cards. Each card has 96 GB of memory, and the machine reports 95.01 GiB of that as usable. We borrowed 4 of the 8 cards for this work. One detail that matters more than it looks: these GPUs are **not** connected by NVLink. NVLink is NVIDIA's fast direct GPU-to-GPU cable. Without it, GPUs talk to each other over PCIe and through the CPU, which is slower. You can check what you have with one command: ```bash root@utho-gpu-rtxpro6000-8-62383:~# nvidia-smi topo -m ``` | Device | GPU0 | GPU1 | GPU2 | GPU3 | GPU4 | GPU5 | GPU6 | GPU7 | NIC0 | CPU Affinity | NUMA Affinity | | :------- | :--: | :--: | :--: | :--: | :--: | :--: | :--: | :--: | :--: | :-------------- | :-----------: | | **GPU0** | X | SYS | SYS | SYS | SYS | SYS | SYS | SYS | SYS | 48-55,176-183 | 6 | | **GPU1** | SYS | X | SYS | SYS | SYS | SYS | SYS | SYS | PHB | 32-39,160-167 | 4 | | **GPU2** | SYS | SYS | X | SYS | SYS | SYS | SYS | SYS | SYS | 0-7,128-135 | 0 | | **GPU3** | SYS | SYS | SYS | X | SYS | SYS | SYS | SYS | SYS | 16-23,144-151 | 2 | | **GPU4** | SYS | SYS | SYS | SYS | X | SYS | SYS | SYS | SYS | 112-119,240-247 | 14 | | **GPU5** | SYS | SYS | SYS | SYS | SYS | X | SYS | SYS | SYS | 96-103,224-231 | 12 | | **GPU6** | SYS | SYS | SYS | SYS | SYS | SYS | X | SYS | SYS | 64-71,192-199 | 8 | | **GPU7** | SYS | SYS | SYS | SYS | SYS | SYS | SYS | X | SYS | 80-87,208-215 | 10 | | **NIC0** | SYS | PHB | SYS | SYS | SYS | SYS | SYS | SYS | X | | | The legend that command prints, trimmed to the codes that matter here: | Symbol | Meaning | | :----- | :---------------------------------------------------------------------------- | | `X` | Self | | `SYS` | Across PCIe **and** the interconnect between CPU sockets. The slowest option. | | `NODE` | Across PCIe and the bridges inside one NUMA node | | `PHB` | Across PCIe and a PCIe host bridge, typically the CPU | | `PXB` | Across multiple PCIe bridges, without touching the host bridge | | `PIX` | Across at most a single PCIe bridge. The fastest non-NVLink option. | | `NV#` | Across a bonded set of `#` NVLinks | On our machine every pair of GPUs reports `SYS`, which means the traffic goes across PCIe and then across the link between the CPU sockets. If you had NVLink you would see `NV1`, `NV2` and so on instead. Keep this in mind, because it changes which splitting method is fastest. **The model:** `Qwen/Qwen3-235B-A22B-Instruct-2507-FP8`. Let's unpack that name, because it is doing a lot of work: - **235B** is the total parameter count, 235 billion. - **A22B** means 22 billion **active** parameters. This is a mixture-of-experts model: each layer holds 128 small expert networks and a router picks just 8 of them per token, so you pay for 235B in memory but only about 22B in arithmetic. - **FP8** is the number format the weights are stored in, 8 bits each, so one byte per parameter. **The software:** vLLM 0.27.1 running in the official container, with PyTorch 2.13.0 and CUDA 13.0, on driver 610.43.02. --- ## Step 1: Getting the model onto the machine Before anything can be split across GPUs it has to be on disk, and with a model this size that is not a formality. ### Check your disk first A quarter of a terabyte has to land somewhere. Run `df -h` before you start, and if the machine is shared, leave real headroom rather than just enough: platforms that manage disk as a resource start taking action well before the disk is actually full. ### The download With the headroom confirmed, you download it with the Hugging Face CLI: ```bash root@utho-gpu-rtxpro6000-8-62383:~# pip install huggingface_hub hf_transfer root@utho-gpu-rtxpro6000-8-62383:~# HF_XET_HIGH_PERFORMANCE=1 hf download Qwen/Qwen3-235B-A22B-Instruct-2507-FP8 Downloading bytes: ████████████████████████████████████████████████▏ | 24.4GB, 234MB/s Reconstructing (incomplete total...): 13%|███████████████▋ | 10.0GB / 80.0GB, 104MB/s Fetching 34 files: 0%| | 0/34 [00:00 **Note on the demo architecture:** For this demonstration each "service" is deployed as `kennethreitz/httpbin`: an echo server that reflects back request headers. This lets us inspect mTLS identity headers directly. The actual policy tests are performed by running `curl` from temporary pods or by `kubectl exec` into the `frontend` pod which uses `curlimages/curl`. The app logic is irrelevant. What matters is whether the mesh allows or blocks the traffic. --- ## Two Architectures, One Goal **Sidecar mode** has been Istio's model since 2017. Every pod that joins the mesh gets a second container injected into it, an Envoy proxy running as `istio-proxy`. A one-time init container installs iptables rules inside the pod's own network namespace so every byte in or out of your app container gets silently rerouted through that sidecar first. The sidecar terminates and originates mTLS, holds that pod's certificate and enforces whatever `AuthorizationPolicy` applies to it. Your application code never changes. But every pod whether or not it ever handles a sensitive request now carries a full proxy. **Ambient mode** splits that same job into two layers instead of bolting a proxy onto every pod. A `ztunnel` runs once per node not once per pod as a DaemonSet. It handles mTLS and workload identity for every pod scheduled on that node using an HTTP CONNECT-based tunnel protocol called HBONE to talk to other nodes. It does not read HTTP. It has no concept of a path or a method. For that ambient adds a second optional component: a **waypoint**, the exact same Envoy binary the sidecar uses but deployed as its own independent workload attached only to the specific service that actually needs L7 rules. In practice this changes how you join the mesh, how you write policy and what you're troubleshooting when the system doesn't do what it says. The rest of this post is that difference proven step by step on the same app on the same cluster. --- ## The Project To make the comparison honest I set one constraint: the same application, the same intended policy under both architectures so nothing could be explained away by "the app was different." The app is deliberately small and one design detail that matters: **each service gets its own Kubernetes ServiceAccount** not a shared one. Istio's identity model is built entirely on the ServiceAccount a pod runs as not the pod itself. If all four services shared one ServiceAccount there'd be no way to write a policy that says "only orders may call payments" because Istio would have no way to tell orders traffic apart from frontend's. Four ServiceAccounts is what makes the whole zero-trust story expressible at all. The target policy is narrow on purpose: **payments only accepts POST requests to `/post` and only from orders.** Everything else including a direct call from frontend gets denied. That one rule gets implemented twice: once as a sidecar-mode policy and once as an ambient-mode one on the same cluster torn down cleanly between runs so neither phase could quietly lean on leftovers from the other. --- ## Standing Up the Cluster A local kind cluster is enough for this: ```bash kind create cluster --name zt-demo ``` ```bash kubectl get nodes ``` One node, one control plane, `Ready` status. No Istio components exist yet. --- ## Deploying the Baseline: No Mesh at All ```bash kubectl apply -f app/ ``` ![Pods starting up with no mesh](/img/blog/zero-trust-istio-sidecar-vs-ambient/01-pods-no-mesh.png) Four services coming up with zero Istio anywhere in the cluster. `READY 1/1`: one container, no sidecar because there's no mesh to inject one yet. At this point calling payments directly from frontend with no policy anywhere just worked. No mesh means no gate. That's the baseline everything else in this post is measured against. ```bash kubectl -n zt-demo exec deploy/frontend -- curl -s http://payments/post -X POST -d '{"amount": 500}' ``` ```json { "args": {}, "data": "", "files": {}, "form": { "{\"amount\": 500}": "" }, "headers": { "Accept": "*/*", "Content-Length": "15", "Content-Type": "application/x-www-form-urlencoded", "Host": "payments", "User-Agent": "curl/8.21.0" }, "json": null, "origin": "10.244.0.8", "url": "http://payments/post" } ``` The response is plain HTTP. No `X-Forwarded-Client-Cert` header. No encryption. No identity. The `origin` field shows the raw pod IP (`10.244.0.8`). --- ## Phase 1: Sidecar Mode, Step by Step ### Install and Inject ```bash istioctl install --set profile=minimal -y kubectl label namespace zt-demo istio-injection=enabled --overwrite kubectl -n zt-demo rollout restart deployment orders payments inventory frontend ``` ```text ✓ Istio core installed ✓ Istiod installed ✓ Installation complete namespace/zt-demo labeled deployment.apps/orders restarted deployment.apps/payments restarted deployment.apps/inventory restarted deployment.apps/frontend restarted ``` Labeling a namespace for sidecar injection does nothing to pods that already exist. Kubernetes has no mechanism to add a container to a running pod so every workload has to be recreated. Watch the `READY` column: pods that were `1/1` a moment ago come back `2/2`. The `0/2` pending rows are pods still finishing sidecar startup. This is the first real operational cost of sidecar mode and it's visible directly in the pod list. ```bash kubectl -n zt-demo wait --for=condition=Ready pod -l app=inventory --timeout=600s kubectl -n zt-demo wait --for=condition=Ready pod -l app=orders --timeout=600s kubectl -n zt-demo wait --for=condition=Ready pod -l app=payments --timeout=600s kubectl -n zt-demo get pods ``` ```text NAME READY STATUS RESTARTS AGE frontend-599cd6b667-8sw7c 2/2 Running 0 40s inventory-6656996d9d-k798x 2/2 Running 0 40s orders-858bc67b6-txzzq 2/2 Running 0 40s payments-5cbcb64d66-6jmws 2/2 Running 0 40s ``` All pods now at `2/2`. Every pod carries its own proxy. ### mTLS Is Already Working, Before Any Policy Says So ```bash kubectl apply -f - < **Transparent proxy note:** application code always sends plain `http://` to its local proxy. The sidecar transparently upgrades the connection to mutual TLS across the wire, you never change application code to `https://`. > **What's `127.0.0.6`?** It's Envoy's internal loopback redirect IP, used in sidecar mode only. The iptables rules installed inside the pod redirect all outbound traffic through the local Envoy proxy first, so the upstream application sees `127.0.0.6` as the source instead of the real pod IP. Hold onto that, it flips to the real pod IP once we get to ambient mode, where there's no per-pod proxy to redirect through. ### Locking It Down: The Sidecar AuthorizationPolicy Before applying one, here's the anatomy of an Istio `AuthorizationPolicy`: - **`action`** - `ALLOW` or `DENY` - **`selector` / `targetRefs`** - which workload or Gateway this policy attaches to - **`rules`** - **`from`** - source identities (`principals`) allowed to connect - **`to`** - operations allowed: HTTP methods, paths, or ports - **`when`** - optional extra conditions The policy below attaches directly to the workload using `selector.matchLabels`. The sidecar inside the `payments` pod evaluates this rule. The `principals` field references the SPIFFE identity derived from the `orders` ServiceAccount. ```bash kubectl apply -f - < **Note on ambient mTLS defaults:** In ambient mode ztunnel automatically encrypts in-mesh traffic using mTLS. You only need a `PeerAuthentication` resource if you want to explicitly control the mode (for example `PERMISSIVE` to allow plaintext from outside the mesh or `STRICT` to reject anything non-mTLS). For this demo we rely on the ambient default. ### mTLS Active by Default ```bash kubectl -n zt-demo exec deploy/frontend -- curl -s http://payments/post -X POST -d '{"amount": 500}' ``` ```json { "args": {}, "data": "", "files": {}, "form": { "{\"amount\": 500}": "" }, "headers": { "Accept": "*/*", "Content-Length": "15", "Content-Type": "application/x-www-form-urlencoded", "Host": "payments", "User-Agent": "curl/8.21.0" }, "json": null, "origin": "10.244.0.20", "url": "http://payments/post" } ``` The call succeeds (`200`) confirming ztunnel is encrypting traffic. But notice: no `X-Forwarded-Client-Cert` header and the `origin` is the real pod IP (`10.244.0.20`), not `127.0.0.6`. In sidecar mode the destination proxy injects the identity header and redirects through localhost. In ambient mode without a waypoint, ztunnel handles encryption at L4 without touching HTTP headers. The identity is still cryptographically verified - you just can't see it in the HTTP response yet. ### Where Ambient Draws Its Line: The Fail-Safe Behavior Here is the critical learning moment. When applying the exact same `AuthorizationPolicy` shape that worked cleanly in sidecar mode directly in ambient mode, it got accepted but with a warning attached to its status field: ```bash kubectl apply -f - < **`000` vs `403`:** `000` is what curl prints for `%{http_code}` when it never receives an HTTP response at all - here, because ztunnel dropped the TCP connection at Layer 4 before any HTTP exchange could happen. `403` is an actual HTTP response returned by a Layer 7 proxy (like Envoy) after it inspected the request and rejected it. In the frontend test below, curl's own process **exit code** is `56` ("failure in receiving network data") - a separate number from the `000` status placeholder, and further confirmation that the connection was cut, not answered. ```bash # Test from orders kubectl run curl-orders -n zt-demo --image=curlimages/curl --restart=Never \ --overrides='{"spec":{"serviceAccountName":"orders"}}' \ -- curl -s -o /dev/null -w '%{http_code}\n' \ http://payments.zt-demo.svc.cluster.local/post -X POST -d '{"amount": 500}' ``` ```text 000 ``` ```bash # Test from frontend kubectl -n zt-demo exec deploy/frontend -- \ curl -s -o /dev/null -w '%{http_code}\n' --max-time 5 \ http://payments/post -X POST -d '{"amount": 500}' ``` ```text 000 command terminated with exit code 56 ``` Both denied. Orders with the correct identity and frontend without it both get blocked. ztunnel is L4-only by design. L4 gives you identity-based rules like "A can call B" which is exactly what ztunnel enforces. The path-and-method rule I wrote needed the L7 layer which is exactly what the waypoint below exists to provide. ![Istio L4 vs L7 security comparison](/img/blog/zero-trust-istio-sidecar-vs-ambient/l4-l7-security-table.png) ### The Bridge: Why Waypoints Use Gateway API ztunnel handles Layer 4 (TCP + mTLS) only. It secures the wire and authenticates peers, but it cannot look inside HTTP requests. To enforce policies based on HTTP paths, methods, or headers, Ambient Mesh deploys an on-demand Envoy pod called a **Waypoint**. Istio models waypoints using the standard Kubernetes Gateway API resources rather than inventing a new CRD. The Waypoint acts as an L7 proxy for a specific service, sitting in the data path only when needed. ### Bringing in a Waypoint for the One Service That Needs It ```bash # Install Gateway API CRDs first kubectl apply -f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.1.0/standard-install.yaml # Create the waypoint istioctl waypoint apply --namespace zt-demo --name payments-waypoint --for service ``` ```text customresourcedefinition.apiextensions.k8s.io/gatewayclasses.gateway.networking.k8s.io created customresourcedefinition.apiextensions.k8s.io/gateways.gateway.networking.k8s.io created ... ✓ waypoint zt-demo/payments-waypoint applied ``` Only payments gets a waypoint. Frontend, orders and inventory never do because none of them need HTTP-level policy. ztunnel's L4 identity and encryption is all they ever require. A waypoint is not a custom Istio object. It is a standard Kubernetes Gateway API resource. Here is what gets created: ```yaml apiVersion: gateway.networking.k8s.io/v1 kind: Gateway metadata: name: payments-waypoint namespace: zt-demo spec: gatewayClassName: istio-waypoint listeners: - name: mesh port: 15008 protocol: HBONE ``` Waypoints plug into the same Gateway API model Kubernetes already has rather than inventing a new one. Once istiod finishes reconciling it, `istioctl waypoint status` confirms it: `Programmed`, assigned to `payments-waypoint.zt-demo.svc.cluster.local:15008`, ready to receive traffic. ### The Full Picture, Running ```bash echo '--- namespace labels ---' && kubectl get namespace zt-demo --show-labels echo '--- app pods ---' && kubectl -n zt-demo get pods -o wide echo '--- ztunnel pods ---' && kubectl -n istio-system get pods -l app=ztunnel -o wide echo '--- waypoint pods ---' && kubectl -n zt-demo get pods -l gateway.networking.k8s.io/gateway-name=payments-waypoint -o wide ``` ![Full ambient mesh pod listing](/img/blog/zero-trust-istio-sidecar-vs-ambient/16-full-ambient-view.png) The entire ambient mesh in one view. Every application pod sits at `1/1` READY. No sidecar anywhere. One ztunnel pod for the node. One `payments-waypoint` pod and only one because it's the only service that needed L7. This is the resource story ambient mode makes visible directly in a pod list rather than asserted in a comparison table. ### The Nuance Getting the waypoint running was the easy part. The `AuthorizationPolicy` that worked perfectly in sidecar mode does not immediately start enforcing anything once the waypoint existed. In sidecar mode an `AuthorizationPolicy` attaches to a workload with a plain label selector (`selector: matchLabels: app: payments`) because the enforcement point (the sidecar) lives inside that exact pod. In ambient mode HTTP-level enforcement happens on the waypoint, a separate workload. The policy has to explicitly target that waypoint resource. **First attempt: `targetRefs` pointing at the `Gateway`:** ```bash kubectl apply -f - < 80/TCP 31m app=payments,istio.io/use-waypoint=payments-waypoint ``` Now the service carries the label `istio.io/use-waypoint=payments-waypoint`. Traffic to payments is routed through the waypoint. Istio's docs recommend `targetRefs: Service` as the more precise option because it binds the policy to the service abstraction rather than the proxy instance. In this demo I used `targetRefs: Gateway` because it feels intuitive: the waypoint is the actual enforcement point so targeting it directly makes the mechanics explicit. Both patterns work. The real gotcha we hit was the `use-waypoint` label on the Service. That is what routes traffic through the waypoint without it, neither Gateway targeting nor Service targeting would have enforced anything. If you are building this for production, use `targetRefs: Service`. It decouples your policy from waypoint lifecycle and reads more naturally: you are protecting the payments service, not the payments-waypoint proxy. ```bash # Test from orders kubectl run curl-orders -n zt-demo --image=curlimages/curl --restart=Never \ --overrides='{"spec":{"serviceAccountName":"orders"}}' \ -- curl -s -o /dev/null -w '%{http_code}\n' \ http://payments.zt-demo.svc.cluster.local/post -X POST -d '{"amount": 500}' ``` ```text 200 ``` Orders gets a `200`. ```bash # Test from frontend kubectl -n zt-demo exec deploy/frontend -- \ curl -s -o /dev/null -w '%{http_code}\n' \ http://payments/post -X POST -d '{"amount": 500}' ``` ```text 403 ``` Frontend gets a `403`. Same cluster, same service, same everything except its identity. The gap is identical to sidecar mode. The mechanism underneath is completely different. --- ## What Actually Changed, Side by Side | Aspect | Sidecar Mode | Ambient Mode | |---|---|---| | Joining the mesh | Full rollout restart of every deployment required | One label applied to already-running pods. Zero restarts. | | Pod shape | Full Envoy proxy in every application pod (`2/2` READY) | No sidecar in application pods. A waypoint is deployed as its own separate pod, only for the service that needs L7 rules. | | Policy authoring | `selector.matchLabels` targets the workload directly | `targetRefs` targets the Gateway or Service, plus an `istio.io/use-waypoint` label on the Service | | Fail-safe when policy exceeds L4 | Not applicable, every pod has a full proxy | ztunnel accepts the policy without erroring, but fails safe to DENY for HTTP attributes it can't evaluate. The AuthorizationPolicy status field explains why. | | mTLS enforcement | Configured via `PeerAuthentication` | Active by default for in-mesh traffic. `PeerAuthentication` is optional, for explicit control. | ### What Istio's Own Comparison Publishes Worth citing: Istio reports typical p90/p99 latency of roughly **0.6 to 0.9ms per hop** in sidecar mode since both the source and destination sidecar process every request versus roughly **0.15 to 0.2ms with ztunnel alone** and **0.4 to 0.5ms when a waypoint is in the path**. That's Istio's benchmark in their environment, not mine. Worth verifying on your own hardware and environment. --- ## Why Run This Yourself Instead of Reading a Comparison Table Every sidecar-vs-ambient article can list the theoretical differences in a table. What a table can't do is show you the exact moment ztunnel refuses your policy and tells you why or make you feel the difference between watching four pods restart and watching a label apply to four pods that never blinked. That gap between reading and running is the actual reason this exists as a runnable project instead of another explainer. Clone it, break it and the L4/L7 split stops being a diagram and starts being something you've debugged. --- ## What's Next If you want to take this further here are immediate hands-on next steps that extend the core comparison: 1. **Verify the fail-safe yourself.** Delete the `payments-waypoint` Gateway but keep the L7 `AuthorizationPolicy` applied. Confirm that all traffic to payments is denied. Then recreate the waypoint, re-apply the Service label and watch access restore. This proves the architecture is protecting you from misconfiguration. 2. **Try `targetRefs: Service` vs `Gateway`.** We used `kind: Gateway` in this demo. Try switching to `kind: Service` (group: `""`, name: `payments`) and confirm identical behavior. Understand when each approach is more appropriate. 3. **Add a second waypoint.** Give `inventory` its own waypoint and an L7 policy. Show that waypoints are per-service not per-namespace and that you only pay the L7 proxy cost where you actually need it. 4. **Measure latency with Fortio.** Run a formal benchmark pass against both modes on the same hardware with Prometheus and Grafana for dashboards to verify Istio's published figures with your own first-hand measurements on a replicable environment. --- Repository: `github.com/Prianshu-git/Service-mesh-Zero-Trust-migration` --- # Running Qwen3.8-Flash-Next on a DGX Spark and RTX PRO 6000 - Canonical: https://blog.kubesimplify.com/running-qwen3-8-flash-next-on-dgx-spark-and-rtx-pro-6000 - Published: 2026-08-27 Qwen dropped Qwen3.8-Flash-Next this week, and the first thing I saw on my timeline was somebody saying it will not fit on a single DGX Spark. The NVFP4 weights are around 135 GB, a Spark has 128 GB of unified memory, so you need two of them. That is correct. I checked it and I will show you why. But it is also only part of the story, because there is one build of this model that does fit on a single Spark, and the reason it fits turned out to be more interesting than the fitting. I have a DGX Spark and access to a box with 8 RTX PRO 6000 Blackwell cards, so let's run it on both and see what the numbers actually look like. In this post we will go through: - What Qwen3.8-Flash-Next actually is, and why its size is confusing - Why "NVFP4 is 135 GB" and "the GGUF is 67 GB" are both true for the same model - Getting it running on a single DGX Spark with llama.cpp - Getting it running on RTX PRO 6000 with vLLM, and how it scales across 1, 2 and 4 GPUs - Why two GPUs beat four on this hardware Every number in this post was measured on my own machines. Where I quote somebody else's number, I say so. ## What the model is Qwen3.8-Flash-Next is a mixture-of-experts model. Total parameters are 176.94B, and that splits into two very different halves: - **125B in the model proper**, of which 512 experts do most of the work. For any given token the router picks only 10 experts plus 1 shared expert. - **51B in an N-gram embedding table**, which is a giant lookup table rather than something you do maths with. Qwen puts the active parameters at about 6B per token. That is the whole point of the design: you get the knowledge of a very large model while paying the compute bill of a small one. Worth noting llama.cpp labels the same model `A3B`, so the two are counting slightly different things, and I have not dug into which is right. The attention is a hybrid. Three out of every four layers use Gated DeltaNet, which compresses the history into a fixed-size state, and every fourth layer uses Qwen Sparse Attention (QSA), which looks at the full context but only scores it in compressed blocks. Qwen calls this a preview of the Qwen4 architecture, and the model type in `config.json` is literally `qwen4_exp`. ## Why the size question is confusing Here is where I lost an hour, so let me save you the same trouble. You would assume "NVFP4" means the whole model is squeezed into 4 bits. It does not. I opened `quantization_config` in both official checkpoints, and both have a `modules_to_not_convert` list. Only the **routed experts** get quantized. Attention, GDN, QSA, shared experts, routers, `lm_head`, embeddings, the vision encoder and the MTP head all stay in BF16. The routed experts are 120.8B of the 125B, so that still covers most of the model. But the 51B N-gram table is the problem. It is stored as FP8 and expanded to BF16 when loaded, which is about 102 GB sitting in memory. That is why the four builds are so far apart in size: | Build | Size on disk | Fits one Spark (121 GiB usable)? | | --- | --- | --- | | BF16 | 335.3 GiB | No | | FP8 | 172.8 GiB | No | | NVFP4 | 135.3 GB | No | | GGUF `UD-IQ1_S` | 67.55 GiB | **Yes** | The GGUF is the only build that quantizes the N-gram table too. That is the entire reason it fits. Now, vLLM has a flag called `VLLM_PLE_CPU_OFFLOAD=1` that pushes that table into host RAM. And this is not a hack somebody bolted on. The Qwen tech report says the tables are "held off the accelerator", and they placed the N-gram layer at **layer 2 specifically so that fetching from host memory overlaps with the compute of layer 1**. The architecture was designed for the table to live somewhere else. Which is also why that flag does nothing on a Spark. On a Spark, host RAM *is* the same unified pool as GPU memory. There is nowhere to offload to. ## Test environment | | DGX Spark | RTX PRO 6000 box | | --- | --- | --- | | GPU | 1x GB10, 128 GB unified (124610 MiB visible to CUDA) | 8x RTX PRO 6000 Blackwell Server Edition, 97887 MiB each | | Compute capability | 12.1 | 12.0 | | Driver | 580.159.03 | 610.43.02 | | Host RAM | shared with GPU | 1259 GB | | GPU interconnect | n/a | **No NVLink**, every pair reports `SYS` | | Engine | llama.cpp build 30, commit `035e227` | vLLM, image `vllm/vllm-openai:qwen38-flash-next` | | Model build | `unsloth/Qwen3.8-Flash-Next-GGUF` UD-IQ1_S | `Qwen/Qwen3.8-Flash-Next-FP8` | On the RTX box only 4 of the 8 cards were free, so everything below uses GPUs 1, 4, 5 and 6. ## Part 1: the DGX Spark ### llama.cpp support is not merged yet First problem. My existing llama.cpp knows `QWEN3NEXT` but not `qwen4_exp`, so it simply will not load this model. Support is an open pull request, [#27742](https://github.com/ggml-org/llama.cpp/pull/27742), written by [Daniel Han](https://github.com/danielhanchen) of [Unsloth](https://unsloth.ai) - all 33 commits of it. Converter, text graph, sparse attention, vision and three quantizer fixes. The entire Spark half of this post exists because of that PR. So we build it: ```bash export PATH=$PATH:/usr/local/cuda/bin git clone --depth 30 --branch qwen4exp/qwen3.8-flash-next \ https://github.com/unslothai/llama.cpp.git ~/llama-qwen4exp cd ~/llama-qwen4exp cmake -B build -DGGML_CUDA=ON -DCMAKE_CUDA_ARCHITECTURES=121 \ -DGGML_CUDA_FA=ON -DCMAKE_BUILD_TYPE=Release cmake --build build --config Release -j 16 \ --target llama-server llama-cli llama-bench llama-perplexity ``` `121` is the GB10 compute capability. Check it worked: ``` $ ~/llama-qwen4exp/build/bin/llama-cli --version version: 0.3.0-dev (build 30, commit 035e227) built with GNU 13.3.0 for Linux aarch64 ``` ### Getting the weights ```bash hf download unsloth/Qwen3.8-Flash-Next-GGUF --local-dir ~/qwen38/gguf ``` If that stalls at 0 B/s, it is the Xet transport. Set `HF_HUB_DISABLE_XET=1` and keep the worker count at 6 to 8. I tried 24 workers and got `SSL handshake timed out`. ### Running it ```bash ~/llama-qwen4exp/build/bin/llama-server \ -m ~/qwen38/gguf/UD-IQ1_S/Qwen3.8-Flash-Next-UD-IQ1_S-00001-of-00003.gguf \ -ngl 999 -c 16384 --host 127.0.0.1 --port 8099 --jinja ``` It loads in about 30 seconds and sits at 72.5 GiB of the 121 GiB available. That leaves roughly 49 GiB free, which is a lot more headroom than I expected. Here is llama-bench, three repetitions: ```bash ~/llama-qwen4exp/build/bin/llama-bench \ -m ~/qwen38/gguf/UD-IQ1_S/Qwen3.8-Flash-Next-UD-IQ1_S-00001-of-00003.gguf \ -ngl 999 -p 2048,8192,32768 -n 128 -r 3 ``` ``` | model | size | params | backend | ngl | test | t/s | | qwen4exp A3B IQ1_S | 67.55 GiB | 176.94 B | CUDA | 999 | pp2048 | 797.76 ± 2.08 | | qwen4exp A3B IQ1_S | 67.55 GiB | 176.94 B | CUDA | 999 | pp8192 | 747.53 ± 3.24 | | qwen4exp A3B IQ1_S | 67.55 GiB | 176.94 B | CUDA | 999 | pp32768 | 599.65 ± 1.18 | | qwen4exp A3B IQ1_S | 67.55 GiB | 176.94 B | CUDA | 999 | tg128 | 34.54 ± 0.18 | ``` **34.5 tokens per second on a single Spark, for a model with 176.94B parameters.** I did not believe that at first either, so let's sanity check it two ways. First against the hardware. The GB10 is specified at about 273 GB/s of memory bandwidth (that is the spec sheet, not something I measured). Decoding reads roughly 5.37 GB per token here, because only 2.36B of the 120.8B expert parameters are touched for any given token. That puts the ceiling around 50 tok/s, and we measured 34.5, or 68% of it. Comfortably under the roof, which is where a real measurement should sit. Second against my own earlier numbers. When I [benchmarked the dense Qwen3.8-27B on this same Spark](https://blog.kubesimplify.com/qwen3-8-27b-on-dgx-spark) a couple of weeks ago, llama.cpp gave 11.6 tok/s. A sparse 177B model running about three times faster than a dense 27B one is what you would expect when only a small slice is active per token. ### The prefill curve is the interesting bit Look again at those prefill numbers. Going from 2,048 tokens to 32,768 tokens is 16 times the context, and throughput only drops 25%. That flatness is consistent with QSA doing its job, although I should be honest that I did not run a dense-attention ablation to prove QSA is the cause. ### "IQ1_S" is not a 1-bit model The quant is called `UD-IQ1_S` and llama.cpp reports `IQ1_S - 1.5625 bpw`, which makes it sound like a 1-bit model. So I dumped the actual tensor types in the file: | Type | Size | Share | | --- | --- | --- | | IQ4_NL | 47.92 GiB | 70.9% | | IQ1_S | 10.38 GiB | 15.4% | | IQ2_XXS | 5.64 GiB | 8.3% | | Q5_K, Q8_0, Q4_K, Q6_K, F32, BF16 | 3.63 GiB | 5.4% | **Effective 3.28 bits per weight, not 1.56.** Seventy percent of the bytes are ordinary 4-bit. Unsloth's dynamic quants spend the bit budget where it matters and squeeze the rest, and the biggest thing getting squeezed is that 51B lookup table. ### What it costs you A couple of prompts coming back correct is not evidence that a quant is fine, so let's measure it with perplexity. Quick definition, because the name is confusing and there is now a search company called Perplexity that has nothing to do with this. **Perplexity scores how surprised a model is by text it has never seen.** You feed it real writing and at each word check what probability it gave to the word that actually came next, then boil that down to roughly "how many words was it torn between at each step". **Lower is better.** It runs locally, no API involved. ```bash # llama.cpp's own scripts/get-wikitext-2.sh is broken: it does not follow the # S3 redirect and leaves you with a 467-byte XML error instead of a zip. curl -sL -o /tmp/wt2.zip \ "https://huggingface.co/datasets/ggml-org/ci/resolve/main/wikitext-2-raw-v1.zip" unzip -oq /tmp/wt2.zip -d /tmp/ ~/llama-qwen4exp/build/bin/llama-perplexity \ -m ~/qwen38/gguf/UD-IQ1_S/Qwen3.8-Flash-Next-UD-IQ1_S-00001-of-00003.gguf \ -f /tmp/wikitext-2-raw/wiki.test.raw -ngl 999 -c 2048 ``` ``` Final estimate: PPL = 4.7876 +/- 0.02848 ``` That is wikitext-2, 145 chunks at context 2048. Daniel Han reports 4.0068 for llama.cpp at high precision and 4.0126 for the reference implementation in the PR write-up. Those are **his** numbers, not mine, and I could not reproduce them because no higher-precision GGUF of this model has been published yet. Taking his figure at face value, this quant costs roughly 19% higher perplexity. A normal Q4_K_M usually costs 1 to 3%. So it is a real trade, not a free lunch. It answers questions correctly in casual use, and I would still not reach for it when accuracy matters. ## Part 2: RTX PRO 6000 with vLLM vLLM had day-zero support with a dedicated image, so this side was much less work than the Spark. Getting the 185 GB checkpoint down was the slow part: ```bash docker pull vllm/vllm-openai:qwen38-flash-next HF_HUB_DISABLE_XET=1 hf download Qwen/Qwen3.8-Flash-Next-FP8 --local-dir /llm/qwen38/fp8 ``` HuggingFace crawled from this box, so I pulled it from ModelScope instead, which serves the identical 145-file manifest. ```bash docker run -d --name q38-tp2 --gpus '"device=1,4"' --ipc=host --shm-size=32g \ -v /llm/qwen38:/llm/qwen38 -e VLLM_PLE_CPU_OFFLOAD=1 -p 8010:8000 \ vllm/vllm-openai:qwen38-flash-next \ --model /llm/qwen38/fp8 --served-model-name q38 \ --tensor-parallel-size 2 --gpu-memory-utilization 0.90 \ --max-model-len 32768 --max-num-seqs 32 \ --enable-prefix-caching --no-enable-flashinfer-autotune \ --reasoning-parser qwen3 ``` If you leave out `--reasoning-parser qwen3`, the model's thinking text ends up inside the normal reply content. Ask for it. For the TP4 runs it is the same command with `--gpus '"device=1,4,5,6"'` and `--tensor-parallel-size 4`. To compare against keeping the N-gram table on the GPU, set `-e VLLM_PLE_CPU_OFFLOAD=0`. For speculative decoding, append the MTP config shown later. Every config below was benchmarked with exactly the same command, only `$C` changing: ```bash docker exec q38-tp2 vllm bench serve \ --backend openai-chat --model /llm/qwen38/fp8 --served-model-name q38 \ --endpoint /v1/chat/completions --base-url http://localhost:8000 \ --dataset-name random --random-input-len 1024 --random-output-len 512 \ --max-concurrency $C --num-prompts $((C*4)) --ignore-eos ``` Before benchmarking anything I asked it a question with a known answer, because a healthy `/health` endpoint does not mean the model is producing sense. It got "a train leaves at 14:35 and arrives at 21:10 the next day" right at 30 hours 35 minutes, so we are good. ### How many GPUs do you need? `--tensor-parallel-size` (TP) is how many GPUs each layer's weight matrices are sliced across. Not "layer 1 on this GPU, layer 2 on that one", that is pipeline parallelism. TP cuts every matrix into pieces, so each GPU computes a partial answer and then they all swap and add. That swap is an all-reduce and it happens at every layer, for every token. With the N-gram table offloaded to host RAM, the weights need about 123 GiB on the GPU, and each card has 95.6 GiB. So one card should not be enough. It is not: ``` torch.OutOfMemoryError: CUDA out of memory. GPU 0 has a total capacity of 95.01 GiB of which 210.38 MiB is free ... 94.02 GiB is allocated by PyTorch ``` TP3, by the way, is not an option at all. I assumed this was about the 2 KV heads on the attention layers, so I tried it to be sure, and the real reason is different: ``` AssertionError: 16 is not divisible by 3 ``` The 16 is `linear_num_key_heads`, the key heads in the Gated DeltaNet layers, and those are 36 of the 48 layers. Your TP size has to divide 16, so the usable values are 1, 2, 4, 8 and 16. Here is TP2 and TP4, benchmarked with 1024 input and 512 output tokens: | Config | 1 stream | 32 streams | Median TPOT, 1 stream | KV cache | | --- | --- | --- | --- | --- | | TP1 | out of memory | - | - | - | | TP2 | **81.45 tok/s** | 739.06 tok/s | 10.01 ms | 19.6 GiB | | TP4 | 64.61 tok/s | **805.38 tok/s** | 13.85 ms | 48.74 GiB | **Two GPUs are 26% faster than four for a single user.** That surprised me until I looked at the wiring. This box has no NVLink, and `nvidia-smi topo -m` reports every GPU pair as `SYS`, meaning traffic crosses PCIe and the CPU sockets. Now remember only 6B parameters are active per token, so there is barely any maths to divide up. Splitting a tiny job across more GPUs mostly means paying more postage. The extra cards still earn their keep under load, where the bigger KV cache lets you batch 32 users and win on total throughput. The practical rule: use the smallest TP that fits in memory, and only go wider when you need the KV cache for longer context or more users. There is also no pipeline-parallel escape hatch here. The vLLM recipe states the N-gram embedding does not support pipeline parallelism, so on a box with bad interconnect you cannot fall back to PP the way you normally would. ### What does offloading the N-gram table actually cost? The tech report implies host prefetching is nearly free. On this hardware it is cheap but not free: | TP4 config | 1 stream | 32 streams | KV cache | | --- | --- | --- | --- | | N-gram table on GPU | 74.84 tok/s | 772.16 tok/s | 4.7 GiB | | N-gram table on host | 64.61 tok/s | 805.38 tok/s | 48.74 GiB | Keeping the table on the GPU is about 16% faster for one user, because you skip the round trip over PCIe. But it eats the memory your KV cache wanted, and it collapses from 48.74 GiB to 4.7 GiB. Maximum concurrency drops from 74x to 10x. Unless you are serving exactly one person, offload it. ### Speculative decoding, and why benchmark workload decides the answer The model ships an MTP head, so let's turn it on: ```bash --speculative-config '{"method":"mtp","num_speculative_tokens":3}' ``` Measured with the same `vllm bench serve` command as everything above, which uses synthetic random tokens: | TP4 config | 1 stream | 32 streams | Median TTFT at 32 | | --- | --- | --- | --- | | without MTP | 64.61 tok/s | 805.38 tok/s | 2578 ms | | with MTP | **87.87 tok/s** | 693.72 tok/s | **588 ms** | That reads as 36% faster for one user, at the cost of 14% of peak throughput. I nearly left it there. Then someone asked what the acceptance rate was, which is the number that actually decides whether speculative decoding is worth anything, and I had not measured it. vLLM exposes it. Here is what the counters say: | workload | acceptance | mean accepted length | | --- | --- | --- | | synthetic random, 1024 in / 512 out | 71.3% | 3.14 of max 4 | | synthetic random, 512 in / 256 out | 84.4% | 3.53 of max 4 | | **real code and prose prompts, greedy** | **55.3%** | **2.66 of max 4** | Real prompts accept considerably worse than random ones. That is the opposite of what I expected, and the reason is worth knowing if you benchmark anything: `--dataset-name random` feeds the model random token IDs. Given nonsense, it produces repetitive low-entropy text, and a draft head predicts repetitive text very easily. **Random-token benchmarks flatter speculative decoding.** So I re-ran on five genuine prompts, an LRU cache in Python, a Kubernetes explanation, a Go CSV reader, a bash one-liner and a plain-English TP explainer, greedy decoding, single stream, measuring wall clock: | TP4, real prompts, temp 0 | tok/s | | --- | --- | | without MTP | 49.90 | | with MTP | **124.93** | **2.5x.** Far better than the 36% the synthetic benchmark implied, despite the lower acceptance rate. Note this is a different measurement method from the table above, wall clock across whole requests rather than vLLM's output-token throughput, so compare within each table and not across them. The lesson is not that one number is right and the other wrong. Both are real. It is that a speculative decoding result without its workload and its acceptance rate does not tell you anything you can act on. ### Does MTP change the answer? Speculative decoding is supposed to be lossless. The draft head proposes, the full model verifies, and rejected tokens are discarded, so the output distribution should be untouched. Worth checking rather than trusting. Same prompt, temperature 0, seed 42, five runs each, on the same four GPUs with the same checkpoint, MTP the only variable: ``` MTP off : 3575aff8aa9c1df5 x5 MTP on : 3575aff8aa9c1df5 x5 ``` Byte-identical, and identical to each other. Speculative decoding here costs you nothing in output fidelity. Worth knowing if you cache responses or snapshot-test them. For reference, llama.cpp on the Spark is also fully deterministic across five runs, though that is a different box and a different quant so the hashes are not comparable. One caveat on all of this: single stream. Under continuous batching, vLLM's batch composition varies between runs and that is where reproducibility usually breaks, not from MTP. ### One card, with NVFP4 There is a community NVFP4 build from RadixArk. It is documented for SGLang, but vLLM picked it up anyway (`Detected ModelOpt NVFP4 checkpoint`): ```bash docker run -d --name q38-nvfp4-tp1 --gpus '"device=1"' --ipc=host --shm-size=32g \ -v /llm/qwen38:/llm/qwen38 -e VLLM_PLE_CPU_OFFLOAD=1 -p 8012:8000 \ vllm/vllm-openai:qwen38-flash-next \ --model /llm/qwen38/nvfp4 --served-model-name q38 \ --tensor-parallel-size 1 --gpu-memory-utilization 0.93 \ --max-model-len 16384 --max-num-seqs 16 \ --no-enable-flashinfer-autotune --reasoning-parser qwen3 ``` The weights genuinely fit on a single card: ``` Actual usage is 74.75 GiB for consumed memory (weights + non-torch), 1.85 GiB for peak activation, and 0.28 GiB for CUDAGraph memory. Current kv cache memory in use is 11.76 GiB. ``` 74.75 GiB of weights on one 95.6 GiB card, with 11.76 GiB of KV cache left over. So the memory answer is yes. I cannot give you a speed number though. The engine finished loading, captured its CUDA graphs at 06:34:14, and then the API server never came up. Twenty minutes later `Application startup complete` had still not been printed a single time, `/health` was refusing connections, and one CPU core was spinning. The only errors in the log were harmless transformers docstring warnings. RadixArk documented this checkpoint for SGLang and not vLLM, so I am not shocked, but I am not going to invent a number I did not measure. ## Both machines side by side ![Qwen3.8-Flash-Next benchmarks on DGX Spark and RTX PRO 6000](/img/blog/running-qwen3-8-flash-next-on-dgx-spark-and-rtx-pro-6000/benchmarks-both-machines.png) | | DGX Spark | RTX PRO 6000 | | --- | --- | --- | | Build | GGUF UD-IQ1_S, 3.28 bpw | FP8, 172.8 GiB | | Engine | llama.cpp (unmerged PR) | vLLM (day-zero support) | | GPUs used | 1 | 2 or 4 | | Best single stream | 34.5 tok/s | 87.9 tok/s (TP4 + MTP) | | Best throughput | not measured | 805 tok/s at 32 streams | | Memory | 72.5 of 121 GiB | ~66 GiB per GPU at TP2 | These are not really competing. One is a desktop box running a heavily compressed build, the other is four datacenter cards running the full FP8 checkpoint. What I find genuinely interesting is that the gap is only about 2.5x. ## What I did not measure To be straight about the edges of this post: - **No high-precision perplexity baseline.** The 4.0068 and 4.0126 figures are the PR author's, and no higher-precision GGUF exists yet for me to check them against. - **No proof that QSA causes the flat prefill curve.** It is consistent with the architecture, but I ran no ablation. - **No NVFP4 throughput.** The weights fit on one card, the server never came up. - **No concurrency sweep on the Spark.** llama-bench numbers there are single stream. - **No BF16 run anywhere.** At 335 GiB it was not worth the download. ## Thanks Two things made the Spark side of this possible and both came from the same place. [Daniel Han](https://x.com/danielhanchen) at [Unsloth](https://x.com/UnslothAI) wrote the llama.cpp support in PR [#27742](https://github.com/ggml-org/llama.cpp/pull/27742), every commit of it, within a day of the model landing. He also published the dynamic quant that is the only build small enough to fit on one Spark, and his write-up of the PR includes perplexity and top-1 agreement numbers against the reference implementation, which is what let me sanity check my own. That is a lot of careful work given away for free. Thanks also to the [llama.cpp](https://github.com/ggml-org/llama.cpp) maintainers, and to [RadixArk](https://huggingface.co/RadixArk/Qwen3.8-Flash-Next-NVFP4) for the NVFP4 conversion, which fit on a single RTX PRO 6000 even though I could not get it serving. ## Wrapping up The claim that started this was right: NVFP4 does not fit on one DGX Spark. But a single Spark still runs this 177B model at 34.5 tok/s through llama.cpp, because Unsloth's GGUF is the one build that also compresses the 51B N-gram table, and it costs you about 19% perplexity to do it. On the RTX PRO 6000 box the surprise was that two GPUs beat four for a single user. If you are sizing hardware for sparse MoE models, more cards past the point where the weights fit will buy you batch throughput and KV cache, not lower latency, especially without NVLink. The scripts, recipes and raw benchmark output are in the repo if you want to reproduce any of this. If you run it on different hardware I would love to see your numbers, so send them over on X [@SaiyamPathak](https://x.com/SaiyamPathak). --- # The Local LLM Glossary: Every Term, Flag, and Number in Plain English - Canonical: https://blog.kubesimplify.com/local-llm-glossary - Published: 2026-08-18 - Summary: Plain-English definitions for every term you hit in local LLM posts: prefill and decode, tokens per second, FP8 and NVFP4, Q4_K_M, KV cache, YaRN, Gated DeltaNet, speculative decoding, and every vLLM, llama.cpp, and Ollama flag worth knowing. Every local LLM post, mine included, is full of shorthand: `pp2048`, `tg128`, FP8, `UD-Q4_K_XL`, KV cache, `gpu_memory_utilization: 0.8`, MTP, YaRN. If you live in this world daily it reads fine. If you do not, it reads like a wall of magic strings. So here is the glossary. Every term, flag, and number that shows up across the local LLM and DGX Spark posts on this blog, explained in plain English, with the reason it matters rather than just the expansion of the acronym. You do not need to read this top to bottom. Ctrl+F the thing that confused you, get your answer, go back to the post you came from. ## Start here: the two halves of every request Almost everything in this glossary makes more sense once you have these two words straight. **Prefill** (also called prompt processing) is the model reading your input. Every token of your prompt can be processed at the same time, in parallel, because they are all already known. This is why prefill numbers look big: 800 to 4,000 tokens per second is normal on a DGX Spark. **Decode** (also called generation) is the model writing its answer. It produces one token, feeds that token back in, produces the next. Each step depends on the one before it, so there is nothing to parallelize. This is why decode numbers look small: 8 to 30 tokens per second for a 27B model on the same box. When someone says "the model feels slow," they nearly always mean decode. When someone says "it took ages before anything appeared," they mean prefill. [Day 2 of the Local LLM series](/blog/day-2-anatomy-of-an-llm-inference-request-from-prompt-to-answer-step-by-step) walks a single request through both halves step by step if you want the long version. ## Reading a benchmark table **Token.** The unit models actually read and write. Roughly three quarters of an English word on average, so 1,000 tokens is about 750 words. Numbers, code, and punctuation eat more tokens than plain prose. **t/s (tokens per second).** The throughput unit for both halves above. Always ask which half it refers to, because prefill t/s and decode t/s can differ by 100x on the same machine. **TTFT (time to first token).** How long from pressing enter until the first word appears. Dominated by prefill, so it grows with prompt length. **pp512, pp2048.** `pp` is prompt processing, the number is how many tokens of prompt. `pp2048` means "prefill throughput measured on a 2,048 token prompt." These names come from `llama-bench` and stuck as a convention. **tg128, tg32.** `tg` is token generation, the number is how many tokens were generated. `tg128` means "decode throughput measured while generating 128 tokens." **Depth (context depth).** How much conversation or document was already in the context window before the measurement started. `depth 0` is a cold, empty context. `depth 32768` means the model was already holding 32K tokens. Decode usually slows down as depth grows, and how much it slows is one of the more interesting things about a model's architecture. **Concurrency.** How many requests were in flight at once. `c=1` is one user. `c=10` is ten simultaneous requests, which is what serving a team or a fleet of agents actually looks like. **Aggregate vs per request.** At concurrency 10 you get two decode numbers. Aggregate is all ten requests added together, which is what a server operator cares about. Per request is what each individual user experiences, which is always lower. A box doing 84 t/s aggregate across 10 users is giving each of them about 9 t/s. ## Why the numbers come out the way they do This is the section that makes bad benchmark numbers stop being mysterious. **Memory bandwidth.** How many gigabytes per second the chip can read out of memory. The DGX Spark's GB10 does about 273 GB/s. A discrete RTX PRO 6000 does roughly 6x that. This single number sets the ceiling for decode. **Weight streaming.** To produce one token, a dense model has to read every one of its weights out of memory. A 16.7GB model at 273 GB/s can therefore do at most about 16 tokens per second, no matter how fast the compute is. Measured 11.6 t/s against a 16 t/s theoretical ceiling is about 70% of peak, which is what real kernels achieve. The mental model I keep coming back to: it is like re-reading an entire book off the shelf before you can write each next word. Your reading speed sets the pace, not how fast you can think. **Bandwidth-bound vs compute-bound.** Decode is bandwidth-bound: the chip is waiting on memory, and the tensor cores are mostly idle. Prefill is compute-bound: there is enough parallel work to actually saturate the math units. This is why the same box can look fast and slow within one request, and why halving your model size roughly doubles decode but barely moves prefill. **Unified memory.** On the GB10 the CPU and GPU share one pool of memory (128GB, of which about 121.7 GiB is addressable) rather than the GPU having its own separate VRAM. Two consequences: big models fit without a discrete card's memory limit, and every process on the box competes for the same pool. If a llama.cpp container is still holding 18GB, your vLLM launch will fail on memory it can see but not have. **VRAM.** The dedicated memory on a discrete GPU. On a unified-memory box like the Spark there is no separate VRAM, which trips up tools that assume there is. **Dense vs MoE.** A dense model uses all its parameters for every token. A Mixture of Experts model has many parameters but routes each token through only a few of them, so a 30B MoE with 3B active parameters streams roughly 3B worth of weights per token and feels dramatically faster. This is the whole reason a 30B MoE can hit 100+ t/s on a Spark while a real dense 27B sits at 11. **Active parameters.** The subset of an MoE's weights actually used per token. Written like `2.4T-A95B`, meaning 2.4 trillion total parameters, 95 billion active. Active is the number that predicts speed. Total is the number that predicts memory. ## Quantization: decoding the format names Quantization is storing the model's numbers in fewer bits. Fewer bits means fewer gigabytes to stream per token, which means faster decode, at some cost in quality. [Day 4](/blog/day-4-quantization-demystified-bf16-fp8-nvfp4-mxfp4-int4-gguf-and-why-it-all-matters) is the full treatment; this is the lookup table. | Format | Bits per weight | What to know | |---|---|---| | FP32 | 32 (4 bytes) | Full precision. Almost nobody serves at this. | | BF16 / FP16 | 16 (2 bytes) | The reference quality. A 27B model is about 54GB. | | FP8 | 8 (1 byte) | Halves the size with very little quality loss. Native on Hopper and Blackwell. A 27B is about 29GB. | | INT4 | 4 (0.5 bytes) | Generic 4-bit integer. Quality depends heavily on how it was produced. | | NVFP4 | 4 | NVIDIA's 4-bit float with fine-grained scaling, with native tensor core support on Blackwell (so on GB10). Usually the best speed on this hardware. | | MXFP4 | 4 | Open Compute Project's 4-bit micro-scaling float. Same idea, different standard body. | **GGUF.** The single-file model format llama.cpp and Ollama use. It packs weights plus metadata plus tokenizer into one file you can move around. Not a precision, a container: a GGUF file also has a quantization type inside it. **Q4_K_M, Q4_K_XL and friends.** The llama.cpp quantization naming scheme, three parts stacked: - `Q4` is 4 bits per weight. - `_K` is the K-quant layout. Instead of one scaling factor for a whole tensor, weights are stored in super-blocks of 256 that are split into blocks of 32, each carrying its own quantized scale. Outlier weights then only distort their own block of 32 rather than dragging a whole tensor's precision down. - The last letter is a size tier: `S` small, `M` medium, `L` large, `XL` extra large. Higher tiers spend extra bits on the tensors that matter most, so the file is a bit bigger and the quality a bit better. **UD (Unsloth Dynamic).** A prefix like `UD-Q4_K_XL` means the layers were deliberately not all quantized to the same width. Embeddings and the first and last blocks keep more bits because everything downstream depends on them, while the more redundant middle feed-forward layers get squeezed harder. The result holds up better than a uniform 4-bit quant of the same file size. **Checkpoint.** A published set of weights, usually a Hugging Face repo at a specific revision. "The official FP8 checkpoint" means the model author's own FP8 publication, as opposed to a community requantization. **Marlin.** A family of fast GPU kernels for quantized matrix multiplication in vLLM. `VLLM_MARLIN_USE_ATOMIC_ADD: '1'` switches those kernels to atomic accumulation, which is a correctness and performance workaround on some GPU and shape combinations. It is the kind of environment variable you copy from a working recipe rather than derive. **Bytes per parameter arithmetic.** The one calculation worth memorizing: parameters times bytes per parameter equals weight size. 27B at FP8 is about 27GB, at 4-bit about 16GB, at BF16 about 54GB. Then add KV cache and runtime overhead on top. ## Context, attention, and the KV cache **Context window.** The maximum number of tokens the model can have in front of it at once, prompt plus generated output plus any system message. 262,144 tokens is a large modern window, and it is often quoted as "262K" or "256K" loosely. **KV cache.** As the model reads your prompt, each attention layer computes key and value vectors per token, and caches them so it does not recompute them for every subsequent token. Very effective, but the cache grows linearly with context length and with the number of concurrent requests, and it lives in the same memory pool as the weights. Long contexts and many users both eat memory here, not in the weights. **KV cache dtype.** The precision the cache is stored at. Storing it at `fp8` instead of 16-bit roughly halves cache memory, letting you serve longer contexts or more users on the same box, at a small accuracy cost. **Prefix caching.** If two requests share the same beginning (a system prompt, a document, a conversation so far), the runtime can reuse the KV cache from the shared part instead of prefilling it again. Enormous win for chat and agent workloads where 90% of every request is the same prefix. In vLLM this is `--enable-prefix-caching`. **Attention.** The mechanism that lets each token look at the other tokens in context and decide what is relevant. Standard ("full") attention lets every token look at every previous token, which is powerful and gets more expensive as context grows. **Flash attention.** A way of computing attention that avoids writing the giant intermediate attention matrix to memory, making it much faster and much cheaper in memory. Effectively always worth turning on: `-fa on` in llama.cpp. **Attention backend.** Which implementation of attention the serving engine actually calls. In vLLM you might see `flashinfer`, `flash_attn`, or `xformers`. **FlashInfer** is a library of highly tuned attention and GEMM kernels; on the Spark's `sm121` architecture it picks the `xqa` decode kernel and supports an FP8 KV cache. Different backends can differ by 2x on the same hardware, which is why recipes pin one. **Linear attention.** An alternative that keeps a fixed-size running state instead of a cache that grows with every token. Cheaper and flat in context length, but with less precise recall than full attention. Modern models often use a hybrid: a few full attention layers for precise recall, the rest linear. **Gated DeltaNet.** The specific linear attention design used by recent Qwen models. The *gate* decides how fast old memory fades, and the *delta rule* writes targeted corrections into the fixed-size state rather than appending to an ever-growing list. The state is the same size at token 100,000 as it is at token 10, which is exactly why decode speed on these models barely sags as context grows. In Qwen3.8-27B, 48 of 64 layers are Gated DeltaNet and every 4th layer is full gated attention. **RoPE (Rotary Position Embedding).** How most models encode *where* a token sits in the sequence, by rotating the token's vector by an angle that depends on its position. Positions the model never saw in training land at angles it does not understand, which is why context windows have a hard native limit. **YaRN (Yet another RoPE extensioN).** A technique for stretching that limit. It rescales the positional frequencies, stretching each one differently depending on its wavelength, so the model can address positions well beyond its training range without a full retrain. "262K native, extensible to 1M with YaRN" means the extra range is available but is an extension, not a native capability. **Vision encoder and mmproj.** A vision language model ships a separate encoder that turns images into tokens the language model can read. In GGUF land that encoder is a companion file called `mmproj` (multimodal projector). No `mmproj`, no images, even if the model is capable of them. **Thinking mode.** Models that emit reasoning inside `` blocks before their actual answer. Better on hard problems, more tokens spent, so slower and more expensive per reply. Vendors publish different recommended sampling settings for thinking and non-thinking modes. **Reasoning parser and tool call parser.** Server-side parsers that pull those `` blocks and any tool or function calls out of the raw token stream and put them in the right fields of the OpenAI-compatible API response. Wrong parser and your client sees reasoning text glued into the answer, or tool calls it cannot recognize. These are per model family: `reasoning_parser: qwen3`, `tool_call_parser: qwen3_coder`. ## Speed tricks **Batching / continuous batching.** Running several requests through one pass over the weights. Since decode is bandwidth-bound, one weight-streaming pass can feed 10 tokens for 10 different users at almost the cost of feeding 1. This is why aggregate throughput climbs with concurrency while per-user throughput barely drops, and it is the main thing production servers like vLLM and SGLang buy you over single-user tools. **Speculative decoding.** A cheap model guesses the next few tokens, the real model verifies them all in one pass. Correct guesses are free tokens; wrong ones are discarded, so the output is identical to what the big model would have produced on its own. No quality risk, real speedup. **Draft model.** The cheap guesser in that scheme, a small separate model. "DSpark" is a community-built 5-layer, ~2.6GB drafter for Qwen3.8-27B. **MTP (Multi-Token Prediction).** The same trick with no separate model: the big model ships an extra head trained to predict several tokens ahead, and drafts for itself. Cheaper to deploy than a draft model since there is nothing extra to load. When Ollama serves a model 2x faster than llama.cpp on the same quantization, MTP being on by default is usually the reason. **NEXTN.** SGLang's name for its MTP-style speculative path. Same idea, different engine. **Acceptance rate.** The fraction of drafted tokens the real model accepts. This is the whole ballgame for speculative decoding, and it is a property of *your workload*, not of the model. Editing existing code, where the draft mostly copies text already in the prompt, can hit 98% acceptance and 3x speedups. Writing fresh prose or new code, where the drafter is genuinely guessing, might hit 30%. This is why a tokens-per-second number without its workload attached is close to meaningless. **k / num_speculative_tokens.** How many tokens the drafter proposes per round. Higher k pays off when acceptance is high and wastes work when it is low. **Tensor parallel (TP).** Splitting each layer's matrices across multiple GPUs so they all work on every token together. Needs fast interconnect between the cards. `tensor_parallel_size: 2` means two GPUs. **Pipeline parallel (PP).** Splitting the model by layer, so GPU 0 runs the first half and GPU 1 the second. Tolerates slower interconnect, but one card is idle while the other works unless you keep several requests in flight. ## The flags you copy-paste ### vLLM and sparkrun recipes A [sparkrun](https://sparkrun.dev) recipe is a YAML file that pins a model, a container, and the serving flags, so a working setup is one file rather than an afternoon of dependency fighting. The `defaults:` block is vLLM server arguments, and `env:` is environment variables passed into the container. | Key | What it does | How to think about it | |---|---|---| | `gpu_memory_utilization: 0.8` | The share of GPU memory vLLM is allowed to claim up front, for weights plus KV cache | Higher means more KV cache, so longer contexts and more concurrent users. Too high and the launch fails or something else on the box starves. On unified memory, remember other processes share the pool. | | `max_model_len: 131072` | The maximum context length the server will accept, in tokens | Can be lower than the model's native window, and often should be: every token of headroom you reserve costs KV cache memory. 131072 is 128K. | | `max_num_batched_tokens: 32768` | Cap on how many tokens the scheduler puts into one forward pass | Bigger batches mean better prefill throughput and chunkier latency. This is the prefill throughput vs responsiveness dial. | | `load_format: instanttensor` | How weights are read off disk into memory | `instanttensor` is a fast-load path that gets a 29GB checkpoint resident in seconds once cached, instead of minutes. Pure startup time, no runtime effect. | | `kv_cache_dtype: fp8` | Precision of the KV cache | Roughly halves cache memory versus 16-bit, so you fit longer contexts or more users. Small accuracy cost. | | `attention_backend: flashinfer` | Which attention kernel library to use | On the Spark's `sm121`, FlashInfer gets the fast decode path and FP8 KV cache support. | | `tool_call_parser: qwen3_coder` | Extracts tool and function calls from the token stream | Must match the model family, or your agent framework sees plain text where it expected a structured call. | | `reasoning_parser: qwen3` | Extracts `` blocks into the response's reasoning field | Must match the model family, or reasoning text leaks into the answer. | | `VLLM_MARLIN_USE_ATOMIC_ADD: '1'` | Environment variable switching Marlin quantized kernels to atomic accumulation | A hardware-specific workaround. Copy it from a working recipe. | | `--enable-prefix-caching` | Reuses KV cache across requests that share a prefix | Big win for chat and agents, effectively free. | | `speculative_config` | Turns on speculative decoding, e.g. `{"method": "mtp", "num_speculative_tokens": 3}` | See MTP and acceptance rate above. | ### llama.cpp | Flag | What it does | |---|---| | `-hf :` | Pulls the GGUF straight from Hugging Face. Note recent builds cache into `/root/.cache/huggingface`, not the old `llama.cpp` path, which matters when you mount a volume. | | `-ngl 99` | Number of layers to offload to the GPU. 99 is the idiomatic "all of them," since anything left on the CPU is dramatically slower. | | `-c 32768` | Context size in tokens for this server instance. Larger costs KV cache memory. | | `-fa on` / `-fa 1` | Flash attention. Turn it on. | | `--host 0.0.0.0 --port 8091` | Bind address and port for the OpenAI-compatible server. | | `mmproj-*.gguf` | The vision projector file. Present in the repo means images work. | `llama-bench` is llama.cpp's built-in benchmark, and it is where `pp512`/`tg128` style names come from. ### Ollama | Thing | What it does | |---|---| | `num_ctx` | Context size, Ollama's equivalent of `-c`. Ollama picks a default from available memory. | | `num_predict` | Maximum tokens to generate in a reply. | | `ollama ps` | Shows loaded models and, critically, whether they are on GPU. If it says anything less than `100% GPU`, your benchmark is measuring the CPU and will be 3 to 5x too slow. | | `prompt_eval_count` / `eval_count` | Ollama's own counters for prompt tokens and generated tokens, with matching `_duration` fields in nanoseconds. Divide to get t/s. | ### Sampling knobs These control how the next token is picked from the model's probability distribution. They change output style, not speed. - **temperature.** How much randomness. 0 is deterministic and repetitive, 1.0 is creative, above about 1.2 usually becomes incoherent. Use 0 when benchmarking so runs are comparable. - **top_p (nucleus sampling).** Only consider tokens inside the top cumulative probability mass, e.g. 0.95. Cuts off the long tail of unlikely tokens. - **top_k.** Only consider the k most likely tokens, e.g. 20. Model authors publish recommended values per mode, and it is worth using theirs. Qwen3.8's thinking mode wants temp 1.0 / top_p 0.95 / top_k 20, non-thinking wants temp 0.7 / top_p 0.80. ## Hardware words **GB10.** The Grace Blackwell superchip inside the DGX Spark: Arm CPU plus Blackwell GPU plus 128GB of unified LPDDR5X at about 273 GB/s. **sm_121 / compute capability.** NVIDIA's architecture version tag for a GPU. GB10 is `sm_121`. Kernels have to be compiled for your architecture, so "supports sm121" in a release note is the difference between working and not. [Day 3](/blog/day-3-the-dgx-spark-unpacked-gb10-unified-memory-sm-121-and-the-one-reason-this-hardware-exists) covers the Spark's hardware story in detail. **Tensor cores.** The dedicated matrix multiply units. Which precisions they support natively (FP8 and FP4 on Blackwell) decides which quantization is genuinely fast rather than merely smaller. **GB vs GiB.** GB is 1,000^3 bytes, GiB is 1,024^3. A "128GB" box reports about 119 GiB, and vendors and tools mix the two freely. When a number looks 7% off, this is usually why. **`nvidia-smi` on GB10.** Cannot report memory usage on this chip and prints `Not Supported`. Use `free -h`, since the memory is unified anyway. ## Where to go next If you want these terms in context rather than as a list, the Local LLM series builds them up in order: - [Day 1: The Local LLM Revolution](/blog/day-1-the-local-llm-revolution-why-your-desk-just-became-the-new-datacenter), why running models locally became viable at all - [Day 2: Anatomy of an LLM Inference Request](/blog/day-2-anatomy-of-an-llm-inference-request-from-prompt-to-answer-step-by-step), prefill and decode end to end - [Day 3: The DGX Spark Unpacked](/blog/day-3-the-dgx-spark-unpacked-gb10-unified-memory-sm-121-and-the-one-reason-this-hardware-exists), the hardware and why bandwidth rules everything - [Day 4: Quantization Demystified](/blog/day-4-quantization-demystified-bf16-fp8-nvfp4-mxfp4-int4-gguf-and-why-it-all-matters), every format name in depth - [Day 5: Inference Engines and What to Pick](/blog/day-5-local-llm-inference-engines-wrappers-and-what-to-pick), Ollama vs llama.cpp vs vLLM vs SGLang And if a term bit you that is not defined here, tell me and I will add it. That is what this page is for. --- # Running Qwen3.8-27B on DGX Spark - Canonical: https://blog.kubesimplify.com/qwen3-8-27b-on-dgx-spark - Published: 2026-08-17 - Summary: Qwen3.8-27B on DGX Spark with llama.cpp, Ollama, vLLM, and SGLang: the recipes, the tokens per second I measured, MTP speculative decoding, and the sharp edges I hit along the way. Qwen announced the 3.8 family on August 3: Qwen3.8-Max, the 2.4T flagship, plus a promise that open weights were coming "next week". The Max weights (2.4T-A95B) landed August 12. The one everyone actually wanted for local inference, the 27B, went quiet. Trackers even reported it as delayed with no new date. It dropped on August 14. I had it running on the DGX Spark within the hour, so here is the full recipe, what worked on day zero, what did not, and the numbers. Let's get into it. Here is exactly what everything below was run on: | Component | What I ran | |---|---| | Box | DGX Spark, GB10, 128GB unified memory | | OS and driver | DGX OS, driver 580.159.03, kernel 6.17.0-1018-nvidia | | Test dates | August 14-15, 2026 (the MTP, SGLang, and DSpark runs came on August 17) | | llama.cpp | build b10423 | | vLLM | spark-arena nightly 0.27.2rc1, plus stable v0.27.1 for the DSpark run | | Ollama | v0.32.12 | | SGLang | latest-cu130 | | Load-test tool | llama-benchy 0.4.0 | | FP8 checkpoint | Qwen/Qwen3.8-27B-FP8 at 017b9c7a (the launch upload, unchanged since) | | GGUF quant | unsloth/Qwen3.8-27B-GGUF at 4604b899 | | NVFP4 quant | unsloth/Qwen3.8-27B-NVFP4 at 60e813d4 (day zero), 7d6f8d4d (MTP runs) | unsloth updated both quants after launch, so I re-benchmarked the updated NVFP4 revision: 11.9 t/s single-stream, within 4% of the numbers below, nothing material changed. Every measurement in this post came from one of these four commands, so you can rerun any table row yourself: ```bash # llama.cpp raw numbers (pp512/pp2048/tg128/tg32 tables) docker run --rm --gpus all -v $HOME/models/qwen38:/root/.cache/huggingface \ --entrypoint /app/llama ghcr.io/ggml-org/llama.cpp:server-cuda \ bench -m /Qwen3.8-27B-UD-Q4_K_XL.gguf -fa 1 -p 512,2048 -n 128,32 # vLLM and SGLang numbers (all pp2048/tg128 tables, any depth/concurrency) uvx llama-benchy@0.4.0 --base-url http://:8000/v1 --model \ --pp 2048 --tg 128 --depth 0 16384 32768 --concurrency 1 2 5 10 \ --enable-prefix-caching --save-result results.csv --format csv # Ollama numbers (from Ollama's own eval counters, temperature 0, 3 runs) curl -s http://127.0.0.1:11435/api/generate -d '{"model":"qwen3.8:27b", "prompt":"","stream":false,"options":{"temperature":0,"num_predict":200}}' \ | jq '{prompt_tok:.prompt_eval_count, prompt_ns:.prompt_eval_duration, gen_tok:.eval_count, gen_ns:.eval_duration}' # Edit-heavy vs fresh-generation workload comparison (DSpark section) python3 edit_bench.py http://127.0.0.1:8002/v1 qwen3.8-27b # from 0xBakeer's repo, bench/ ``` Two words to have straight before the tables below, because every number in this post is one or the other. **Prefill** is the model reading your prompt: all the input tokens get processed in parallel, so it is fast, hundreds to thousands of tokens per second. **Decode** is the model writing its answer one token at a time, each token waiting on the one before it, so it is slow, single or low double digits here. Prefill is the wait before the first word appears; decode is the speed you watch it type at. In benchmark names, `pp2048` is prefill measured on a 2,048 token prompt and `tg128` is decode measured over 128 generated tokens. Anything else that reads like a magic string in this post is in the [local LLM glossary](/blog/local-llm-glossary). ## What Qwen3.8-27B actually is Reading the config before running things saves a lot of confusion, and this one is interesting: - 27B parameters, and it is NOT a MoE. 64 layers with a hybrid attention pattern: every 4th layer is full gated attention, the other 48 layers are [Gated DeltaNet](https://arxiv.org/abs/2412.06464) (linear attention). Same hybrid lineage as Qwen3.5/3.6. - Native vision language model. There is a 27-layer vision encoder in the checkpoint, images and video in, text out. - 262,144 token native context, extensible to 1M with [YaRN](https://arxiv.org/abs/2309.00071), short for Yet another RoPE extensioN: it rescales the model's positional frequencies, stretching each one differently depending on its wavelength, so the model can address positions further out than it was ever trained on without a full retrain. - Thinking mode on by default (`` blocks), with recommended sampling temp 1.0 / top_p 0.95 / top_k 20. Non-thinking: temp 0.7 / top_p 0.80. - Apache 2.0. - Architecture class is `Qwen3_5ForConditionalGeneration` (`model_type: qwen3_5`). This detail matters: it is the same architecture family the inference engines already support, which is why day-zero support mostly just works. Gated DeltaNet deserves a sentence of its own, because it quietly explains most of the numbers later in this post. Normal attention keeps a KV cache that grows with every token you feed it, so the deeper your context gets, the more memory has to be read before the next token can come out. A linear attention layer keeps a fixed-size running state instead: the gate decides how fast old memory fades, and the delta rule writes targeted corrections into that state rather than appending to an ever-growing list. The state is the same size at token 100,000 as it is at token 10. Hold onto that one, it is why decode speed barely moves as context grows further down. Qwen's own (vendor-reported, so calibrate accordingly) numbers for the 27B: SWE-bench Pro 61.7, LiveCodeBench v6 90.3, Terminal Bench 2.1 at 73.0, GPQA Diamond 89.2, OSWorld-Verified 84.3. ## What works on the Spark on day zero | Path | Status on day zero | |---|---| | llama.cpp + unsloth GGUF | Works (stock release build b10423, vision mmproj included) | | vLLM + official FP8 checkpoint | Works, on both the stable v0.27.1 release and the spark-arena nightly (recipes below) | | Ollama | Works, needs v0.32.12 (released the same day) | | SGLang | Works on upstream latest; the older pinned dev container silently produced garbage (see below) | ## The llama.cpp path (fastest way to first token) Let's start with the fastest way to first token. One command, and the server pulls the GGUF straight from Hugging Face: ```bash docker run -d --name qwen38-llamacpp --gpus all -p 8091:8091 \ -v $HOME/models/qwen38:/root/.cache/huggingface \ --entrypoint /app/llama-server \ ghcr.io/ggml-org/llama.cpp:server-cuda \ -hf unsloth/Qwen3.8-27B-GGUF:Q4_K_XL \ --port 8091 --host 0.0.0.0 -ngl 99 -c 32768 -fa on ``` Three of those flags do the heavy lifting: `-ngl 99` offloads all layers to the GPU (99 is the idiomatic "all of them", and anything left on the CPU is dramatically slower), `-c 32768` sets the context window to 32K tokens, and `-fa on` enables flash attention, which computes attention without materializing the giant intermediate matrix in memory. That last one is free speed, leave it on. Two gotchas I hit so you do not have to: 1. Recent llama.cpp downloads `-hf` models into the Hugging Face hub cache (`/root/.cache/huggingface`), not the old `/root/.cache/llama.cpp` path. Mount the right one or your 18GB download disappears with the container. 2. The GGUF repo ships `mmproj-BF16.gguf` alongside the weights (llama.cpp pulled it automatically), so the vision path is wired up for llama.cpp as well. `mmproj` is the multimodal projector, the companion file that turns images into tokens the language model can read: no mmproj, no images, however capable the model is. I test it below. First token arrives fast and the model correctly identified what it was running on (a nice recursive moment: Qwen3.8-27B on a DGX Spark explaining what a DGX Spark is). Quick decode of that quant name before the numbers, because `UD-Q4_K_XL` is three labels stacked on top of each other. `Q4_K` is llama.cpp's 4-bit K-quant: instead of one scaling factor for a whole tensor, weights are stored in super-blocks of 256, split into blocks of 32 that each carry their own quantized scale, so outlier weights do less damage to their neighbours. `UD` is Unsloth Dynamic, meaning the layers are deliberately not all quantized the same: embeddings and the first and last blocks keep more bits because everything downstream depends on them, while the more redundant middle feed-forward layers get squeezed harder. `XL` is the size tier, which promotes selected important matrices to 5-bit where unsloth judges that safe. The practical upshot is 16.68 GiB of weights that hold up better than a uniform 4-bit quant of the same size. The `llama-bench` numbers (build b10423, flash attention on, UD-Q4_K_XL, 16.68 GiB weights): | Test | Result | |---|---| | pp512 (prefill) | 838.6 t/s | | pp2048 (prefill) | 837.4 t/s | | tg128 (decode) | 11.6 t/s | | tg32 (decode) | 11.6 t/s | Let me be honest about that decode number, because this is where dense models and the Spark have a complicated relationship. The GB10's unified memory is the whole reason a 27B fits comfortably, but its ~273 GB/s of bandwidth is the ceiling for decode on any dense model: every generated token has to stream all 16.7GB of weights. Think of it like re-reading a whole book off the shelf before you can write each next word: your reading speed sets the pace, not how fast you can think. Do the arithmetic and 273 GB/s over 16.7GB puts the theoretical ceiling around 16 t/s, so the measured 11.6 is roughly 70% of peak, which is about what real kernels get. If you have been running MoE models like Nemotron 3.5 Lightning (30B but only ~3B active) on the Spark and got used to 100+ t/s decode, recalibrate: this is a real dense 27B, all parameters working on every token. Prefill is a different story: 838 t/s means a 2,000 token prompt is processed in under 2.5 seconds, and the hybrid DeltaNet layers keep that roughly flat as context grows. Fun detail: llama-bench identifies the model as "qwen35 27B", the architecture tag from the config showing through in the GGUF metadata. ## The vLLM path (best throughput) Now let's do it properly with vLLM. No official Spark Arena recipe existed for Qwen3.8 when it dropped (the model was hours old), but the `@official/qwen3.6-27b-fp8-vllm` recipe is the same architecture family, so I adapted it. This is the part I want to highlight about the Spark ecosystem right now: [sparkrun](https://sparkrun.dev) recipes made a day-zero model a config-file edit, not an afternoon of dependency fighting. ```yaml recipe_version: '2' model: Qwen/Qwen3.8-27B-FP8 runtime: vllm container: ghcr.io/spark-arena/dgx-vllm-eugr-nightly:latest defaults: gpu_memory_utilization: 0.8 max_model_len: 131072 max_num_batched_tokens: 32768 load_format: instanttensor kv_cache_dtype: fp8 attention_backend: flashinfer tool_call_parser: qwen3_coder reasoning_parser: qwen3 env: VLLM_MARLIN_USE_ATOMIC_ADD: '1' ``` That is a lot of magic strings in twelve lines, so here is what each one is actually doing, because these are the knobs you will end up turning yourself: | Setting | What it does | Why this value | |---|---|---| | `gpu_memory_utilization: 0.8` | The share of memory vLLM claims up front, for weights plus KV cache | Higher means more KV cache, so longer contexts and more concurrent users. On unified memory you cannot go greedy: everything else on the box shares this pool. | | `max_model_len: 131072` | Longest context the server will accept, in tokens | 128K, well under the model's native 262K. Every token of headroom you reserve costs KV cache memory, and I would rather have the memory. | | `max_num_batched_tokens: 32768` | Cap on tokens the scheduler puts in one forward pass | The prefill-throughput vs responsiveness dial. Bigger batches process prompts faster and make latency chunkier. | | `load_format: instanttensor` | How the weights get off disk and into memory | Pure startup time. It is why a 29GB checkpoint is resident in under 5 seconds once cached instead of minutes. | | `kv_cache_dtype: fp8` | Precision the KV cache is stored at | Roughly halves cache memory versus 16-bit, which is what buys the long-context headroom. Small accuracy cost. | | `attention_backend: flashinfer` | Which attention kernel library runs | On `sm121` FlashInfer picks the `xqa` decode kernel and supports the FP8 cache above. Backends can differ by 2x, which is why recipes pin one. | | `tool_call_parser: qwen3_coder` | Pulls tool and function calls out of the raw token stream | Has to match the model family, or your agent framework sees plain text where it expected a structured call. | | `reasoning_parser: qwen3` | Pulls `` blocks into the response's reasoning field | Same story: wrong parser and reasoning text leaks into the answer. | | `VLLM_MARLIN_USE_ATOMIC_ADD: '1'` | Switches Marlin's quantized kernels to atomic accumulation | A hardware-specific workaround. This one you copy from a working recipe rather than derive. | Then: ```bash uvx sparkrun run ./qwen38-27b-fp8-vllm.yaml ``` The official FP8 checkpoint is 29GB, `instanttensor` loads the weights in under 5 seconds once cached, and FlashInfer picks the `xqa` decode backend on sm121 with FP8 KV cache. Changes from the 3.6 recipe: I dropped the qwen3.6-specific chat template mods (3.8 ships a correct template) and set `max_model_len` to 128K, which is plenty while leaving KV headroom. The numbers, measured with [llama-benchy](https://pypi.org/project/llama-benchy/) 0.4.0 (the same tool Spark Arena standardizes on), pp2048/tg128 at depth 0: | Concurrency | Prefill pp2048 (t/s) | Decode tg128 aggregate (t/s) | Decode per request (t/s) | |---|---|---|---| | 1 | 1,914 | 8.2 | 8.2 | | 2 | 1,305 | 16.1 | 8.2 | | 5 | 540 | 36.3 | 7.8 | | 10 | 627 | 57.9 | 7.2 | Two things jump out: 1. vLLM's prefill is 2.3x llama.cpp's (1,914 vs 837 t/s). The FlashInfer path on sm121 is doing its job. 2. vLLM's single-stream decode (8.2 t/s) is SLOWER than llama.cpp's (11.6 t/s). This is not a vLLM problem, it is arithmetic: the FP8 checkpoint streams 28.75GB per token, the Q4_K_XL GGUF streams 16.7GB. On a bandwidth-bound box the smaller quant wins single-stream, always. What vLLM buys you is batching: 10 concurrent requests get 57.9 t/s aggregate, because one weight-streaming pass now feeds 10 tokens instead of 1. So the honest serving decision tree on a Spark: single user chatting → llama.cpp with the smallest quant you can tolerate quality-wise. Serving a team or agents in parallel → vLLM FP8. ### The long-context concurrency wedge (day-zero honesty) Not everything works yet. When I pushed 10 concurrent requests at 32K context depth, the engine effectively wedged: requests admitted one at a time, prefill bursts healthy at ~3,277 t/s, but aggregate generation collapsed to 0.2-0.6 t/s and sat there. It had not crashed, it was just stuck. Short-context concurrency: fine. Single-stream: fine (numbers below). Concurrent + deep context: pathological, at least in this nightly build with the hybrid DeltaNet architecture. At low concurrency, deep context is actually where this architecture shines. Measured at 32,768 tokens of context depth: | Config | Value | |---|---| | Prefill into 32K context (c=1) | 534-700 t/s | | Decode at 32K context (c=1) | 7.9 t/s | | Decode at 32K context (c=2, aggregate) | 15.1 t/s | The decode number is the one to notice here: 8.2 t/s at zero context, 7.9 t/s at 32K context, a 3% drop. On a conventional full-attention model the KV cache reads grow with context and decode sags noticeably; here 48 of the 64 layers are linear attention with constant-size state, so decode speed is nearly flat in context depth. For long-document and agentic workloads on local hardware, that flatness matters more than the headline number. This is what day zero actually looks like: the happy paths work because the architecture class was already supported, and the corner cases (linear-attention state management under concurrent long-context load) still need the engines to catch up. If you are evaluating this model for production serving, test YOUR context/concurrency profile before committing. ## NVFP4: the best-numbers recipe GB10 is a Blackwell chip, and Blackwell has native FP4 tensor cores (NVFP4 being NVIDIA's 4-bit float format, covered properly in [Day 4](/blog/day-4-quantization-demystified-bf16-fp8-nvfp4-mxfp4-int4-gguf-and-why-it-all-matters)), so the natural question is whether the NVFP4 quant (unsloth/Qwen3.8-27B-NVFP4, ~16GB) buys real speed. Same recipe as above with the model swapped, and FlashInfer autotuned 46 fp4_gemm kernel configs on first boot. It does: | Concurrency | Prefill pp2048 (t/s) | Decode tg128 aggregate (t/s) | Decode per request (t/s) | |---|---|---|---| | 1 | 1,794 | 11.5 | 11.5 | | 2 | 2,393 | 21.6 | 11.0 | | 5 | 2,719 | 49.8 | 10.5 | | 10 | 3,999 | 84.3 | 9.6 | NVFP4 ties llama.cpp's single-stream decode (11.5 vs 11.6, both stream ~16GB of weights, the physics is consistent), and wins everything else: 84.3 t/s aggregate decode at concurrency 10 (46% over FP8's 57.9), and batched prefill that scales UP with concurrency to nearly 4,000 t/s where FP8's fell. If you serve this model on a Spark, NVFP4 is the recipe. The quality tradeoff of 4-bit quantization is real and workload-dependent; benchmark your own evals before standardizing on it. ## Spark Arena The numbers in this post are reproducible: I submitted the NVFP4 run to [Spark Arena](https://spark-arena.com), the community leaderboard where GB10 owners run the same standardized llama-benchy profile (pp2048/tg128, depths 0 to 100K, concurrency 1/2/5/10) and publish results with the full recipe attached. Submission sub1786754097881, status Completed. Grab the recipe from the leaderboard and you should land on the same numbers. Two full-grid results worth highlighting: decode at 100K context depth is still 9.8 t/s single-stream (15% below zero-context, at ONE HUNDRED THOUSAND tokens of context), and the 32K-deep concurrency-10 cell that wedged FP8 completed fine on NVFP4 at 63.1 t/s aggregate. ## The Ollama path (same-day support) `ollama pull qwen3.8` failed all afternoon with "pull model manifest: file does not exist", and then Ollama shipped v0.32.12 the same day the weights landed, with qwen3.8 in the library: 27b tag, 18GB, 256K context, vision, thinking mode on by default. Older Ollama versions (including 0.32.11 from a day earlier) refuse the pull with a "requires a newer version" error, so upgrade first. ```bash OLLAMA_HOST=127.0.0.1:11435 ollama pull qwen3.8:27b # 18GB, Q4_K_M ``` Measured the same way as my previous Spark posts (temperature 0, 3 runs, numbers from Ollama's own eval counters): | Metric | Result | |---|---| | Prefill (~2,200 token prompt, cold) | 731 t/s | | Decode (200 tokens) | 26.5 t/s | Wait. 26.5 t/s decode, when llama.cpp does 11.6 on the same-size Q4 and the bandwidth ceiling says ~16? The answer is in the model config Ollama ships: `draft_num_predict 4`, and the runner launches with `--spec-type draft-mtp`. The Ollama build of Qwen3.8 includes the model's multi-token prediction head and turns speculative decoding ON by default, so each weight-streaming pass validates up to 4 drafted tokens. Same trick their Nemotron builds used. The plain unsloth GGUF I benchmarked in llama.cpp does not carry the MTP head, so it pays full price per token. That makes Ollama the single-stream champion of the day, and it did it with zero flags. Credit where due: they also default the context to 262K on this box (vram-based default) and the vision projector is wired up. Here is the day-zero picture across all the engines in one chart (the vLLM MTP and SGLang numbers further down came later): ![Qwen3.8-27B on DGX Spark: measured prefill and decode across llama.cpp, Ollama, and vLLM](/img/blog/qwen3-8-27b-on-dgx-spark/chart.png) One operational note: the GB10 needs Ollama's cuda_v13 runner. My first attempt loaded the model on 100% CPU (7.5 t/s decode) because of a botched duplicate server start, so check `ollama ps` says `100% GPU` before you trust any numbers. ## Vision test: it read its own benchmark chart The GGUF ships with the vision projector and llama.cpp loads it automatically, so I gave the model the chart you just scrolled past, the one measuring the model itself, and asked what it shows. It correctly identified the hardware, both metric panels, every engine and quantization in the comparison, the color coding, and the footnote about bandwidth-bound decode. Then it started reading the exact numbers back to me. There is something pleasingly recursive about a model reading a chart of its own decode speed at 11.6 t/s, which is the llama.cpp number on that chart, in the engine it was running in while it read it. The image cost 1,470 prompt tokens, and vision decode runs at effectively the same speed as text. ## Unified memory sequencing lesson The Spark's memory is unified, and CUDA sees essentially all of it: 124,609 MiB, which is 121.7 GiB of the 128GB. That is the whole appeal, a 27B in bf16 fits without quantization. But it also means inference engines fight over the same pool: my vLLM launch failed with "Free memory 74.82/121.69 GiB is less than desired 0.8 utilization (97.35 GiB)" because the llama.cpp server was still resident. On a discrete-GPU box you would notice immediately; on unified memory it is easy to forget a container is holding 18GB. `docker ps` before you launch. Also the eternal Spark reminder: `nvidia-smi` cannot report memory usage on GB10 (Not Supported), use `free -h`. ## Three days later: MTP arrives on vLLM, and finds a cliff I sat on this post for three days and the ecosystem moved fast, so here is the update. An official Spark Arena recipe landed with MTP speculative decoding for vLLM (`speculative_config {"method": "mtp", "num_speculative_tokens": 3}`), the same trick Ollama shipped on day one. The gains are real: | Config | Single-stream tg128 | c10 aggregate | |---|---|---| | vLLM NVFP4 plain | 11.5 t/s | 84.3 t/s | | vLLM NVFP4 + MTP | 22.0 t/s | 105.8 t/s | | vLLM FP8 + MTP | 13.8 t/s | 55.8 t/s | | Ollama Q4 (MTP default) | 26.5 t/s | n/a | NVFP4 + MTP at 105.8 t/s aggregate is the best serving number anything has produced on my Spark. Ollama still holds single-stream. Now the honest part, and please read this before you run the MTP recipes at long context: **twice in a row, the MTP config hard-rebooted my entire Spark** at exactly the same benchmark cell (16K context, 2 concurrent requests), once on FP8 and once on NVFP4. Not a container crash, a full machine reset, with the journal cut off mid-line and no panic trace, which points at a GPU/SoC lockup. The non-MTP configs ran the same cell and the full 28-cell grid for 6.5 hours without a hiccup. Environment: GB10, driver 580.159.03, kernel 6.17.0-1018-nvidia, the spark-arena vLLM nightly. Until this is understood, treat MTP on vLLM as short-context-only on this box. I will update here when I know more. ### SGLang joins the matrix (and teaches the scariest lesson) SGLang was the last officially recommended engine I had not run, so I adapted the qwen3.5-27b-fp8 sglang recipe the same way. First attempt: the server came up healthy, answered every request, and produced complete token soup ("visit visit visits 訪...逻辑逻辑logic..."). Disabling the recipe's NEXTN speculative config changed nothing. The actual culprit was the pinned container image, an SGLang dev build created before Qwen3.8 existed: it loads the new checkpoint without a single warning and generates garbage. Swapping to upstream `lmsysorg/sglang:latest-cu130` fixed it instantly. The scary part is that the health check was green and the API answered every request the whole time - a crash at least tells you something is wrong, silent garbage does not. If you inherit a recipe with a pinned image, coherence-test the output before you benchmark anything. Numbers on the working upstream build (FP8, no speculation): | Cell | SGLang | vLLM FP8 (for comparison) | |---|---|---| | Prefill pp2048 (c=1) | 1,225 t/s | 1,914 t/s | | Decode tg128 (c=1) | 7.7 t/s | 8.2 t/s | | Decode c=10 aggregate | 54.3 t/s | 57.9 t/s | | Decode at 16K context (c=1) | 7.3 t/s | ~7.9 t/s | Same shape as vLLM, a few percent behind everywhere on this build, and the same context-flatness. Then I re-enabled NEXTN speculation (SGLang's MTP equivalent) on the working upstream build, and this is where SGLang earns its seat: | Cell | SGLang FP8 | SGLang FP8 + NEXTN | vLLM FP8 + MTP | |---|---|---|---| | Decode tg128 (c=1) | 7.7 t/s | 13.4 t/s | 13.8 t/s | | Decode c=10 aggregate | 54.3 t/s | 71.0 t/s | 55.8 t/s | | Decode at 16K context (c=1) | 7.3 t/s | 10.3 t/s | 14.4 t/s | Speculation on FP8 gives SGLang almost exactly vLLM's MTP single-stream number (same weights, same trick), and its speculative scheduler scales better under batch: 71 t/s aggregate at concurrency 10 where vLLM's MTP drops to 56. If you see people posting bigger SGLang numbers than mine from earlier in this post, this is why: speculation on versus off. The overall throughput crown still belongs to vLLM NVFP4+MTP, because the 4-bit quant halves the weight traffic that everything else queues behind. And for the record, SGLang with speculation survived the 16K single-stream cell; I deliberately did not run speculation at deep context plus concurrency, the combination that hard-rebooted the box twice under vLLM. ## The 75 tok/s post, reproduced While I was sitting on this draft, a post by [@0xBakeer](https://x.com/0xBakeer) went around claiming 75 tok/s single-stream and 256 tok/s across 16 parallel requests, on the same model, on the same machine. My first reaction was that it contradicted everything above. It does not, and reproducing it taught me the most useful lesson in this post. The lever is a dedicated draft model: "DSpark" (community-built, 5 layers, ~2.6GB) proposes blocks of tokens and the full model verifies them in one weight-read. Same speculative idea as MTP, but the drafter is 3x cheaper per guess. Their [recipe repo](https://github.com/0xBakeer/Qwen3.8-27B-FP8-on-a-single-DGX-Spark) is excellent, annotated flag by flag, so I ran it verbatim on my Spark (vLLM v0.27.1 stable, FP8, DSpark k=7): | Workload | Their number | My Spark | |---|---|---| | Edit-heavy (98.5% draft acceptance) | 46.8 t/s | 45.0-48.6 t/s | | Fresh generation (~30% acceptance) | n/a | 20.8 t/s | | llama-benchy free-gen, single stream | n/a | 17.2 t/s | | llama-benchy, 10 concurrent aggregate | n/a | 65.6 t/s | Reproduced within noise. Their 75 t/s headline is the 4-bit checkpoint plus a deeper draft on the edit-heavy workload, and it is real too. The lesson: speculative decoding's speedup is the drafter's acceptance rate, and acceptance is a property of YOUR WORKLOAD, not the model. The same server on the same box is 3.5x faster editing existing code (where the draft just copies the prompt) than writing new code (where it genuinely guesses). So a single tokens-per-second number without its workload attached is close to meaningless, including the ones in this post: my numbers are free-form generation, the pessimistic end of the range. If your work is editing, refactoring, or structured rewriting, multiply accordingly. Practical takeaway for single-user Sparks: DSpark k=7 on the official FP8 checkpoint beats every configuration I measured above (17.2 t/s on the neutral benchmark, 45+ on edit work) with zero quality risk, since verification discards every wrong guess. And it ran on the stable vLLM release without the hard reboot the nightly's MTP path gave me, though I have not dared re-run the exact crash cell. ## Wrapping up Qwen3.8-27B on a DGX Spark, one day in: - Day-zero support was real everywhere: llama.cpp immediately, vLLM immediately, Ollama by end of day with v0.32.12. The qwen3_5 architecture class being pre-supported did most of the work. - Best single-stream chat: Ollama, 26.5 t/s, because it ships the MTP head with speculative decoding on by default. Nobody else does yet. - Best serving throughput: vLLM NVFP4, 84.3 t/s aggregate at 10 concurrent, prefill scaling to 4,000 t/s. - The architecture's superpower on this hardware is context flatness: on NVFP4, decode runs 11.5 t/s at zero context, 10.9 at 32K, and still 9.8 at 100K. Long documents are effectively free at decode time. - Rough edge: FP8 wedged under concurrent deep-context load in the current vLLM nightly. NVFP4 did not. Test your traffic profile. A dense 27B with native vision and 262K context that runs at usable speeds on a desk box, with Apache 2.0 attached, is exactly what this hardware was built for. The MoE models are still faster chatters, but in my opinion this is the most capable thing my Spark has run so far. Huge thanks to the unsloth team for having the GGUF and NVFP4 quants up within hours, the Ollama team for shipping same-day support, and the Spark Arena maintainers (Drew Botwinick, Eugene Rakhmatulin, Raphael Amorim) whose recipes and containers turned a day-zero model into a config-file edit. If you have a Spark, give these recipes a try and let me know what numbers you get - and submit them to [Spark Arena](https://spark-arena.com) so we can compare notes. ## Links - Model: [Qwen/Qwen3.8-27B](https://huggingface.co/Qwen/Qwen3.8-27B) and [Qwen/Qwen3.8-27B-FP8](https://huggingface.co/Qwen/Qwen3.8-27B-FP8) - Quants: [unsloth/Qwen3.8-27B-GGUF](https://huggingface.co/unsloth/Qwen3.8-27B-GGUF), [unsloth/Qwen3.8-27B-NVFP4](https://huggingface.co/unsloth/Qwen3.8-27B-NVFP4) - Ollama: [ollama.com/library/qwen3.8](https://ollama.com/library/qwen3.8) (needs v0.32.12+) - Tools: [sparkrun](https://sparkrun.dev), [llama-benchy](https://pypi.org/project/llama-benchy/), [Spark Arena](https://spark-arena.com) - My submission: sub1786754097881 on the Spark Arena leaderboard --- # I Ran an AI SRE Copilot on My Own Hardware. Here Is What It Actually Does. - Canonical: https://blog.kubesimplify.com/nudgebee-ai-sre-copilot-hands-on - Published: 2026-08-17 - Summary: Running NudgeBee v1.4.0 end to end - a self-hosted AIOps platform behind AI-SRE, AI-FinOps, AI-K8sOps, and agentic automation - on a Mac, a kiac cluster, and a DGX Spark. **TL;DR** - NudgeBee is an AIOps platform built around AI agents for DevOps and SRE teams - AI-SRE is one surface on it, alongside AI-FinOps, AI-K8sOps, and an automation builder. What sold me is that it implements a full SRE control loop in code: collect signals, rank events, investigate with real tools, recommend fixes, run durable workflows, and keep an audit trail. In this post I run the whole thing locally - the control plane in Docker Compose on my Mac, a 3-node Kubernetes cluster in lightweight VMs via kiac, and the LLM served from a DGX Spark on my desk. Real screenshots, real commands, real sharp edges. > Source note: everything below was run against **NudgeBee v1.4.0** (released August 3, 2026) on August 10, 2026. I installed it, connected a cluster, and broke things so you don't have to. For a fast-moving open-source project, always cross-check the upstream README. --- ## The Problem: On-Call Engineers Are Human Glue Most teams do not fail on-call because they have zero dashboards. They fail because the dashboards, alerts, logs, cloud inventory, cost data, tickets, and runbooks all live in different places. The on-call engineer becomes the integration layer: copy a pod name from Slack, search logs in another tab, check metrics in a third tool, open a ticket, paste a summary, then run a command from a runbook that may or may not still be true. That is the real problem an SRE copilot should solve - and it has nothing to do with asking an LLM what Kubernetes is. The mental model that makes this class of tool click: > **An SRE copilot is a control loop for production systems.** ![The SRE copilot control loop](/img/blog/nudgebee-ai-sre-copilot-hands-on/diagram-control-loop.svg) SRE teams already run this loop by hand every day: 1. **Observe** - alerts, metrics, logs, Kubernetes events, cloud inventory. 2. **Normalize** - turn tool-specific mess into consistent resources and events. 3. **Rank** - decide what is noise and what pages a human. 4. **Investigate** - gather evidence from the cluster, metrics, logs, tickets. 5. **Recommend** - a fix, a rollback, a rightsizing change, a runbook. 6. **Act** - run the remediation, or guide a human through it. 7. **Record** - keep the investigation and outcome for the next incident. NudgeBee's value is that it treats these stages as **one product surface**. The loop is the product - not the dashboard, and not the LLM. --- ## What NudgeBee Is (as of v1.4.0) NudgeBee describes itself as a unified AIOps / CloudOps platform: **AI-SRE** (troubleshooting), **AI-FinOps** (cost and rightsizing), **AI-K8sOps** (cluster operations), and an **Agentic Automation Builder** - without fragmented tools or model lock-in. Under the hood it is a monorepo of TypeScript, Go, and Python services: ![NudgeBee v1.4.0 architecture - app, backend services, data layer, collectors, and the in-cluster agent](/img/blog/nudgebee-ai-sre-copilot-hands-on/diagram-architecture.svg) Each service owns a stage of the loop: | Loop stage | NudgeBee piece | | --- | --- | | Observe | `k8s-collector`, `cloud-collector`, in-cluster agent, webhooks | | Normalize | `services-server`, migrations, resource model | | Rank | triage scoring, event aggregation (LLM-assisted since v1.4) | | Investigate | `llm-server` agents + tools, RAG, knowledge graph | | Act | `workflow-server` (Temporal), relay to the cluster | | Notify | `notifications-server` - Slack, Teams, Discord, email | | Record | Postgres: conversations, tool calls, executions, tickets | Because these surfaces share the same collectors, knowledge graph, and integrations (more on bCortex below), a team can start with just one - say, triage - and add FinOps or automation later without re-plumbing anything. The right way to read the repo is not file by file. It is: "which stage of the loop does this service own?" (One naming heads-up if you go source diving: the service that deploys as `workflow-server` lives in the code as `runbook-server`. Same thing - one Temporal worker wearing two names.) --- ## Quick Start: Two Ways to Run It Since v1.3.0, every first-party image is published to `ghcr.io/nudgebee/*` and the umbrella Helm chart is on GHCR as an OCI artifact - so you no longer need to build anything from source. ### Path 1: Kubernetes (the one-liner-ish path) ```bash export NUDGEBEE_ENC_KEY=$(openssl rand -hex 32) # store this safely helm install nudgebee oci://ghcr.io/nudgebee/charts/nudgebee \ --namespace nudgebee --create-namespace \ --set nudgebee_secret.NUDGEBEE_ENCRYPTION_KEY="$NUDGEBEE_ENC_KEY" \ --wait --timeout 20m kubectl -n nudgebee port-forward svc/app 3000:80 ``` The post-install hook applies migrations automatically. Grab the bootstrap password from the `nudgebee` secret and sign in at `localhost:3000`. ### Path 2: Docker Compose on a laptop (what I did) ```bash git clone https://github.com/nudgebee/nudgebee.git && cd nudgebee docker compose --profile full up -d ``` The default profile starts the infrastructure (Postgres, Redis, RabbitMQ, Qdrant, Temporal, one-shot migrations). The `full` profile adds all the app services as containers - about 18 in total, all pulled from GHCR. Here is my stack once everything settled: ```text SERVICE IMAGE STATUS api-server-services ghcr.io/nudgebee/services-server:1.4.0 Up app ghcr.io/nudgebee/app:1.4.0 Up cloud-collector ghcr.io/nudgebee/cloud-collector-server:1.4.0 Up k8s-collector-app ghcr.io/nudgebee/k8s-collector:1.4.0 Up llm-server ghcr.io/nudgebee/llm-server:1.4.0 Up ml-k8s-server ghcr.io/nudgebee/ml-k8s-server:1.4.0 Up notifications-server ghcr.io/nudgebee/notifications:1.4.0 Up postgres ghcr.io/nudgebee/postgres:16 Up (healthy) qdrant ghcr.io/nudgebee/qdrant:v1.18.3 Up rabbitmq ghcr.io/nudgebee/rabbitmq:3-management Up rag-server ghcr.io/nudgebee/rag-server:1.4.0 Up redis ghcr.io/nudgebee/redis:7-alpine Up relay-server ghcr.io/nudgebee/relay-server:1.4.0 Up temporal temporalio/auto-setup:1.29.1 Up temporal-ui temporalio/ui:2.44.0 Up ticket-server ghcr.io/nudgebee/ticket-server:1.4.0 Up workflow-server ghcr.io/nudgebee/workflow-server:1.4.0 Up ``` **Real-world notes from my install** (the kind of thing READMEs never tell you): - Several `full`-profile services ship without environment config in the compose file. I added a `docker-compose.override.yaml` that gives each one its database URL, RabbitMQ host, and the shared `NUDGEBEE_ENCRYPTION_KEY`. The key must be identical everywhere - it encrypts integration credentials at rest. - On macOS, the k8s-collector wants host port **5000**, which AirPlay already squats on. Remap it in the override. - The k8s-collector expects the backend at the hostname `services-server`; the compose service is named `api-server-services`. A one-line network alias fixes event ingestion. - If you disable ClickHouse (`clickhouse.enabled=false`), the agent chart still references the ClickHouse secret. Create a stub secret or leave it enabled. Sign in with **Admin Login**, any email, and the local dev password `Test!24#5` (the dummy-credentials provider - local development only). ![NudgeBee login screen](/img/blog/nudgebee-ai-sre-copilot-hands-on/01-login.jpg) One pleasant surprise: v1.4.0 no longer drops you into an empty product. First login lands on a **demo dataset** - active incidents, error-rate events, rightsizing recommendations - so you can explore every surface before connecting anything real. ![Home dashboard with demo data - incidents, optimize recommendations, quick links](/img/blog/nudgebee-ai-sre-copilot-hands-on/02-home-dashboard.jpg) --- ## The Lab: Cluster on the Mac, Brain on the DGX Spark For the demo I wanted everything self-hosted, including the model. My setup: ![The lab - NudgeBee control plane and kiac cluster on the MacBook, Ollama on the DGX Spark](/img/blog/nudgebee-ai-sre-copilot-hands-on/diagram-lab-setup.svg) - **Control plane**: the Compose stack above. - **Tenant cluster**: a 3-node k3s cluster created with [kiac](https://github.com/saiyam1814/kiac) (Kubernetes in Apple Containers - every node is its own lightweight VM with a routable IP, so the in-cluster agent can reach the control plane over the vmnet gateway like a real remote cluster would). - **Inference**: Ollama on a DGX Spark across the room. NudgeBee's llm-server calls it over plain HTTP. The model does not need to be anywhere near the cluster - the LLM is just an API. The point of this setup is the architecture lesson: **the brain, the hands, and the workloads are three separate places**, glued together by exactly two protocols - a websocket relay for the cluster and an OpenAI-compatible endpoint for the model. ### Demo workloads I deployed a `payments` namespace with three deployments: a healthy nginx `payments-gateway`, a `payments-api` that crashes on boot with a missing `DATABASE_URL`, and a deliberately over-provisioned `report-worker` (1 CPU / 1Gi requested per replica to do nothing) - one problem for each of NudgeBee's three surfaces: troubleshooting, RCA, and FinOps. ### Connecting the cluster Admin → Integrations shows the catalog: Kubernetes and clouds, plus categories for messaging, ticketing, observability backends, repos, CI/CD, databases, and LLM providers. ![Integrations catalog - Kubernetes, AWS, Azure, GCP, Cloud Foundry](/img/blog/nudgebee-ai-sre-copilot-hands-on/03-integrations-catalog.jpg) Adding a Kubernetes account generates an agent key and a copy-paste install command (shell script or Helm). You can toggle components off - Prometheus stack, OpenCost, eBPF node agent, OpenTelemetry collector - and the command updates live. ![Add Kubernetes Account - component toggles and generated install command](/img/blog/nudgebee-ai-sre-copilot-hands-on/04-connect-cluster-modal.jpg) The agent chart installs kube-prometheus-stack and OpenCost alongside the NudgeBee agent, then dials **out** to the control plane over a websocket: ```json {"msg":"greeting","payload":"{\"action\":\"auth\",\"version\":\"0.1.11\",...}"} {"msg":"updated relay connection status to true","agent_type":"k8s"} ``` That outbound-only relay design matters: real clusters sit behind NAT and firewalls, so the control plane can never assume it can dial in. Commands flow down the same websocket the agent opened. Two minutes later the cluster shows up with a candid message: ![Connected account - "Give me about an hour to generate insights"](/img/blog/nudgebee-ai-sre-copilot-hands-on/05-connected-home.jpg) I like this honesty. Trend-based insights need trends. But live state is immediate: ![Cluster overview - 3 nodes, 21 pods, real CPU and memory](/img/blog/nudgebee-ai-sre-copilot-hands-on/06-cluster-overview.jpg) Three nodes, twenty-one pods, live CPU and memory pulled from the Prometheus the agent just installed. No demo data - this is the kiac cluster. --- ## The Loop, Live: Signal → Triage → AI Investigation Within minutes, real events started flowing. The home page surfaced a firing issue with an **Investigate** button next to it: ![Live issue on home - 1 pod has ImagePullBackoff, with Investigate button](/img/blog/nudgebee-ai-sre-copilot-hands-on/07-live-issue-investigate.jpg) The Troubleshoot section turns raw events into a **triage inbox**: every issue gets a triage score, severity, alert status, and an owner path - sliced by error type (OOM Killed, Image Pull Backoff, High Restarts, CPU Throttling, Replica Mismatch): ![Triage inbox - pod errors with triage score, severity, and Investigate action](/img/blog/nudgebee-ai-sre-copilot-hands-on/08-triage-pod-errors.jpg) This is the **Rank** stage of the loop, and it is where alert fatigue goes to die. v1.4.0 added LLM-assisted triage scoring on top of the rule-based signals. Clicking **Investigate** opens the AI side. This is where NudgeBee stops being a dashboard: ![AI investigation in progress - parallel tool calls with live status](/img/blog/nudgebee-ai-sre-copilot-hands-on/11-ai-investigation-parallel-tools.jpg) Read that screenshot carefully, because it shows the architecture: - The agent states its plan in plain language. - It then fires **multiple tool calls in parallel**: an events query for the pod, a `kubectl describe` for image and status, a resource-graph search - each with its own live status and a "Tool Details" expander showing the raw evidence. - Evidence accumulates as **sources** attached to the conversation, not vibes. Ten minutes later (on a 26B model running on my own hardware - more on that below), the finished analysis landed: ![Completed AI investigation - summary, 5-Whys causality chain, evidence, resolution](/img/blog/nudgebee-ai-sre-copilot-hands-on/12-ai-investigation-result.jpg) Let's pause on the *structure* of that answer for a second, because it is doing a lot of work: - **Investigation Summary** - symptom plus the exact signal: Kubernetes events reporting `NotFound` for the specific image reference. - **Causality Chain (5-Whys)** - pod is in ImagePullBackOff → runtime cannot pull the image → the registry returned `404 Not Found` for that tag → root cause: the manifest references a non-existent image tag. - **Evidence** - a clickable source (`Events - E2`) carrying the raw `rpc error: code = NotFound`, with 4 sources attached to the conversation. - **Resolution** - an immediate fix (point the manifest at a valid tag, verify against the registry) *and* a long-term recommendation (validate image tags in CI/CD before rollout). And the diagnosis was correct - I verified the tag really does not exist in the registry. Not "it may be due to an image pull error, OOM kill, config issue, or failing dependency." A specific root cause, with the evidence to check its work. One small detail that shows the loop thinking: under the answer, NudgeBee suggests **Related Questions** - verify the fix was applied and the pod is Running, inspect the corrected manifest, analyze the CI/CD logs so the bad tag never ships again: ![Related Questions - verify the fix, inspect the manifest, prevent the regression](/img/blog/nudgebee-ai-sre-copilot-hands-on/13-related-questions.jpg) That last suggestion is the Record stage turning into prevention: today's investigation trying to make sure tomorrow's page never fires. ### ReWOO is gone - meet the Orchestrating planner If you read about NudgeBee before mid-2026, you may remember its two planning styles: ReWOO (plan first, execute after) and ReAct (think, act, observe, repeat). **v1.4.0 retired the ReWOO planner.** Both agent types - *Orchestrating* (the top-level coordinator) and *ReAct* (domain investigators) - now run a hybrid planner the code calls **ReAct3**: a ReAct loop extended with an `` block that lets the model declare several independent tool calls in one step. That is the parallel execution visible in the screenshot, and it is the sensible endpoint of the planner debate: keep ReAct's evidence-driven loop, recover ReWOO's efficiency by batching independent lookups. The tool layer is wide: `kubectl` and Helm, Prometheus/PromQL, Loki, Elasticsearch, Datadog, New Relic, OpenObserve (new in v1.4.0), cloud APIs, ticket systems, GitHub, spend analysis, and workflow lifecycle actions. The agent decides *what* to look at; tools are *how* it touches reality: > The model should not hallucinate your cluster. It should ask tools for evidence. ### Guardrails you can see in the logs Watching llm-server logs during the investigation was its own education: ```text plannerexecutor: submitting tool for parallel execution (x4) plannerexecutor: pre-flight detected tool with LLM-only classification, assuming potential write plannerexecutor: tool output truncated at source ``` Pre-flight classification of potentially write-capable tools, output truncation before context stuffing, per-account tool scoping, and an egress filter (default "detect" mode) that watches for secrets leaving via LLM calls. None of it is glamorous, and all of it is what makes "AI with kubectl access" survivable. --- ## bCortex: The Context Layer Under Every Agent There is a failure mode every naive "agentic ops" tool shares: **the agent rediscovers your infrastructure from scratch on every question.** List the namespaces. Describe the deployments. Page through events. Ask again tomorrow and it does all of it again. Token spend grows with cluster size, latency grows with token spend, and worst of all, whenever discovery is incomplete the model fills the gaps by guessing - which is where operational hallucinations come from. NudgeBee's answer is a context layer the team calls **bCortex**, and it has three parts you can find in the codebase: - An **auto-generated Knowledge Graph** (`api-server/services/knowledge_graph/`) that models the relationships between resources, events, and findings - so "what depends on this pod" is a graph lookup, not a fresh round of kubectl calls. - A **Service Map** built from APM and trace data - deliberately a *different* artifact than the KG: the service map is dataflow (who calls whom), the KG is resource state. Both feed the agent. - A **multi-tiered, usage-based memory layer**. The code describes it as a layered "memory slab" - preferences and identity first, with patterns and decisions layered on - extracted from actual usage rather than hand-written. I did not have to take anyone's word for this, because my own run left its fingerprints in the logs. The moment my ImagePullBackOff investigation completed, llm-server ran `long-term memory extraction` on the conversation and logged the stats - the platform was already mining my investigation for reusable knowledge. During the run I also watched `kb_sync` cycles keeping the knowledge base current, `tool call cache` hits skipping discovery calls the platform had already made, and the planner `comparing with history` before deciding what to fetch. My first investigation was the expensive one; everything after it starts warmer. The economics follow directly. Right-sized context per call instead of dump-everything-into-the-prompt means fewer tokens. Graph and cache lookups replace repeated discovery tool calls. Model-tier routing (a `reasoning` / `retrieval` / `summary` split in the config) sends heavyweight thinking to the big model and summarization to a cheap one. At the scale where agentic ops gets interesting - hundreds of investigations a week across a fleet - that is the difference between a bill that grows with every question and one that amortizes. And accuracy moves the same direction as cost, because a model grounded in a graph that already knows what exists has far less room to hallucinate. > A chatbot loop rediscovers your infrastructure every time. A context layer remembers it. This is also why the multi-agent pitch holds up in practice: a new agent - FinOps, K8sOps, a custom automation - does not start from zero. It inherits everything bCortex already knows about the environment. --- ## FinOps Is in the Same Loop The Optimize surface treats cost as an operational signal, not a finance spreadsheet: workload/replica/PV rightsizing, abandoned-resource detection, spot recommendations, best practices, and an **Auto Optimize** response that can act on them: ![Optimize - rightsizing categories, abandoned resources, auto-optimize](/img/blog/nudgebee-ai-sre-copilot-hands-on/10-optimize-rightsizing.jpg) Mine shows zeros because recommendations are trend-based (OpenCost plus NudgeBee's ML rightsizing service watch for a day or more before opining). The structure is the takeaway: an over-provisioned pod, an idle disk, and a crashlooping deployment are the *same kind of problem* - operational hygiene - and they belong on the same screen with the same Investigate button. --- ## Why Temporal Ships Inside an SRE Copilot The moment a copilot crosses from *answering* to *acting*, durability stops being optional. A remediation workflow that dies silently when a process restarts after step 3 of 7 is worse than no automation at all. NudgeBee runs every runbook and scheduled job through **Temporal**. I opened the bundled Temporal UI (`:8233`) and found the platform eating its own dog food - 102 workflow executions from just a few hours of uptime: ![Temporal UI - 102 workflows: agent status checks, notification batching, insight refresh](/img/blog/nudgebee-ai-sre-copilot-hands-on/09-temporal-workflows.jpg) Agent health checks, notification batching, recommendation-resolution updates, insight refreshes, system cleanup - all as versioned, retryable, resumable workflows with full history. The Automations surface builds on the same engine: manual, scheduled, webhook, and event-triggered workflows, with approval steps, retries, child workflows, and persistent state. The division of labor here is exactly right: the LLM helps decide what to do, and Temporal makes doing it operationally boring. It is also why "AI will replace runbooks" has it backwards - AI makes durable runbooks *more* important. --- ## Bring Your Own Model (Including the One on Your Desk) NudgeBee's pitch includes "no model lock-in," and v1.4.0 implements providers for OpenAI, Anthropic, Bedrock, SageMaker, Azure, Google AI, Vertex AI, and HuggingFace/vLLM-style endpoints. Two field findings worth your time: **1. For Ollama, use the OpenAI-compatible path - it is what the [official docs](https://docs.nudgebee.com/docs/integrations/LLM/Ollama/) configure, and it works.** The trap I fell into: the sample env file also lists `ollama` as a provider value, and that switch case is not wired up in v1.4.0, so picking it fails with `llm model not found - ollama`. Stick to the documented config, which goes through Ollama's OpenAI-compatible endpoint (the team told me they will make this more explicit): ```bash LLM_PROVIDER=openai LLM_MODEL_NAME=qwen3.5:35b-a3b LLM_PROVIDER_API_ENDPOINT=http://:11434/v1 LLM_PROVIDER_API_KEY=anything-non-empty ``` **2. Local models need bigger timeouts.** The defaults assume cloud-API latency: 30 seconds to first token, a 10-minute global retry budget. An agent prompt here is 15k+ tokens, and a big local model can blow through both. Raise them: ```bash LLM_PROVIDER_TTFT_TIMEOUT_SECONDS=300 LLM_SERVER_GLOBAL_RETRY_BUDGET_MINUTES=30 LLM_SERVER_MAX_INDIVIDUAL_CALL_TIMEOUT_MINUTES=15 ``` On hardware: my Mac could not prefill the agent's ~16k-token prompts inside the deadlines. The DGX Spark prefilled the same prompt in **0.9 seconds warm (~17,900 tokens/sec)**. Two more lessons from the run: reasoning-mode models (qwen3.5's thinking) generate thousands of deliberation tokens per ReAct step, so decode speed - not prefill - becomes the loop's bottleneck; and a fast non-thinking model often beats a smarter slow one for agentic work. The completed investigation above ran on `gemma4:26b` - the full multi-tool loop plus write-up in about ten minutes, entirely on hardware I own. The architecture take-away is bigger than my desk, though: because the model is just an HTTP endpoint, "cluster on one machine, GPU on another, control plane on a third" works with zero special configuration. Your prompts and evidence stay on your network. --- ## Where Would You Actually Use This? Six Scenarios Because the surfaces share one platform, these are not six separate products to evaluate - they are six entry points into the same one. **1. On-call triage.** Connect the cluster, wire Slack/Teams, and let the triage inbox rank what fires. The score plus event aggregation turns 40 raw alerts into 3 issues with owners. Start here - it is read-only and pays off day one. **2. Crashloop and error RCA.** The `payments-api` pattern: pod crashes, event fires, Investigate pulls describe + events + logs + recent changes in parallel and writes up a root cause with evidence attached. The investigation is recorded, so the *next* engineer searching that error finds a documented case, not a blank page. **3. FinOps and rightsizing.** After a few days of trends: workload/replica/PV rightsizing with monthly savings estimates, abandoned-volume detection, spot candidates - each with an Optimize action, gated behind approvals if you want them. **4. Runbook automation.** Codify the fix once as a Temporal-backed workflow: event-triggered ("on ImagePullBackOff in namespace X, check the registry and page the owning team"), scheduled (nightly hygiene), or webhook-driven (from your existing alertmanager). Approval steps make the write path safe to roll out gradually. **5. Ticket and incident hygiene.** The ticket-server syncs Jira, ServiceNow, PagerDuty, and Zenduty, so investigations attach to tickets and resolutions flow back - the Record stage, automated. **6. Multi-cluster and hybrid estates.** The relay design (agents dial out) means clusters behind NAT, in customer VPCs, or on edge hardware all connect the same way. One control plane, N clusters, per-account scoping. The common thread: **start read-only, earn trust, then open the write path** - observation → investigation → recommendation → automation, in that order. --- ## Things I Would Be Careful About in Production The standard sharp edges of the category, plus what I hit: - **Start read-only.** Let it observe, investigate, and recommend before it remediates. The approval-gated workflows exist for a reason - use them. - **Treat the LLM provider as a data boundary.** Prompts carry pod names, log lines, maybe secrets that slipped into logs. Self-hosting the model (above) is the strongest version of this control; the built-in egress filter is defense in depth, not a substitute for thinking. - **The dummy credentials and sample secrets are for laptops.** `Test!24#5` and friends must never see a routable network. Disable dummy auth, rotate `NUDGEBEE_ENCRYPTION_KEY` handling into a real secret store, set the relay and internal service tokens, and read `docs/auth-and-networkpolicy.md` before exposing anything. - **The encryption key is a one-way door.** Rotate it and previously encrypted rows are unreadable. There is no automatic re-encryption. - **No telemetry by default** - data leaves only through integrations you configure (LLM calls, notifications, ticket sync, webhooks). Those paths are exactly where your security review should look. - **Licensing**: Business Source License 1.1 - free to self-host internally; hosted/managed-service use is restricted; each version converts to Apache 2.0 after its change date. Read `LICENSE` and `LICENSING.md` if you are evaluating for a company. --- ## What This Teaches About Building AIOps Platforms Five lessons I keep coming back to after a day inside it: 1. **Context beats chat.** The interesting part is not the assistant - it is bCortex, the graph and memory around it: resources, events, tickets, tool calls, prior investigations. AI without context is a guesser; AI with context is an operator interface. 2. **Tools need ownership boundaries.** Tenant scoping, credential isolation, pre-flight write detection, output truncation - the boring parts are what make the write path safe. 3. **Runbooks are the safety rail, not the legacy.** The agent discovers and parameterizes workflows; Temporal gives them retries, approvals, versioning, and history. 4. **Cost is an ops signal.** Reliability and FinOps on one surface matches how platform teams actually work. 5. **A monorepo can be a teaching tool.** TypeScript for the app, Go for the backends, Python for ML and collectors, Postgres/RabbitMQ/Redis/Qdrant/Temporal each doing the one job they are best at. Studying why each piece exists is a free course in platform engineering. If you want to explore the code, follow the loop: start at `docs/ARCHITECTURE.md` and `docs/GLOSSARY.md`, then `llm/llm-server/agents/` and `tools/` for the AI layer (grep `RegisterNBAgentFactory` and `RegisterNBTool`), then `runbook-server/tests/integration/testdata/` (the code home of workflow-server) for a catalog of what the workflow engine can do, and finally `collector-server/` for how reality enters the system. --- ## Final Mental Model ```text signals -> resources -> events -> triage -> investigations -> recommendations -> runbooks -> records ``` That is the SRE copilot loop, and NudgeBee is the most complete open implementation of it I have run. It earns the "copilot" name not because there is a chat box, but because every stage - collection, ranking, tool-driven investigation, durable remediation, and the paper trail - lives in one system that a small team can actually self-host. And because that system is agent-agnostic, the same AIOps platform covers SRE, FinOps, K8s ops, and whatever custom automation a team builds next. SRE still needs humans. What it should stop needing is humans doing all the glue work by hand. Give it a try on a test cluster and let me know what you find - and if you hit the same sharp edges I did, the fixes above should save you an evening. If the project looks useful to you, [star the repo on GitHub](https://github.com/nudgebee/nudgebee) - it is the easiest way to support the team building it - and tag me on [X @SaiyamPathak](https://x.com/SaiyamPathak) with what your investigations turn up. --- ## Useful Links - Repo: [github.com/nudgebee/nudgebee](https://github.com/nudgebee/nudgebee) - Release v1.4.0: [github.com/nudgebee/nudgebee/releases](https://github.com/nudgebee/nudgebee/releases) - Helm chart: `oci://ghcr.io/nudgebee/charts/nudgebee` - Architecture: [docs/ARCHITECTURE.md](https://github.com/nudgebee/nudgebee/blob/main/docs/ARCHITECTURE.md) - Glossary: [docs/GLOSSARY.md](https://github.com/nudgebee/nudgebee/blob/main/docs/GLOSSARY.md) - Auth & NetworkPolicy: [docs/auth-and-networkpolicy.md](https://github.com/nudgebee/nudgebee/blob/main/docs/auth-and-networkpolicy.md) - Agent chart: [github.com/nudgebee/k8s-agent](https://github.com/nudgebee/k8s-agent) - kiac (the local cluster tool): [github.com/saiyam1814/kiac](https://github.com/saiyam1814/kiac) --- # Running Nemotron 3.5 Lightning on DGX Spark - Canonical: https://blog.kubesimplify.com/nemotron-3-5-lightning-on-dgx-spark - Published: 2026-08-11 - Summary: NVIDIA's new Nemotron 3.5 Lightning on DGX Spark: how to run it with Ollama and vLLM, the tokens per second I measured, and how the two paths compare. NVIDIA released [Nemotron 3.5 Lightning](https://developer.nvidia.com/blog/nvidia-nemotron-3-5-lightning-delivers-fast-accurate-specialized-task-execution-for-long-running-agents/) today (August 11, 2026), and the pitch is simple: long-running agents spend most of their tokens on boring execution work (tool calls, validating outputs, formatting results), and you should not be burning frontier-model money on that. Lightning is the small, fast worker model for that layer. NVIDIA specifically calls out DGX Spark as a deployment target. I have a Spark on my desk. So instead of quoting their charts, I pulled the model the hour it landed and measured it myself. This is a short post with what I measured, and one gotcha you should know about if you try it right away. ## What it actually is The specs, verified against the [model config on Hugging Face](https://huggingface.co/nvidia/NVIDIA-Nemotron-3.5-Lightning-30B-A3B-BF16), not the press release: | Spec | Value | |---|---| | Total parameters | 30B | | Active parameters | ~3B per token | | Architecture | Hybrid Mamba-Transformer MoE (`nemotron_h`), 52 layers | | Experts | 128 routed, 6 active per token, plus 1 shared expert | | Context window | Up to 1M tokens (the HF config defaults to 256K, sized for single-GPU deployment) | | Pre-training | Over 20T tokens, with an NVFP4 pre-training recipe | | Speculative decoding | MTP layer baked in, plus separate DSpark and DFlash draft models on HF | | Checkpoints | BF16 and NVFP4 | | License | OpenMDW-1.1 (weights, data, and recipes released) | The family resemblance to Nemotron 3 Super and Ultra is deliberate. Same hybrid Mamba-Transformer MoE recipe, same multi-token prediction training, just shrunk to a size where 3B active parameters means memory bandwidth stops being your enemy. On a bandwidth-bound box like the Spark (273 GB/s), the active parameter count is what decides your decode speed. That is the reason this model exists at this size. NVIDIA's own positioning worth repeating: on PinchBench it scores 86% while completing 10,000 tasks about 30% faster than Qwen3.6 35B at similar accuracy (their blog says 30%, the launch tweet says 35%, I am going with the blog). They also claim up to 4x the output speed of similar-sized models. Those are NVIDIA's numbers, mine are below. ## The gotcha: your Ollama is too old Ollama is a day-one launch partner, and the model is already in the library. But: ``` $ ollama pull nemotron-3.5-lightning:30b-a3b Error: pull model manifest: 412: The model you are attempting to pull requires a newer version of Ollama. ``` Support for the Nemotron 3.5 architecture landed in [Ollama v0.32.9](https://github.com/ollama/ollama/releases/tag/v0.32.9), released today, a few hours after the model itself. Anything older fails with that 412, and on launch day "older" included both my Spark's install (0.30.10) and the `ollama/ollama:latest` Docker image (still 0.32.6 when I tried it). The fix is to upgrade, then pull again. ```bash curl -fsSL https://ollama.com/install.sh | sh ollama pull nemotron-3.5-lightning:30b-a3b ollama run nemotron-3.5-lightning:30b-a3b ``` By the time you read this, a plain upgrade is probably all you need. I mention it because if you searched that 412 error, this is why. 25GB download. The Ollama build is a Q4_K_M GGUF that `ollama show` reports as 32.9B parameters (the gap vs the marketing 30B is likely the MTP layer and embeddings being counted). Two things worth noticing in the model metadata: it ships with `draft_num_predict 2`, meaning Ollama is already using the baked-in multi-token prediction for speculative decoding out of the box, and it lists `tools` and `thinking` capabilities. Keep in mind this is not the NVFP4 checkpoint. More on that at the end. ## The numbers All runs on my DGX Spark (GB10, 128GB unified memory, DGX OS), Ollama 0.32.9, temperature 0, measured via the API so the tok/s figures come from Ollama's own eval counters, 3 runs each. First, the footprint. Cold load took 27.3 seconds, and `ollama ps` reports 26GB resident at 100% GPU. Interesting detail: Ollama loads this model with the full 262,144 token context window by default, and even at 256K context the whole thing plus KV cache room fits with roughly 86GB of the unified pool still available. On a 24GB card you would be making painful tradeoffs; here it just loads. ``` NAME SIZE PROCESSOR CONTEXT nemotron-3.5-lightning:30b-a3b 26 GB 100% GPU 262144 ``` The throughput numbers: | Test | Prefill (uncached) | Decode | |---|---|---| | Short prompt (39 tok), 500 token generation | small prompt, not meaningful | 71.7 to 73.0 tok/s | | 8,194 token prompt, 200 token generation | 2,583 tok/s | 85 to 87 tok/s | | 15,820 token prompt (cache-busted rerun) | 2,655 tok/s | 83.9 tok/s | | Agent-style prompt (tool call JSON) | | 85.6 to 86.7 tok/s | And the raw runs behind that table, straight from the API counters (repeat runs of the same long prompt hit Ollama's prompt cache, which is why I only count uncached first passes for prefill): ``` short run 1: prompt 39 tok @ 87.6 tok/s | decode 500 tok @ 73.01 tok/s short run 2: prompt 39 tok @ 918.5 tok/s | decode 500 tok @ 71.90 tok/s short run 3: prompt 39 tok @ 824.5 tok/s | decode 500 tok @ 71.73 tok/s long run 1: prompt 8194 tok @ 2583.4 tok/s | decode 200 tok @ 38.36 tok/s (first-load outlier) long run 2: prompt cached | decode 200 tok @ 86.97 tok/s long run 3: prompt cached | decode 200 tok @ 85.38 tok/s cache-busted: prompt 15820 tok @ 2654.7 tok/s | decode 200 tok @ 83.93 tok/s agent run 1: decode 150 tok @ 85.62 tok/s agent run 2: decode 150 tok @ 86.17 tok/s agent run 3: decode 150 tok @ 85.94 tok/s ``` So: roughly **72 to 87 tok/s single-stream decode** and about **2,600 tok/s prefill**, sustained even with 8K to 16K tokens of context on the clock. An 8K-token prompt is fully ingested in just over 3 seconds. The one 38 tok/s decode reading happened immediately after the model's very first prefill and never reproduced; the cache-busted rerun confirms decode stays in the 80s with a fresh 15K-token prefill. Two things surprised me: **Decode speed depends on what the model is generating.** The short test (YAML plus prose) sat at 72 tok/s while the log-summary and JSON tool-call tests ran 84 to 87 tok/s. My best explanation is the built-in speculative decoding: the Ollama build ships with `draft_num_predict 2`, so the MTP head drafts ahead and predictable output (JSON, repetitive summaries) gets a higher acceptance rate than free-form prose. So the tok/s you get depends on the workload. **Agent-style calls come back fast.** I gave it a tool-calling prompt (find why an nginx deployment is CrashLooping, respond with the next tool call as JSON). It reasoned for a few hundred tokens, then returned exactly this, end to end in 5.9 seconds: ```json { "tool": "run_command", "parameters": { "cmd": "kubectl get pods -n web" } } ``` That is the right first step, and it came back as clean JSON with nothing extra around it. This is a reasoning model by default (Ollama reports the `thinking` capability), so budget for a couple hundred thinking tokens per call, at 86 tok/s that is about 3 seconds of overhead per agent step. ## How it compares with other models on the same box I first reached for the numbers I measured back in May and the comparison looked flattering. Then I reran everything today, on the same Ollama 0.32.9 server, same prompt, same settings, and the picture changed. (Note: NVIDIA's PinchBench comparison is against Qwen3.6 35B; what I have locally is its predecessor, qwen3.5:35b-a3b, so treat these as class comparisons, not a re-run of NVIDIA's benchmark.) | Model | Active params | Decode tok/s (today) | My May number | |---|---|---|---| | **Nemotron 3.5 Lightning 30B-A3B** | ~3B | **71.7 to 73.0** (prose), **84 to 87** (JSON, summaries) | n/a, launched today | | qwen3.5:35b-a3b | ~3B | 78.1 | 52.7 | | gemma4:26b (26B-A4B MoE) | ~4B | 66.2 | 58.0 | | nemotron-3-super (120B-A12B) | ~12B | 21.8 | 17.7 | The first thing this table shows has nothing to do with Nemotron: **the runtime itself got faster**. Qwen3.5 35B-A3B went from 52.7 to 78.1 tok/s on the same hardware since May, because Ollama now exploits its MTP head for speculative decoding too. If you are still quoting tok/s numbers from a months-old Ollama, they are stale. Mine were. The second thing: **through Ollama, raw decode speed against Qwen3.5 35B-A3B is basically a tie.** On the identical prose prompt, Qwen was actually a touch faster (78 vs 72). NVIDIA's "up to 4x the output speed of similar-sized models" comes from the Artificial Analysis leaderboard, measuring hosted NVFP4 endpoints with the full draft-model stack; you do not see that 4x through Ollama today. Against its own big brother Nemotron 3 Super, the model it is meant to take execution work from, Lightning is a real 3.3x to 4x. Where Lightning does win is token efficiency. I gave qwen3.5:35b-a3b the exact CrashLoop agent prompt from earlier, temperature 0. Both models produced the identical correct `kubectl get pods -n web` tool call: | | Tokens to answer | Wall clock | |---|---|---| | Nemotron 3.5 Lightning | 485 | 5.9s | | qwen3.5:35b-a3b | 1,953 | 26.0s | Qwen thought four times longer to reach the same place. One prompt is not a benchmark, but it is exactly the behavior NVIDIA claims Lightning was trained for: their PinchBench pitch is 30% faster task completion at similar accuracy, a time-to-done argument rather than a tok/s argument. On this one task the agent step finished 4.4x sooner. For an agent doing thousands of steps a day, tokens per step matters more than tokens per second. ## Update: the NVFP4 checkpoint with vLLM and DSpark The Ollama numbers above are the Q4_K_M GGUF. NVIDIA's recommended path for the Spark is different: the NVFP4 checkpoint served by vLLM with the DSpark draft model doing speculative decoding. The exact recipe is on the [NVFP4 model card](https://huggingface.co/nvidia/NVIDIA-Nemotron-3.5-Lightning-30B-A3B-NVFP4), and it runs in the stock `vllm/vllm-openai:v0.27.1` image, no special Spark build needed: ```bash docker run -d --name vllm-lightning --gpus all --ipc=host --network=host \ -v $HOME/.cache/huggingface:/root/.cache/huggingface \ vllm/vllm-openai:v0.27.1 \ --model nvidia/NVIDIA-Nemotron-3.5-Lightning-30B-A3B-NVFP4 \ --served-model-name nemotron-3.5-lightning \ --moe-backend marlin \ --kv-cache-dtype fp8 \ --max-model-len 65536 \ --enable-prefix-caching \ --speculative_config.method dspark \ --speculative_config.model nvidia/NVIDIA-Nemotron-3.5-Lightning-30B-A3B-NVFP4-DSpark \ --speculative_config.num_speculative_tokens 3 \ --mamba-backend flashinfer \ --mamba-cache-mode align \ --gpu-memory-utilization 0.80 \ --reasoning-parser nemotron_v3 \ --tool-call-parser qwen3_coder \ --enable-auto-tool-choice ``` One note on NVIDIA's recipe: it sets `--gpu-memory-utilization 0.91`, which on this box allocates 88GB of KV cache. I tested both. 0.91 starts and runs fine on this stack, and decodes at the same speed, but it leaves about 1GB of free system memory on a machine where CPU and GPU share the pool. I ran the benchmarks at 0.80: identical single-stream decode, 75GB of KV cache, and 12GB of headroom. The bigger allocation only buys you more concurrent requests, so if the Spark is doing anything else at all, 0.80 is the safer number (weights are 21.5GB, engine init took just under 2 minutes). Same prompts, same method as the Ollama tests: ![Ollama vs vLLM decode and prefill speeds for Nemotron 3.5 Lightning on DGX Spark](/img/blog/nemotron-3-5-lightning-on-dgx-spark/ollama-vs-vllm.png) | Test | Ollama (GGUF Q4_K_M) | vLLM (NVFP4 + DSpark) | |---|---|---| | Short prompt, 500 token decode | 72 tok/s | **108 tok/s** | | Agent-style prompt, decode | 86 tok/s | **95 to 98 tok/s** | | Long prompt, decode after prefill | 84 to 87 tok/s | **88 to 90 tok/s** | | Prefill, uncached | ~2,600 tok/s | **~5,400 tok/s** | The raw vLLM runs: ``` short run 1: decode 500 tok @ 108.31 tok/s short run 2: decode 500 tok @ 108.26 tok/s short run 3: decode 500 tok @ 108.22 tok/s long run 1: prompt 21218 tok, TTFT 3.93s (~5,394 tok/s prefill) | decode @ 89.92 tok/s agent runs: decode @ 98.28 / 94.31 / 95.90 tok/s ``` So the recommended path is roughly 1.5x the Ollama decode speed and about 2x the prefill, on the same hardware, and it holds ~90 tok/s with 21K tokens of context on the clock. The DSpark draft model is doing real work here: the one public comparison point, a sibling 30B-A3B NVFP4 on a Spark without speculative decoding, sits at 57 tok/s. One tuning note before someone asks: there are configs circulating that set 256K context and `num_speculative_tokens: 7` instead of 3, some on vLLM nightly builds. I tested the draft depth both ways on both the release image and a current nightly, four combinations total. Depth 3 gives about 108 tok/s single stream on the release image and on EXO's exact nightly alike; depth 7 gives 80 to 93 on both. The mechanics: every verification pass pays for all drafted tokens whether or not they get accepted, and on these prompts the acceptance rate at depth 7 does not cover the extra cost. EXO's numbers come from an 884-task agentic suite where longer contexts may shift that tradeoff, but for single-stream use on this box, 3 is the number I would run. Two smaller things this test surfaced. First, my "8,194 token prompt" in the Ollama runs was actually Ollama silently truncating a much longer prompt to fit its context setting; vLLM processed the full 21,218 tokens. The per-token prefill rates stand, but it is a good reminder to check `prompt_eval_count` when you benchmark Ollama. Second, these vLLM numbers came through the completions endpoint without the chat template, so the model skipped its long thinking pass on the agent prompt; decode speed is comparable, token counts are not, so I am not re-running the token-efficiency comparison here. ### Benchmark it yourself The community leaderboard for this box is [Spark Arena](https://spark-arena.com), and it standardizes on llama-benchy sweeps. To run the same sweep against the server above, raise `--max-model-len` to 262144 in the serve command so the deeper context points fit, then: ```bash pip install llama-benchy llama-benchy --base-url http://localhost:8000/v1 --model nemotron-3.5-lightning \ --depth 0 4096 8192 16384 32768 65535 100000 \ --pp 2048 --tg 128 --enable-prefix-caching \ --concurrency 1 2 5 10 --save-result results.csv ``` One reading tip for leaderboard numbers, there or anywhere: check whether a tok/s figure is single-stream or an aggregate peak across concurrent requests. The same recipe that decodes around 110 tok/s for one user can show 230+ tok/s summed over ten parallel streams, and the two numbers answer different questions. ## Reality check A few things to know before you use these numbers: 1. **Runtime matters as much as model.** The same checkpoint family spans 72 to 108 tok/s on this box depending on how you serve it. Quote numbers with their runtime attached. 2. **Single stream only.** These are one-user interactive numbers. If you want aggregate throughput, that is a vLLM or TensorRT-LLM concurrency story (NVIDIA published [deployment guides](https://developer.nvidia.com/blog/nvidia-nemotron-3-5-lightning-delivers-fast-accurate-specialized-task-execution-for-long-running-agents/) for both). 3. **Launch-day software.** Ollama support is hours old. Expect the numbers to move as kernels and the runtime settle. I will update if they move meaningfully. There is one other public number to compare against: the [NVIDIA forum benchmark of Nemotron 3 Nano Omni 30B-A3B](https://forums.developer.nvidia.com/t/benchmark-nvidia-nemotron-3-nano-omni-30b-a3b-reasoning-nvfp4/368566), an architecturally similar 30B-A3B, at 56.96 tok/s decode on a Spark via vLLM NVFP4 without speculative decoding. A 55 to 60 tok/s baseline for this size class, lifted into the 70s and 80s by MTP drafting, matches what I measured. ## Why I care about this model specifically The interesting part is not the benchmark table, it is the division of labor NVIDIA is pushing. Alongside Lightning they released [NeMo Switchyard](https://developer.nvidia.com/blog/route-ai-agent-workloads-across-models-with-nvidia-nemo-switchyard/), an open source model router: plans go up to a frontier model, execution comes down to Lightning. LangChain measured a 74% cost reduction routing between Lightning and Claude Opus 4.8 with only 7% of calls escalating to the frontier model, at about a 6 point accuracy tradeoff. A 3B-active model decoding at 108 tok/s on a desktop box fits that execution layer well. The follow-up I want to do next: Lightning on the Spark as the local execution model, a frontier model in the cloud for planning, and Switchyard routing between them. If that sounds interesting, subscribe. --- # HAMi Dynamic MIG on RTX PRO 6000: A Live Kubernetes Test - Canonical: https://blog.kubesimplify.com/dynamic-mig-in-kubernetes-with-hami - Published: 2026-08-11 - Summary: Hands-on HAMi Dynamic MIG test on Kubernetes and RTX PRO 6000 Blackwell: setup commands, real allocations, mixed profiles, reclamation, and recovery. [HAMi (Heterogeneous AI Computing Virtualization Middleware)](https://project-hami.io/docs/next) Dynamic MIG lets a Kubernetes pod request GPU memory and receive a real [NVIDIA Multi-Instance GPU (MIG)](https://docs.nvidia.com/datacenter/tesla/mig-user-guide/latest/introduction.html) hardware instance created for that pod. In our RTX PRO 6000 Blackwell test, HAMi selected the smallest legal profile, packed different profiles on one card, reclaimed only the deleted pod's instance, and preserved a live allocation across a device-plugin restart. The first two posts in this series explored opposite ends of GPU sharing. In [the MIG deep dive](/blog/slicing-gpus-in-kubernetes-with-nvidia-mig), we carved Blackwell cards into hardware-isolated slices. MIG gives each instance dedicated memory, cache, and compute resources, but a static layout makes profile changes an operational task. In [the HAMi vGPU post](/blog/sharing-gpus-in-kubernetes-with-hami), we requested exact memory and compute fractions through software-enforced `hami-core` mode. That improves packing density, but it is not a hardware isolation boundary. The natural third question is: can a Kubernetes pod request GPU memory, receive a real MIG instance, and let HAMi manage that instance from creation to cleanup? We reran that experiment from scratch on August 11, 2026, on an eight-GPU RTX PRO 6000 Blackwell server. This post follows HAMi's topology-aware, per-pod Dynamic MIG implementation from installation through cleanup. The results were straightforward: - Four 8,000 MiB requests became four `1g.24gb` instances on one GPU. - A fifth identical pod moved to a second GPU after we made that GPU available to HAMi. - A `1g.24gb` workload and a `2g.48gb` workload ran together on the same card. - Deleting the small workload reclaimed only its MIG instance; the neighboring CUDA workload continued. - A valid active allocation survived a HAMi device-plugin restart with the same MIG UUID and continued CUDA progress. > **Version and migration note:** [PR #2378](https://github.com/Project-HAMi/HAMi/pull/2378) is merged, and this post tests the resulting per-pod implementation at [commit `634bf2b32e68`](https://github.com/Project-HAMi/HAMi/commit/634bf2b32e68e07d3fbcbd6da1ee079392fc07c1). At the time of this rerun, the latest tagged release was `v2.9.0`, which predates that implementation, so reproducing the lab requires the pinned source build below. Once HAMi publishes a release containing PR #2378, prefer its matching official chart and image. Existing `knownMigGeometries` users should follow the [migration guide](https://github.com/Project-HAMi/HAMi/blob/634bf2b32e68e07d3fbcbd6da1ee079392fc07c1/docs/develop/dynamic-mig-migration.md); the walkthrough below covers only the merged per-pod design. ## What is HAMi Dynamic MIG in Kubernetes? A pod asks for GPU memory. HAMi chooses the smallest allowed MIG profile that has enough NVML-reported memory and a legal free placement, then creates that exact GPU Instance (GI) and Compute Instance (CI) for the pod. {{dynamic-mig-lifecycle-animation}} The workload request remains small: ```yaml metadata: annotations: nvidia.com/vgpu-mode: "mig" spec: schedulerName: hami-scheduler containers: - resources: limits: nvidia.com/gpu: 1 nvidia.com/gpumem: 8000 ``` On a `hami-core` node, `gpumem: 8000` is a software-enforced memory limit. On a Dynamic MIG node, it is a minimum memory requirement used to select a hardware profile. This GPU has no 8 GB profile, so the 8,000 MiB request receives `1g.24gb`; the container sees the complete 24,192 MiB MIG instance. Three details matter: - `nvidia.com/vgpu-mode: "mig"` explicitly selects the MIG path. - Profile selection is memory-driven. `nvidia.com/gpucores` does not select a MIG profile; the profile fixes the compute fraction in hardware. - NVIDIA's legal profile sizes and placements still apply. HAMi automates those rules; it does not remove them. ## Why use Dynamic MIG instead of HAMi-Core? NVIDIA MIG divides a supported GPU into hardware-isolated instances. Each instance receives dedicated memory paths, cache, and compute resources. That is a stronger boundary than several workloads sharing one full GPU through software. | | HAMi-Core | Topology-aware Dynamic MIG | | --- | --- | --- | | Isolation | Software-enforced sharing | NVIDIA MIG hardware instance | | Size choices | Fine-grained memory and core fractions | Fixed NVIDIA profiles | | Pod request | `gpu`, `gpumem`, optional `gpucores` | `gpu`, `gpumem`, and MIG mode annotation | | Lifecycle | Software allocation | Create and reclaim one GI/CI per pod | | Best fit | High packing flexibility | Stronger workload isolation | MIG can be part of a multi-tenant security design, but it does not make a platform secure or compliant by itself. Identity, admission, network, storage, runtime, and host controls still matter. ## Which MIG profiles does the RTX PRO 6000 support? NVIDIA's [supported MIG profile table](https://docs.nvidia.com/datacenter/tesla/mig-user-guide/latest/supported-mig-profiles.html) lists three profile sizes for the RTX PRO 6000 Blackwell Server Edition: | Profile | Approximate memory | SM fraction | Maximum instances | | --- | ---: | ---: | ---: | | `1g.24gb` | 24 GB | 1/4 | 4 | | `2g.48gb` | 48 GB | 1/2 | 2 | | `4g.96gb` | 96 GB | Full GPU | 1 | Profile rounding remains. An 8,000 MiB request receives the 24 GB profile, and the unused difference cannot be assigned to another pod inside that instance. The implementation tested here uses a profile allowlist as policy: ```yaml nvidia: migProfileAllowlist: - models: ["RTX PRO 6000 Blackwell Server Edition"] profiles: ["1g.24gb", "2g.48gb", "4g.96gb"] ``` For every allowed profile, the HAMi device plugin running on the GPU node asks [NVIDIA's Management Library (NVML)](https://docs.nvidia.com/deploy/nvml-api/nvml-api-reference.html) for memory, compute metadata, instance count, and legal placements. The scheduler then chooses the smallest profile that satisfies the request and fits without overlapping a live placement. ## Lab environment | Component | Tested value | | --- | --- | | Server | Utho single-node GPU server | | GPUs | 8 × NVIDIA RTX PRO 6000 Blackwell Server Edition | | GPU memory | 97,887 MiB per physical GPU | | NVIDIA driver | `610.43.02` | | Kubernetes | `v1.35.6` | | OS | Ubuntu 24.04.4 LTS, kernel `6.8.0-100-generic` | | Container runtime | containerd `2.2.1` | | HAMi source | `634bf2b32e68e07d3fbcbd6da1ee079392fc07c1` | The run started with MIG mode enabled on all eight cards and no active CUDA processes: ```bash nvidia-smi \ --query-gpu=index,name,uuid,driver_version,memory.total,mig.mode.current \ --format=csv nvidia-smi \ --query-compute-apps=gpu_uuid,pid,process_name,used_gpu_memory \ --format=csv ``` The first command inventories the hardware and MIG mode. It returned all eight cards: ```text index, name, uuid, driver_version, memory.total [MiB], mig.mode.current 0, NVIDIA RTX PRO 6000 Blackwell Server Edition, GPU-8b89b58e-b427-108d-ac50-06138d78fe78, 610.43.02, 97887 MiB, Enabled 1, NVIDIA RTX PRO 6000 Blackwell Server Edition, GPU-03a041b7-8abf-360a-d1a2-dfd70188cd5f, 610.43.02, 97887 MiB, Enabled 2, NVIDIA RTX PRO 6000 Blackwell Server Edition, GPU-ba09367f-dd50-32ca-e988-7ff66bece885, 610.43.02, 97887 MiB, Enabled 3, NVIDIA RTX PRO 6000 Blackwell Server Edition, GPU-30512c46-708b-f374-5698-ee24be6cd626, 610.43.02, 97887 MiB, Enabled 4, NVIDIA RTX PRO 6000 Blackwell Server Edition, GPU-4c395b7a-a7e6-d90f-1ced-d96e8dd68288, 610.43.02, 97887 MiB, Enabled 5, NVIDIA RTX PRO 6000 Blackwell Server Edition, GPU-04dc48d7-7048-aef5-ad36-f5db716e7668, 610.43.02, 97887 MiB, Enabled 6, NVIDIA RTX PRO 6000 Blackwell Server Edition, GPU-f4f5db98-143f-0a8d-47ce-956fab39a736, 610.43.02, 97887 MiB, Enabled 7, NVIDIA RTX PRO 6000 Blackwell Server Edition, GPU-f4c61521-240a-da09-2787-e576034e197e, 610.43.02, 97887 MiB, Enabled ``` The second command checks for active compute processes before any lifecycle operation. Its real output contained only the header: ```text gpu_uuid, pid, process_name, used_gpu_memory [MiB] ``` That means no CUDA compute process was active at that instant. > **Host versus cluster commands:** run host-level `nvidia-smi`, Docker, and `ctr` commands on the GPU node. Run `kubectl` and Helm from any machine whose kubeconfig targets the intended cluster. In this lab they all ran on the single RTX node. ## Pin the HAMi build before testing This step exposed the easiest version trap in the entire lab. At commit `634bf2b`, the checked-out chart and source contain topology-aware Dynamic MIG, but the chart metadata and default image tag still say `2.9.0`. Rendering that chart without image overrides deploys the `v2.9.0` binaries, not the code in the checkout. Helm's chart or app version is therefore not proof of the running binary. We built the commit and used the same pinned image for the scheduler extender, device plugin, and monitor. ### 1. Back up and inventory the existing installation Before changing a running lab, capture both Helm's saved values and the live objects. They can differ: ```bash export NODE=utho-gpu-rtxpro6000-8-62383 export LAB=/root/hami-dynamic-mig-rerun-2026-08-11 mkdir -p "$LAB" helm get values hami -n hami-system --all -o yaml \ > "$LAB/helm-values-before.yaml" helm get manifest hami -n hami-system \ > "$LAB/helm-manifest-before.yaml" kubectl get configmaps -n hami-system -o yaml \ > "$LAB/live-configmaps-before.yaml" kubectl get node "$NODE" -o yaml \ > "$LAB/node-before.yaml" kubectl get pods -A --field-selector spec.nodeName="$NODE" -o wide nvidia-smi -L > "$LAB/nvidia-smi-L-before.txt" ``` After taking the backups, we stopped every GPU workload and verified that the entire node was idle before continuing. ### 2. Build the exact source snapshot ```bash export HAMI_SHA=634bf2b32e68e07d3fbcbd6da1ee079392fc07c1 export HAMI_TAG=master-634bf2b32e68 export HAMI_IMAGE=localhost/hami-dynamic-mig:$HAMI_TAG git clone --recurse-submodules \ https://github.com/Project-HAMi/HAMi.git "$LAB/HAMi" git -C "$LAB/HAMi" checkout --detach "$HAMI_SHA" git -C "$LAB/HAMi" submodule update --init --recursive make -C "$LAB/HAMi" docker \ IMG_NAME=localhost/hami-dynamic-mig \ IMG_TAG="$HAMI_TAG" \ VERSION="$HAMI_TAG" \ TARGET_PLATFORMS=linux/amd64 docker image inspect "$HAMI_IMAGE" \ --format='ID={{.Id}} Architecture={{.Architecture}} SizeBytes={{.Size}}' ``` The final command verifies what was built. Our result was: ```text ID=sha256:0ddda56e333ff74e52d9908e00b85e7860cf4694fc09951aaa178e8c8e6dde76 Architecture=amd64 SizeBytes=411671341 ``` For a normal multi-node cluster, push that immutable tag to a registry every target node can reach. Our single-node lab instead imported the image into containerd: ```bash docker save --output "$LAB/hami-$HAMI_TAG.tar" "$HAMI_IMAGE" sudo ctr --namespace k8s.io images import "$LAB/hami-$HAMI_TAG.tar" sudo ctr --namespace k8s.io images list | grep -F "$HAMI_IMAGE" ``` That local import is suitable only because the scheduler, plugin, and both tested GPUs lived on the same node. `localhost/...` is not an image that another node can pull. On multiple nodes, push the pinned build to a registry or import it on every target node. ### 3. Use current Dynamic MIG values The relevant parts of our `hami-current-mig-values.yaml` were: ```yaml global: imageTag: master-634bf2b32e68 scheduler: defaultSchedulerPolicy: nodeSchedulerPolicy: binpack gpuSchedulerPolicy: binpack extender: image: registry: localhost repository: hami-dynamic-mig tag: master-634bf2b32e68 pullPolicy: Never devicePlugin: image: registry: localhost repository: hami-dynamic-mig tag: master-634bf2b32e68 pullPolicy: Never monitor: image: registry: localhost repository: hami-dynamic-mig tag: master-634bf2b32e68 pullPolicy: Never # This is NVIDIA's static resource-exposure strategy. # Keep it separate from HAMi's per-node operating mode below. migStrategy: none nodeConfiguration: config: | { "nodeconfig": [ { "name": "utho-gpu-rtxpro6000-8-62383", "operatingmode": "mig", "devicememoryscaling": 1, "devicecorescaling": 1, "devicesplitcount": 10, "preconfigureddevicememory": 0, "enablenumatopology": false, "migstrategy": "none", "filterdevices": { "uuid": [], "index": [0, 1, 2, 3, 5, 6, 7] }, "enablegetpreferredallocation": false } ] } # Empty means: use the device-config.yaml bundled in this pinned chart. device-config: content: "" ``` Two similarly named settings do different jobs: - `operatingmode: "mig"` activates HAMi Dynamic MIG for this node. - Top-level `devicePlugin.migStrategy: none` tells the NVIDIA device-plugin path not to publish pre-created MIG resources separately. The workload still requests the parent resource `nvidia.com/gpu` and HAMi creates the MIG instance dynamically. Also, `filterdevices.index` is an **exclusion list**. The initial list excluded every card except GPU 4. It did not protect the excluded GPUs from all startup actions; we return to that safety boundary later. ### 4. Render before installing ```bash helm lint "$LAB/HAMi/charts/hami" \ -f "$LAB/hami-current-mig-values.yaml" helm template hami "$LAB/HAMi/charts/hami" \ --namespace hami-system \ --kube-version 1.35.6 \ -f "$LAB/hami-current-mig-values.yaml" \ > "$LAB/rendered-current-hami.yaml" grep -n -A 25 'migProfileAllowlist' \ "$LAB/rendered-current-hami.yaml" grep -n -E 'image:|imagePullPolicy:' \ "$LAB/rendered-current-hami.yaml" ``` The rendered manifest contained the RTX profile allowlist and the pinned image in all three HAMi containers. No `projecthami/hami:v2.9.0` runtime image remained. Avoid `--reuse-values` here. A saved per-component tag takes precedence over `global.imageTag`, so a stale custom plugin image can survive even when the global tag looks correct. ### 5. Perform the controlled lab handover > **Destructive lab step:** we used a fresh reinstall only after every GPU pod and process on the single node was gone. Existing clusters should follow the linked migration guide instead of treating `helm uninstall` as a general upgrade procedure. ```bash helm uninstall hami -n hami-system --wait --timeout 5m helm upgrade --install hami "$LAB/HAMi/charts/hami" \ -n hami-system \ --create-namespace \ --reset-values \ -f "$LAB/hami-current-mig-values.yaml" \ --wait \ --timeout 10m kubectl get pods -n hami-system \ -o custom-columns='POD:.metadata.name,CONTAINERS:.spec.containers[*].name,IMAGES:.spec.containers[*].image' ``` The live output confirmed that the scheduler extender, device plugin, and monitor all used: ```text localhost/hami-dynamic-mig:master-634bf2b32e68 ``` The monitor had one transient CDI `StartError` referring to a stale MIG UUID during the handover. Kubernetes retried it, and both plugin containers became ready. We checked the previous container state instead of hiding that transition. ## How does HAMi discover legal MIG placements through NVML? The node registration annotation is the clearest view of what the plugin learned from the driver: ```bash kubectl get node "$NODE" -o json | jq ' .metadata.annotations["hami.io/node-nvidia-register"] | fromjson | .[] | {id, index, type, mode, count, migProfiles} ' ``` The live discovery was: | Profile | `memoryMB` | Core | `sliceCount` | Legal NVML placements (`start`, `size`) | | --- | ---: | ---: | ---: | --- | | `1g.24gb` | 24,192 | 25 | 1 | `(0,3)`, `(3,3)`, `(6,3)`, `(9,3)` | | `2g.48gb` | 48,512 | 50 | 2 | `(0,6)`, `(6,6)` | | `4g.96gb` | 97,408 | 100 | 4 | `(0,12)` | [NVML reports each legal placement as `start` and `size`](https://docs.nvidia.com/deploy/nvml-api/structnvmlGpuInstancePlacement__t.html): `start` is the index of the first occupied memory slice, and `size` is the number of memory slices occupied. Together they describe the half-open interval `[start, start + size)`. On this RTX PRO 6000, the reported placement range was `[0,12)`; these values are not GiB and are specific to this GPU. Also, `size` is not the same thing as `sliceCount`: `1g.24gb` has `sliceCount: 1` but placement `size: 3` here. The registered `count: 4` is a coarse maximum derived from the profiles. It does not mean every arbitrary combination of four profiles fits. The placement arrays and current occupancy determine real capacity. ## A repeatable CUDA workload The test container repeatedly runs NVIDIA's `vectorAdd` sample and increments `/tmp/gpu-progress` after each successful run. That gives us a better health check than a sleeping container. For readability, this Deployment combines the same workload template we used for the single-pod and packing tests. Save it as `mig-small-pack.yaml` and replace the node name if yours differs: ```yaml apiVersion: apps/v1 kind: Deployment metadata: name: mig-small-pack namespace: hami-mig-retest spec: replicas: 1 selector: matchLabels: app: mig-small-pack template: metadata: labels: app: mig-small-pack annotations: nvidia.com/vgpu-mode: "mig" hami.io/gpu-scheduler-policy: "binpack" spec: schedulerName: hami-scheduler nodeSelector: kubernetes.io/hostname: utho-gpu-rtxpro6000-8-62383 containers: - name: cuda image: nvcr.io/nvidia/k8s/cuda-sample:vectoradd-cuda12.5.0-ubuntu22.04 imagePullPolicy: IfNotPresent command: - bash - -lc - | set -euo pipefail n=0 echo 0 > /tmp/gpu-progress while true; do /cuda-samples/vectorAdd > /tmp/vectoradd.last 2>&1 n=$((n + 1)) echo "$n" > /tmp/gpu-progress.next mv /tmp/gpu-progress.next /tmp/gpu-progress done resources: limits: nvidia.com/gpu: 1 nvidia.com/gpumem: 8000 ``` Create the namespace, apply the workload, and wait for the pod to become ready: ```bash kubectl create namespace hami-mig-retest kubectl apply -f mig-small-pack.yaml kubectl rollout status deployment/mig-small-pack \ -n hami-mig-retest --timeout=180s POD=$(kubectl get pods -n hami-mig-retest \ -l app=mig-small-pack \ -o jsonpath='{.items[0].metadata.name}') ``` ## Test 1: 8,000 MiB becomes one `1g.24gb` instance HAMi writes its controller-owned identity to `hami.io/vgpu-mig-allocations`. Users should inspect this annotation, but never create or edit it: ```bash kubectl get pod "$POD" -n hami-mig-retest -o json | jq '.metadata.annotations["hami.io/vgpu-mig-allocations"] | fromjson' ``` Our first allocation was: ```json [ { "containerIndex": 0, "deviceIndex": 0, "gpuUUID": "GPU-4c395b7a-a7e6-d90f-1ced-d96e8dd68288", "profile": "1g.24gb", "placement": {"start": 9, "size": 3}, "migUUID": "MIG-a5fa6120-f6fa-51b6-9820-a42112640629", "gpuInstanceID": 6, "computeInstanceID": 0 } ] ``` The host and container agreed about the device: ```bash # On the GPU node nvidia-smi -L # Through the container's device view kubectl exec -n hami-mig-retest "$POD" -- nvidia-smi -L ``` Both showed one `1g.24gb` instance with UUID `MIG-a5fa...`. The placement happened to start at `9`; the first allocation is not required to start at `0`. Finally, we verified that the CUDA loop was doing work: ```bash before=$(kubectl exec -n hami-mig-retest "$POD" -- cat /tmp/gpu-progress) sleep 3 after=$(kubectl exec -n hami-mig-retest "$POD" -- cat /tmp/gpu-progress) printf 'before=%s after=%s\n' "$before" "$after" test "$after" -gt "$before" ``` ```text before=75 after=77 ``` ## Test 2: four legal placements, then real saturation Scale the same Deployment to four replicas: ```bash kubectl scale deployment/mig-small-pack \ -n hami-mig-retest --replicas=4 kubectl rollout status deployment/mig-small-pack \ -n hami-mig-retest --timeout=180s nvidia-smi -L ``` GPU 4 now contained four `1g.24gb` instances. The allocation annotations used all four legal starts: ```bash kubectl get pods -n hami-mig-retest -l app=mig-small-pack -o json | jq -r ' ["PARENT_GPU", "PROFILE", "START", "SIZE"], ( .items[] | (.metadata.annotations["hami.io/vgpu-mig-allocations"] | fromjson | .[0]) as $a | [$a.gpuUUID, $a.profile, ($a.placement.start | tostring), ($a.placement.size | tostring)] ) | @tsv ' ``` ```text PARENT_GPU PROFILE START SIZE GPU-4c395b7a-a7e6-d90f-1ced-d96e8dd68288 1g.24gb 0 3 GPU-4c395b7a-a7e6-d90f-1ced-d96e8dd68288 1g.24gb 3 3 GPU-4c395b7a-a7e6-d90f-1ced-d96e8dd68288 1g.24gb 6 3 GPU-4c395b7a-a7e6-d90f-1ced-d96e8dd68288 1g.24gb 9 3 ```
Four pods fill the four legal 1g.24gb placements on GPU 4.
With only GPU 4 registered, scaling to five did **not** overcommit the card: ```bash kubectl scale deployment/mig-small-pack \ -n hami-mig-retest --replicas=5 kubectl get pods -n hami-mig-retest -o wide ``` Four pods remained `Running`; the fifth stayed `Pending` and unbound. Its event included: ```text 0/1 nodes are available: 1 1/1 CardTimeSlicingExhausted. ``` That inherited event label is misleading—the test did not use time slicing. Here it meant that no legal MIG placement remained on any registered GPU. Scale back to four before the next test: ```bash kubectl scale deployment/mig-small-pack \ -n hami-mig-retest --replicas=4 ``` ## Test 3: Can mixed MIG profiles share one physical GPU? The topology-aware implementation can place different profiles together whenever NVML reports legal, non-overlapping placements.
A live terminal recording: requests for 8,000 MiB and 30,000 MiB become 1g.24gb and 2g.48gb instances on the same GPU. Each instance is reclaimed when its requesting pod is deleted; the recording is shown at 2.5× speed to shorten the waits.
We cleared the small-pod Deployment, then created an 8,000 MiB pod and a 30,000 MiB pod. Both used the same CUDA loop and were pinned to GPU 4 with `nvidia.com/use-gpuuuid` so the test measured one physical card: ```bash kubectl scale deployment/mig-small-pack \ -n hami-mig-retest --replicas=0 kubectl wait -n hami-mig-retest \ --for=delete pod -l app=mig-small-pack --timeout=180s ``` This Bash helper creates the same pod twice; only its name and memory request change: ```bash export GPU4=GPU-4c395b7a-a7e6-d90f-1ced-d96e8dd68288 create_mig_pod() { local name="$1" local memory="$2" kubectl apply -f - < /tmp/gpu-progress while true; do /cuda-samples/vectorAdd > /tmp/vectoradd.last 2>&1 n=\$((n + 1)) echo "\$n" > /tmp/gpu-progress.next mv /tmp/gpu-progress.next /tmp/gpu-progress done resources: limits: nvidia.com/gpu: 1 nvidia.com/gpumem: ${memory} EOF } create_mig_pod mixed-small 8000 create_mig_pod mixed-large 30000 kubectl wait -n hami-mig-retest --for=condition=Ready \ pod/mixed-small pod/mixed-large --timeout=180s ``` We inspected the controller-owned allocation records with: ```bash kubectl get pods mixed-small mixed-large -n hami-mig-retest -o json | jq -r ' ["POD", "PROFILE", "START", "SIZE", "MIG_UUID", "GI", "CI"], ( .items | sort_by(.metadata.name)[] | . as $pod | ($pod.metadata.annotations["hami.io/vgpu-mig-allocations"] | fromjson | .[0]) as $a | [ $pod.metadata.name, $a.profile, ($a.placement.start | tostring), ($a.placement.size | tostring), $a.migUUID, ($a.gpuInstanceID | tostring), ($a.computeInstanceID | tostring) ] ) | @tsv ' ``` The live allocation table was: ```text POD PROFILE START SIZE MIG_UUID GI CI mixed-large 2g.48gb 0 6 MIG-b23491d8-d784-58d9-bcfa-3c171ead22da 1 0 mixed-small 1g.24gb 9 3 MIG-a5fa6120-f6fa-51b6-9820-a42112640629 6 0 ``` The intervals `[0,6)` and `[9,12)` do not overlap, so both profiles could coexist. `nvidia-smi -L` showed one `2g.48gb` and one `1g.24gb` instance under GPU 4. Both CUDA loops advanced during the same three-second window: ```text small: 64 -> 67 large: 37 -> 39 PASS: both mixed-profile CUDA workloads progressed ``` ## Test 4: Does HAMi reclaim only the deleted pod's MIG instance? Before deleting the small pod, we recorded the large pod's progress. Then we deleted only `mixed-small` and polled the host until its `1g.24gb` instance disappeared: ```bash large_before=$(kubectl exec -n hami-mig-retest mixed-large -- \ cat /tmp/gpu-progress) kubectl delete pod mixed-small -n hami-mig-retest # Reclamation is asynchronous; poll instead of assuming delete is instant. watch -n 1 nvidia-smi -L ``` The host retained only: ```text MIG 2g.48gb Device 0: (UUID: MIG-b23491d8-d784-58d9-bcfa-3c171ead22da) ``` The neighboring CUDA workload continued: ```text large: 61 -> 94 PASS: 2g workload survived 1g reclamation ``` We produced that check with: ```bash large_after=$(kubectl exec -n hami-mig-retest mixed-large -- \ cat /tmp/gpu-progress) printf 'large: %s -> %s\n' "$large_before" "$large_after" test "$large_after" -gt "$large_before" \ && echo 'PASS: 2g workload survived 1g reclamation' ``` HAMi does not synchronously destroy the instance inside the `kubectl delete` call. Its annotation reconciler notices that the reservation is no longer active and removes the tracked CI and GI shortly afterward. We also recreated a `1g.24gb` instance at the freed placement. On this GPU and driver, it received the same `MIG-a5fa...` UUID. The UUID's observed disappearance proved reclamation; its later reappearance proved placement reuse. A MIG UUID is not a generation counter, so do not require a different UUID as proof of recreation. ## Test 5: Does a live allocation survive a HAMi device-plugin restart? This is an advanced and disruptive controller test, not a normal workload step. We kept `mixed-large` active, recorded its progress and MIG UUID, then replaced the device-plugin pod: ```bash OLD_DP_POD=$(kubectl get pods -n hami-system \ -l app.kubernetes.io/component=hami-device-plugin \ -o jsonpath='{.items[0].metadata.name}') progress_before=$(kubectl exec -n hami-mig-retest mixed-large -- \ cat /tmp/gpu-progress) kubectl delete pod "$OLD_DP_POD" -n hami-system kubectl rollout status daemonset/hami-device-plugin \ -n hami-system --timeout=180s ``` The replacement plugin logged: ```text mig init: resolved startup layout inUseGPUs=[4] resetGPUs=[0,1,2,3,5,6,7] ``` With a complete runtime allocation annotation, it classified GPU 4 as in use, left that card untouched during startup cleanup, verified the live profile and identity through NVML, and adopted the allocation. The same `2g.48gb` UUID remained, and the workload continued: ```text progress: 115 -> 187 PASS: MIG UUID and CUDA workload survived device-plugin restart ``` The verification checked both the device and the progress counter: ```bash nvidia-smi -L | grep -F 'MIG-b23491d8-d784-58d9-bcfa-3c171ead22da' progress_after=$(kubectl exec -n hami-mig-retest mixed-large -- \ cat /tmp/gpu-progress) printf 'progress: %s -> %s\n' "$progress_before" "$progress_after" test "$progress_after" -gt "$progress_before" ``` This proves the tested happy path for a valid annotation. It is not a guarantee that incomplete or malformed state can always be adopted. > **Node-wide safety warning:** `filterdevices` limits HAMi registration and scheduling, but at this commit it does not limit Dynamic MIG startup cleanup. The log shows that startup reconciled all eight physical GPUs, including filtered ones. Inventory and drain the entire node before the first install or a plugin restart; filtering a GPU is not a protection boundary. ## Test 6: What happens when one GPU's legal MIG placements are full? After deleting the mixed-profile workload and confirming no MIG instances remained, we changed the exclusion list from: ```bash kubectl delete pod mixed-large -n hami-mig-retest until ! nvidia-smi -L | grep -q '^ MIG '; do sleep 2 done ``` ```json "index": [0, 1, 2, 3, 5, 6, 7] ``` to: ```json "index": [0, 1, 2, 3, 6, 7] ``` That made GPUs 4 and 5 available to HAMi. We applied the values and explicitly restarted the plugin only after confirming the whole node was idle: ```bash helm upgrade hami "$LAB/HAMi/charts/hami" \ -n hami-system \ --reset-values \ -f "$LAB/hami-current-mig-values.yaml" \ --wait \ --timeout 10m # This configuration change did not trigger a plugin rollout by itself. kubectl rollout restart daemonset/hami-device-plugin -n hami-system kubectl rollout status daemonset/hami-device-plugin \ -n hami-system --timeout=180s ``` This revealed a chart behavior worth knowing: Helm successfully updated the node-configuration ConfigMap, but the DaemonSet template did not checksum that ConfigMap. Registration stayed unchanged until we restarted the plugin. The node then registered GPUs 4 and 5 in MIG mode. Scaling the small-pod Deployment to five produced this real distribution: ```bash kubectl scale deployment/mig-small-pack \ -n hami-mig-retest --replicas=5 kubectl rollout status deployment/mig-small-pack \ -n hami-mig-retest --timeout=180s kubectl get pods -n hami-mig-retest -l app=mig-small-pack -o json | jq -r ' ["POD", "PARENT_GPU", "PROFILE", "START", "MIG_UUID"], ( .items | sort_by(.metadata.name)[] | . as $pod | ($pod.metadata.annotations["hami.io/vgpu-mig-allocations"] | fromjson | .[0]) as $a | [ $pod.metadata.name, $a.gpuUUID, $a.profile, ($a.placement.start | tostring), $a.migUUID ] ) | @tsv ' ``` ```text POD PARENT_GPU PROFILE START mig-small-pack-6f5b7bd7b-dwld2 GPU-4c395b7a-a7e6-d90f-1ced-d96e8dd68288 1g.24gb 3 mig-small-pack-6f5b7bd7b-g72fd GPU-4c395b7a-a7e6-d90f-1ced-d96e8dd68288 1g.24gb 0 mig-small-pack-6f5b7bd7b-jgql2 GPU-04dc48d7-7048-aef5-ad36-f5db716e7668 1g.24gb 9 mig-small-pack-6f5b7bd7b-rlxbn GPU-4c395b7a-a7e6-d90f-1ced-d96e8dd68288 1g.24gb 9 mig-small-pack-6f5b7bd7b-vjfv9 GPU-4c395b7a-a7e6-d90f-1ced-d96e8dd68288 1g.24gb 6 ``` GPU 4 held all four legal `1g.24gb` placements. The fifth pod received a legal placement on GPU 5. Again, its first placement happened to start at `9`; HAMi does not promise to allocate starts in numerical order. ## Cleanup and final state We deleted the test namespace and waited for all per-pod MIG instances to be reclaimed: ```bash kubectl delete namespace hami-mig-retest \ --wait=true --timeout=180s if nvidia-smi -L | grep -q '^ MIG '; then echo 'FAIL: MIG instances remain' nvidia-smi -L else echo 'PASS: no MIG instances remain' fi ``` Then we restored the original exclusion list, applied the values, and performed the same safe plugin restart. The final verification was: ```bash printf 'Registered GPU indices: ' kubectl get node "$NODE" -o json | jq -r ' .metadata.annotations["hami.io/node-nvidia-register"] | fromjson | map(.index) | join(",") ' if nvidia-smi -L | grep -q '^ MIG '; then echo 'MIG state: FAIL — instances remain' else echo 'MIG state: PASS — no instances remain' fi kubectl get pods -n hami-system ``` ```text Registered GPU indices: 4 MIG state: PASS — no instances remain NAME READY STATUS RESTARTS hami-device-plugin-6snlc 2/2 Running 0 hami-scheduler-74fbfcfbb5-qxftm 2/2 Running 0 ``` That left the lab in its intended baseline: only GPU 4 registered with HAMi, no test MIG instances, and both HAMi components healthy. ## Operational traps we hit ### Chart metadata is not the runtime version At the tested commit, the chart still defaults to `v2.9.0`. Pin and inspect the live images for the scheduler extender, device plugin, and monitor. Do not publish `latest`, and do not rely on Helm's app-version label. ### `operatingmode` is not `migStrategy` Use per-node `operatingmode: "mig"` for HAMi Dynamic MIG. Keep the Helm-level `devicePlugin.migStrategy` decision separate; changing only the similarly named field inside the JSON is not how this chart controls the NVIDIA plugin flag. ### `filterdevices` excludes registration, not startup mutation The exclusion list controls which GPUs HAMi advertises for scheduling. It does not isolate the other physical cards from startup reconciliation. Treat first installation and plugin restart as node-wide maintenance at this commit. ### A Helm upgrade may not restart the device plugin Changing `devicePlugin.nodeConfiguration.config` updated the ConfigMap but did not roll the DaemonSet in our test. Restart it deliberately, only after the node-wide safety check, then verify the registration annotation rather than trusting Helm's success message. ### Scheduler events can use inherited language `CardTimeSlicingExhausted` described exhausted MIG placements in this run; it did not mean HAMi silently switched to time slicing. Confirm the allocation annotation and host MIG state before interpreting a generic reason string. ### Reclamation is eventual, and UUIDs may be reused Poll the actual host state after pod deletion. The same placement can return the same MIG UUID, so disappearance between deletion and recreation is stronger lifecycle evidence than UUID inequality. ### Legal placement still controls mixed profiles Dynamic does not mean arbitrary. The scheduler can combine profiles only when their NVML placement intervals do not overlap, and active instances cannot be destroyed just to satisfy a new request. ### Homogeneous success is not a heterogeneous-node guarantee This node contained eight identical supported GPUs. Test mixed-model nodes separately; do not assume that filtering unsupported cards reproduces the same startup behavior. ## Conclusion Topology-aware Dynamic MIG kept the Kubernetes API simple while making the hardware lifecycle precise. An 8,000 MiB request selected `1g.24gb`; four legal placements filled GPU 4; a fifth pod used GPU 5; and `1g.24gb` plus `2g.48gb` occupied legal mixed placements on the same card. The most useful result was not just allocation. HAMi reclaimed the small pod's exact GI/CI while its neighbor kept computing, and a valid allocation survived device-plugin restart and adoption. Those are the behaviors a dynamic controller needs to prove. The caveats are equally important. Profile rounding and NVIDIA placement rules remain. Version alignment must be verified from live images. At this snapshot, plugin startup has a node-wide hardware scope even when only one GPU is registered, so controlled installation and restart procedures are mandatory. HAMi is a [CNCF Incubating project](https://www.cncf.io/projects/hami/). Its source is at [github.com/Project-HAMi/HAMi](https://github.com/Project-HAMi/HAMi). Existing installations can use the [pinned Dynamic MIG migration guide](https://github.com/Project-HAMi/HAMi/blob/634bf2b32e68e07d3fbcbd6da1ee079392fc07c1/docs/develop/dynamic-mig-migration.md) when moving to this per-pod implementation. If you are joining the series here, read the [static MIG deep dive](/blog/slicing-gpus-in-kubernetes-with-nvidia-mig) and the [HAMi software vGPU guide](/blog/sharing-gpus-in-kubernetes-with-hami) first. --- # Devin Outposts on Kubernetes: Why Your AI Agent Needs Your Cluster - Canonical: https://blog.kubesimplify.com/devin-outposts-on-kubernetes - Published: 2026-08-10 - Summary: Devin Outposts runs AI coding agent sessions as pods on your own Kubernetes cluster, with an open-source operator to manage the fleet. Ask a cloud coding agent to fix the bug that only reproduces against your staging database, and watch it fail in the most useless way possible: not loudly, but blindly. It clones your repo fine. Then `pip install` times out: your packages live on an internal Artifactory mirror. `docker build` fails: your base images are in a private Harbor registry. The integration tests can't run, because staging Postgres is a ClusterIP service with no public endpoint, and your security team is never going to IP-allowlist a SaaS vendor into it. The agent can still *edit* code. But it can't **verify** anything. And an agent that can't run the tests is just a very confident PR generator. The whole value of an autonomous agent is the run-fail-fix loop, and that loop dies at your firewall. On July 21, Cognition shipped their answer: [Devin Outposts](https://devin.ai/blog/introducing-devin-outposts). Their tagline is honest about the direction of travel: *"Some work can't come to the cloud, so we're bringing Devin to it."* And the part that made me sit up: they didn't just publish an API. They shipped an [open-source Kubernetes operator](https://github.com/CognitionAI/devin-outpost-k8s). I've been running it since launch week, first on kind, then on a two-node cluster running inside Apple Containers on my Mac (using [kiac](https://github.com/saiyam1814/kiac)). I want to walk you through what it actually is, because the architecture is genuinely clever and the Kubernetes fit is not an accident. ## What runs where (read this twice, it's the whole concept) Outposts does **not** self-host Devin. There is no model on your machines, no GPU requirement, no weights. The split is: | Component | Runs where | What it does | | --- | --- | --- | | Brain: model, inference, planning, session UI | Cognition's cloud, always | Decides *what* to do next | | Operator (one tiny pod) | Your cluster, always on | Watches the queue, claims sessions, creates worker pods | | Worker (one pod per active session) | Your cluster, only while a session runs | The computer Devin types on: shell, filesystem, git, your network position | A session is a ping-pong over a single outbound HTTPS/websocket connection: the brain says "run `pytest`", the worker pod runs it, the traceback streams back up, the model reads it, decides the fix, sends the next command. Your cluster never thinks. The cloud never executes. No inbound ports, no VPN, no public IPs: workers only dial out. The mental model that clicks for anyone who runs Kubernetes: **self-hosted CI runners, except the pipeline isn't static YAML; it's a model deciding the next step from the last output.** You already operate this exact pattern for GitLab or GitHub Actions runners. One honest caveat before we go further: the code context the agent reads still goes up to the model in the cloud. What stays home is *execution*: your secrets, your network access, your build artifacts, your hardware. If you need the model itself inside your walls, this isn't that product. ## The use case, end to end Here's the workflow that justifies the whole feature. Say your team wants Devin to burn down 15 boring backlog tickets overnight (dependency bumps, a flaky test, a deprecated API migration) and, like every real company, your builds need internal infrastructure. **Once:** create an outpost (a named queue) in Devin Cloud, install the operator in your cluster, and apply an `OutpostPool`, the CRD that binds queue to pod template. Mount your deploy keys and registry creds as Secrets in the worker template, cap it with `maxConcurrentSessions: 10`. **Per ticket, zero new steps:** a dev (or a schedule, or the API) starts a session in Devin Cloud and picks the outpost as the machine; it shows up in the UI right next to Ubuntu and Windows. Then: 1. The session enters your outpost's queue. 2. The operator, watching the queue over the API, atomically claims it and creates a worker pod from your template. 3. The pod dials out to Devin's cloud and starts executing tool calls: `git clone` (deploy key from your Secret), `pip install` (hits your internal mirror, since it's inside your network), `pytest` (reaches staging Postgres over ClusterIP, same reason). 4. Tests fail, traceback streams up, model edits, tests rerun. The verify loop is *alive* because execution sits next to your services. 5. Devin pushes the branch and opens the PR. Session ends. **The operator deletes the pod.** Fifteen tickets means up to ten concurrent pods, bin-packed by your scheduler, autoscaler adding a node for the overnight burst. In the morning: fifteen *tested* PRs and a cluster that's back to one 50-millicore operator pod. Devin's announcement lists the same pattern for GPU boxes (debug the training run where it crashed, with the real drivers and dataset) and Mac minis (Xcode builds, Devin building iOS apps end to end), but the internal-network case is the one I think most teams feel weekly. ## Why Kubernetes is the natural home for this You can serve an outpost from any box with `devin worker start`: a VM, even your laptop. But look at what the docs recommend for security: only give the agent sudo on machines *"dedicated to Devin and recycled after each session."* A long-lived VM is exactly not that. Sessions serially share an increasingly dirty machine; ticket 3's leftover `node_modules` pollutes ticket 9's build; one wedged process stalls the queue behind it. A pod, though? A pod **is** "dedicated and recycled after each session" *by construction*. Fresh sandbox on claim, deleted on termination. Add `runtimeClassName: gvisor`, an egress NetworkPolicy ("agents may reach staging, never prod"), resource limits so a runaway build can't starve the cluster, and a dedicated node pool. All standard Kubernetes machinery, all applying to AI agents now because the agent is just a pod. Cognition clearly knows this, because their fleet API is Kubernetes-shaped to a suspicious degree: queue entries are `metadata`/`spec`/`status` objects, you page with cursors then watch via SSE ("the standard Kubernetes-style list-then-watch pattern", as their own docs put it), delivery is at-least-once, and claims are an atomic compare-and-swap where losing the race is normal operation. Claims expire on a server deadline, so a dead worker's session self-heals back into the queue with no fleet-level health tracking. This is a reconciliation loop. Someone at Cognition writes controllers for a living. The operator (`devin-outpost-k8s`, Rust, MIT) packages that loop with one CRD: ```yaml apiVersion: outposts.cognition.com/v1alpha1 kind: OutpostPool metadata: name: kiac spec: poolId: "outpost_env-3a1abb1c2bb84512ad9aedb3ca0bf411" tokenSecretRef: name: kiac-pool-token key: token maxConcurrentSessions: 2 resume: policy: StartFresh # or FilesystemSnapshot / GkeSnapshot worker: template: # a real PodTemplateSpec, anything goes spec: containers: - name: devin-worker resources: requests: { cpu: "500m", memory: 1Gi } limits: { cpu: "2", memory: 2Gi } ``` Nice touches: the worker pod is assembled in three layers (your template, then the operator's managed fields, then your explicit overrides get the final say), so one operator serves heterogeneous pools: a GPU pool next to a general pool on spot nodes. Resume policies handle Devin's suspend/resume on ephemeral infra: `StartFresh` anywhere, `FilesystemSnapshot` keeps a per-session PVC, `GkeSnapshot` checkpoints the whole pod on GKE. Plus leader election, Prometheus metrics, a Helm chart, multi-arch images. ## I ran it on Kubernetes inside Apple Containers To prove the "any certified cluster" claim, I deployed it on my most exotic cluster: **kiac**, Kubernetes running in Apple Containers on a Mac, two arm64 Debian nodes on containerd. The account side takes a minute: Settings → Environment → Outposts. I ended up with two: `myhome` (macOS, for a worker running directly on the Mac) and `kiac` (Linux, served by the operator on the cluster). Platform matters here, more on that below. ![Devin Cloud outposts settings showing the myhome macOS outpost and the kiac Linux outpost](/img/blog/devin-outposts-on-kubernetes/outposts-settings.png) ```bash helm install outposts charts/devin-outposts-k8s -n devin-outposts --create-namespace kubectl -n devin-outposts create secret generic kiac-pool-token --from-literal=token=$TOKEN kubectl -n devin-outposts apply -f outpostpool.yaml ``` Thirty seconds later: ``` $ kubectl -n devin-outposts get opool NAME POOL PHASE CLAIMED AGE kiac outpost_env-3a1abb1c2bb84512ad9aedb3ca0bf411 Ready 0 29s ``` `Ready` means the operator is authenticated against the real API and watching the queue. On the other side, your cluster now literally appears as a machine option when starting a session, in the same menu as Ubuntu and Windows: ![Devin's virtual environment picker showing the kiac outpost selected alongside the hosted Ubuntu and Windows machines](/img/blog/devin-outposts-on-kubernetes/virtual-env-picker.png) Start a session with the outpost selected, and a worker pod appears in `kubectl get pods -w`. ### A bug, a workaround, and a quiet fix Not everything was smooth: at launch, my arm64 nodes crash-looped with `Error: no published devin-remote binary for linux-aarch64`, even though the arm64 binaries were published; the CLI just lacked the platform mapping. I built a small workaround, kept it in [saiyam1814/devin-outposts-arm64](https://github.com/saiyam1814/devin-outposts-arm64), and reported it upstream in [devin-outpost-k8s#2](https://github.com/CognitionAI/devin-outpost-k8s/issues/2). Today I see it fixed in the stock CLI, which is nice to see. Everything below runs the stock image with zero overrides. ### The money shot I had deployed a ClusterIP-only service (`inventory.demo.svc`, no ingress, no LoadBalancer, invisible to the internet) and prompted: > Curl http://inventory.demo.svc/api/items and write SERVICE.md documenting what this service returns. The worker pod went `Pending → ContainerCreating → Running` in under a second (pre-pulled image). Devin itself was skeptical ("that URL is a cluster-internal Kubernetes service name, so it may not resolve from my VM; I'll report what I get") and then it resolved anyway, because the agent's "VM" is a pod inside the cluster. Even better: the stock worker image ships without `curl`, so Devin improvised and probed the service over bash's raw `/dev/tcp` instead. Thirty-five seconds of work later, SERVICE.md existed: ![The finished Devin session documenting the internal inventory service, with SERVICE.md open beside the conversation](/img/blog/devin-outposts-on-kubernetes/session-result.png) And it's *good*: the ClusterIP and cluster DNS name, the nginx version read from response headers, a field table for the JSON schema, endpoint probes I never asked for (`/healthz` → 200; it even tried `/api/items/K8S-001` to prove there's no per-item route), and the deadpan observation that the 151-byte response "looks like a fixed demo dataset rather than live inventory." Busted, fair enough. An agent whose brain never entered my network produced accurate documentation of a service that does not exist on the internet. That's the product. More field notes from actually doing this: - **Outpost platform must match the worker OS.** I first created my outpost as macOS (it was for my Mac); Linux pods can't serve it. Create a separate Linux outpost for your cluster: `devin worker outpost create kiac --platform linux`. - **Size worker requests for your nodes.** My first pod sat Pending: 1Gi requests didn't fit a 2Gi node, and the roomier control-plane node was tainted. Both fixes go in the same `worker.template`: smaller requests plus a toleration. The three-layer template earns its keep fast. - **The token is shown exactly once** at outpost creation, and it carries account-level scopes. Straight into a Secret manager, never into git. - **Pre-pull the worker image** (`public.ecr.aws/e0h8a4b6/devin-cli:stable`) on your nodes unless you enjoy watching ImagePullBackOff instead of an AI agent. - The queue lists outposts at `GET /opbeta/outposts`, one path segment less than the docs currently claim. ## Should you use it? Credit to Cognition for being unusually honest here: their own announcement recommends managed hosting for most customers and says Outposts' operational complexity is "comparable to a VPC deployment." Outposts is for teams that *must* run execution on their own machines or network, and that already know how to operate ephemeral workloads securely at scale. Which is exactly the job description of a platform team with a Kubernetes cluster. Provisioning, isolation, capacity, monitoring, recycling: the operator maps every one of those onto primitives your cluster already has. Also note: Outposts currently works with multi-tenant Devin hosting only, not Dedicated Tenant deployments. ## Agents are becoming a workload type Step back and look at the launch: the partner list is the sandbox-infrastructure crowd (Modal, E2B, Daytona, Cloudflare, NVIDIA Brev, Namespace), but the only orchestrator Cognition open-sourced themselves targets Kubernetes. When an AI lab needed to express "run untrusted, bursty, ephemeral compute on customer infrastructure," they reached for a CRD, a controller, and a PodTemplateSpec. First it was microservices, then CI, then ML training. Now agent execution is becoming a Kubernetes workload type: queued, scheduled, sandboxed, metered, garbage-collected. Your cluster already knew how to do all of that. As of this launch, one of the most capable coding agents in the world can take advantage of it. Next question, and the one I'd think about before rolling this out for real: what happens when five teams each want their own agent pool on shared clusters? Namespaces, quotas, dedicated node pools, virtual clusters. That isolation story is a post of its own. Watch this space. --- *Sources: [announcement](https://devin.ai/blog/introducing-devin-outposts) · [Outposts docs](https://docs.devin.ai/cloud/outposts/overview) · [quickstart](https://docs.devin.ai/cloud/outposts/quickstart) · [orchestration](https://docs.devin.ai/cloud/outposts/orchestration) · [reference](https://docs.devin.ai/cloud/outposts/reference) · [devin-outpost-k8s](https://github.com/CognitionAI/devin-outpost-k8s)* --- # How to Share GPUs in Kubernetes at Scale with HAMi (Software vGPU Slicing) - Canonical: https://blog.kubesimplify.com/sharing-gpus-in-kubernetes-with-hami - Published: 2026-07-23 - Summary: Share NVIDIA GPUs in Kubernetes with HAMi software vGPU slicing: memory and compute limits, Helm configuration, a verified PyTorch manifest, a real RTX PRO 6000 OOM test, and Prometheus monitoring. Your platform team did everything right. You bought MIG-capable GPUs, carved them into hardware-isolated slices exactly like we did in [the MIG deep dive](/blog/slicing-gpus-in-kubernetes-with-nvidia-mig), and stopped handing a whole 96GB card to every notebook that asked for one. And yet the tickets keep coming. One team needs 8GB of VRAM, but the smallest MIG profile on our RTX PRO 6000 is `1g.24gb`. That leaves most of the slice unused. Another workload needs 30GB, which does not match the available 24GB or 48GB profiles cleanly. Changing a MIG geometry also means stopping workloads that occupy the instances you need to destroy and recreate. On Ampere GPUs, toggling MIG mode itself can additionally require a GPU reset; Hopper and newer GPUs no longer require that reset. NVIDIA documents the exact [RTX PRO 6000 profiles](https://docs.nvidia.com/datacenter/tesla/mig-user-guide/supported-mig-profiles.html#rtx-pro-6000-blackwell-mig-profiles) and the [generation-specific reset behavior](https://docs.nvidia.com/datacenter/tesla/mig-user-guide/getting-started-with-mig.html#enable-mig-mode). Then there is the rest of the fleet: NVIDIA cards that do not support MIG, or clusters where fixed hardware partitions are simply the wrong fit. Kubernetes normally treats a GPU as an indivisible extended resource, so a pod asking for one GPU can occupy a whole card even when it uses only a small part of the memory and compute. **HAMi (Heterogeneous AI Computing Virtualization Middleware)** adds a software sharing layer. In plain English, it gives Kubernetes three numbers to work with: - **How many physical GPUs does this container need?** `nvidia.com/gpu` - **How much memory may it use on each GPU?** `nvidia.com/gpumem`, in MiB - **How much compute time may it receive?** `nvidia.com/gpucores`, in 1% steps HAMi is now a [CNCF Incubating project](https://www.cncf.io/projects/hami/), and its maintained [device support matrix](https://project-hami.io/docs/userguide/device-supported) covers NVIDIA plus several other accelerator families through vendor-specific plugins. This walkthrough runs on our actual test rig: **8 NVIDIA RTX PRO 6000 Blackwell Server Edition GPUs**. All eight cards are in non-MIG mode, and `deviceSplitCount: 10` makes Kubelet report `8 × 10 = 80` logical scheduling slots. That does **not** create 80 GPUs or multiply the machine's VRAM. It creates 80 scheduling slots backed by the same eight cards. The exact live command and output appear below. For this guide, we rebuilt that node as a clean single-node Kubernetes v1.35.6 cluster, installed HAMi v2.9.0 as a new Helm release, and then ran the sharing and quota tests you will see below. The point is not merely to show the final YAML. It is to make every software layer between the physical GPU and the pod understandable. Who this is for: - Platform engineers who already run (or have evaluated) NVIDIA MIG and want to know what changes when isolation moves from silicon to software. - Teams who need finer-grained GPU fractions than MIG's fixed profiles allow, or whose cards don't support MIG at all. What you'll get from this guide: - Why a GPU node may advertise more scheduling units than physical cards, what that number means, and what actually limits placement. - A mental model for how HAMi enforces memory and compute limits without touching the hardware's memory crossbars. - The exact Helm values that control the split factor, and how to size a workload's `nvidia.com/gpumem` / `nvidia.com/gpucores` requests. - A verified Deployment manifest running a PyTorch workload against a HAMi vGPU slice on a Blackwell (`sm_120`) card. - A concrete blast-radius test with captured output: one pod's process allocates past its memory grant while a second pod shares the same physical card. The lab has one Kubernetes node, so this post does not pretend to benchmark multi-node scheduling. The same device-plugin architecture extends to every labeled GPU node, but cluster-scale policy and failure testing are separate exercises. ## The Problem: Fixed Hardware Profiles vs. Flexible Software Budgets MIG solves the "one pod locks a whole 96GB card" problem by carving the GPU into hardware-isolated partitions. On this RTX PRO 6000, those partitions come in fixed profiles such as `1g.24gb`, `2g.48gb`, and `4g.96gb`. That creates its own friction: - If a workload needs 8GB, the smallest standard compute profile on this card is still 24GB. You waste less than with a whole card, but you still waste 16GB. - A workload that needs 30GB does not fit the 24GB profile, so it must take 48GB. - Changing the profile layout requires destroying and recreating affected MIG instances. That is operationally heavier than changing a pod resource request. - MIG only works on supported GPUs and software combinations. It is not a universal sharing mechanism for every NVIDIA card. **HAMi** takes a different approach. Instead of partitioning the silicon, its NVIDIA path combines device-aware scheduling with HAMi-Core, an injected user-space library. The `hami-scheduler` reserves a memory and compute budget; HAMi-Core tracks supported CUDA allocations and throttles compute while the container runs. The official [GPU virtualization walkthrough](https://project-hami.io/docs/core-concepts/gpu-virtualization) documents the complete webhook → scheduler → device plugin → HAMi-Core flow. That means: - Memory can be requested in 1 MiB units: `nvidia.com/gpumem: 8000` is valid, rather than choosing a fixed profile name. - No GPU reset is required to change how a card is divided. The limits live in the pod spec, not the hardware. - Compute can be requested in 1% steps with `nvidia.com/gpucores`. - HAMi provides a common scheduling model across multiple accelerator families, with capabilities and plugins that vary by vendor. This article tests only the NVIDIA HAMi-Core path. The tradeoff is the boundary. HAMi's NVIDIA limits are enforced in user space by intercepting CUDA/NVML paths. MIG's memory and compute boundaries are implemented in hardware. Both can prevent an ordinary CUDA workload from consuming another tenant's allocation, but they are not equivalent isolation guarantees. {{hami-request-flow-animation}} ## Before Installing HAMi: The Four Layers GPU setup becomes confusing when four different jobs are described as if they were one installation. Keep this stack in your head: 1. **NVIDIA driver:** lets Linux communicate with the physical GPU. `nvidia-smi` proves this layer works. 2. **NVIDIA Container Toolkit:** lets a container runtime expose that GPU inside a container. 3. **Kubernetes:** places pods on nodes and asks a device plugin for the devices assigned to each container. 4. **HAMi:** replaces whole-GPU scheduling with device-aware placement plus software memory and compute limits. HAMi does not replace the driver, Container Toolkit, containerd, or Kubernetes. It builds on them. Our clean test node used: - **Host OS:** Ubuntu 24.04.4 LTS on Utho Cloud. - **Kubernetes:** v1.35.6, one control-plane node (`utho-gpu-rtxpro6000-8-62383`). - **Container runtime:** containerd 2.2.1. - **GPUs:** 8x NVIDIA RTX PRO 6000 Blackwell Server Edition. - **Memory per GPU:** 97,887 MiB reported by the driver. - **NVIDIA driver:** 610.43.02. - **NVIDIA Container Toolkit:** 1.19.1. ```bash root@utho-gpu-rtxpro6000-8-62383:~# nvidia-smi -L GPU 0: NVIDIA RTX PRO 6000 Blackwell Server Edition (UUID: GPU-8b89b58e-b427-108d-ac50-06138d78fe78) GPU 1: NVIDIA RTX PRO 6000 Blackwell Server Edition (UUID: GPU-03a041b7-8abf-360a-d1a2-dfd70188cd5f) GPU 2: NVIDIA RTX PRO 6000 Blackwell Server Edition (UUID: GPU-ba09367f-dd50-32ca-e988-7ff66bece885) GPU 3: NVIDIA RTX PRO 6000 Blackwell Server Edition (UUID: GPU-30512c46-708b-f374-5698-ee24be6cd626) GPU 4: NVIDIA RTX PRO 6000 Blackwell Server Edition (UUID: GPU-4c395b7a-a7e6-d90f-1ced-d96e8dd68288) GPU 5: NVIDIA RTX PRO 6000 Blackwell Server Edition (UUID: GPU-04dc48d7-7048-aef5-ad36-f5db716e7668) GPU 6: NVIDIA RTX PRO 6000 Blackwell Server Edition (UUID: GPU-f4f5db98-143f-0a8d-47ce-956fab39a736) GPU 7: NVIDIA RTX PRO 6000 Blackwell Server Edition (UUID: GPU-f4c61521-240a-da09-2787-e576034e197e) ``` ## Build the Kubernetes 1.35 Cluster If you already have a healthy Kubernetes cluster, do not reset it just to follow this guide. We rebuilt a dedicated test node so the installation path was clean. The package commands below follow Kubernetes' official [kubeadm installation guide](https://kubernetes.io/docs/setup/production-environment/tools/kubeadm/install-kubeadm/). First enable the kernel settings Kubernetes networking needs and turn off swap: ```bash sudo swapoff -a sudo modprobe overlay sudo modprobe br_netfilter cat <<'EOF' | sudo tee /etc/sysctl.d/99-kubernetes-cri.conf net.bridge.bridge-nf-call-iptables = 1 net.bridge.bridge-nf-call-ip6tables = 1 net.ipv4.ip_forward = 1 EOF sudo sysctl --system ``` `swapoff -a` changes the running system only. If this node may reboot, also disable its swap entry in `/etc/fstab` or the corresponding systemd swap unit before treating the cluster as persistent. Add the Kubernetes v1.35 package repository and install the node tools: ```bash sudo apt-get update sudo apt-get install -y apt-transport-https ca-certificates curl gpg sudo mkdir -p -m 755 /etc/apt/keyrings curl -fsSL https://pkgs.k8s.io/core:/stable:/v1.35/deb/Release.key \ | sudo gpg --dearmor -o /etc/apt/keyrings/kubernetes-apt-keyring.gpg echo 'deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] https://pkgs.k8s.io/core:/stable:/v1.35/deb/ /' \ | sudo tee /etc/apt/sources.list.d/kubernetes.list sudo apt-get update sudo apt-get install -y kubelet kubeadm kubectl sudo apt-mark hold kubelet kubeadm kubectl ``` ### Make the NVIDIA runtime the default The host already had its NVIDIA driver and Container Toolkit. We configured containerd with the NVIDIA runtime and made it the default, which is also the setup expected by HAMi's [prerequisites](https://project-hami.io/docs/installation/prerequisites): ```bash sudo nvidia-ctk runtime configure \ --runtime=containerd \ --set-as-default sudo systemctl restart containerd ``` Verify the result instead of assuming it worked: ```bash root@utho-gpu-rtxpro6000-8-62383:~# containerd config dump \ | grep -E 'default_runtime_name|BinaryName' | head -2 default_runtime_name = 'nvidia' BinaryName = '/usr/bin/nvidia-container-runtime' ``` ### Initialize the control plane The `10.244.0.0/16` pod network below matches Flannel's default manifest: ```bash PUBLIC_IP="" sudo kubeadm init \ --kubernetes-version v1.35.6 \ --apiserver-advertise-address "$PUBLIC_IP" \ --pod-network-cidr 10.244.0.0/16 \ --cri-socket unix:///run/containerd/containerd.sock mkdir -p "$HOME/.kube" sudo cp /etc/kubernetes/admin.conf "$HOME/.kube/config" sudo chown "$(id -u):$(id -g)" "$HOME/.kube/config" ``` This is a one-node lab, so the control-plane node must also accept workloads. Do not remove this taint on a production control plane unless that is an intentional design decision: ```bash kubectl taint nodes --all node-role.kubernetes.io/control-plane- kubectl apply -f \ https://github.com/flannel-io/flannel/releases/download/v0.28.7/kube-flannel.yml ``` The rebuilt node came up on the expected versions: ```text NAME STATUS KUBERNETES RUNTIME utho-gpu-rtxpro6000-8-62383 Ready v1.35.6 containerd://2.2.1 ``` ## RuntimeClass and GPU Operator: Which Path Are We Using? It is reasonable to expect an `nvidia` RuntimeClass if you normally install GPUs through the **NVIDIA GPU Operator**. Many Operator configurations create it. That is not a universal rule anymore. In current GPU Operator releases, ordinary CDI (Container Device Interface)-injected workloads do not need to name a RuntimeClass, and enabling the newer NRI plugin deliberately removes the `nvidia` RuntimeClass. NVIDIA documents those mode differences in its [CDI and NRI guide](https://docs.nvidia.com/datacenter/cloud-native/gpu-operator/latest/cdi.html). This lab does **not** install GPU Operator. The host already has the driver and Container Toolkit, and containerd's default runtime is `nvidia`. Therefore: ```bash root@utho-gpu-rtxpro6000-8-62383:~# kubectl get runtimeclass No resources found ``` That empty output is correct for this setup. The later workload works without `runtimeClassName` because the NVIDIA runtime is already the default. If your cluster already uses GPU Operator, do not run two device plugins that both advertise `nvidia.com/gpu`. HAMi's [GPU Operator compatibility guidance](https://project-hami.io/docs/faq) says to disable the Operator-managed device plugin (`devicePlugin.enabled=false`) when HAMi owns NVIDIA GPU scheduling. Keep the Operator for the driver/toolkit lifecycle if you need it; let HAMi own the conflicting device-plugin role. ## Install HAMi From Scratch HAMi's NVIDIA device plugin selects nodes labeled `gpu=on` by default, so label the GPU node first: ```bash kubectl label node utho-gpu-rtxpro6000-8-62383 gpu=on --overwrite ``` Now install a pinned HAMi chart. We pin its bundled `kube-scheduler` sidecar to the same version as the API server. Kubernetes' [version-skew policy](https://kubernetes.io/releases/version-skew-policy/#kube-controller-manager-kube-scheduler-and-cloud-controller-manager) expects the same minor version and permits the scheduler to be one minor older; an exact match simply removes avoidable skew from this lab. Notice the full Helm path, `scheduler.kubeScheduler.image.tag`; `scheduler.kubeScheduler.imageTag` is not a chart 2.9.0 value and would be silently ignored. The remaining values make the sharing policy explicit instead of hiding important behavior behind defaults: ```bash helm repo add hami-charts https://project-hami.github.io/HAMi/ helm repo update hami-charts K8S_VERSION=$(kubectl version -o json | jq -r '.serverVersion.gitVersion') helm install hami hami-charts/hami \ --version 2.9.0 \ --namespace hami-system \ --create-namespace \ --wait \ --timeout 10m \ --set scheduler.kubeScheduler.image.tag="$K8S_VERSION" \ --set devicePlugin.deviceSplitCount=10 \ --set devicePlugin.deviceMemoryScaling=1 \ --set devicePlugin.deviceCoreScaling=1 \ --set devicePlugin.migStrategy=none \ --set devicePlugin.createRuntimeClass=false \ --set-string devicePlugin.disablecorelimit=false ``` This is the release created by that command, not a dry-run render or an inherited installation: ```text NAME NAMESPACE REVISION STATUS CHART APP VERSION hami hami-system 1 deployed hami-2.9.0 2.9.0 ``` Both HAMi components became healthy: ```text NAME READY STATUS RESTARTS hami-device-plugin-5wdzg 2/2 Running 0 hami-scheduler-6f74879cc7-9fddb 2/2 Running 0 ``` The scheduler pod is cluster-wide. The device-plugin DaemonSet runs once on every selected GPU node. During the final review, we read the capacity and allocatable values directly from the live Node object: ```bash root@utho-gpu-rtxpro6000-8-62383:~# kubectl get node utho-gpu-rtxpro6000-8-62383 \ -o custom-columns='NAME:.metadata.name,KUBERNETES:.status.nodeInfo.kubeletVersion,GPU-CAPACITY:.status.capacity.nvidia\.com/gpu,GPU-ALLOCATABLE:.status.allocatable.nvidia\.com/gpu' NAME KUBERNETES GPU-CAPACITY GPU-ALLOCATABLE utho-gpu-rtxpro6000-8-62383 v1.35.6 80 80 ``` That `80` is the real output from the all-eight non-MIG configuration: eight registered physical cards, each contributing ten logical device IDs. ### Where did `nvidia.com/gpu` come from? Not from RuntimeClass, and not directly from GPU Operator. A **device plugin** is the component that registers an extended resource with Kubelet. In a typical GPU Operator installation, the Operator deploys NVIDIA's official device plugin, which registers `nvidia.com/gpu`. In this installation, there is no GPU Operator or NVIDIA device-plugin DaemonSet. The HAMi chart installed **`hami-device-plugin`**, and that plugin owns the same resource name: ```text NAME READY DESIRED IMAGE hami-device-plugin 1 1 docker.io/projecthami/hami:v2.9.0 ``` The live sequence is: 1. The host NVIDIA driver exposes eight physical GPUs. 2. `hami-device-plugin` discovers and registers all eight whole GPUs. 3. It connects to Kubelet's Device Plugin API and registers the configured resource name, `nvidia.com/gpu`. 4. `deviceSplitCount: 10` makes each registered whole GPU contribute ten schedulable device IDs. 5. Kubelet publishes `8 × 10 = 80` as the node's `nvidia.com/gpu` capacity. This came from the **HAMi device-plugin container log**, not from Kubelet or the NVIDIA GPU Operator: ```bash PLUGIN_POD=$(kubectl get pod -n hami-system \ -l app.kubernetes.io/component=hami-device-plugin \ -o jsonpath='{.items[0].metadata.name}') kubectl logs -n hami-system "$PLUGIN_POD" -c device-plugin \ | grep -E 'Discovered [0-9]+ device\(s\) for registration' \ | tail -1 I0723 09:53:36.227471 1910347 register.go:197] Discovered 8 device(s) for registration ``` The node's `hami.io/node-nvidia-register` annotation records `"count": 10` for each of those eight registered GPU UUIDs. The physical inventory and MIG state agree: ```bash root@utho-gpu-rtxpro6000-8-62383:~# nvidia-smi \ --query-gpu=index,name,mig.mode.current --format=csv,noheader 0, NVIDIA RTX PRO 6000 Blackwell Server Edition, Disabled 1, NVIDIA RTX PRO 6000 Blackwell Server Edition, Disabled 2, NVIDIA RTX PRO 6000 Blackwell Server Edition, Disabled 3, NVIDIA RTX PRO 6000 Blackwell Server Edition, Disabled 4, NVIDIA RTX PRO 6000 Blackwell Server Edition, Disabled 5, NVIDIA RTX PRO 6000 Blackwell Server Edition, Disabled 6, NVIDIA RTX PRO 6000 Blackwell Server Edition, Disabled 7, NVIDIA RTX PRO 6000 Blackwell Server Edition, Disabled ``` That is why the host has eight GPUs while this live HAMi registration contributes 80 logical slots. Without either HAMi's device plugin or NVIDIA's official device plugin, `nvidia-smi` could work perfectly on the host while Kubernetes would advertise **no** `nvidia.com/gpu` capacity. This also explains the conflict warning above: HAMi's plugin and NVIDIA's official plugin should not both try to own `nvidia.com/gpu` on the same node. The `nvcr.io/nvidia/pytorch:25.01-py3` image used later is an official NGC image. NVIDIA's [25.01 release notes](https://docs.nvidia.com/deeplearning/frameworks/pytorch-release-notes/rel-25-01.html) mark that release as optimized for Blackwell, and our test confirmed compute capability `(12, 0)` on this RTX PRO 6000. ## How HAMi's Architecture Differs from the MIG Stack The MIG stack first creates hardware instances, then the NVIDIA device plugin advertises those instances to Kubernetes. HAMi leaves the physical GPU unpartitioned in `hami-core` mode and coordinates four software responsibilities instead: admission, placement, device injection, and in-container enforcement. ![Excalidraw-style flow showing a HAMi request moving through the webhook, scheduler extender, device plugin, HAMi-Core, and physical GPU](/img/blog/sharing-gpus-in-kubernetes-with-hami/hami-architecture.png) ### HAMi mutating webhook When a pod requests HAMi-managed resources, the webhook routes it to `hami-scheduler`. It does **not** send every ordinary pod through HAMi. A CPU-only sanity-check pod on the rebuilt cluster retained `schedulerName: default-scheduler`, while the GPU test pods reported `schedulerName: hami-scheduler`. ### `hami-scheduler` (cluster-wide) The HAMi scheduler pod contains a Kubernetes scheduler instance plus HAMi's extender logic. During **Filter**, it rejects nodes or cards that lack a free sharing slot, requested VRAM, or requested compute. During **Score**, it applies the configured binpack/spread policy. During **Bind**, it chooses a physical GPU UUID and records the allocation in pod annotations. The official [architecture documentation](https://project-hami.io/docs/core-concepts/architecture) describes these roles separately. ### `hami-device-plugin` (DaemonSet, one per GPU node) Registers the logical GPU count with Kubelet, publishes each physical card's UUID/memory/core information in node annotations, and reads the scheduler's chosen allocation from the pod annotation. During the Device Plugin `Allocate` call it exposes `/dev/nvidia*`, mounts `libvgpu.so` and `/etc/ld.so.preload`, and injects limit variables into the container. Its `vgpu-monitor` sidecar exports real-time usage metrics; the OOM message itself is emitted by HAMi-Core inside the workload process. ### `hami-core` (the actual enforcement mechanism) This is the shared library, `libvgpu.so`, that HAMi mounts into a managed NVIDIA workload container. `/etc/ld.so.preload` causes the dynamic linker to load it into processes before CUDA/NVML libraries. It intercepts supported calls used to allocate/query memory and throttles kernel launches for compute control. The [HAMi-Core design](https://project-hami.io/docs/developers/hami-core-design) places it between the CUDA runtime and driver. When a container's tracked allocation would exceed its configured `nvidia.com/gpumem` limit, HAMi-Core returns an OOM on that intercepted path even if the physical card still has free memory. This is also why `nvidia-smi` _inside_ a HAMi container reports its virtualized total instead of the card's real 97887 MiB: the relevant NVML memory query is intercepted too. Here is the mechanism from one of the fresh 8,000 MiB / 10% test containers: ```bash root@utho-gpu-rtxpro6000-8-62383:~# kubectl exec \ -n hami-blog-verify pytorch-hami-demo-77b6f5dcd9-whrbg -- \ sh -c 'cat /etc/ld.so.preload; printf "CUDA_DEVICE_MEMORY_LIMIT_0=%s\n" "$CUDA_DEVICE_MEMORY_LIMIT_0"; printf "CUDA_DEVICE_SM_LIMIT=%s\n" "$CUDA_DEVICE_SM_LIMIT"' /usr/local/vgpu/libvgpu.so CUDA_DEVICE_MEMORY_LIMIT_0=8000m CUDA_DEVICE_SM_LIMIT=10 ``` No `runtimeClassName` appears in our pod because the node uses `nvidia-container-runtime` by default and this chart was installed with `devicePlugin.createRuntimeClass: false`. HAMi still depends on the NVIDIA Container Toolkit/runtime; omitting the field does not mean that layer is unnecessary. ## What “80 GPUs” Means on an Eight-GPU Node This node advertises `nvidia.com/gpu: 80` even though `nvidia-smi -L` lists eight physical cards. The missing mental model is simple: - `deviceSplitCount: 10` means **up to ten separate workload containers may share one physical GPU**. - `nvidia.com/gpu: 1` means the container needs one physical GPU in its device set. It does not mean “one tenth of a GPU,” and a single container cannot request two logical slots from the same card by setting this to `2`. - `nvidia.com/gpumem` and `nvidia.com/gpucores` describe the per-GPU budget for that container. The arithmetic is `8 HAMi-registered whole GPUs × 10 possible sharing workloads = 80 schedulable GPU units`. The node still has only eight physical memory systems and eight sets of SMs. {{hami-slot-math-animation}} The chart defaults to 10, but the right value is a concurrency choice, not a performance promise. A smaller number reduces how many containers can contend on one card. A larger number permits more tiny workloads, but adds more contexts, more neighbors, and more operational complexity. Setting it to `1` restores exclusive placement: only one workload can occupy each physical GPU. With `deviceMemoryScaling: 1` and `deviceCoreScaling: 1`, the scheduler also checks the real aggregate budget. It does not admit a pod merely because a count slot is free. After deploying the two 8,000 MiB replicas in the next section, we prove this with a separate 90,000 MiB pod. Its complete manifest, commands, and captured `CardInsufficientMemory` event are included there. That scheduler decision is **not an OOM**: the rejected container never starts. A real runtime OOM happens when an admitted container allocates beyond its own grant. We reproduce that separately in [the blast-radius test](#testing-the-blast-radius-what-happens-when-a-pod-exceeds-its-memory-quota). The practical upside is density without fixed profile sizes. A notebook can request 8,000 MiB, a small inference replica 12,000 MiB, and another workload 20,000 MiB, as long as the total count, memory, and compute requests all fit. The practical costs are shared-hardware contention and a software isolation boundary, so latency-sensitive or adversarial tenants may still deserve MIG or dedicated GPUs. ### Verify the count yourself The split factor appears in `hami.io/node-nvidia-register`, where each registered whole GPU has `"count": 10`. The simplest summary is the live node capacity: ```bash root@utho-gpu-rtxpro6000-8-62383:~# kubectl get node utho-gpu-rtxpro6000-8-62383 \ -o custom-columns='NAME:.metadata.name,KUBERNETES:.status.nodeInfo.kubeletVersion,GPU-CAPACITY:.status.capacity.nvidia\.com/gpu,GPU-ALLOCATABLE:.status.allocatable.nvidia\.com/gpu' NAME KUBERNETES GPU-CAPACITY GPU-ALLOCATABLE utho-gpu-rtxpro6000-8-62383 v1.35.6 80 80 ``` The node's capacity confirms `8 × 10 = 80`, while `nvidia-smi -L` remains the source of truth for the eight-card physical inventory. ## Reading the Helm Values That Control the Split The `count: 10` comes straight from the live Helm release's computed values. Here are only the fields that affect this walkthrough; the full output also contains image, service, security-context, and vendor configuration: ```bash root@utho-gpu-rtxpro6000-8-62383:~# helm get values -n hami-system hami --all ``` ```yaml devicePlugin: deviceSplitCount: 10 deviceMemoryScaling: 1 deviceCoreScaling: 1 migStrategy: none disablecorelimit: "false" createRuntimeClass: false runtimeClassName: "" nvidiaNodeSelector: gpu: "on" resourceName: nvidia.com/gpu resourceMem: nvidia.com/gpumem resourceMemPercentage: nvidia.com/gpumem-percentage resourceCores: nvidia.com/gpucores scheduler: defaultSchedulerPolicy: gpuSchedulerPolicy: spread nodeSchedulerPolicy: binpack ``` The current [HAMi configuration reference](https://project-hami.io/docs/userguide/configure) defines the semantics: - **`deviceSplitCount: 10`** is the maximum number of workload containers HAMi may assign to one physical GPU. `1` means exclusive placement, not fractional sharing. - **`deviceMemoryScaling: 1`** means the schedulable memory budget equals physical memory. Values above `1` deliberately advertise more memory than exists; HAMi documents memory overcommit as experimental, and a real physical OOM becomes possible if tenants use all promised memory together. - **`deviceCoreScaling: 1`** keeps the aggregate schedulable compute budget at 100% per card. - **`disablecorelimit: "false"`** enables HAMi-Core's compute limiting path. Core control is time-based throttling, so `nvidia-smi` utilization can fluctuate around the requested percentage rather than drawing a perfectly flat line. - **`migStrategy: none`** means HAMi is not layering on top of MIG partitions here; it's doing pure `hami-core` software slicing against whole physical GPUs. - **`nvidiaNodeSelector.gpu: "on"`** restricts the device plugin to nodes labeled `gpu=on`, which matches the label already on this node. - **`gpuSchedulerPolicy: spread`** prefers different physical cards; **`nodeSchedulerPolicy: binpack`** prefers already-used nodes. For the same-GPU isolation test, relying on a preference is not precise enough, so the manifest below pins a known idle GPU UUID. ## How to Request vGPU Slices in a Pod Spec Where a MIG manifest requests a fixed profile (`nvidia.com/mig-1g.24gb: 1`), a HAMi manifest can request three independent values: One unit detail matters throughout the test: `gpumem` is measured in **MiB**. One MiB is 1,048,576 bytes, so 8,000 MiB is 8,388,608,000 bytes or 7.8125 GiB. That is why HAMi accepts `8000` while PyTorch later prints `7.81 GiB`; the numbers describe the same capacity. ```yaml resources: limits: nvidia.com/gpu: 1 # use one physical GPU, possibly shared with other containers nvidia.com/gpumem: 8000 # reserve and expose 8000 MiB on that GPU nvidia.com/gpucores: 10 # request 10% of that GPU's compute time ``` Kubernetes extended resources belong in `limits`; if you also write `requests`, they must match. This manifest includes both explicitly. ### The exact two-container test For an ordinary workload, let HAMi choose a card. For a blast-radius test, both replicas must share the **same** card, so I pinned them to idle GPU 5 with `nvidia.com/use-gpuuuid`. The additional `binpack` annotation states the intent, while the UUID makes the result deterministic. Replace the UUID for your node, or remove both annotations outside a test. I kept the workload out of the `hami-system` control-plane namespace: ```bash kubectl create namespace hami-blog-verify ``` ```yaml apiVersion: apps/v1 kind: Deployment metadata: name: pytorch-hami-demo namespace: hami-blog-verify labels: app: pytorch-hami-demo spec: replicas: 2 selector: matchLabels: app: pytorch-hami-demo template: metadata: labels: app: pytorch-hami-demo annotations: # Test-only pin so both replicas definitely share GPU 5. nvidia.com/use-gpuuuid: "GPU-04dc48d7-7048-aef5-ad36-f5db716e7668" hami.io/gpu-scheduler-policy: "binpack" spec: containers: - name: pytorch image: nvcr.io/nvidia/pytorch:25.01-py3 command: ["python3", "-c"] args: - | import torch import time print("=== CUDA vGPU Slice Diagnostics ===", flush=True) print("CUDA Available:", torch.cuda.is_available(), flush=True) if torch.cuda.is_available(): print("Device Name:", torch.cuda.get_device_name(0), flush=True) print("Device Capability:", torch.cuda.get_device_capability(0), flush=True) print("CUDA Device Count:", torch.cuda.device_count(), flush=True) print("Allocating tensors and starting matrix math...", flush=True) device = torch.device("cuda") x = torch.randn(10000, 10000, device=device) y = torch.randn(10000, 10000, device=device) while True: z = torch.matmul(x, y) time.sleep(0.5) else: print("ERROR: CUDA is not available!", flush=True) time.sleep(3600) resources: limits: nvidia.com/gpu: 1 nvidia.com/gpumem: 8000 nvidia.com/gpucores: 10 requests: nvidia.com/gpu: 1 nvidia.com/gpumem: 8000 nvidia.com/gpucores: 10 ``` Apply it and wait for both replicas: ```bash root@utho-gpu-rtxpro6000-8-62383:~# kubectl apply -f pytorch-hami-demo.yaml deployment.apps/pytorch-hami-demo created root@utho-gpu-rtxpro6000-8-62383:~# kubectl rollout status \ deployment/pytorch-hami-demo -n hami-blog-verify --timeout=180s deployment "pytorch-hami-demo" successfully rolled out ``` Read the startup logs: ```bash root@utho-gpu-rtxpro6000-8-62383:~# kubectl logs \ -n hami-blog-verify -l app=pytorch-hami-demo \ --prefix --tail=20 \ | grep -E 'CUDA Available|Device Name|Device Capability|CUDA Device Count' [pod/pytorch-hami-demo-77b6f5dcd9-whrbg/pytorch] CUDA Available: True [pod/pytorch-hami-demo-77b6f5dcd9-whrbg/pytorch] Device Name: NVIDIA RTX PRO 6000 Blackwell Server Edition [pod/pytorch-hami-demo-77b6f5dcd9-whrbg/pytorch] Device Capability: (12, 0) [pod/pytorch-hami-demo-77b6f5dcd9-whrbg/pytorch] CUDA Device Count: 1 [pod/pytorch-hami-demo-77b6f5dcd9-wx677/pytorch] CUDA Available: True [pod/pytorch-hami-demo-77b6f5dcd9-wx677/pytorch] Device Name: NVIDIA RTX PRO 6000 Blackwell Server Edition [pod/pytorch-hami-demo-77b6f5dcd9-wx677/pytorch] Device Capability: (12, 0) [pod/pytorch-hami-demo-77b6f5dcd9-wx677/pytorch] CUDA Device Count: 1 ``` Both placement annotations name GPU 5 and the same 8,000 MiB / 10% grant: ```bash root@utho-gpu-rtxpro6000-8-62383:~# kubectl get pods \ -n hami-blog-verify -l app=pytorch-hami-demo \ -o custom-columns='NAME:.metadata.name,ALLOCATION:.metadata.annotations.hami\.io/vgpu-devices-allocated' NAME ALLOCATION pytorch-hami-demo-77b6f5dcd9-whrbg GPU-04dc48d7-7048-aef5-ad36-f5db716e7668,NVIDIA,8000,10:; pytorch-hami-demo-77b6f5dcd9-wx677 GPU-04dc48d7-7048-aef5-ad36-f5db716e7668,NVIDIA,8000,10:; ``` Inside either container, `nvidia-smi` shows the virtualized view: ```bash root@utho-gpu-rtxpro6000-8-62383:~# kubectl exec \ -n hami-blog-verify pytorch-hami-demo-77b6f5dcd9-whrbg -- \ sh -c 'nvidia-smi --query-gpu=uuid,name,memory.total,memory.used --format=csv,noheader 2>/dev/null' GPU-04dc48d7-7048-aef5-ad36-f5db716e7668, NVIDIA RTX PRO 6000 Blackwell Server Edition, 8000 MiB, 2107 MiB ``` The host sees the two real processes on the same physical UUID: ```bash root@utho-gpu-rtxpro6000-8-62383:~# nvidia-smi \ --query-compute-apps=gpu_uuid,pid,used_memory --format=csv,noheader \ | grep 'GPU-04dc48d7-7048-aef5-ad36-f5db716e7668' GPU-04dc48d7-7048-aef5-ad36-f5db716e7668, 1932078, 2110 MiB GPU-04dc48d7-7048-aef5-ad36-f5db716e7668, 1932782, 2110 MiB ``` The few-MiB difference between in-container and host accounting is normal tool/accounting overhead. The important facts are the shared UUID and each container's separate 8,000 MiB view. ### Prove that memory budget beats free count slots At this point, two of GPU 5's ten count slots are occupied. The allocation annotation proves that both 8,000 MiB reservations landed on the same physical card: ```bash root@utho-gpu-rtxpro6000-8-62383:~# kubectl get pods \ -n hami-blog-verify -l app=pytorch-hami-demo \ -o custom-columns='NAME:.metadata.name,ALLOCATION:.metadata.annotations.hami\.io/vgpu-devices-allocated' NAME ALLOCATION pytorch-hami-demo-77b6f5dcd9-whrbg GPU-04dc48d7-7048-aef5-ad36-f5db716e7668,NVIDIA,8000,10:; pytorch-hami-demo-77b6f5dcd9-wx677 GPU-04dc48d7-7048-aef5-ad36-f5db716e7668,NVIDIA,8000,10:; ``` Eight count slots are still free, but only `97,887 - 8,000 - 8,000 = 81,887 MiB` remains in HAMi's scheduling budget for that card. This third pod deliberately asks for 90,000 MiB: ```yaml apiVersion: v1 kind: Pod metadata: name: hami-too-large namespace: hami-blog-verify annotations: nvidia.com/use-gpuuuid: "GPU-04dc48d7-7048-aef5-ad36-f5db716e7668" spec: restartPolicy: Never containers: - name: too-large image: ubuntu:22.04 command: ["sleep", "3600"] resources: limits: nvidia.com/gpu: 1 nvidia.com/gpumem: 90000 nvidia.com/gpucores: 10 requests: nvidia.com/gpu: 1 nvidia.com/gpumem: 90000 nvidia.com/gpucores: 10 ``` Apply it, inspect placement, and read only this pod's events: ```bash root@utho-gpu-rtxpro6000-8-62383:~# kubectl apply -f hami-too-large.yaml pod/hami-too-large created root@utho-gpu-rtxpro6000-8-62383:~# kubectl get pod hami-too-large \ -n hami-blog-verify \ -o custom-columns='NAME:.metadata.name,STATUS:.status.phase,SCHEDULER:.spec.schedulerName,NODE:.spec.nodeName' NAME STATUS SCHEDULER NODE hami-too-large Pending hami-scheduler root@utho-gpu-rtxpro6000-8-62383:~# kubectl get events \ -n hami-blog-verify \ --field-selector involvedObject.name=hami-too-large \ --sort-by=.lastTimestamp LAST SEEN TYPE REASON OBJECT MESSAGE 18s Warning FailedScheduling pod/hami-too-large 0/1 nodes are available: 1 NodeUnfitPod. no new claims to deallocate, preemption: 0/1 nodes are available: 1 No preemption victims found for incoming pod. 18s Warning FailedScheduling pod/hami-too-large 0/1 nodes are available: 1 NodeUnfitPod. no new claims to deallocate, preemption: 0/1 nodes are available: 1 No preemption victims found for incoming pod. 18s Warning FilteringFailed pod/hami-too-large 1 nodes CardInsufficientMemory(utho-gpu-rtxpro6000-8-62383) 18s Warning FilteringFailed pod/hami-too-large 1 nodes CardUuidMismatch(utho-gpu-rtxpro6000-8-62383) 18s Warning FilteringFailed pod/hami-too-large no available node, 1 nodes do not meet ``` The HAMi scheduler log makes the per-card result unambiguous: ```bash root@utho-gpu-rtxpro6000-8-62383:~# kubectl logs \ -n hami-system -l app.kubernetes.io/component=hami-scheduler \ -c vgpu-scheduler-extender --tail=2000 \ | grep '"NodeUnfitPod".*hami-too-large' | tail -1 I0723 10:27:23.686479 1 score.go:169] "NodeUnfitPod" pod="hami-blog-verify/hami-too-large" node="utho-gpu-rtxpro6000-8-62383" reason="7/8 CardUuidMismatch, 1/8 CardInsufficientMemory" ``` The UUID mismatch is expected for the seven cards excluded by the explicit GPU 5 pin. The matching card's decisive result is `CardInsufficientMemory`: `90,000 + 8,000 + 8,000 = 106,000 MiB`, which exceeds its 97,887 MiB scheduling budget. The pod remains Pending and has no node because HAMi rejects it during scheduling. No container starts, so there is no CUDA or HAMi-Core OOM message in this test. ### Fixed MiB or percentage? `nvidia.com/gpumem-percentage` requests a percentage of whichever card the scheduler chooses. HAMi's docs explicitly say not to combine it with fixed `gpumem`. On this 97,887 MiB card, a tested 50% memory request, paired with a separate 5% core request, produced: ```text GPU-f4c61521-...,NVIDIA,48943,5:; GPU-f4c61521-..., 48943 MiB ``` Use fixed MiB when the application has a known memory requirement. Use a percentage when the intent is “half of any selected card” across a mixed-capacity fleet. The official [memory allocation guide](https://project-hami.io/docs/userguide/nvidia-device/specify-device-memory-usage) defines both forms. ## How to Verify Where a Slice Landed With `deviceSplitCount: 10`, "my pod got a GPU" no longer tells you _which_ physical card. HAMi records its placement decision in pod annotations: ```bash POD=$(kubectl get pods -n hami-blog-verify \ -l app=pytorch-hami-demo \ -o jsonpath='{.items[0].metadata.name}') kubectl get pod -n hami-blog-verify "$POD" -o json \ | jq '.metadata.annotations | with_entries(select(.key | test("hami|gpu"; "i")))' ``` The annotation to look for is `hami.io/vgpu-devices-allocated`. Its value encodes the physical GPU UUID, vendor, memory grant, and core grant for each allocated device. Our fresh test reported `GPU-04dc48d7-...,NVIDIA,8000,10:;`, which maps to GPU 5 in `nvidia-smi -L`. Two ways to cross-check that annotation against reality: - **From the host:** `nvidia-smi --query-compute-apps=gpu_uuid,pid,used_memory --format=csv` shows the physical UUID for each process. It should match the annotation. - **Across replicas:** compare both annotations. Same UUID means the containers share a card. For a deterministic test, use `nvidia.com/use-gpuuuid`; do not keep adding replicas and hope two collide. ## Software Isolation vs. Hardware Isolation This is the tradeoff to internalize before using HAMi for multi-tenancy: ![Excalidraw-style comparison of NVIDIA time-slicing, HAMi software quotas, and MIG hardware partitions](/img/blog/sharing-gpus-in-kubernetes-with-hami/hami-isolation-spectrum.png) | | **NVIDIA time-slicing** | **HAMi (`hami-core`)** | **MIG** | | ----------------------- | --------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | | Isolation boundary | No per-replica GPU memory/compute boundary | User-space interception through `libvgpu.so` and `/etc/ld.so.preload` | Hardware GPU instances with dedicated memory/compute resources | | Memory shape | Every replica sees the whole card | 1 MiB request granularity | Fixed profiles such as `1g.24gb` and `2g.48gb` | | Compute shape | Workloads take turns; no per-replica cap | 1% request granularity, implemented through time-based throttling | Fixed fraction of SMs in the profile | | What the container sees | Full physical GPU | Virtual total equal to its grant | Its MIG device/profile | | Reconfiguration | Change plugin config/redeploy affected pods | Change the workload request | Destroy/recreate affected instances; toggling mode may require a reset on Ampere | | Important limitation | One workload can consume free VRAM needed by another workload's later allocations | Direct-driver paths, Docker-in-Docker, or `CUDA_DISABLE_CONTROL=true` can bypass enforcement | Requires supported GPU, driver, and profile geometry | Time-slicing does not automatically crash every neighbor when one process gets an OOM. The precise risk is that all replicas share the same physical memory pool without per-replica caps: one workload can consume the remaining VRAM, causing later allocations in other workloads to fail. Time-slicing shares access but does not give each pod a protected memory budget. HAMi adds software-enforced memory and compute quotas for supported CUDA workloads; in our PyTorch test, it stopped the container at its 8,000 MiB grant. The [HAMi troubleshooting guide](https://project-hami.io/docs/troubleshooting) documents paths that can bypass that user-space enforcement, so HAMi should be treated as resource control rather than a hard security boundary. MIG partitions supported GPUs in hardware, providing stronger isolation and more predictable performance, but with fixed profile sizes and more involved reconfiguration. For mutually untrusted tenants, combine MIG or a dedicated GPU with the VM or host isolation appropriate to your security model. ## Testing the Blast Radius: What Happens When a Pod Exceeds Its Memory Quota With two replicas running, each holding an 8,000 MiB / 10% grant on the _same_ physical GPU, the question is straightforward: if one container allocates beyond 8,000 MiB, does only that allocation fail, or does the neighbor fail too? {{hami-blast-radius-animation}} This test covers a normal PyTorch/CUDA allocation path. It proves the configured quota for that path; it does not prove that user-space interception is impossible to bypass. ### Step 1: Spike memory usage from inside the pod No need to change the Deployment. Exec into one replica and retain 20,000 × 20,000 FP32 tensors until HAMi-Core refuses the next allocation. Each tensor contains 400 million four-byte values: 1.6 GB in decimal units, or about 1.49 GiB. ```bash POD=pytorch-hami-demo-77b6f5dcd9-whrbg kubectl exec -i -n hami-blog-verify "$POD" -- python3 - <<'PY' import torch tensors = [] print( f"Visible limit: {torch.cuda.get_device_properties(0).total_memory / 1024**2:.0f} MiB", flush=True, ) try: for index in range(1, 9): tensors.append( torch.empty((20_000, 20_000), dtype=torch.float32, device="cuda") ) torch.cuda.synchronize() allocated = torch.cuda.memory_allocated() / 1024**2 print(f"block {index}: {allocated:.0f} MiB allocated", flush=True) except torch.OutOfMemoryError as error: print("HAMi boundary reached: CUDA out of memory", flush=True) print(str(error).split(". If reserved")[0], flush=True) PY ``` The main matmul process already held about 2.06 GiB, and the exec process shared the same container budget. The command returned: ```text Visible limit: 8000 MiB [HAMI-core ERROR (pid:563 thread=134804753249792 allocator.c:52)]: Device 0 OOM 9185657856 / 8388608000 [HAMI-core ERROR (pid:563 thread=134804753249792 allocator.c:52)]: Device 0 OOM 9185657856 / 8388608000 block 1: 1526 MiB allocated block 2: 3052 MiB allocated block 3: 4578 MiB allocated HAMi boundary reached: CUDA out of memory CUDA out of memory. Tried to allocate 1.49 GiB. GPU 0 has a total capacity of 7.81 GiB of which 765.87 MiB is free. Process 1 has 2.06 GiB memory in use. Including non-PyTorch memory, this process has 5.01 GiB memory in use. ``` The two identical raw HAMi-Core lines came from the intercepted allocation path; the script then caught PyTorch's `OutOfMemoryError` and printed the readable summary. The first three new tensors succeeded. The fourth allocation would have pushed HAMi's tracked total to **9,185,657,856 bytes** against an **8,388,608,000-byte** limit, exactly 8,000 MiB. PyTorch sees 7.81 GiB because GiB uses powers of 1024. The card itself was nowhere near its 97,887 MiB physical limit, so this was a software quota verdict rather than a physical-card OOM. ### Step 2: Check the offending container, neighbor, and host ```bash # 1. Virtualized view inside the offending container. kubectl exec -n hami-blog-verify pytorch-hami-demo-77b6f5dcd9-whrbg -- \ nvidia-smi --query-gpu=uuid,memory.total,memory.used --format=csv # 2. Real processes and physical UUID from the host. nvidia-smi --query-compute-apps=gpu_uuid,pid,used_memory --format=csv # 3. Pod state and restart count for both replicas. kubectl get pods -n hami-blog-verify -l app=pytorch-hami-demo \ -o custom-columns='NAME:.metadata.name,STATUS:.status.phase,RESTARTS:.status.containerStatuses[0].restartCount' # 4. The neighbor's virtualized memory after the failed allocation. kubectl exec -n hami-blog-verify pytorch-hami-demo-77b6f5dcd9-wx677 -- \ sh -c 'nvidia-smi --query-gpu=uuid,memory.total,memory.used --format=csv,noheader 2>/dev/null' ``` The terminal view below shows the same virtual capacity and rejected allocation: ![The pod's virtualized nvidia-smi pinned near its 8000MiB grant while hami-core rejects the next allocation](/img/blog/sharing-gpus-in-kubernetes-with-hami/oom-blast-radius.png) The fresh post-test checks returned: ```text NAME STATUS RESTARTS pytorch-hami-demo-77b6f5dcd9-whrbg Running 0 pytorch-hami-demo-77b6f5dcd9-wx677 Running 0 GPU-04dc48d7-7048-aef5-ad36-f5db716e7668, 8000 MiB, 2107 MiB ``` - **Offending container:** HAMi-Core returned OOM at its 8,000 MiB account boundary. - **Neighbor:** remained `Running`, had zero restarts, and still reported 2,107 MiB used of its own 8,000 MiB grant. - **Host:** showed both long-running processes on GPU 5. Physical memory headroom did not override the container limit. - **Application behavior:** this exec script catches the `RuntimeError`, so the pod stays alive. An uncaught OOM may terminate the process and trigger whatever restart policy the workload defines. The result is deliberately narrow and useful: **for this PyTorch/CUDA path, the memory overrun was contained to the offending container's allocation and the neighbor continued running.** The boundary is still a user-space contract, not a MIG hardware partition. ## Monitoring the Slices A one-off test is not an operations strategy. HAMi exposes two Prometheus-format endpoints: one for allocation decisions and one for live usage. Prometheus/Grafana are still separate components if you want storage, alerting, and dashboards. Do not guess the ports from an old example. Check the Services installed by your chart: ```text root@utho-gpu-rtxpro6000-8-62383:~# kubectl get service -n hami-system NAME TYPE PORT(S) hami-device-plugin-monitor NodePort 31992:31992/TCP hami-scheduler NodePort 443:31998/TCP,31993:31993/TCP ``` **The scheduler's view:** cluster-wide allocation state, exposed by the `hami-scheduler` service (NodePort `31993` by default): ```bash curl http://127.0.0.1:31993/metrics ``` This answers allocation questions: how much of each physical card's memory/core budget is promised (`hami_gpu_memory_allocated_bytes`, `hami_gpu_core_allocated_ratio`), how many containers share it (`hami_gpu_shared_count`), and which pods own each grant. With two 8,000 MiB / 10% containers on GPU 5, the scheduler endpoint reported an aggregate 16,000 MiB and 20% reservation: ```text hami_gpu_core_allocated_ratio{device_index="5",device_uuid="GPU-04dc48d7-..."} 20 hami_gpu_memory_allocated_bytes{device_cores="20",device_index="5",device_uuid="GPU-04dc48d7-..."} 1.6777216e+10 ``` The official [cluster allocation metrics reference](https://project-hami.io/docs/userguide/monitoring/device-allocation) documents the metric names and labels. **The node's view:** real-time per-container usage, exposed by the `vgpu-monitor` sidecar through NodePort `31992` on this chart: ```bash curl http://127.0.0.1:31992/metrics ``` This is the usage-side counterpart. For the unaffected neighbor, it reported about 2.21 GB used against an 8.39 GB byte limit: ```text hami_container_device_memory_bytes{namespace="hami-blog-verify",pod="...-tj9rg"} 2.208433152e+09 hami_vgpu_memory_limit_bytes{namespace="hami-blog-verify",pod="...-tj9rg"} 8.388608e+09 ``` The current [real-time usage reference](https://project-hami.io/docs/userguide/monitoring/real-time-device-usage) lists host, container, memory, and utilization metrics. Alert on both dimensions: **allocated** tells you what the scheduler has promised; **used** tells you what applications actually consume. Wire both endpoints into Prometheus and a few panels give you the dashboard a shared-GPU platform actually needs. A useful layout keeps fleet capacity, per-pod grants, and per-GPU allocation visible together: ![Grafana dashboard built from HAMi scheduler metrics: fleet stats, per-pod slices, and per-GPU allocation against the physical limit](/img/blog/sharing-gpus-in-kubernetes-with-hami/hami-grafana-dashboard.png) The dashboard is useful because it keeps three different numbers separate: physical cards, logical sharing slots, and actual resource grants. Mixing those is how an eight-GPU node gets mistaken for an 80-GPU node. Once the outputs were captured, the temporary workloads were removed while the clean HAMi installation remained running: ```bash kubectl delete namespace hami-blog-verify --wait=true ``` ## Common Pitfalls and How to Solve Them ### Pitfall A: Assuming `nvidia.com/gpu` Count Reflects Physical GPU Count The first time you see `nvidia.com/gpu: 80` on an 8-GPU node, it's easy to assume something is broken. Here, all eight whole GPUs are registered and each contributes ten logical slots. Check the `hami.io/node-nvidia-register` annotation before assuming a misconfiguration; the number of GPU entries and the `"count"` field per entry explain the capacity. ### Pitfall B: Expecting `nvidia.com/gpu: 1` Alone to Mean “One Tenth” HAMi documents `nvidia.com/gpu` without memory/core fields as exclusive-GPU mode. I tested it on idle GPU 6; the allocation annotation was `GPU-f4f5db98-...,NVIDIA,97887,100:;`, and `nvidia-smi` inside the container reported all 97,887 MiB. If you intend to share, state `gpumem` and/or `gpucores` explicitly. ### Pitfall C: Treating `requests` and `limits` Differently Kubernetes extended resources cannot be overcommitted like CPU. Put the HAMi resources in `limits`; if you include `requests`, use the same values. A mismatched manifest is rejected before HAMi can schedule it. ### Pitfall D: Removing `runtimeClassName` Without Checking the Runtime Our manifest has no `runtimeClassName` because containerd's default is already `nvidia` and the HAMi chart did not create one. That is a property of this installation, not a universal copy/paste rule. A GPU Operator cluster may have `nvidia` or CDI-related RuntimeClasses, while an NRI-enabled Operator cluster may intentionally have none. Check `kubectl get runtimeclass`, `containerd config dump`, and your Operator mode before copying the field. A reference to a nonexistent runtime handler prevents the pod sandbox from starting. ### Pitfall E: Blaming HAMi for a Slow First Deployment The NGC PyTorch image used above is a multi-gigabyte pull. On a fresh node, the pod will sit in `ContainerCreating` for several minutes while containerd downloads it, which looks a lot like a scheduling or webhook failure if you're watching for HAMi problems. `kubectl describe pod` disambiguates instantly: a `Pulling image` event means wait; a `FilteringFailed` event with `CardInsufficientMemory` means the selected card cannot fit the request alongside its existing tenants. ### Pitfall F: Treating HAMi-Core Limits as Hardware-Equivalent to MIG As covered above, plan your multi-tenancy story around what `hami-core` actually demonstrated here (per-container quota enforcement through supported CUDA runtime and NVML paths), not around MIG's stronger silicon-level isolation. For adversarial or security-sensitive workloads, use MIG or dedicated GPUs together with the VM or host isolation required by your threat model. ## Conclusion HAMi trades MIG's hardware boundary for flexibility: memory in 1 MiB units, compute in 1% steps, and limits that change with the workload spec instead of a hardware repartition. Here's what this setup walked through: 1. Built a clean Kubernetes v1.35.6 node, configured the NVIDIA default runtime, and installed HAMi v2.9.0 as Helm revision 1. 2. Traced the live `nvidia.com/gpu: 80` to eight registered whole GPUs with `deviceSplitCount: 10`, separating logical concurrency from physical capacity. 3. Ran two fresh PyTorch replicas on one explicitly pinned GPU, each with an 8,000 MiB / 10% grant. 4. Proved that an unfit 90,000 MiB request stayed Pending with `CardInsufficientMemory`, even though logical count slots remained. 5. Reproduced HAMi-Core rejecting an attempted 9,185,657,856-byte total against an 8,388,608,000-byte grant while the neighboring container remained Running with zero restarts. 6. Tested the two easy-to-misunderstand resource forms: GPU-only produced an exclusive 97,887 MiB / 100% allocation, and a 50% request produced 48,943 MiB. 7. Queried the live allocation endpoint on `31993` and usage endpoint on `31992`, then checked the corresponding Grafana view. The payoff is fractional, self-service GPU access without pretending software quotas are silicon walls. For cooperative notebooks, CI jobs, and inference services, that can recover a great deal of stranded capacity. For strict QoS, choose MIG. For mutually untrusted tenants, pair MIG or dedicated GPUs with an appropriate VM or host boundary. HAMi is a CNCF Incubating project. The docs live at [project-hami.io](https://project-hami.io/) and the source at [github.com/Project-HAMi/HAMi](https://github.com/Project-HAMi/HAMi). For the hardware-isolation side of this story, continue with our [MIG deep dive](/blog/slicing-gpus-in-kubernetes-with-nvidia-mig). --- # Slicing GPUs in Kubernetes with NVIDIA Multi-Instance GPU (MIG) - Canonical: https://blog.kubesimplify.com/slicing-gpus-in-kubernetes-with-nvidia-mig - Published: 2026-07-20 - Summary: GPU sharing in Kubernetes explained: time-slicing vs MPS vs MIG, every nvidia-smi command to enable and disable MIG on one GPU or eight, GPU Operator automation, pitfalls, and DCGM monitoring. GPUs are the most expensive thing in your cluster and the worst shared. A CPU can be divided into millicores. Memory can be requested byte by byte. But ask Kubernetes for a GPU and you get the whole card, all 96GB of it, even if your model needs 20GB. This post is the story of fixing that. We take a node with **8x NVIDIA RTX PRO 6000 Blackwell GPUs** (768GB of VRAM in total) and slice it into **32 fully isolated 24GB GPU instances** using **Multi-Instance GPU (MIG)**. First by hand with `nvidia-smi`, one card at a time, so you see every command including how to undo everything. Then across all 8 cards at once. Then declaratively with the NVIDIA GPU Operator so it survives beyond a single SSH session. Who this is for: - Platform engineers and SREs who run GPU nodes in Kubernetes and are tired of watching 96GB cards sit mostly idle. - Teams that need hardware-level tenant isolation on shared AI infrastructure while keeping finance happy about utilization. By the end, a PyTorch workload will be running on a single hardware-isolated slice, and you will be able to prove the isolation from both inside and outside the container. While this setup was tested on a single node, nothing here is single-node specific. It works the same regardless of how many GPU nodes you have. ## The Problem: Kubernetes Hands Out GPUs Whole Here is a scenario that plays out on GPU clusters everywhere. Your team provisions a node equipped with 8x NVIDIA RTX PRO 6000 Blackwell GPUs. You deploy the standard Kubernetes GPU Operator, which registers the node resources as `nvidia.com/gpu: 8`. Then a developer deploys a lightweight LLM inference pod or a small PyTorch training job. Kubernetes assigns them a whole GPU. The workload claims the entire 96GB Blackwell card but only uses 20GB. The remaining 76GB sits idle. Because standard GPUs are scheduled as indivisible resources, eight small workloads lock down eight entire cards. Your platform is left with zero schedulable GPU capacity, artificially high queue latency, and a cluster operating at a fraction of its financial and compute potential. Now you are stuck answering an uncomfortable question: how do you justify low cluster utilization to finance while your development teams are complaining about lack of GPU availability? The fix is GPU sharing. But "sharing a GPU" means very different things depending on how you do it, and picking the wrong mechanism is how you end up with one tenant's memory leak crashing another tenant's training job. ## The Three Ways to Share a GPU NVIDIA gives you three mechanisms to put more than one workload on a card. To understand why they behave so differently, you first need to know what actually happens when a process uses a GPU. ### First: How a Process Talks to a GPU A GPU is a big box of tiny workers. Our card has 188 of them (NVIDIA calls each one a **Streaming Multiprocessor**, or **SM**), and they all crunch numbers at the same time. That parallelism is the whole reason GPUs are fast. Your actual program runs on the CPU. When it needs the GPU, it opens a **session** with the card (the technical name is a *CUDA context*). That session is the program's private workspace on the GPU: it holds the program's data in GPU memory, plus the small GPU programs it wants to run (each of those is called a **kernel**). Every program gets its own session, and one session can never read another's memory. Here is the catch that makes sharing hard: **by default, the GPU runs only one session at a time.** Suppose your program is light and only keeps 20 of the 188 workers busy. The other 168 do not get handed to anyone else, they just sit idle, because it is your turn and the whole card is yours until you finish. A second program cannot squeeze its work onto the free workers; it waits in line. So a GPU, out of the box, is a single-tenant machine: one program at a time, everyone else waiting. Every method below is a different way to attack that one problem. ![The GPU sharing spectrum: time-slicing, MPS, and MIG compared](/img/blog/slicing-gpus-in-kubernetes-with-nvidia-mig/gpu-sharing-spectrum.png) ### Time-Slicing: Take Turns, Fast Time-slicing keeps the "one at a time" rule but makes the turns very short, so it *feels* like sharing. Picture one meeting room and three teams: team A uses it for a few minutes, steps out, team B goes in, then team C, and around again. A GPU does the same thing: program A gets the whole card for a moment, then it is frozen and program B gets it, then C, then back to A. They rotate. Nothing ever runs side by side. Two things go wrong with this. **Switching costs real time.** Every time the card changes hands, it has to save everything the current program was in the middle of and load in the next one's. There is a lot of that state on a GPU, so the hand-off itself burns time that could have gone to actual work. **Nobody enforces fairness.** On a CPU, the operating system forces everyone to take fair turns, so no single program can hog the machine. GPU time-slicing has no such referee: no guaranteed turn length, no priorities. A program that keeps the card busy with back-to-back work simply keeps holding it, and the others starve. (Older GPUs were worse: the card could only change hands once a program *finished* the piece of work it had started, so one long job froze everyone until it was done. Newer GPUs can cut a long job off partway, which helps, but there is still no promise of a fair share.) There is also a practical split worth knowing: this hurts training and inference differently. A training job runs long, heavy work that keeps the whole card busy, so forcing it to take turns just makes everyone slower. Inference usually runs in short bursts with gaps in between, which packs into shared turns much more comfortably. This is why the Kubernetes "time-slicing" feature is a bit of a misnomer. It does not divide time fairly; it just tells Kubernetes "this one GPU is really four," lets four pods land on it, and leaves them to fight over turns. A more honest name is oversubscription: Kubernetes thinks there are four GPUs, the hardware knows there is one. And the biggest gap is memory, which is not divided at all. Every pod draws from the same pool of GPU memory, and nothing tracks who is supposed to get how much. If one pod uses it all (or leaks), the next pod that asks the GPU for memory is refused and crashes, through no fault of its own. Time-slicing is fine for a dev box or bursty, trusted jobs. It is not real isolation. ### MPS: Everyone Shares One Session Time-slicing leaves all those workers idle during each turn. MPS (Multi-Process Service) goes straight after that waste with a trick: if the GPU only runs one session at a time, then put *everyone* in the same session. A helper process sits in front of the card. Every program hands its work to that helper, and the helper feeds all of it to the GPU as though it came from a single, well-behaved program. Because it is now one session, work from different programs really does run at the same time on different workers. Three light services that each need 20% of the card can run together and fill it, instead of taking turns and leaving most of it idle. For lots of small jobs, that is a real speed-up. The catch is the flip side of the same trick. Once everyone is in one shared session, the natural walls between programs are gone. You *can* ask MPS to cap how much of the card or memory each program gets, but those caps are limits you opt into, not walls the hardware enforces. And everyone shares one fate: if a single program crashes hard, it can take down the shared helper, and when that dies, every program's GPU work dies with it. (Modern GPUs at least stop one program from reading another's data.) MPS shines when one team is packing its own jobs onto a card. It is not where you put an untrusted stranger. ### MIG: Split the Card for Real MIG (Multi-Instance GPU) stops playing turn-taking games and physically splits the card into several smaller GPUs. Each one gets its own fixed chunk of memory and its own fixed set of workers, walled off from the rest. Each mini-GPU runs its own sessions at the same time as the others, because as far as the hardware is concerned it genuinely *is* a separate, smaller GPU. That fixes all three problems at once. Run out of memory? You only exhaust *your* slice's memory; the neighbors never notice. Noisy neighbor hogging the card? Cannot happen, because their workers are literally different workers from yours, and their memory traffic runs on separate lanes, so your speed stays steady. A crash? It stays inside your slice. To your workload, a slice just looks like a smaller GPU that behaves predictably. | | Time-slicing | MPS | MIG | | --- | --- | --- | --- | | The trick | Take turns, fast | Everyone shares one session | Split the hardware itself | | Parallelism | None (take turns) | Real (share the workers) | Real (separate hardware) | | Isolation | None | Software (shared session) | Hardware (silicon level) | | Memory protection | None, one shared pool | Opt-in limits | Enforced by hardware | | Fault blast radius | Whole card | Whole card (helper dies, all die) | One slice | | Performance | Variable, context-switch overhead | Good for small kernels | Predictable, dedicated units | | Best for | Dev/test, trusted bursty jobs | One team packing its own card | Shared platforms with real tenant boundaries | If you are building a platform where different teams, customers, or environments share the same silicon, MIG is the only option of the three that gives you a hardware guarantee instead of a promise. That is the mechanism this post is about. One caveat before you commit: MIG needs supported hardware (A100/A30 Ampere onwards, Hopper, Blackwell including the RTX PRO 6000 Server Edition we use here), and a MIG slice cannot exceed one physical card. If your model needs more than 96GB, MIG is not your problem to solve; multi-GPU is. ## Prerequisites To follow along, you will need root access to a GPU node and admin access to a Kubernetes cluster. Here is the exact infrastructure used in this setup: - **Host OS:** Enterprise Linux VM (Ubuntu-based) on Utho Cloud. - **Cloud Provider:** Utho Cloud. - **Kubernetes:** v1.35.6 - **GPU Operator Chart:** gpu-operator-v26.3.3 - **Container Runtime:** `containerd`. - **CPUs:** 256 Cores. - **RAM:** 1259GB. - **GPUs:** 8x NVIDIA RTX PRO 6000 Blackwell Server Edition (96GB VRAM, 188 Streaming Multiprocessors / SMs per card). - **NVIDIA Host Driver:** `610.43.02` with **CUDA:** `13.3`. ```sh root@gpu-rtxpro6000-8:~# nvidia-smi -L GPU 0: NVIDIA RTX PRO 6000 Blackwell Server Edition (UUID: GPU-8b89b58e-b427-108d-ac50-06138d78fe78) GPU 1: NVIDIA RTX PRO 6000 Blackwell Server Edition (UUID: GPU-03a041b7-8abf-360a-d1a2-dfd70188cd5f) GPU 2: NVIDIA RTX PRO 6000 Blackwell Server Edition (UUID: GPU-ba09367f-dd50-32ca-e988-7ff66bece885) GPU 3: NVIDIA RTX PRO 6000 Blackwell Server Edition (UUID: GPU-30512c46-708b-f374-5698-ee24be6cd626) GPU 4: NVIDIA RTX PRO 6000 Blackwell Server Edition (UUID: GPU-4c395b7a-a7e6-d90f-1ced-d96e8dd68288) GPU 5: NVIDIA RTX PRO 6000 Blackwell Server Edition (UUID: GPU-04dc48d7-7048-aef5-ad36-f5db716e7668) GPU 6: NVIDIA RTX PRO 6000 Blackwell Server Edition (UUID: GPU-f4f5db98-143f-0a8d-47ce-956fab39a736) GPU 7: NVIDIA RTX PRO 6000 Blackwell Server Edition (UUID: GPU-f4c61521-240a-da09-2787-e576034e197e) ``` ## The MIG Mental Model: GPU Instances and Compute Instances Before slicing a GPU, it is crucial to understand what a slice actually contains. Every slice consists of a **GPU Instance (GI)** and a **Compute Instance (CI)**. The easiest way to think about them: 1. **The GPU Instance (GI) is the plot of land.** When you create a GI, you carve out a physical chunk of VRAM and its memory controllers. This land is securely fenced off at the silicon level. No other partition or process on the GPU can cross this boundary or access this memory. 2. **The Compute Instance (CI) is the building built on that land.** The building houses the execution machinery: the Streaming Multiprocessors (SMs) and Tensor Cores that perform the actual mathematical computations. ![GPU Instance is the plot of land, Compute Instance is the building on it](/img/blog/slicing-gpus-in-kubernetes-with-nvidia-mig/gi-ci-relationship.png) The relationship between a GI and a CI is strictly hierarchical: - **No building without land.** You cannot establish a Compute Instance without first creating a parent GPU Instance. Execution units must have a dedicated memory boundary to run in. - **Size constraints.** The capacity of the building (CI) cannot exceed the size of the plot of land (GI). You cannot allocate more compute slices (SMs) than the parent VRAM slice naturally supports. - **Sub-division.** You can build a single large structure (one CI matching the full GI) or divide the plot to support multiple smaller buildings (multiple smaller CIs). In the latter scenario, those compute instances run in parallel, sharing the same VRAM pool of the parent GI while keeping their execution cores strictly isolated. - **Teardown order matters.** You cannot clear the plot of land (destroy the GI) while the building is still standing (the CI exists). The driver will reject the command. You must demolish the building first (destroy all CIs), then reclaim the land (destroy the parent GI). This rule shows up again below in the hands-on teardown steps. Why split both memory and compute? If you only partitioned the memory and left the compute cores shared, you would have multiple isolated storage units but one set of hands trying to access all of them at once, causing traffic jams. If you only partitioned the compute cores but kept a shared memory pool, you would have independent workers writing on the same sheet of paper, causing conflicts and corruption. By slicing both, every tenant gets their own locked filing cabinet and their own dedicated worker. ### How to Read MIG Profile Names NVIDIA profiles follow the naming scheme `{X}g.{Y}gb`: - **`{Y}gb`** is the VRAM partition (the GI layer). - **`{X}g`** is the compute slice count. The RTX PRO 6000 Blackwell has 188 SMs and divides its silicon into **4 base compute slices**. So a **`1g.24gb`** slice gets roughly 1/4th of the card: **46 SMs** paired with a 24GB VRAM partition. (It is a hair under a clean quarter because the driver holds a few SMs back, so four slices use 184 of the 188.) A `2g.48gb` slice gets half the card, and `4g.96gb` is the full card expressed as a single MIG instance. You will also see suffixed variants like `1g.24gb-me` (a pure-compute slice with the video decode/encode engines stripped out) and `1g.24gb+me.all` (one slice that grabs all of the card's media engines). We will see the full list straight from the driver in a moment. ## Hands-On Part 1: Slice One GPU, End to End Everything in this section happens on the host, with no Kubernetes involved. We will take GPU 0 through the complete lifecycle: check, enable, inspect, carve, verify, run, and then tear it all back down. If you understand this section, the rest of the post is just automation. ### Step 0: Check the current MIG mode ```sh root@gpu-rtxpro6000-8:~# nvidia-smi -i 0 --query-gpu=index,name,mig.mode.current --format=csv index, name, mig.mode.current 0, NVIDIA RTX PRO 6000 Blackwell Server Edition, Disabled ``` `-i 0` targets GPU index 0. Drop the `-i` flag and the same query prints the state of every card in the box. ### Step 1: Stop the daemons holding the GPU Two operations here look similar but behave very differently under load: - **Toggling MIG mode (`nvidia-smi -mig 1`):** on older architectures like the Ampere A100, enabling MIG forced a disruptive GPU reset. Starting with Hopper and Blackwell, this is a non-disruptive logical toggle in the driver. - **Carving the slices (`nvidia-smi mig -cgi ...`):** this is where physical reality hits. The command forces the GPU's memory controllers to partition the memory crossbars on the silicon. If any process holds even a single megabyte of VRAM, the operation fails with `Device or resource busy`. On enterprise systems, two background services commonly hold handles on GPU device files: 1. **`nvidia-persistenced`:** keeps the driver loaded in kernel memory to avoid launch latency for new CUDA processes. 2. **`nvidia-fabricmanager`:** used on multi-GPU systems connected via NVSwitches (HGX baseboards). If your GPUs are slotted directly into PCIe like ours, this service is not present. Stop what applies to you: ```sh root@gpu-rtxpro6000-8:~# sudo systemctl stop nvidia-persistenced ``` Also make sure no CUDA workload (an Ollama server, a Jupyter kernel, anything) is running on the GPU you are about to slice. `nvidia-smi` shows active processes at the bottom of its output. ### Step 2: Enable MIG mode on GPU 0 ```sh root@gpu-rtxpro6000-8:~# sudo nvidia-smi -i 0 -mig 1 Enabled MIG Mode for GPU 00000000:01:00.0 Warning: persistence mode is disabled on device 00000000:01:00.0. See the Known Issues section of the nvidia-smi(1) man page for more information. Run with [--help | -h] switch to get more information on how to enable persistence mode. All done. ``` The warning is expected: we stopped `nvidia-persistenced` ourselves one step ago, and the driver is just pointing that out. If the driver cannot flip the mode because a client is still attached, it reports the GPU as being in a *pending enable* state instead. Kill the remaining clients (or reboot) and the mode activates. At this point the GPU is in MIG mode but has zero slices, which means **no CUDA workload can use it at all** until you carve instances. An enabled-but-empty MIG GPU is effectively offline for compute. Do not stop halfway. ### Step 3: See what profiles the card supports Ask the driver what shapes it can cut: ```sh root@gpu-rtxpro6000-8:~# nvidia-smi mig -i 0 -lgip +-------------------------------------------------------------------------------+ | GPU instance profiles: | | GPU Name ID Instances Memory P2P SM DEC ENC | | Free/Total GiB CE JPEG OFA | |===============================================================================| | 0 MIG 1g.24gb 14 4/4 23.62 No 46 1 1 | | 1 1 0 | +-------------------------------------------------------------------------------+ | 0 MIG 1g.24gb+me 21 1/1 23.62 No 46 1 1 | | 1 1 1 | +-------------------------------------------------------------------------------+ | 0 MIG 1g.24gb+gfx 47 4/4 23.62 No 46 1 1 | | 1 1 0 | +-------------------------------------------------------------------------------+ | 0 MIG 1g.24gb+me.all 65 1/1 23.62 No 46 4 4 | | 1 4 1 | +-------------------------------------------------------------------------------+ | 0 MIG 1g.24gb-me 67 4/4 23.62 No 46 0 0 | | 1 0 0 | +-------------------------------------------------------------------------------+ | 0 MIG 2g.48gb 5 2/2 47.38 No 94 2 2 | | 2 2 0 | +-------------------------------------------------------------------------------+ | 0 MIG 2g.48gb+gfx 35 2/2 47.38 No 94 2 2 | | 2 2 0 | +-------------------------------------------------------------------------------+ | 0 MIG 2g.48gb+me.all 64 1/1 47.38 No 94 4 4 | | 2 4 1 | +-------------------------------------------------------------------------------+ | 0 MIG 2g.48gb-me 66 2/2 47.38 No 94 0 0 | | 2 0 0 | +-------------------------------------------------------------------------------+ | 0 MIG 4g.96gb 0 1/1 95.12 No 188 4 4 | | 4 4 1 | +-------------------------------------------------------------------------------+ | 0 MIG 4g.96gb+gfx 32 1/1 95.12 No 188 4 4 | | 4 4 1 | +-------------------------------------------------------------------------------+ ``` Read this table carefully, it is the source of truth for your card: - **ID** is the numeric profile ID you can use in create commands (`14` and the name `1g.24gb` are interchangeable). - **Instances Free/Total** tells you how many of each profile fit: four 1g.24gb slices, or two 2g.48gb, or one 4g.96gb. - **SM** confirms the compute split: 46/94/188 SMs. - The suffixed variants redistribute the media engines and graphics support. Every plain slice already gets a proportional share of the media engines (the `1g.24gb` row shows one NVDEC and one NVENC). The suffixes change that: `-me` strips them out for a pure-compute slice, `+me.all` hands a single slice all of the card's decode/encode engines (hence it is limited to 1/1), and `+gfx` (new on Blackwell) enables graphics APIs inside the slice. You can also ask where those slices physically land on the card: ```sh root@gpu-rtxpro6000-8:~# nvidia-smi mig -i 0 -lgipp GPU 0 Profile ID 14 Placements: {0,3,6,9}:3 GPU 0 Profile ID 21 Placements: {0,3,6,9}:3 GPU 0 Profile ID 47 Placements: {0,3,6,9}:3 GPU 0 Profile ID 65 Placements: {0,3,6,9}:3 GPU 0 Profile ID 67 Placements: {0,3,6,9}:3 GPU 0 Profile ID 5 Placements: {0,6}:6 GPU 0 Profile ID 35 Placements: {0,6}:6 GPU 0 Profile ID 64 Placements: {0,6}:6 GPU 0 Profile ID 66 Placements: {0,6}:6 GPU 0 Profile ID 0 Placement : {0}:12 GPU 0 Profile ID 32 Placement : {0}:12 ``` Think of the card's memory as a row of 12 equal parking spots. A `1g.24gb` slice is a small car that takes 3 spots, a `2g.48gb` is a longer car that takes 6, and the whole-card `4g.96gb` takes all 12. The `{0,3,6,9}:3` notation just lists where each size is allowed to park: a small slice can start at spot 0, 3, 6, or 9; a `2g.48gb` needs 6 spots in a row, so it can only start at 0 or 6. This is where fragmentation bites. Say you park one small slice starting at spot 3 and another at spot 6. You have used 6 of the 12 spots, so half the card looks free, but the free spots are 0-2 and 9-11: two separate gaps of 3. A `2g.48gb` needs 6 in a row, and there is no run of 6 left, so it will not fit even though half the memory is idle. The fix is to plan the layout up front (carve the big slices first, or make every slice the same size) instead of adding slices ad hoc and painting yourself into a corner. ### Step 4: Carve the slices Create four `1g.24gb` GPU instances, each with its compute instance, in one command. `-cgi` creates the GPU Instances (the land), and the `-C` flag immediately builds the matching Compute Instance (the building) inside each one: ```sh root@gpu-rtxpro6000-8:~# sudo nvidia-smi mig -i 0 -cgi 1g.24gb,1g.24gb,1g.24gb,1g.24gb -C Successfully created GPU instance ID 3 on GPU 0 using profile MIG 1g.24gb (ID 14) Successfully created compute instance ID 0 on GPU 0 GPU instance ID 3 using profile MIG 1g.24gb (ID 0) Successfully created GPU instance ID 4 on GPU 0 using profile MIG 1g.24gb (ID 14) Successfully created compute instance ID 0 on GPU 0 GPU instance ID 4 using profile MIG 1g.24gb (ID 0) Successfully created GPU instance ID 5 on GPU 0 using profile MIG 1g.24gb (ID 14) Successfully created compute instance ID 0 on GPU 0 GPU instance ID 5 using profile MIG 1g.24gb (ID 0) Successfully created GPU instance ID 6 on GPU 0 using profile MIG 1g.24gb (ID 14) Successfully created compute instance ID 0 on GPU 0 GPU instance ID 6 using profile MIG 1g.24gb (ID 0) ``` `sudo nvidia-smi mig -i 0 -cgi 14,14,14,14 -C` does exactly the same thing using the profile IDs from the table above. You do not have to make them all the same size. Want one big tenant and two small ones on the same card? Mix profiles: ```sh root@gpu-rtxpro6000-8:~# sudo nvidia-smi mig -i 0 -cgi 2g.48gb,1g.24gb,1g.24gb -C Successfully created GPU instance ID 1 on GPU 0 using profile MIG 2g.48gb (ID 5) Successfully created compute instance ID 0 on GPU 0 GPU instance ID 1 using profile MIG 2g.48gb (ID 1) Successfully created GPU instance ID 5 on GPU 0 using profile MIG 1g.24gb (ID 14) Successfully created compute instance ID 0 on GPU 0 GPU instance ID 5 using profile MIG 1g.24gb (ID 0) Successfully created GPU instance ID 6 on GPU 0 using profile MIG 1g.24gb (ID 14) Successfully created compute instance ID 0 on GPU 0 GPU instance ID 6 using profile MIG 1g.24gb (ID 0) root@gpu-rtxpro6000-8:~# nvidia-smi mig -i 0 -lgi +---------------------------------------------------------+ | GPU instances: | | GPU Name Profile Instance Placement | | ID ID Start:Size | |=========================================================| | 0 MIG 1g.24gb 14 5 6:3 | +---------------------------------------------------------+ | 0 MIG 1g.24gb 14 6 9:3 | +---------------------------------------------------------+ | 0 MIG 2g.48gb 5 1 0:6 | +---------------------------------------------------------+ ``` One 48GB half-card slice (placement 0:6) plus two 24GB quarter-card slices (6:3 and 9:3), and the placement grid adds up to exactly 12. This is how you serve a large LLM and two small inference services from a single physical GPU with hard boundaries between them. For the rest of this walkthrough we stick with the uniform four-slice layout, so tear the mixed one down (`-dci`, then `-dgi`) and recreate the four `1g.24gb` slices if you followed along. ### Step 5: Verify the slices exist Three views of the same truth. The device list: ```sh root@gpu-rtxpro6000-8:~# nvidia-smi -L GPU 0: NVIDIA RTX PRO 6000 Blackwell Server Edition (UUID: GPU-8b89b58e-b427-108d-ac50-06138d78fe78) MIG 1g.24gb Device 0: (UUID: MIG-445da789-865d-5fd1-b2b6-32a48bf66c39) MIG 1g.24gb Device 1: (UUID: MIG-e78fd5d2-f2de-5632-8961-9a368cec8080) MIG 1g.24gb Device 2: (UUID: MIG-b8861912-6285-56a1-99ca-297ac0f38ddb) MIG 1g.24gb Device 3: (UUID: MIG-f46c5ab9-40ef-54a2-b796-a2101a6ed56d) GPU 1: NVIDIA RTX PRO 6000 Blackwell Server Edition (UUID: GPU-03a041b7-8abf-360a-d1a2-dfd70188cd5f) ... ``` Every MIG device has its own UUID. This is exactly how the Kubernetes device plugin will identify and schedule the slices later. The GPU instances (the land plots) with their physical placements: ```sh root@gpu-rtxpro6000-8:~# nvidia-smi mig -i 0 -lgi +---------------------------------------------------------+ | GPU instances: | | GPU Name Profile Instance Placement | | ID ID Start:Size | |=========================================================| | 0 MIG 1g.24gb 14 3 0:3 | +---------------------------------------------------------+ | 0 MIG 1g.24gb 14 4 3:3 | +---------------------------------------------------------+ | 0 MIG 1g.24gb 14 5 6:3 | +---------------------------------------------------------+ | 0 MIG 1g.24gb 14 6 9:3 | +---------------------------------------------------------+ ``` Each instance occupies 3 units of the 12-unit placement grid, exactly as `-lgipp` promised. And the compute instances (the buildings) inside them: ```sh root@gpu-rtxpro6000-8:~# nvidia-smi mig -i 0 -lci +--------------------------------------------------------------------+ | Compute instances: | | GPU GPU Name Profile Instance Placement | | Instance ID ID Start:Size | | ID | |====================================================================| | 0 3 MIG 1g.24gb 0 0 0:1 | +--------------------------------------------------------------------+ | 0 4 MIG 1g.24gb 0 0 0:1 | +--------------------------------------------------------------------+ | 0 5 MIG 1g.24gb 0 0 0:1 | +--------------------------------------------------------------------+ | 0 6 MIG 1g.24gb 0 0 0:1 | +--------------------------------------------------------------------+ ``` ### Step 6: Run something on a slice (no Kubernetes needed) MIG slices are addressable directly from the host via their UUID. This is handy for smoke-testing before the cluster ever gets involved. With PyTorch available on the host (a quick `python3 -m venv` plus `pip install torch --index-url https://download.pytorch.org/whl/cu130` is enough), pin a process to the first slice's UUID from `nvidia-smi -L`: ```sh root@gpu-rtxpro6000-8:~# CUDA_VISIBLE_DEVICES=MIG-445da789-865d-5fd1-b2b6-32a48bf66c39 python3 -c " import torch print('device count:', torch.cuda.device_count()) print('device name :', torch.cuda.get_device_name(0)) print('total memory:', torch.cuda.get_device_properties(0).total_memory // 2**20, 'MiB')" device count: 1 device name : NVIDIA RTX PRO 6000 Blackwell Server Edition MIG 1g.24gb total memory: 24192 MiB ``` The process sees exactly one device, it identifies itself as a `1g.24gb` slice, and it has 24192 MiB, not the 97887 MiB of the full card. Isolation is already in force at the host level, no Kubernetes required. ### Step 7: Tear it all down (disable MIG) You will reconfigure slices far more often than you think: profile sizes change, tenants come and go, and sometimes you just want the whole card back for one big training run. Remember the hierarchy rule: buildings before land. Compute instances first, then GPU instances, then the mode itself. Destroy the compute instances on GPU 0: ```sh root@gpu-rtxpro6000-8:~# sudo nvidia-smi mig -i 0 -dci Successfully destroyed compute instance ID 0 from GPU 0 GPU instance ID 3 Successfully destroyed compute instance ID 0 from GPU 0 GPU instance ID 4 Successfully destroyed compute instance ID 0 from GPU 0 GPU instance ID 5 Successfully destroyed compute instance ID 0 from GPU 0 GPU instance ID 6 ``` Then the GPU instances: ```sh root@gpu-rtxpro6000-8:~# sudo nvidia-smi mig -i 0 -dgi Successfully destroyed GPU instance ID 3 from GPU 0 Successfully destroyed GPU instance ID 4 from GPU 0 Successfully destroyed GPU instance ID 5 from GPU 0 Successfully destroyed GPU instance ID 6 from GPU 0 ``` If you run `-dgi` before `-dci`, the driver rejects it. This is what actually happens if you try: ```sh root@gpu-rtxpro6000-8:~# sudo nvidia-smi mig -i 0 -dgi Unable to destroy GPU instance ID 3 from GPU 0: In use by another client Failed to destroy GPU instances: In use by another client ``` That is the land-and-building rule enforced in silicon: the CI still standing on GI 3 is the "another client". Also note: a slice with a running workload cannot be destroyed. Stop the pods or processes using it first, otherwise `-dci` fails with the same *In use by another client* error. Now disable MIG mode: ```sh root@gpu-rtxpro6000-8:~# sudo nvidia-smi -i 0 -mig 0 Disabled MIG Mode for GPU 00000000:01:00.0 Warning: persistence mode is disabled on device 00000000:01:00.0. See the Known Issues section of the nvidia-smi(1) man page for more information. Run with [--help | -h] switch to get more information on how to enable persistence mode. All done. ``` And confirm the card is whole again, then bring back the persistence daemon we stopped in Step 1: ```sh root@gpu-rtxpro6000-8:~# nvidia-smi -i 0 --query-gpu=index,mig.mode.current --format=csv index, mig.mode.current 0, Disabled root@gpu-rtxpro6000-8:~# nvidia-smi -L | head -1 GPU 0: NVIDIA RTX PRO 6000 Blackwell Server Edition (UUID: GPU-8b89b58e-b427-108d-ac50-06138d78fe78) root@gpu-rtxpro6000-8:~# sudo systemctl start nvidia-persistenced ``` One GPU, full lifecycle, both directions. That is the entire mechanical core of MIG. ## Hands-On Part 2: All 8 GPUs at Once Everything above used `-i 0` to target one card. The scaling trick is almost embarrassing: **drop the `-i` flag and every command applies to all GPUs**. Enable MIG everywhere: ```sh root@gpu-rtxpro6000-8:~# sudo systemctl stop nvidia-persistenced root@gpu-rtxpro6000-8:~# sudo nvidia-smi -mig 1 Enabled MIG Mode for GPU 00000000:01:00.0 Enabled MIG Mode for GPU 00000000:21:00.0 Enabled MIG Mode for GPU 00000000:41:00.0 Enabled MIG Mode for GPU 00000000:61:00.0 Enabled MIG Mode for GPU 00000000:81:00.0 Enabled MIG Mode for GPU 00000000:A1:00.0 Enabled MIG Mode for GPU 00000000:C1:00.0 Enabled MIG Mode for GPU 00000000:E1:00.0 All done. ``` (The per-GPU persistence-mode warnings are trimmed here; they are the same one we saw in Part 1.) Carve four slices on every MIG-enabled card in one command. The same GI IDs 3 through 6 appear on each of the 8 GPUs: ```sh root@gpu-rtxpro6000-8:~# sudo nvidia-smi mig -cgi 1g.24gb,1g.24gb,1g.24gb,1g.24gb -C Successfully created GPU instance ID 3 on GPU 0 using profile MIG 1g.24gb (ID 14) Successfully created compute instance ID 0 on GPU 0 GPU instance ID 3 using profile MIG 1g.24gb (ID 0) Successfully created GPU instance ID 4 on GPU 0 using profile MIG 1g.24gb (ID 14) Successfully created compute instance ID 0 on GPU 0 GPU instance ID 4 using profile MIG 1g.24gb (ID 0) Successfully created GPU instance ID 5 on GPU 0 using profile MIG 1g.24gb (ID 14) Successfully created compute instance ID 0 on GPU 0 GPU instance ID 5 using profile MIG 1g.24gb (ID 0) Successfully created GPU instance ID 6 on GPU 0 using profile MIG 1g.24gb (ID 14) Successfully created compute instance ID 0 on GPU 0 GPU instance ID 6 using profile MIG 1g.24gb (ID 0) ... (identical output repeats for GPU 1 through GPU 7) ... root@gpu-rtxpro6000-8:~# sudo systemctl start nvidia-persistenced ``` And verify. This is the money shot, 8 physical cards now presenting as 32 isolated devices: ```sh root@gpu-rtxpro6000-8:~# nvidia-smi -L GPU 0: NVIDIA RTX PRO 6000 Blackwell Server Edition (UUID: GPU-8b89b58e-b427-108d-ac50-06138d78fe78) MIG 1g.24gb Device 0: (UUID: MIG-445da789-865d-5fd1-b2b6-32a48bf66c39) MIG 1g.24gb Device 1: (UUID: MIG-e78fd5d2-f2de-5632-8961-9a368cec8080) MIG 1g.24gb Device 2: (UUID: MIG-b8861912-6285-56a1-99ca-297ac0f38ddb) MIG 1g.24gb Device 3: (UUID: MIG-f46c5ab9-40ef-54a2-b796-a2101a6ed56d) GPU 1: NVIDIA RTX PRO 6000 Blackwell Server Edition (UUID: GPU-03a041b7-8abf-360a-d1a2-dfd70188cd5f) MIG 1g.24gb Device 0: (UUID: MIG-c614986b-8ca6-5114-b292-f7fdf532b32e) MIG 1g.24gb Device 1: (UUID: MIG-ba182ecc-31c1-5a67-92f1-b6f14f39cc2f) MIG 1g.24gb Device 2: (UUID: MIG-5976103f-bae0-5bd9-8de2-efcb0651ae5d) MIG 1g.24gb Device 3: (UUID: MIG-09aa478f-191b-55ce-a012-235285f56a44) GPU 2: NVIDIA RTX PRO 6000 Blackwell Server Edition (UUID: GPU-ba09367f-dd50-32ca-e988-7ff66bece885) MIG 1g.24gb Device 0: (UUID: MIG-07cdcefa-6330-5da7-9f25-0686ef5e6e7d) MIG 1g.24gb Device 1: (UUID: MIG-91b206d2-cfbb-57d3-9ffb-00151f932469) MIG 1g.24gb Device 2: (UUID: MIG-83bf707a-b369-5de9-a22a-e882e2a15f23) MIG 1g.24gb Device 3: (UUID: MIG-db98d829-38c6-5490-ab37-54b3c1911690) GPU 3: NVIDIA RTX PRO 6000 Blackwell Server Edition (UUID: GPU-30512c46-708b-f374-5698-ee24be6cd626) MIG 1g.24gb Device 0: (UUID: MIG-2d5513c3-03f4-54f0-9c70-688f7472c927) MIG 1g.24gb Device 1: (UUID: MIG-471a3e25-40ba-5794-9d7b-2dfa7aa0a0ab) MIG 1g.24gb Device 2: (UUID: MIG-f8584627-55b2-52be-ac92-0b4708e7dad6) MIG 1g.24gb Device 3: (UUID: MIG-6e4f5dfc-7e1f-59dc-9fec-a13642abcd08) GPU 4: NVIDIA RTX PRO 6000 Blackwell Server Edition (UUID: GPU-4c395b7a-a7e6-d90f-1ced-d96e8dd68288) MIG 1g.24gb Device 0: (UUID: MIG-0b090ecd-97b3-5022-b410-353a54064db3) MIG 1g.24gb Device 1: (UUID: MIG-12e12a0a-56aa-5258-9cce-fb652a6d60ca) MIG 1g.24gb Device 2: (UUID: MIG-e80daae6-94df-5114-b105-f4b8e14fe00c) MIG 1g.24gb Device 3: (UUID: MIG-c652619d-ef73-5243-8313-163ba19341ce) GPU 5: NVIDIA RTX PRO 6000 Blackwell Server Edition (UUID: GPU-04dc48d7-7048-aef5-ad36-f5db716e7668) MIG 1g.24gb Device 0: (UUID: MIG-ac982967-d17f-5636-8641-078e3f7ee88a) MIG 1g.24gb Device 1: (UUID: MIG-9a6a33e8-352c-5b71-8523-7918f07f19d3) MIG 1g.24gb Device 2: (UUID: MIG-dcdb7566-7372-56e3-ac66-29bbc7332280) MIG 1g.24gb Device 3: (UUID: MIG-61f3820e-e817-5ae1-ac70-7d4ccc6752bd) GPU 6: NVIDIA RTX PRO 6000 Blackwell Server Edition (UUID: GPU-f4f5db98-143f-0a8d-47ce-956fab39a736) MIG 1g.24gb Device 0: (UUID: MIG-fdae208b-7b6b-5360-b14d-7943f835591d) MIG 1g.24gb Device 1: (UUID: MIG-bd87ccf3-dd60-556b-8fae-dd76a00f9f32) MIG 1g.24gb Device 2: (UUID: MIG-4eb83867-7a48-50ec-98e4-590ca4a34bdb) MIG 1g.24gb Device 3: (UUID: MIG-aa39a320-78fe-54f3-a33f-4e14a69a34fc) GPU 7: NVIDIA RTX PRO 6000 Blackwell Server Edition (UUID: GPU-f4c61521-240a-da09-2787-e576034e197e) MIG 1g.24gb Device 0: (UUID: MIG-83e26b2c-d325-5f9e-b6ab-1dd76bf49ee0) MIG 1g.24gb Device 1: (UUID: MIG-769267a5-61cf-5391-815c-df1af5592f2f) MIG 1g.24gb Device 2: (UUID: MIG-bd09eec4-b779-57ff-b2e9-0c5dd4db132d) MIG 1g.24gb Device 3: (UUID: MIG-0f8a469f-1116-5095-9e94-65e7809b554d) ``` Each physical card can now serve 4 tenants independently, each with a hardware-isolated slice. Across 8 cards, that is 32 schedulable GPUs where you previously had 8, and you did not buy a single new card. The fleet-wide teardown is the same story without `-i`, in the same strict order. First all compute instances, then all GPU instances (32 "Successfully destroyed" lines each, trimmed to the last GPU here), then the mode itself: ```sh root@gpu-rtxpro6000-8:~# sudo nvidia-smi mig -dci ... Successfully destroyed compute instance ID 0 from GPU 7 GPU instance ID 3 Successfully destroyed compute instance ID 0 from GPU 7 GPU instance ID 4 Successfully destroyed compute instance ID 0 from GPU 7 GPU instance ID 5 Successfully destroyed compute instance ID 0 from GPU 7 GPU instance ID 6 root@gpu-rtxpro6000-8:~# sudo nvidia-smi mig -dgi ... Successfully destroyed GPU instance ID 3 from GPU 7 Successfully destroyed GPU instance ID 4 from GPU 7 Successfully destroyed GPU instance ID 5 from GPU 7 Successfully destroyed GPU instance ID 6 from GPU 7 root@gpu-rtxpro6000-8:~# sudo nvidia-smi -mig 0 Disabled MIG Mode for GPU 00000000:01:00.0 Disabled MIG Mode for GPU 00000000:21:00.0 Disabled MIG Mode for GPU 00000000:41:00.0 Disabled MIG Mode for GPU 00000000:61:00.0 Disabled MIG Mode for GPU 00000000:81:00.0 Disabled MIG Mode for GPU 00000000:A1:00.0 Disabled MIG Mode for GPU 00000000:C1:00.0 Disabled MIG Mode for GPU 00000000:E1:00.0 All done. root@gpu-rtxpro6000-8:~# nvidia-smi --query-gpu=index,mig.mode.current --format=csv index, mig.mode.current 0, Disabled 1, Disabled 2, Disabled 3, Disabled 4, Disabled 5, Disabled 6, Disabled 7, Disabled ``` Eight whole GPUs again, as if nothing happened. ## Bringing Kubernetes In: The GPU Operator Everything we just did by hand works. It also does not scale. There is no way to GitOps a series of `nvidia-smi` commands and maintain them across a fleet. And the host slices are invisible to Kubernetes until something advertises them to the Kubelet. To run GPU workloads inside containers, three distinct layers have to cooperate, and the **NVIDIA GPU Operator** manages all of them for you: ![The three-layer Kubernetes GPU stack managed by the GPU Operator](/img/blog/slicing-gpus-in-kubernetes-with-nvidia-mig/k8s-gpu-stack.png) **Layer 1: The Host Kernel Driver.** Installed directly on the host OS. It interfaces with the physical PCIe silicon and exposes character device files such as `/dev/nvidia0` and `/dev/nvidiactl`. It does not know or care that Kubernetes exists. **Layer 2: The Container Toolkit (the OCI integration).** Container runtimes like `containerd` can partition CPU and memory, but they cannot natively manage GPUs. The **NVIDIA Container Toolkit** hooks into containerd: when a container requests a GPU, it mounts the driver files (`/dev/nvidia*`, `libcuda.so`) into the container's namespace. **Layer 3: The Kubernetes Device Plugin.** A DaemonSet that queries the host driver, counts the available GPUs or MIG slices, and advertises them to the Kubelet as schedulable capacity. Install the operator with Helm: ```sh root@gpu-rtxpro6000-8:~# helm repo add nvidia https://helm.ngc.nvidia.com/nvidia root@gpu-rtxpro6000-8:~# helm repo update root@gpu-rtxpro6000-8:~# helm install gpu-operator nvidia/gpu-operator \ -n gpu-operator --create-namespace \ --set mig.strategy=mixed ``` About that `mig.strategy` flag, it controls how slices show up as Kubernetes resources: - **`single`** (the default): all GPUs on the node carry one uniform profile, and slices are advertised as plain `nvidia.com/gpu`. Workloads do not even know MIG is involved. - **`mixed`:** each profile is advertised as its own resource, like `nvidia.com/mig-1g.24gb` or `nvidia.com/mig-2g.48gb`. This is what you want when different cards carry different geometries, or when workloads should explicitly pick a slice size. We use `mixed` in this post so the slice type is visible end to end. The operator deploys these components in the `gpu-operator` namespace: - **`gpu-operator` (controller):** watches `ClusterPolicy` custom resources and reconciles all the DaemonSets below on every GPU node. - **`node-feature-discovery (NFD)`:** scans host hardware and labels nodes (for example, `nvidia.com/gpu.present=true`). - **`gpu-feature-discovery (GFD)`:** adds fine-grained GPU labels such as memory size, model, and active MIG profiles (for example, `nvidia.com/mig.config=all-1g.24gb`). - **`nvidia-container-toolkit`:** registers the `nvidia` runtime class in `/etc/containerd/config.toml`: ```toml [plugins.'io.containerd.cri.v1.runtime'.containerd.runtimes.'nvidia'] runtime_type = "io.containerd.runc.v2" [plugins.'io.containerd.cri.v1.runtime'.containerd.runtimes.'nvidia'.options] BinaryName = "/usr/local/nvidia/toolkit/nvidia-container-runtime" SystemdCgroup = true ``` - **`nvidia-device-plugin`:** queries the driver's NVML library for MIG slice UUIDs and advertises them to the Kubelet (for example, `nvidia.com/mig-1g.24gb: 32`). - **`nvidia-mig-manager`:** watches the `nvidia.com/mig.config` node label and reconfigures MIG geometry declaratively. More on this next. - **`nvidia-dcgm-exporter`:** exposes per-slice hardware telemetry on a Prometheus `/metrics` endpoint. - **`nvidia-operator-validator`:** runs a one-shot CUDA job to verify the whole software-to-hardware pipeline before user pods land. ### Declarative MIG: One Label Instead of All Those Commands With the operator in place, the entire hands-on section above compresses into a single node label: ```sh root@gpu-rtxpro6000-8:~# kubectl label node nvidia.com/mig.config=all-1g.24gb --overwrite ``` The MIG Manager notices the label and orchestrates the full lifecycle through a structured loop: 1. **Evicts GPU workloads:** sets the node's GPU allocatable to `0` and drains GPU pods to release device locks. 2. **Stops telemetry daemons:** pauses the device plugin and DCGM exporter so NVML has no clients. 3. **Resets state:** clears VRAM and any existing MIG geometry. 4. **Applies the new geometry:** enables MIG mode and carves GIs and CIs exactly like our manual commands did. 5. **Regenerates CDI specs:** writes new Container Device Interface configs so the runtime can inject the new devices. 6. **Restores the stack:** restarts the device plugin and exporter, which advertise the 32 new slices to the Kubelet. Verify from the cluster side: ```sh root@gpu-rtxpro6000-8:~# kubectl describe node | grep mig-1g.24gb nvidia.com/mig-1g.24gb: 32 ``` To go back to whole GPUs, the disable path is also just a label. `all-disabled` destroys every slice and turns MIG mode off, and the node advertises `nvidia.com/gpu: 8` again: ```sh root@gpu-rtxpro6000-8:~# kubectl label node nvidia.com/mig.config=all-disabled --overwrite ``` Mixed geometries are possible too: built-in profiles like `all-balanced`, or a custom layout in the `mig-parted` ConfigMap that gives different cards different shapes. Everything we did with `-cgi 2g.48gb,1g.24gb,1g.24gb` has a declarative equivalent. ### How the NVIDIA Runtime Injects GPUs into Containers To understand why those containerd configuration blocks are necessary, here is the flow when a pod actually starts: ![How the NVIDIA runtime injects GPU devices into a container](/img/blog/slicing-gpus-in-kubernetes-with-nvidia-mig/nvidia-runtime-flow.png) 1. **Pod submission:** a developer submits a Pod requesting a GPU (`nvidia.com/gpu: 1` or a specific MIG resource). 2. **containerd interception:** containerd sees the pod uses the `nvidia` runtime class and prepares the container. 3. **nvidia-container-runtime (the middleman):** reads the container's environment (such as `NVIDIA_VISIBLE_DEVICES` carrying the MIG UUID), locates the matching device files under `/dev/nvidia*` and driver libraries like `libcuda.so` on the host, and injects them into the container's OCI spec. 4. **runc execution:** the modified spec goes to `runc`, which sets up namespaces, cgroups, and mounts, then starts the container. 5. **Workload runs:** PyTorch or TensorFlow inside the container talks to its GPU slice natively, because the devices and libraries were injected during the handshake. ## Common Production Pitfalls and How to Solve Them Here is the quick-reference version, the way you will hit these at 2 AM: | Symptom | Likely cause | Fix | | --- | --- | --- | | Toolkit cannot find containerd, or runtime class never appears | Non-standard containerd paths (RKE2/K3s) | Point the toolkit at the right socket and config via Helm env overrides (Pitfall A) | | Your manual slices vanish after installing the Operator | MIG Manager defaults to `all-disabled` when no node label exists | Label the node with the matching `nvidia.com/mig.config` profile (Pitfall B) | | You changed slices on the host but Kubernetes shows the old layout, Operator logs frozen | MIG Manager only reacts to node label events, it never polls the hardware | Restart the MIG Manager pod or toggle the label to force reconciliation (Pitfall C) | | Manual MIG mode: Kubelet never discovers new slices | Nothing tells the device plugin the hardware changed | Disable `migManager` in Helm and restart the device plugin daemonset (Pitfall D) | Now the details. ### Pitfall A: Non-Standard containerd Socket Configurations The GPU Operator assumes default paths for the containerd socket and config files. If your setup runs containerd through RKE2, you must explicitly point the toolkit to the non-standard paths during Helm installation: ```yaml toolkit: env: - name: CONTAINERD_CONFIG value: /var/lib/rancher/rke2/agent/etc/containerd/config.toml - name: CONTAINERD_SOCKET value: /run/k3s/containerd/containerd.sock - name: CONTAINERD_RUNTIME_CLASS value: nvidia - name: CONTAINERD_SET_AS_DEFAULT value: "true" ``` ### Pitfall B: MIG Manager Overriding Host Configurations If the `nvidia-mig-manager` pod is active and detects no configuration label on a node, it defaults to the `all-disabled` profile, wiping out any manual host-level slicing you performed. All that careful `nvidia-smi` work, gone. **The fix:** apply the appropriate label to your node so it aligns with the operator's config: ```sh root@gpu-rtxpro6000-8:~# kubectl label node nvidia.com/mig.config=all-1g.24gb --overwrite ``` ### Pitfall C: Host-Level State Drift Undetected by MIG Manager If you manually delete or modify MIG profiles directly on the host using the `nvidia-smi` CLI, the changes will not be reflected in Kubernetes, and the operator will appear completely silent about it. **The root cause:** the `nvidia-mig-manager` watches for **Kubernetes node label events**. It does not poll the host's physical GPU registers continuously. Because your manual host modifications do not trigger Kubernetes events, the manager remains idle, thinking the old state is still successfully applied (its logs will remain frozen). **The fix (for operator-managed MIG):** trigger a reconciliation event. Either restart the MIG Manager pod (which forces a full check on boot) or toggle the node label back and forth: ```sh # Option 1: Restart the MIG Manager daemonset root@gpu-rtxpro6000-8:~# kubectl rollout restart daemonset -n gpu-operator nvidia-mig-manager # Option 2: Toggle the node label to trigger the watch loop root@gpu-rtxpro6000-8:~# kubectl label node nvidia.com/mig.config=all-disabled --overwrite # Wait 10 seconds, then re-apply: root@gpu-rtxpro6000-8:~# kubectl label node nvidia.com/mig.config=all-1g.24gb --overwrite ``` ### Pitfall D: Forcing Kubelet Discovery for Manual MIG Configurations Maybe you want the opposite arrangement: manage MIG slices manually on the host with the exact commands from the hands-on sections, prevent the GPU Operator from ever overwriting them, but still have Kubernetes discover and schedule workloads on those manual slices. **The root cause:** when the automatic `nvidia-mig-manager` is disabled to allow manual slicing, there is no automated trigger to notify the Kubelet when the host-level MIG configuration changes. **The fix (force Kubelet discovery):** 1. **Disable the MIG Manager in Helm** so the operator never wipes your manual settings: ```sh root@gpu-rtxpro6000-8:~# helm upgrade --install gpu-operator nvidia/gpu-operator \ -n gpu-operator \ --set migManager.enabled=false \ --set mig.strategy=mixed ``` 2. **Provision host slices** manually with the `nvidia-smi mig -cgi ... -C` commands from Part 1 and Part 2. 3. **Force Kubelet discovery** by restarting the device plugin daemonset: ```sh root@gpu-rtxpro6000-8:~# kubectl rollout restart daemonset -n gpu-operator nvidia-device-plugin-daemonset ``` ## How to Monitor GPU Slices with Prometheus and Grafana A platform is only as good as its observability. Once your 32 GPU slices are registered, you need a centralized dashboard to monitor metrics like VRAM usage, temperature, and Tensor Core utilization. To achieve this, deploy the Prometheus community stack and hook it into the telemetry streams of the `nvidia-dcgm-exporter`. **Step 1: Install the kube-prometheus-stack.** ```sh root@gpu-rtxpro6000-8:~# helm repo add prometheus-community https://prometheus-community.github.io/helm-charts root@gpu-rtxpro6000-8:~# helm repo update root@gpu-rtxpro6000-8:~# helm install prometheus prometheus-community/kube-prometheus-stack \ -n monitoring --create-namespace ``` **Step 2: Apply the ServiceMonitor.** By default, Prometheus only scans its own namespace. This manifest uses a `namespaceSelector` to target the `nvidia-dcgm-exporter` Service inside the `gpu-operator` namespace: ```yaml apiVersion: monitoring.coreos.com/v1 kind: ServiceMonitor metadata: name: nvidia-dcgm-exporter # Deploy in the 'monitoring' namespace where Prometheus runs namespace: monitoring labels: release: prometheus app.kubernetes.io/instance: prometheus app.kubernetes.io/managed-by: Helm spec: # Scrapes the service in the gpu-operator namespace namespaceSelector: matchNames: - gpu-operator selector: matchLabels: # Matches the exact standard label created by the NVIDIA GPU Operator Helm chart app: nvidia-dcgm-exporter endpoints: - port: gpu-metrics interval: 15s path: /metrics ``` Apply it: ```sh root@gpu-rtxpro6000-8:~# kubectl apply -f nvidia-servicemonitor.yaml ``` **Step 3: Configure the Grafana dashboard.** NVIDIA maintains an official dashboard for DCGM exporter metrics: 1. Log into your Grafana UI. 2. Navigate to **Dashboards** -> **Import**. 3. Import **Dashboard ID: `22515`**. 4. Select your Prometheus data source and click **Import**. This loads an interactive panel showing real-time health and performance across all 32 partitions: ![Grafana NVIDIA DCGM dashboard showing per-GPU power, memory, temperature, and Tensor Core utilization](/img/blog/slicing-gpus-in-kubernetes-with-nvidia-mig/gpu-observability.jpg) ## Run a Real Workload on a Slice (Blackwell, sm_120) There is one final trap waiting at the workload layer. The NVIDIA Blackwell architecture uses a new compute capability version: **Compute Capability 12.0 (`sm_120`)**. If you use older container images (such as `pytorch:2.1.2-cuda12.1`), execution crashes with: ``` RuntimeError: CUDA error: no kernel image is available for execution on the device ``` Older PyTorch binaries simply do not contain compiled kernels for `sm_120`. Use a modern PyTorch image compiled with CUDA 12.8+ or the official **NVIDIA NGC PyTorch containers** (version `25.01-py3` or later), which natively support Blackwell. Here is the verified Deployment manifest that runs a matrix multiplication load on a single 24GB Blackwell MIG slice. Note the resource request: `nvidia.com/mig-1g.24gb`, the mixed-strategy resource name our device plugin advertises: ```yaml apiVersion: apps/v1 kind: Deployment metadata: name: pytorch-mig-demo labels: app: pytorch-mig-demo spec: replicas: 1 selector: matchLabels: app: pytorch-mig-demo template: metadata: labels: app: pytorch-mig-demo spec: runtimeClassName: nvidia containers: - name: pytorch # NVIDIA's official PyTorch NGC container (25.01 or later) # compiled to support the Blackwell architecture (sm_120) image: nvcr.io/nvidia/pytorch:25.01-py3 command: ["python3", "-c"] args: - | import torch import time print("=== CUDA MIG Slice Diagnostics ===") print("CUDA Available:", torch.cuda.is_available()) if torch.cuda.is_available(): print("Device Name:", torch.cuda.get_device_name(0)) print("Device Capability:", torch.cuda.get_device_capability(0)) print("CUDA Device Count:", torch.cuda.device_count()) # Allocate memory and perform matrix multiplication to generate GPU load print("Allocating tensors on GPU and starting matrix math load...") device = torch.device("cuda") x = torch.randn(10000, 10000, device=device) y = torch.randn(10000, 10000, device=device) # Keep running matrix multiplications to hold the CUDA context and load while True: z = torch.matmul(x, y) time.sleep(0.5) else: print("ERROR: CUDA is not available inside the container!") time.sleep(3600) resources: limits: nvidia.com/mig-1g.24gb: 1 requests: nvidia.com/mig-1g.24gb: 1 ``` ### Prove the Isolation, From Both Sides Once the pod is running, exec into the container and run `nvidia-smi`. You will observe exactly **one GPU** with **24GB VRAM**. The container cannot see the other 7 physical cards or the other 31 slices: ```sh root@gpu-rtxpro6000-8:~# kubectl exec -it pytorch-mig-demo-c9f7c8b49-sl5qh -- bash root@pytorch-mig-demo-c9f7c8b49-sl5qh:/workspace# nvidia-smi Tue Jul 14 19:44:18 2026 +-----------------------------------------------------------------------------------------+ | NVIDIA-SMI 610.43.02 KMD Version: 610.43.02 CUDA UMD Version: 13.3 | +-----------------------------------------+------------------------+----------------------+ | GPU Name Persistence-M | Bus-Id Disp.A | Volatile Uncorr. ECC | | Fan Temp Perf Pwr:Usage/Cap | Memory-Usage | GPU-Util Compute M. | | | | MIG M. | |=========================================+========================+======================| | 0 NVIDIA RTX PRO 6000 Blac... On | 00000000:61:00.0 Off | On | | N/A 34C P0 108W / 600W | N/A | N/A Default | | | | Enabled | +-----------------------------------------+------------------------+----------------------+ +-----------------------------------------------------------------------------------------+ | MIG devices: | +------------------+----------------------------------+-----------+-----------------------+ | GPU GI CI MIG | Shared Memory-Usage | Vol| Shared | | ID ID Dev | Shared BAR1-Usage | SM Unc| CE ENC DEC OFA JPG | | | | ECC| | |==================+==================================+===========+=======================| | 0 6 0 0 | 1786MiB / 24192MiB | 46 0 | 1 1 1 0 1 | | | 0MiB / 8317MiB | | | +------------------+----------------------------------+-----------+-----------------------+ +-----------------------------------------------------------------------------------------+ | Processes: | | GPU GI CI PID Type Process name GPU Memory | | ID ID Usage | |=========================================================================================| | 0 6 0 1 C python3 1714MiB | +-----------------------------------------------------------------------------------------+ ``` The pod only sees the `1g.24gb` slice that has been given to it and nothing else, with its 24GiB of memory shown under the MIG devices section. Now look at the same moment from the host. The host sees everything: all cards, all slices, and the exact same `python3` process burning memory on one specific slice (output trimmed to two GPUs to keep it readable): ```sh root@gpu-rtxpro6000-8:~# nvidia-smi Tue Jul 14 19:52:39 2026 +-----------------------------------------------------------------------------------------+ | NVIDIA-SMI 610.43.02 KMD Version: 610.43.02 CUDA UMD Version: 13.3 | +-----------------------------------------+------------------------+----------------------+ | GPU Name Persistence-M | Bus-Id Disp.A | Volatile Uncorr. ECC | | Fan Temp Perf Pwr:Usage/Cap | Memory-Usage | GPU-Util Compute M. | | | | MIG M. | |=========================================+========================+======================| | 2 NVIDIA RTX PRO 6000 Blac... On | 00000000:41:00.0 Off | On | | N/A 26C P8 40W / 600W | 256MiB / 97887MiB | N/A Default | | | | Enabled | +-----------------------------------------+------------------------+----------------------+ | 3 NVIDIA RTX PRO 6000 Blac... On | 00000000:61:00.0 Off | On | | N/A 36C P0 108W / 600W | 1977MiB / 97887MiB | N/A Default | | | | Enabled | +-----------------------------------------+------------------------+----------------------+ +-----------------------------------------------------------------------------------------+ | MIG devices: | +------------------+----------------------------------+-----------+-----------------------+ | GPU GI CI MIG | Shared Memory-Usage | Vol| Shared | | ID ID Dev | Shared BAR1-Usage | SM Unc| CE ENC DEC OFA JPG | | | | ECC| | |==================+==================================+===========+=======================| | 2 3 0 0 | 64MiB / 24192MiB | 46 0 | 1 1 1 0 1 | | | 0MiB / 8317MiB | | | +------------------+----------------------------------+-----------+-----------------------+ | 2 4 0 1 | 64MiB / 24192MiB | 46 0 | 1 1 1 0 1 | | | 0MiB / 8317MiB | | | +------------------+----------------------------------+-----------+-----------------------+ | 2 5 0 2 | 64MiB / 24192MiB | 46 0 | 1 1 1 0 1 | | | 0MiB / 8317MiB | | | +------------------+----------------------------------+-----------+-----------------------+ | 2 6 0 3 | 64MiB / 24192MiB | 46 0 | 1 1 1 0 1 | | | 0MiB / 8317MiB | | | +------------------+----------------------------------+-----------+-----------------------+ | 3 3 0 0 | 64MiB / 24192MiB | 46 0 | 1 1 1 0 1 | | | 0MiB / 8317MiB | | | +------------------+----------------------------------+-----------+-----------------------+ | 3 4 0 1 | 64MiB / 24192MiB | 46 0 | 1 1 1 0 1 | | | 0MiB / 8317MiB | | | +------------------+----------------------------------+-----------+-----------------------+ | 3 5 0 2 | 64MiB / 24192MiB | 46 0 | 1 1 1 0 1 | | | 0MiB / 8317MiB | | | +------------------+----------------------------------+-----------+-----------------------+ | 3 6 0 3 | 1786MiB / 24192MiB | 46 0 | 1 1 1 0 1 | | | 0MiB / 8317MiB | | | +------------------+----------------------------------+-----------+-----------------------+ +-----------------------------------------------------------------------------------------+ | Processes: | | GPU GI CI PID Type Process name GPU Memory | | ID ID Usage | |=========================================================================================| | 3 6 0 698976 C python3 1714MiB | +-----------------------------------------------------------------------------------------+ ``` Same `python3` process, same 1714MiB, visible in both views because both are looking at the same hardware-isolated MIG slice. The other slices on that card show 64MiB of idle overhead each, completely untouched by the running workload. This is the beauty of MIG. ## The Platform Properties Compared Here is the before-and-after, the way your finance and platform teams will evaluate it: | Property | 8 whole GPUs | 32 MIG slices | | --- | --- | --- | | Schedulable GPU resources | 8 | 32 | | Small workload claims | Entire 96GB card | One 24GB hardware-isolated slice | | Isolation model | Software-level (container boundaries) | Silicon-level (memory crossbars + SM channels) | | OOM blast radius | Can destabilize the whole card | Contained within the 24GB slice | | Developer wait times | Queue behind 8 indivisible cards | Immediate self-service on 32 slices | | Configuration | Ad-hoc `nvidia-smi` scripts | Declarative node labels via the GPU Operator | ## Conclusion GPU sharing is a spectrum. Time-slicing shares by taking turns, MPS shares by trusting neighbors, and MIG shares by building walls in silicon. When the tenants are real (different teams, different customers, different blast radii), MIG is the one that lets you sleep. Here is what you accomplished in this walkthrough: 1. Understood the three GPU sharing mechanisms and why only MIG gives hardware-enforced tenant isolation. 2. Learned the GPU Instance / Compute Instance hierarchy and why MIG must partition both memory and compute. 3. Took a single GPU through the full MIG lifecycle by hand: enable, inspect profiles, carve slices, verify, run a workload, and tear everything back down to a whole card. 4. Scaled the same commands to all 8 GPUs by dropping one flag, producing 32 hardware-isolated `1g.24gb` instances. 5. Deployed the NVIDIA GPU Operator to make the whole configuration declarative and GitOps-friendly, with a single node label replacing the manual command sequence. 6. Diagnosed and fixed four real production pitfalls: non-standard containerd paths, MIG Manager overwrites, host-level state drift, and manual-mode Kubelet discovery. 7. Wired DCGM telemetry into a Prometheus + Grafana dashboard for slice-level observability. 8. Verified the isolation from both sides by running a PyTorch workload on a single `sm_120` slice. Building a high-efficiency GPU platform is not just about having the fastest silicon. It is about managing and distributing that compute pool effectively. Slicing the Blackwell architecture with MIG gives you the balance of strict isolation, cost optimization, and developer self-service that makes the platform actually work. If this was useful, the NVIDIA GPU Operator lives at [docs.nvidia.com](https://docs.nvidia.com/datacenter/cloud-native/gpu-operator/latest/index.html) and on [GitHub](https://github.com/NVIDIA/gpu-operator), and the full MIG user guide is at [docs.nvidia.com/datacenter/tesla/mig-user-guide](https://docs.nvidia.com/datacenter/tesla/mig-user-guide/). --- # Day 5: Local LLM Inference Engines, Wrappers, and What to Pick - Canonical: https://blog.kubesimplify.com/day-5-local-llm-inference-engines-wrappers-and-what-to-pick - Published: 2026-07-17 - Summary: A beginner-friendly guide to local LLM inference, with the same Qwen model tested through Ollama, llama.cpp, Docker Model Runner, vLLM, SGLang, and TensorRT-LLM on NVIDIA DGX Spark. *Day 5 of the Local LLM series, tested on DGX Spark. A field guide to running serious models on a $4,699 box on your desk.* --- In [Day 4](/blog/day-4-quantization-demystified-bf16-fp8-nvfp4-mxfp4-int4-gguf-and-why-it-all-matters), we learned how model numbers are stored: BF16, FP8, NVFP4, MXFP4, INT4, GGUF, and the rest of the 4-bit zoo. Now comes the next layer. You can have the right model and the right quantization format, and still get the wrong experience if you pick the wrong runtime. This is the part people usually compress into one sentence: "Just run it with Ollama." That is a good start. It is not the whole story. On a DGX Spark, the software layer decides: - how the model is loaded into memory - how requests are queued - how multiple users share the GPU - how the KV cache is stored - whether shared prompts are reused - whether speculative decoding is available - whether NVFP4, FP8, MXFP4, or GGUF paths are actually fast - whether your app sees an OpenAI-compatible API or a custom interface So Day 5 is about local LLM inference engines: Ollama, llama.cpp, vLLM, SGLang, Docker Model Runner, LM Studio, TensorRT-LLM, NIM, and the newer serving projects worth watching. One sentence to keep in your head: **The model is the brain. The runtime is the factory floor that makes the brain useful.** ## First, what is inference? One word needs a plain definition before we compare tools: **inference**. Training is when a model learns. The model reads huge amounts of text, makes predictions, gets things wrong, and slowly adjusts its internal numbers, called weights. Training changes the model. It is expensive, usually done on big clusters, and it is not what most people mean when they run a model on a desk machine. Inference is when a trained model is used. The weights are already learned. They are loaded into memory, your prompt goes in, and the model uses those frozen weights to produce an answer one token at a time. So the beginner version is: - **Training** means teaching the model. - **Inference** means using the model. - **Fine-tuning** is a smaller kind of training where you adjust an existing model for a narrower task. Every time you run `ollama run`, send a request to vLLM, or call a local OpenAI-compatible endpoint, you are doing inference. That is why this post matters. Your Spark is not mostly valuable because it can train a frontier model from scratch. It is valuable because it can keep useful trained models close to your apps and run inference locally. ## Then, what is an inference engine? Let us slow this down properly. When you ask a local LLM a question, there are several layers involved. People often call all of them "the inference engine," but that is not quite right. | Layer | Plain English | Example | |---|---|---| | Model file | The learned weights on disk | GGUF file, safetensors folder | | Inference engine | The code that reads the weights and runs the math | llama.cpp, vLLM, SGLang, TensorRT-LLM | | Serving wrapper | The CLI or server around the engine | Ollama, LM Studio, Docker Model Runner | | API surface | The protocol your app talks to | OpenAI-compatible `/v1/chat/completions` | | Application layer | The thing using the model | Hermes Agent, your app, a RAG service | That distinction matters. Ollama is not the same kind of thing as llama.cpp. Ollama is the friendly service and model registry. llama.cpp is the lower-level engine doing the actual GGUF inference underneath. NIM is not the same kind of thing as TensorRT-LLM. NIM is NVIDIA's packaged, supported microservice. TensorRT-LLM is one of the high-performance backend engines NIM can use. Hermes Agent is not an inference engine at all. It is an agent framework that calls a local serving endpoint underneath it. If you remember this stack, the rest of the post becomes much easier. {{day5-runtime-stack-animation}} ## What happens when a request arrives This is the beginner version of the runtime job: 1. Your app sends a prompt. 2. The tokenizer turns the prompt into tokens. 3. The runtime runs **prefill**, where the model reads the prompt and builds the KV cache. 4. The runtime runs **decode**, where the model generates one token at a time. 5. If more users arrive, the scheduler decides which requests get batched together. 6. If prompts share a prefix, the cache manager may reuse old work. 7. If speculative decoding is enabled, a draft path may guess future tokens and let the main model verify them. 8. The server streams tokens back to your app. So the runtime is not just "run matrix multiplication." It is a traffic controller, memory manager, model loader, API server, and performance engineer in one package. This is why two setups on the same Spark can feel completely different. Same GB10. Same unified memory. Similar model size. Wildly different runtime behavior. ## Four clocks, not one speed number Before comparing engines, we need one more beginner mental model. People often ask, "How fast did the model run?" But an inference request has at least four useful clocks: | Clock | What it measures | What it feels like | |---|---|---| | **Model startup** | Time to load weights, compile kernels, and prepare memory | "Why is the server not ready yet?" | | **Time to first token (TTFT)** | Time from sending the request until the first generated token arrives | "Why is the chat bubble still empty?" | | **Inter-token latency (ITL)** | Delay between one generated token and the next | "How smoothly is the answer streaming?" | | **Aggregate throughput** | Total tokens produced for all active users each second | "How many requests can this server handle together?" | The prompt-reading phase, called **prefill**, has a large effect on TTFT. The one-token-at-a-time phase, called **decode**, controls the streaming rhythm after the first token appears. We built those stages from first principles in [Day 2](/blog/day-2-anatomy-of-an-llm-inference-request-from-prompt-to-answer-step-by-step). A runtime can win one clock and lose another. Ollama may feel wonderful for one person. vLLM may take longer to start but serve many simultaneous requests far more efficiently. A benchmark that prints only one `tok/s` number can hide that difference completely. {{day5-inference-metrics-animation}} For the controlled single-request tests in this post, I report **output tokens divided by the full API request time**. I call that **wall output tok/s**. When llama.cpp or Ollama also reports its internal decode rate, I show that separately. This prevents us from pretending two different measurements are the same thing. ## The cast Here is the map for the rest of the post. | Tool | What it really is | Easiest sentence | |---|---|---| | **Ollama** | Friendly local model service, mostly llama.cpp underneath | "Type two commands and chat." | | **llama.cpp** | Low-level GGUF inference engine | "Power users who want every knob." | | **Docker Model Runner** | Docker-native model runner and packaging flow | "Treat models like Docker artifacts." | | **LM Studio / llmster** | Desktop and headless local model service | "Great UX, now Spark-friendly too." | | **vLLM** | Production serving engine | "Best general API-serving path for teams." | | **SGLang** | Serving engine built for structured and shared-prefix workloads | "When many requests reuse the same prompt prefix." | | **TensorRT-LLM** | NVIDIA's high-performance inference library | "When you want the NVIDIA-optimized path and can handle setup." | | **NVIDIA NIM** | Packaged NVIDIA inference microservice | "Supported container with tested model profiles." | | **Hermes Agent / agent frameworks** | Application layer on top | "The thing using the runtime, not the runtime itself." | Calling all of them "inference engines" would be inaccurate. This post is really about **ways to serve LLMs on Spark**, because some entries are engines, some are wrappers, and some are application layers. ## Ollama: the easiest first step [Ollama](https://ollama.com) is still the best first run for most people. On a Spark, DGX OS gives you the NVIDIA and Docker foundation, but Ollama is still a separate install: ```bash curl -fsSL https://ollama.com/install.sh | sh ``` Then: ```bash ollama pull nemotron-3-super ollama run nemotron-3-super ``` That is the magic of Ollama. You are not thinking about safetensors folders, model loaders, Docker images, or server ports. You pull a model and start talking. Under the hood, Ollama usually gives you a llama.cpp-style local inference path. Most of the models people run locally through Ollama are GGUF-style quantized artifacts, though Ollama hides a lot of that detail. ### Ollama on Spark today: what I measured Early Spark testing was messy, and I once hit an older Ollama build that failed to offload correctly. That bug is not the current state of Ollama on Spark. Here is the current box: ```bash nvidia-smi # NVIDIA-SMI 580.159.03 # Driver Version: 580.159.03 # CUDA Version: 13.0 # GPU: NVIDIA GB10 ollama -v # ollama version is 0.30.10 ``` The local model list is not toy-sized: ```bash ollama list # NAME ID SIZE # llama3.1:8b 46e0c10c039e 4.9 GB # gemma4:26b 5571076f3d70 17 GB # qwen2.5vl:7b 5ced39dfa4ba 6.0 GB # nemotron-3-super:latest 95acc78b3ffd 86 GB # qwen3.5:35b-a3b 3460ffeede54 23 GB ``` For the controlled comparison, I pulled Qwen2.5 3B and confirmed the artifact: ```bash ollama pull qwen2.5:3b ollama show qwen2.5:3b # parameters 3.1B # quantization Q4_K_M ``` Then I sent the fixed prompt through Ollama's local API. The `num_predict` option makes the 256-token output limit explicit: ```bash curl -s http://127.0.0.1:11434/api/chat -d '{ "model": "qwen2.5:3b", "messages": [{ "role": "user", "content": "Explain Kubernetes Pods to an absolute beginner. Include a kitchen analogy, what containers inside one Pod share, how a Deployment uses Pods, and one practical example. Write at least 350 words and do not conclude early." }], "stream": false, "options": { "temperature": 0, "seed": 42, "num_predict": 256 } }' | jq '{load_duration, prompt_eval_count, prompt_eval_duration, eval_count, eval_duration}' ``` The first request included model loading: ```text load duration: 5.344s prompt eval count: 75 token(s) prompt eval rate: 738.85 tokens/s eval count: 256 token(s) eval rate: 98.27 tokens/s ``` Across the next three warm requests, the engine-reported decode rates were `98.71`, `99.14`, and `99.52 tok/s`. The first request measured `31.68 wall output tok/s` including load, and the warm median was `93.74 wall output tok/s`. To reproduce the wall numbers, capture the full request time too: either wrap the `curl` in `time`, or add `total_duration` (nanoseconds for the whole request) to the `jq` field list and divide `eval_count` by it. Always verify where the model actually landed: ```bash ollama ps # NAME SIZE PROCESSOR CONTEXT # qwen2.5:3b 3.4 GB 100% GPU 32768 ``` That confirms both the speed and the GPU path. But a 3B model is not why someone buys a 128 GB Spark. I also ran one large-model sanity check. On the same Spark, with the 86 GB Ollama artifact already on disk: ```bash ollama run nemotron-3-super:latest \ "what is a docker container" \ --verbose ``` The output was a long answer, so this is not the same controlled 256-token test as the Qwen result above. But the decode number is still useful: ```text total duration: 1m6.807946086s load duration: 307.733036ms prompt eval count: 22 token(s) prompt eval rate: 31.37 tokens/s eval count: 1343 token(s) eval rate: 20.45 tokens/s ``` That is a 120B-class mixture-of-experts model generating locally through Ollama at about `20 tok/s`. "120B-class" describes all the stored parameters; only part of the model is active for each token. Earlier captures for this same 86 GB Ollama artifact landed around `17.71-19.5 tok/s`, so the current result is a healthy confirmation, not an unexplained jump. This run was done with no other active inference model loaded. The plain-English interpretation: - `prompt eval` is prefill: reading the input prompt and building the KV cache. - `eval` is decode: generating the answer tokens. - For interactive chat, `eval rate` is the number most people feel. Ollama also publishes official DGX Spark benchmarks. The practical lesson is simple: current Ollama builds can use the Spark GPU well. If your run is unexpectedly slow, check `ollama ps`, `ollama -v`, model format, context size, and firmware/driver state before blaming the hardware. ### When Ollama is the right choice Use Ollama when: - you are one person chatting with one model - you want the easiest install - you want a huge model catalog - you want a local endpoint that many tools already understand - you are integrating an agent framework that already supports Ollama Do not start with Ollama when: - you need heavy multi-user batching - you need every low-level runtime flag - you need the fastest NVFP4 or FP8 production path - you need strict production observability and scheduling control Ollama exposes OpenAI-compatible APIs, including newer API surfaces in recent versions, but its strongest role is still local-first serving rather than a full production scheduler. ## llama.cpp: the engine under the friendly wrappers [`llama.cpp`](https://github.com/ggml-org/llama.cpp) is the C++ inference engine behind a huge part of local AI. Ollama wraps it. LM Studio often wraps it. Docker Model Runner can use it. Many GGUF workflows eventually lead back to it. If Ollama is the automatic car, llama.cpp is the manual gearbox. Example: ```bash docker run -d --name llama-server --gpus all -p 8080:8080 \ -v /opt/models:/models \ ghcr.io/ggml-org/llama.cpp:server-cuda \ -m /models/gemma-3-27b-it-Q4_K_M.gguf \ --n-gpu-layers 999 \ --ctx-size 4096 \ --port 8080 ``` The flag `--n-gpu-layers 999` means: put as much of the model as possible on the GPU path. If you are debugging CPU fallback, this kind of explicit control is useful. llama.cpp gives you: - GGUF model support, the compact quantized format from Day 4 - CPU, CUDA, Metal, Vulkan, and other backend paths depending on build, so the same GGUF file can run on a Spark, a MacBook, or a Raspberry Pi - explicit GPU layer offload, meaning you decide how many of the model's layers live on the GPU. That is the `--n-gpu-layers` flag above - an OpenAI-compatible server mode, so apps written for the OpenAI API can point at your Spark instead - grammar and JSON-constrained decoding: you hand the server a grammar or JSON schema, and during generation it simply refuses any token that would break the format. As long as generation completes within its token budget and the grammar covers your schema features, the output stays parseable instead of "usually valid JSON" - continuous batching, the request-packing idea explained with an example in the vLLM section below - speculative decoding options, covered in their own section below - a large ecosystem of quantized model files, because GGUF is the de facto community format for local models The tradeoff is that llama.cpp exposes the sharp edges. You can tune it beautifully. You can also tune it into the floor. ### Bare llama.cpp on Spark: what I measured Many people skip the wrapper and run `llama-server` directly. To make that comparison honest, I mounted the exact blob Ollama had just used. You can see the blob path in Ollama's generated model file: ```bash ollama show --modelfile qwen2.5:3b # Look for the FROM /.../sha256-... line. ``` That `sha256-...` file name is not random. Ollama stores models content-addressed, meaning the file is named by the hash of its own bytes. Which is exactly why this comparison is airtight: if llama.cpp opens that path, it is reading byte-for-byte the same artifact Ollama just served. Then start the direct engine with that file mounted read-only: ```bash OLLAMA_BLOB=/usr/share/ollama/.ollama/models/blobs/sha256-5ee4f07cdb9beadbbb293e85803c569b01bd37ed059d2715faa7bb405f31caa6 docker run -d --rm --name day5-llama-qwen3b \ --gpus all \ --ipc=host \ -p 127.0.0.1:8080:8080 \ -v "${OLLAMA_BLOB}:/models/qwen2.5-3b-q4_k_m.gguf:ro" \ ghcr.io/ggml-org/llama.cpp@sha256:b58e2ecb2b3964080f1ca9662237ed92c2d267b0b0211c5eacfe3417ff1c20a1 \ -m /models/qwen2.5-3b-q4_k_m.gguf \ --ctx-size 4096 \ --n-gpu-layers 99 \ --flash-attn on \ --parallel 1 \ --port 8080 \ --host 0.0.0.0 \ --jinja ``` Three flags in there deserve a plain explanation, because they are the kind of thing wrappers normally hide: - `--flash-attn on` enables FlashAttention, a smarter way to compute attention. Instead of building the full attention matrix in memory, it works through small tiles that stay in the GPU's fast on-chip memory. The attention it computes is mathematically equivalent, though reordered floating-point operations can produce tiny numerical differences. Less memory traffic, and the win grows with context length. - `--jinja` tells the server to apply the model's own chat template, the Jinja-format wrapper that converts your `messages` array into the exact text layout the model was trained on, including its special role and turn markers. If a chat model ever ignores your question and just keeps completing your text, a missing or wrong chat template is one of the first things to check. - `--ipc=host` on the Docker side gives the container the host's shared-memory space. Docker's default shared-memory allowance is tiny, and multi-process inference servers that pass tensors between processes (vLLM, for example) can crash or slow down without it. Single-process llama.cpp does not normally need it; I kept the flag so every container in this post ran with the same Docker settings. Wait for the server: ```bash curl http://127.0.0.1:8080/health ``` The `@sha256` digest is Docker's immutable reference form. It pins the exact image I measured (the `server-cuda` tag at test time, llama.cpp build `9917`), because `server-cuda` itself is a mutable tag that moves with new releases. Then send the same fixed request. This small Python snippet works with any OpenAI-compatible chat endpoint; change only `url` and `model`: ```bash python3 - <<'PY' import json import time import urllib.request payload = { "model": "qwen2.5-3b-q4_k_m.gguf", "messages": [ { "role": "user", "content": "Explain Kubernetes Pods to an absolute beginner. Include a kitchen analogy, what containers inside one Pod share, how a Deployment uses Pods, and one practical example. Write at least 350 words and do not conclude early." } ], "max_tokens": 256, "temperature": 0, "seed": 42, "stream": False, } request = urllib.request.Request( "http://127.0.0.1:8080/v1/chat/completions", data=json.dumps(payload).encode(), headers={"Content-Type": "application/json"}, ) start = time.time() with urllib.request.urlopen(request, timeout=240) as response: result = json.loads(response.read()) elapsed = time.time() - start completion_tokens = result["usage"]["completion_tokens"] print("completion_tokens:", completion_tokens) print("elapsed_s:", round(elapsed, 3)) print("wall_decode_tok_s:", round(completion_tokens / elapsed, 2)) PY ``` Run the request three times after the first call, then check llama.cpp's own decode timing: ```bash docker logs day5-llama-qwen3b 2>&1 | grep "eval time" | tail -4 ``` The first request took `17.689s` wall time. The next three took `2.515s`, `2.521s`, and `2.523s` for 256 output tokens. That gives a warm median of `101.55 wall output tok/s`. llama.cpp's own warm decode median was `102.26 tok/s`. The direct path was about 8% ahead of Ollama's full-request wall number in this tiny, sequential test. That is measurable, but it is not a reason to abandon Ollama. Ollama is doing useful wrapper work: model discovery, manifests, lifecycle management, defaults, and a friendly CLI. Bare llama.cpp gives you the knobs and removes some wrapper overhead. I also ran a much larger `Qwen3.6-27B-Q4_K_M` artifact directly. Its server-side baseline was: ```text eval time = 20170.43 ms / 256 tokens (78.79 ms per token, 12.69 tokens per second) ``` That second result is not part of the six-path comparison. It is here because the same 27B checkpoint appears in the speculative-decoding work below, and our `12.69 tok/s` baseline closely matches the PR author's `12.57 tok/s` Spark baseline. Cleanup: ```bash docker rm -f day5-llama-qwen3b ``` The point is not just the number. Direct llama.cpp is a real server path, and it gives you lower-level control over model files, context length, batching, flash attention, GPU offload, chat templates, and speculative decoding. ## Speculative decoding and MTP, in plain English This is important enough to pause on. Normal decode is slow because the big model usually produces one token at a time. The model reads the active weights, produces scores, picks one next token, appends it, and repeats. Speculative decoding tries to cheat that loop. The idea: 1. A cheap draft path guesses several future tokens. 2. The big model checks those guessed tokens in one bigger pass. 3. Accepted tokens are kept. 4. Rejected tokens are thrown away and the runtime falls back. If the guesses are good, you get multiple output tokens for something closer to one expensive big-model step. If the guesses are bad, you wasted work. That is the whole idea. {{day5-speculative-decoding-animation}} There are different ways to create the draft tokens: | Method | Simple meaning | |---|---| | Small draft model | A smaller model guesses ahead; the large model verifies. | | MTP heads | The main model has extra heads trained to predict future tokens. | | N-gram draft | The runtime looks for repeated token patterns in the current context. | | DFlash | A draft model proposes a whole block of candidate tokens in one pass, then the target verifies. | | EAGLE / Medusa-style methods | More advanced draft-and-verify families used by some serving stacks. | llama.cpp documents speculative decoding and includes a `draft-mtp` mode: ```bash llama-server \ -m model-with-mtp.gguf \ --n-gpu-layers 999 \ --spec-type draft-mtp \ --spec-draft-n-max 2 ``` And this part is moving fast. On June 28, 2026, llama.cpp merged PR [#22105](https://github.com/ggml-org/llama.cpp/pull/22105), adding DFlash speculative decoding support with `--spec-type draft-dflash`: ```bash llama-server \ -m target-model.gguf \ -md dflash-draft-model.gguf \ --spec-type draft-dflash \ --spec-draft-n-max 15 \ -ngl 99 \ -fa on ``` That PR includes a Spark-relevant SpeedBench result: `Qwen3.6-27B` and its DFlash draft, both `Q4_K_M`, tested on DGX Spark. The overall decode number moved from `12.57` predicted tok/s to `33.76` predicted tok/s, a `2.69x` decode speedup. Some categories were higher, such as RAG at `4.07x` and coding at `3.11x`. Treat this as **bleeding edge**, not a normal "copy this command and it works everywhere" feature. DFlash is now merged into llama.cpp `master` and documented there, but your installed release or container may not include it yet. The target model, draft model, runtime build, and GGUF metadata all have to line up. I did **not** run the DFlash path on my Spark for this post. I ran the ordinary 27B baseline and verified that it matched the PR baseline closely; the `2.69x` result reported here belongs to the PR author's Spark test. That distinction matters. So the honest Day 5 takeaway is: **Speculative decoding is no longer only a research-paper idea or a hosted-inference trick. It is landing in local runtimes too. But it needs the right model, the right draft artifact, and a workload where the draft tokens are accepted often enough.** This is not a guaranteed "2x flag": **Speculative decoding can speed up decode when the model, draft path, and runtime match well. It can also do nothing or slow you down if acceptance is poor. Benchmark your checkpoint.** Speculative decoding belongs in an engine guide because it is not just a model feature. The engine has to support the draft path, schedule the verification pass, and report whether it actually helped. ## Docker Model Runner: Docker-native local models [Docker Model Runner](https://docs.docker.com/ai/model-runner/) is for people who want models to behave like Docker artifacts. The mental model: ```bash docker model pull ai/gemma3 docker model run ai/gemma3 "Explain Kubernetes pods in one paragraph." ``` That feels very different from managing a Python environment or custom inference server. Docker's current docs describe Docker Model Runner as supporting: - pulling models from Docker Hub - pulling from OCI-compatible registries - pulling from Hugging Face - serving OpenAI-compatible and Ollama-compatible APIs - llama.cpp, vLLM, and Diffusers inference engines, with platform-specific support The important Spark nuance is platform support. Docker's docs list llama.cpp as the default engine across platforms, while the vLLM backend has stricter platform requirements. On a DGX Spark ARM64 box, treat Docker Model Runner mainly as the Docker-native GGUF/llama.cpp path unless the backend docs for your version explicitly say the vLLM runner is Spark/ARM64 ready. That changes the framing. Hugging Face support exists. The real distinction is packaging and workflow: Docker Model Runner makes the Docker-native path cleaner. On my Spark, Docker Model Runner was already running, with client `1.1.37` and server `1.1.11`: ```bash docker ps # docker-model-runner docker/model-runner:latest-cuda Up 12 hours 127.0.0.1:12434->12434/tcp ``` The runner logs made the active Spark backend clear: ```text installed llama-server gpuSupport=true Backend installation failed for backend=vllm error="vLLM binary not found" Backend installation failed for backend=sglang error="python3 not found in PATH" ``` So for this Spark, Docker Model Runner was not "vLLM hidden inside Docker." It was the Docker-native llama.cpp/GGUF path. For the controlled test, I packaged the exact same Ollama GGUF blob as a Docker model. The temporary symlink gives the content-addressed blob a `.gguf` filename that the packager recognizes: ```bash mkdir -p ~/models/day5-qwen25 ln -s /usr/share/ollama/.ollama/models/blobs/sha256-5ee4f07cdb9beadbbb293e85803c569b01bd37ed059d2715faa7bb405f31caa6 \ ~/models/day5-qwen25/qwen2.5-3b-q4_k_m.gguf docker model package \ --gguf ~/models/day5-qwen25/qwen2.5-3b-q4_k_m.gguf \ --context-size 4096 \ day5-qwen25:3b-q4km ``` `docker model inspect` identified it as a 3.09B-parameter Qwen2.5 Instruct artifact, `MOSTLY_Q4_K_M`, 1.79 GiB. I then sent the same JSON request (the same Python snippet once more, with `url` pointing at the endpoint below and `model` set to the packaged model name) to Docker Model Runner's engine-specific endpoint: ```text http://127.0.0.1:12434/engines/llama.cpp/v1/chat/completions ``` The first request returned 256 tokens in `4.666s`, including model loading. The next three took `2.587s`, `2.583s`, and `2.568s`, giving `99.11 wall output tok/s` at the median. The backend's warm decode median was `100.05 tok/s`. That near-match with bare llama.cpp is exactly what we should expect. Docker Model Runner's value here is the Docker workflow, packaging, lifecycle, and API, not a mysterious new math engine. After testing, unload and remove the temporary model: ```bash docker model unload day5-qwen25:3b-q4km docker model rm day5-qwen25:3b-q4km rm ~/models/day5-qwen25/qwen2.5-3b-q4_k_m.gguf rmdir ~/models/day5-qwen25 ``` Use Docker Model Runner when: - your app already runs in Docker Compose - you want a consistent local developer workflow - you want to package and move model artifacts through OCI-style tooling - you want llama.cpp-style local inference without hand-managing llama.cpp Do not pick it when: - you need every low-level engine flag - you are chasing the newest model architecture on day one - you need the most mature production scheduler ## LM Studio and llmster: GUI when you want it, headless when you do not [LM Studio](https://lmstudio.ai) used to be easy to summarize as "the GUI one." That is still true, but incomplete. LM Studio now has official DGX Spark support through NVIDIA's Spark playbook, and that playbook uses `llmster`, a headless terminal-native LM Studio daemon. So "avoid LM Studio for headless" is outdated advice. The better framing: **LM Studio is the most approachable local model experience, and `llmster` lets the same ecosystem run headless on Spark.** Use LM Studio when: - you want a polished UI - you want to browse, download, and test models interactively - you want a local API server without hand-building one - you want to access Spark-hosted models from a laptop through the LM Studio ecosystem Use something else when: - you want raw llama.cpp flags - you are building a high-concurrency API service - you need a runtime that exposes detailed scheduler and cache tuning ## vLLM: the general production-serving workhorse [vLLM](https://docs.vllm.ai/en/latest/) is the first serious jump from "local chat" to "serving." It is not just faster because it has better kernels. It is faster because it is built around serving many requests efficiently. vLLM gives you: - continuous batching, explained just below with an example - PagedAttention for KV cache management, explained just below with an example - prefix caching: when a new prompt starts with the same text as an earlier one, the engine reuses the earlier prefill work instead of recomputing it - chunked prefill, explained below - an OpenAI-compatible API server - structured outputs: you attach a JSON schema to a request and the engine forces the generated tokens to match it, so a response like `{"severity": "high", "restart": true}` parses reliably when generation finishes within the token limit and the schema features are supported, the same constrained-decoding idea llama.cpp offers through grammars - tool calling support, the mechanism that lets a model reply "call this function with these arguments" in a machine-readable way, which is what agent frameworks build on - speculative decoding support, covered in its own section above - multi-LoRA serving: LoRA adapters are small fine-tuned add-on weights. vLLM keeps one base model loaded and applies a different adapter per request, so ten team-specific model variants do not need ten full copies of a model in memory - quantization paths including FP8, MXFP4, NVFP4, INT4, GGUF, and more, the formats from Day 4 That list matters because production serving is a queueing problem. One request comes in. Another starts. A third has a long prompt. A fourth is already decoding. The runtime has to keep the GPU busy without letting one request block the whole line. That is what continuous batching is for. Instead of running: `request A -> request B -> request C` the engine can pack active work together: `A token 1 + B token 1 + C prefill + D token 1` That is why vLLM can look worse for a single user but much better for many users. ### PagedAttention, in plain English Now the other headline feature, because "paged KV cache management" deserves better than a bullet point. The KV cache from Day 2 grows token by token while a request runs, and the server cannot know in advance how long any conversation will get. The naive approach reserves one big contiguous memory block per request, sized for the worst case. Suppose a request reserves room for 4,096 tokens and the conversation only ever reaches 900. The remaining 3,196 slots sit empty, and because the block is contiguous and private, no other request can use them. Serve a handful of users this way and the GPU runs out of "reserved but unused" memory long before it runs out of real capacity. In our example, one request wasted more than three quarters of its reservation, and every concurrent request wastes its own share. PagedAttention borrows the fix from operating systems. It splits the KV cache into small fixed-size blocks, like memory pages, and hands them out on demand. Our 900-token conversation now occupies about 57 blocks of 16 tokens each (vLLM's default block size) instead of one 4,096-token slab. The moment the request finishes, its blocks return to the shared pool. Blocks do not need to sit next to each other in memory, so external fragmentation is greatly reduced, and far more concurrent requests fit into the same GPU memory. So the two ideas divide the work: **continuous batching keeps the compute busy, PagedAttention keeps the memory honest.** Together they are most of the reason vLLM behaves so well when requests overlap. ### Chunked prefill, in plain English One more scheduler idea from the feature list, and you already know the ingredients. Imagine your answer is streaming smoothly, and a teammate submits an 11,000-token document to the same server. Prefill for that document is a big, dense chunk of work. If the engine processes it in one go, every other user's stream freezes until it finishes. Chunked prefill slices the big prompt into pieces and interleaves those pieces with everyone else's decode steps. The big document may take a little longer to be read, and everyone else's streams stay much smoother, with the balance tunable through `max_num_batched_tokens`. On a Spark you share with teammates, this is the difference between "smooth" and "the server hiccups whenever someone pastes a log file." For the controlled test, I used vLLM `0.25.1`, pinned by tag so you run the same build I measured, with the BF16 Qwen checkpoint: ```bash docker run -d --rm --name day5-vllm-qwen3b --gpus all --ipc=host -p 8000:8000 \ -v /home/saiyam/.cache/huggingface:/root/.cache/huggingface \ vllm/vllm-openai:v0.25.1 \ Qwen/Qwen2.5-3B-Instruct \ --dtype bfloat16 \ --max-model-len 4096 \ --gpu-memory-utilization 0.85 \ --seed 42 ``` It loaded the same cached Hugging Face snapshot later used by SGLang. Server startup took about `156s` end to end: roughly `39s` to read the 5.79 GiB of weights, about `72s` of engine init covering profiling, compilation, KV-cache setup, and CUDA graph capture, and the rest in container and API server startup. The CUDA graph part is worth a one-line explanation: generating a token normally means the CPU issuing thousands of small GPU instructions, so the engine records the whole launch sequence once as a "graph" and then replays it cheaply for every token. To measure it yourself, reuse the Python snippet from the llama.cpp section: set `url` to `http://127.0.0.1:8000/v1/chat/completions` and `model` to `Qwen/Qwen2.5-3B-Instruct`, run it once cold, then three more times. When you are done, `docker stop day5-vllm-qwen3b` stops the container, and `--rm` removes it. The first API request took `8.062s`, or `31.75 wall output tok/s`. The next three took `8.042s`, `8.050s`, and `8.079s`, for a warm median of `31.80 tok/s`. Cold and warm match because vLLM `0.25.1` does its heavy compilation during startup instead of on your first request. Two practical notes before the results. Pin the image version instead of using `latest`: Docker only downloads a tag it does not already have, so an old `latest` sitting on your box will silently run an older vLLM. An earlier pass of this test did exactly that and landed on vLLM `0.19.0`, which printed a PyTorch warning that it supported at most cuda capability `12.0` (the GB10 is `12.1`) and spent `26.8s` on the first request compiling kernels on first use. Both are fixed in current builds. And for NVFP4, MoE, or larger-model work, prefer a Spark-validated image over assuming a generic container has the right `sm_121` kernels. Now the production-serving point. In a separate earlier sweep with the same Qwen2.5 3B BF16 family and 512 output tokens per request, aggregate throughput climbed as concurrent requests were batched: | Concurrent requests | Aggregate output throughput | |---:|---:| | 1 | 26.14 tok/s | | 8 | 246.79 tok/s | | 32 | 851.84 tok/s | | 64 | 1,462.30 tok/s | That older sweep used a different date, build, output length, and benchmark goal, so do not splice it into the six-path comparison. It answers a different question: **what can the scheduler do when work overlaps?** Aggregate throughput rose enormously even though one user's stream did not become 56 times faster. That is why "vLLM is slower than Ollama" is the wrong conclusion. Q4 GGUF versus BF16 explains much of the single-request gap, while vLLM's scheduling value appears under concurrency. ### The vLLM image problem on Spark On Spark, the Docker image can matter as much as the model. The Spark is ARM64. The GPU is Blackwell GB10 with `sm_121`. CUDA 13 and model architecture support have moved quickly. If your vLLM image does not include the right kernels or architecture support, performance can be much worse than the hardware should allow. This has improved since the earliest Spark experiments. vLLM now has a DGX Spark-specific writeup, and NVIDIA has a Spark vLLM playbook with a current support matrix. Start there before trying random containers. The practical wording is: **vLLM is one of the strongest production-style serving paths on Spark, but the exact image, model recipe, CUDA build, and kernel support are load-bearing. Treat upstream, NVIDIA, and community images as different runtime builds, not interchangeable wrappers.** Use vLLM when: - you are serving a small team or app - concurrency matters - OpenAI-compatible APIs matter - structured output or tool calling matters - you want Hugging Face safetensors model support - you can spend time choosing the correct image and runtime flags Do not start with vLLM when: - you only want local chat - you do not want Docker/runtime debugging - your model only has a GGUF path and llama.cpp already serves it well ## SGLang: when many requests share the same prefix [SGLang](https://github.com/sgl-project/sglang) is a serving framework for structured generation and agentic workloads. The key idea is **RadixAttention**. Plain English: If 100 requests share the same system prompt, do not process that system prompt 100 times. Cache the shared prefix once. Reuse it. Example: ```text Shared prefix: You are a Kubernetes assistant. Use these tools... Request A: ...explain pods Request B: ...explain services Request C: ...debug this YAML ``` A normal runtime may repeat a lot of prefill work. SGLang is designed around reusing common prefixes through a radix-tree-style cache. This is especially useful for: - multi-agent systems - RAG systems with shared instructions - tool-calling systems with repeated tool schemas - evaluation workloads with repeated few-shot examples - long system prompts reused across many short user turns NVIDIA also publishes an official SGLang playbook for DGX Spark, so the old "you must build from source" warning is no longer the right default. Start with the official Spark playbook unless you need a specific patch. For the controlled test, I used SGLang's CUDA 13 Spark image (the `latest-cu130` tag at test time, pinned below by its immutable digest) and the exact same cached BF16 checkpoint used by vLLM: ```bash docker run -d --rm --name day5-sglang-qwen3b \ --gpus all \ --ipc=host \ -p 127.0.0.1:30000:30000 \ -v /home/saiyam/.cache/huggingface:/root/.cache/huggingface \ lmsysorg/sglang@sha256:00c53fe4c31bf22d7b37537f28bbdfd924c02de13cdfb4bff7378c9c34d75ab2 \ python3 -m sglang.launch_server \ --model-path Qwen/Qwen2.5-3B-Instruct \ --host 0.0.0.0 \ --port 30000 \ --dtype bfloat16 \ --context-length 4096 \ --attention-backend flashinfer \ --mem-fraction-static 0.75 \ --random-seed 42 ``` Measurement is the same routine again: the Python snippet from the llama.cpp section with `url` set to `http://127.0.0.1:30000/v1/chat/completions` and `model` set to `Qwen/Qwen2.5-3B-Instruct`, one cold run plus three warm. `docker stop day5-sglang-qwen3b` cleans up afterward. The image reported SGLang `0.5.15.post1`. Startup took about `82s`, including a `34s` weight load and graph capture. The first API request returned 256 tokens in `9.068s`, or `28.23 wall output tok/s`. The next three took `8.264s`, `8.086s`, and `8.062s`, for a warm median of `31.66 tok/s`. That is almost the same as vLLM's `31.80 tok/s`, which is a healthy result. This test used one request at a time and did not give SGLang repeated prefixes to exploit. It confirms the basic BF16 path; it does not benchmark RadixAttention's actual advantage. The startup logs exposed one excellent beginner lesson. With `--mem-fraction-static 0.75`, SGLang reserved about 80 GiB for key and value caches even though the 3B weights needed only about 6 GiB. That flag is a **budget**, not a speed setting. A large cache can support long contexts or more concurrent requests, but on unified-memory Spark it also leaves less room for other models and applications. Size it for your workload instead of copying `0.75` blindly. The tested module command also printed a notice that `sglang serve` is now the recommended CLI. I have shown the exact command I ran for reproducibility; for a fresh deployment, follow the current Spark playbook syntax. Use SGLang when: - your workload has repeated prompt prefixes - you are building agents - you care about structured generation - you are willing to learn a newer serving stack Use vLLM or Ollama when: - every prompt is unique - you only need single-user chat - you want the largest beginner support surface ## TensorRT-LLM: NVIDIA's performance path [TensorRT-LLM](https://github.com/NVIDIA/TensorRT-LLM) deserves its own entry because it is a different level of the stack. It is not a friendly first-run tool like Ollama. It is NVIDIA's open-source library for optimizing LLM inference on NVIDIA GPUs. NVIDIA's DGX Spark playbook for TensorRT-LLM says the goal directly: install and use TensorRT-LLM on Spark for lower latency and higher throughput through kernel-level optimizations, efficient memory layouts, and advanced quantization. Those words deserve a plain-English translation. TensorRT-LLM's heritage is **compilation**: the classic flow built the model ahead of time into a fixed engine for one exact GPU, fusing operations together, picking kernels tuned for that specific architecture, and locking in the memory layout. Think of compiling a program with every optimization flag turned on for one specific CPU. You paid with setup time and lost flexibility, and won on latency and throughput. Current TensorRT-LLM 1.x has softened that trade. The default runtime is now PyTorch-based, so serving no longer requires an offline engine-build step, and the NVIDIA-specific value lives in the optimized kernels, CUDA graphs, and quantization recipes it brings along. What has not changed is the philosophy: TensorRT-LLM works best when you stay on combinations NVIDIA has validated, which is exactly why the Spark playbook publishes a support matrix per model and precision. One version note: the support matrix below comes from NVIDIA's current Spark playbook, which tracks newer TensorRT-LLM builds than the `1.2.1` stable release I used for the controlled benchmark further down. It supports Spark model paths such as: - Nemotron 3 Nano Omni BF16, FP8, and NVFP4 - Nemotron 3 Super 120B NVFP4 - GPT-OSS 20B and 120B MXFP4 - Llama 3.1 8B FP8 and NVFP4 - Llama 3.3 70B NVFP4 - Qwen3 variants - Llama 4 Scout NVFP4 - two-Spark Qwen3 235B-A22B NVFP4 path That does not mean every random Hugging Face model will work out of the box. It means TensorRT-LLM is a serious Spark path when your model is supported and you are willing to work closer to NVIDIA's optimized stack. ### TensorRT-LLM on Spark: what I measured TensorRT-LLM's old reputation is that you must build an engine offline before you can serve anything. Single-node serving is simpler now: `trtllm-serve` in the release container takes the Hugging Face model handle directly, and no separate engine-build step was needed for this run. That is because this run used TensorRT-LLM's default **PyTorch backend**; the classic compiled-TensorRT-engine path still exists as a separate backend, and you can pin the default explicitly with `--backend pytorch`. ```bash docker run -d --name day5-trtllm-qwen3b --gpus all --ipc=host -p 8000:8000 \ -v /home/saiyam/.cache/huggingface:/root/.cache/huggingface \ nvcr.io/nvidia/tensorrt-llm/release:1.2.1 \ trtllm-serve Qwen/Qwen2.5-3B-Instruct --host 0.0.0.0 --port 8000 ``` The server reported TensorRT LLM version `1.2.1` and was ready in about `80s`, roughly half of vLLM's `156s` startup. Then the exact same controlled test as the other paths: same cached BF16 checkpoint, same prompt, 256 output tokens, temperature 0, seed 42, one cold plus three warm requests, sent with the same Python snippet from the llama.cpp section (`url` on port `8000`, `model` set to `Qwen/Qwen2.5-3B-Instruct`). - first request: 256 tokens in `8.238s`, `31.07 wall output tok/s` - next three: `8.250s`, `8.279s`, `8.273s`, a warm median of `30.94 wall output tok/s` Read that honestly: all three engines landed within `0.86 tok/s` of each other in this small batch-one sample (vLLM `31.80`, SGLang `31.66`, TensorRT-LLM `30.94`), which is consistent with bandwidth-bound decode. For one BF16 request at a time, "NVIDIA's performance path" was not faster here. All three engines are pushing the same bytes through the same 273 GB/s of unified memory, so once the artifact and precision are fixed, single-request decode leaves an engine very little room to differentiate. Where TensorRT-LLM is built to earn its name is everything this little test deliberately excluded: the NVFP4 and FP8 quantized paths in the support matrix, speculative decoding variants, and batched serving. Those are separate, bigger experiments. Cleanup: ```bash docker rm -f day5-trtllm-qwen3b ``` Use TensorRT-LLM when: - you want to benchmark the NVIDIA-optimized path - your model is in the Spark support matrix - you care about FP8, FP4, KV cache handling, kernel-level tuning, and speculative decoding variants like MTP and EAGLE (EAGLE, like MTP, is a draft-and-verify method from the speculative decoding family explained earlier) - setup complexity is acceptable Use something else when: - you want the easiest first local chat - you are still exploring models - your target model is not supported yet ## NVIDIA NIM: packaged and supported serving [NIM](https://developer.nvidia.com/nim) stands for NVIDIA Inference Microservice. The simple version: **NIM is a model-serving container with NVIDIA's supported packaging around it.** It exposes an HTTP inference endpoint, handles containerized deployment, and gives you a more enterprise-shaped runtime story than hand-rolling an engine container yourself. A common shortcut is to say "NIM is TensorRT-LLM under the hood" or "NIM is only for Nemotron." That is too absolute. Current NIM documentation and NVIDIA/Hugging Face material describe NIM as a serving layer that can select among backends such as TensorRT-LLM, vLLM, or SGLang depending on model/profile. A "profile" is a pre-tuned bundle of model, precision, backend engine, and target hardware that NVIDIA has validated together, so you deploy a known-good combination instead of assembling one by hand. NVIDIA's Spark NIM playbook also mentions Llama 3.1 8B and Qwen3-32B options, not just Nemotron. The accurate mental model is: **NIM is the right choice when NVIDIA has a tested NIM/profile for the model you want and you care about support, packaging, metrics, health checks, and standard operations more than maximum tinkering freedom.** Use NIM when: - you want NVIDIA-supported containers - you are in an enterprise or production setting - your target model has a tested NIM/profile - you want a hardened API service with operations built in Do not use NIM as the first beginner path when: - you are casually exploring local models - you do not have NGC access - you want to run arbitrary community checkpoints - you want to tweak every engine flag directly ## Hermes Agent, NemoClaw, OpenClaw: not engines, but still important Agent frameworks sit above the runtime. They do not replace Ollama, LM Studio, vLLM, or NIM. They call them. Hermes Agent is a good example. NVIDIA's current Spark playbook connects Hermes to a local model served by vLLM. NemoClaw's Spark playbook also routes inference to local vLLM now (it previously used Ollama). OpenClaw can also sit on top of a local OpenAI-compatible endpoint. The important point is the same in all cases: the agent layer handles tool use, task loops, and local workflow logic. The runtime underneath still does the token generation. The same mental model applies to NemoClaw, OpenClaw, custom RAG apps, coding agents, and your own tools. The clean framing is: **Hermes is a reason you need a runtime. It is not the runtime itself.** This is a nice bridge to Day 7, where the local lab becomes an actual workflow. ## The fairest comparison I could make on one Spark The obvious question is: **why not run the same model through every path?** That is exactly what I did with `Qwen2.5-3B-Instruct`. But "the same model" still needs one careful explanation. The learned model can be packaged in different files: - **GGUF Q4_K_M** stores a compressed 4-bit version used by llama.cpp-style engines. - **BF16 safetensors** stores the model at 16-bit precision and is the normal Hugging Face path for vLLM and SGLang. Those files come from the same Qwen2.5 3B Instruct model, but they do not move the same number of bytes through memory. Comparing Q4 directly with BF16 would mix the effect of the **engine** with the effect of the **format**. So I used two honest lanes. ### Lane A: the exact same GGUF file Ollama stores its pulled model as a content-addressed blob. I mounted that exact `Qwen2.5-3B-Instruct Q4_K_M` blob read-only into bare llama.cpp, then packaged the same blob for Docker Model Runner. No second conversion. No similarly named download. | Serving path | First request wall output | Warm median wall output | Engine-reported warm decode | |---|---:|---:|---:| | Ollama `0.30.10` | 31.68 tok/s | **93.74 tok/s** | 99.14 tok/s | | bare llama.cpp, build `9917` | 14.47 tok/s | **101.55 tok/s** | 102.26 tok/s | | Docker Model Runner `1.1.11` | 54.86 tok/s | **99.11 tok/s** | 100.05 tok/s | The warm numbers are close because all three paths eventually use the same compact GGUF weights and llama.cpp-family compute path. The wrappers change loading, packaging, defaults, APIs, and operations. They do not magically change a 3B model into different math. ### Lane B: the exact same BF16 checkpoint For vLLM, SGLang, and TensorRT-LLM, I mounted the same cached Hugging Face snapshot of `Qwen/Qwen2.5-3B-Instruct` and served it as BF16. | Serving path | First request wall output | Warm median wall output | What this path adds | |---|---:|---:|---| | vLLM `0.25.1` | 31.75 tok/s | **31.80 tok/s** | Paged KV cache, continuous batching, production API controls | | SGLang `0.5.15.post1` | 28.23 tok/s | **31.66 tok/s** | Radix prefix cache and structured-serving controls | | TensorRT-LLM `1.2.1` | 31.07 tok/s | **30.94 tok/s** | NVIDIA-optimized kernels, FP8/NVFP4 recipes, MTP and EAGLE paths | Their warm single-request speeds are nearly identical, all three within about one token per second. That does **not** mean these engines are the same. Their real differences appear when requests overlap, prefixes repeat, schemas constrain output, quantized formats come into play, caches fill, and operators need metrics and control. ### The test protocol Every path ran alone. I unloaded or stopped one runtime before starting the next. - same Spark, driver `580.159.03`, NVIDIA GB10 - same prompt, 75 input tokens - exactly 256 requested output tokens - temperature `0`, seed `42` - one first request, then three warm sequential requests - warm result is the median of those three requests - no concurrency during this comparison This table answers: **how do these paths behave for one controlled local request?** It does not answer: **which server wins at 32 simultaneous users?** The concurrency sweep in the vLLM section above showed that distinction. The most useful result is not a winner. It is this: **Keep the artifact fixed when comparing wrappers. Keep the precision fixed when comparing engines. Then test the workload you actually care about.** Every number in these tables comes from the exact commands, flags, and measurement steps shown in the engine sections above, so you can redo the whole comparison on your own Spark. ## The honest comparison table The controlled numbers are in the two-lane tables in the previous section. This final table answers a different question: **what kind of job is each path built to do?** | Path | Best for | Single-user feel | Multi-user serving | Setup pain | Spark status | |---|---|---:|---:|---|---| | Ollama | first local chat | strong | basic to moderate | low | official path; Q4 tested here | | llama.cpp | GGUF power users | strong | moderate | medium | direct CUDA/GGUF tested here | | Docker Model Runner | Docker-native workflows | strong | moderate, backend-dependent | low to medium | ARM64 llama.cpp path tested here | | LM Studio / llmster | GUI or headless local service | strong | small-team API | low | official Spark playbook | | vLLM | team API serving | model-dependent | strong | medium to high | BF16 tested here; image matters | | SGLang | agents and shared prompts | model-dependent | strong when prefixes repeat | medium to high | BF16 tested here; official playbook | | TensorRT-LLM | NVIDIA optimized path | strong when supported | strong | high | official Spark playbook | | NIM | supported packaged serving | strong when profiled | strong | medium | best with tested model profile | | Dynamo | distributed orchestration | not the point | very strong at scale | high | watch for multi-Spark/larger systems | | ZML/LLMD alpha | cross-hardware serving | promising, unbenchmarked here | unknown | medium | smoke-tested on Spark with Qwen3 0.6B | And here is the simple version: {{day5-engine-choice-animation}} ## My practical decision tree If you remember nothing else: - **Just chatting on Spark:** start with Ollama. - **You want GUI or a friendly local server:** use LM Studio or `llmster`. - **You want raw GGUF control:** use llama.cpp directly. - **You are Docker-native:** try Docker Model Runner. - **You are serving a team or app:** use vLLM. - **Your prompts repeat across agents or tools:** evaluate SGLang. - **Your model is in NVIDIA's Spark TensorRT-LLM matrix:** benchmark TensorRT-LLM. - **You need NVIDIA-supported packaged serving:** use NIM. - **You are thinking multi-Spark or larger distributed serving:** watch Dynamo. ## The six things that go wrong Once readers run these engines, six failure patterns appear repeatedly. ### 1. CUDA out of memory The model loads, then dies. Or it runs until the prompt gets longer, then dies. Most likely cause: the model weights fit, but the KV cache does not. A long context window can eat memory quickly, especially with concurrency. First fixes: - reduce context length - lower `--max-model-len` in vLLM - lower `num_ctx` in Ollama - try FP8 KV cache if your engine supports it - use a smaller model or smaller quantization ### 2. The model repeats the prompt or talks nonsense Most likely cause: wrong chat template. A base model and an instruct model are not the same thing. A model trained with one chat wrapper may behave badly if you send a different wrapper. First fixes: - confirm you pulled the instruct/chat variant - use the model card's chat template - prefer `/v1/chat/completions` over raw completions for chat models ### 3. First token takes forever Most likely cause: prefill is doing real work. A long prompt has to be read before generation starts. If your system prompt is 20,000 tokens, the runtime is not frozen. It is building the KV cache. First fixes: - shorten the system prompt - enable or verify prefix caching - warm the model with a representative prompt - separate "load time" from "first-token latency" ### 4. A small model streams slowly Most likely cause: CPU fallback or wrong GPU path. First fixes: - check `ollama ps` for GPU use - check container logs for CUDA or kernel fallback - confirm your image supports ARM64 and Spark's Blackwell path - verify the model was not partly spilled to CPU by accident ### 5. JSON output keeps breaking Most likely cause: you are asking a probabilistic model to freestyle a strict schema. First fixes: - set temperature near zero - use structured output or grammar-constrained decoding - use a tool-tuned model - validate and retry in the app ### 6. The model loops or never stops Most likely cause: stop tokens or sampling settings. First fixes: - check the model's expected stop tokens - add a mild repetition penalty - cap max output tokens The three-step debug ladder: 1. Is the chat template right? 2. Is the memory budget honest? 3. Is the GPU actually being used? These three checks solve a surprising number of "Spark is slow" reports. ## Optional deep cuts: newer serving layers worth watching You can skip this section on a first read. The core choices above are enough to start serving a model. These projects matter when you begin asking harder questions about clusters, persistent caches, and cross-hardware runtimes. There is a lot happening outside the core tools above. ### NVIDIA Dynamo [NVIDIA Dynamo](https://developer.nvidia.com/dynamo) is an open-source distributed inference framework. It is not a replacement for vLLM, SGLang, or TensorRT-LLM. It coordinates them. The important ideas are: - route requests intelligently - split prefill and decode across different workers - move KV cache between memory tiers - support distributed serving with backends like vLLM, SGLang, and TensorRT-LLM On one Spark, this is probably not your Day 5 first move. For multi-Spark or larger production inference, Dynamo is worth watching closely. ### LMCache: tested on this Spark, with a surprise ending [LMCache](https://lmcache.ai) is not an engine either. It is a KV cache layer that plugs into an engine, mainly vLLM, where it is integrated with upstream vLLM and used in vLLM and Dynamo production-style workflows. The shortest possible explanation: - vLLM can reuse a shared prompt while its own in-memory cache is alive - LMCache tries to keep or share that expensive prompt work beyond one engine's memory - on this single-Spark test, vLLM's own cache already handled the easy case - the extra LMCache disk-persistence path wrote data successfully but could not find it after restart in the tested version pairing So this is a useful real experiment, not a recommendation to add LMCache to every local setup. Remember the mental model from earlier: prefill reads your prompt and builds the KV cache, and that work is expensive. Normally the cache lives in GPU memory and dies when it is evicted or the server restarts. LMCache treats that cache as something worth keeping. It can: - save KV cache to disk or a remote store, so it survives restarts - reuse cached work for repeated text anywhere in a prompt, not just shared prefixes (through its CacheBlend feature, which selectively recomputes tokens to recover quality) - share cache between multiple serving instances The classic pitch assumes a discrete GPU, where moving KV cache from GPU memory to CPU RAM frees precious VRAM. On a Spark that trick matters less, because the 128 GB is one unified pool. The parts that could matter here are disk persistence and cross-request reuse, for example a RAG setup where many requests carry the same large document. Instead of leaving this as a map entry, I tested it on this Spark on 2026-07-15 and retested on 2026-07-16 after a vLLM upgrade, three version pairings in total. Here is what actually happened. **Getting it installed.** LMCache publishes x86_64 manylinux wheels, but on ARM64/Spark I only had the source build path. Inside `vllm/vllm-openai:v0.25.1`, a plain `pip install lmcache` builds 0.5.1 from source cleanly, because the image ships `nvcc` and gcc. It was messier on the older vLLM 0.19.0 image: there, pip quietly backtracked to LMCache 0.4.2 to avoid downgrading the image's torch, and forcing 0.5.1 with `--no-deps` failed on a missing `cusparse.h` header until `CPATH` pointed at the headers inside torch's pip-installed CUDA packages. Across the two images, I built and tested LMCache 0.4.2 and 0.5.1. **Running it.** Here is the exact path I used, so you can redo it on your own Spark. First bake LMCache into the vLLM image (the source build takes a few minutes): ```bash docker run --name lmcache-build --entrypoint bash \ -e TORCH_CUDA_ARCH_LIST=12.1 \ vllm/vllm-openai:v0.25.1 \ -c "pip install --no-cache-dir --no-build-isolation lmcache" docker commit lmcache-build lmcache-vllm:spark docker rm lmcache-build ``` Then write a small LMCache config, `~/lmcache-config.yaml`. This enables the CPU and disk tiers: ```yaml chunk_size: 256 local_cpu: true max_local_cpu_size: 20 local_disk: "file:///lmcache-disk/" max_local_disk_size: 40 ``` And start vLLM with the connector attached: ```bash mkdir -p ~/lmcache-disk docker run -d --rm --name day5-lmcache --gpus all --ipc=host -p 8000:8000 \ -v /home/saiyam/.cache/huggingface:/root/.cache/huggingface \ -v /home/saiyam/lmcache-disk:/lmcache-disk \ -v /home/saiyam/lmcache-config.yaml:/lmcache-config.yaml:ro \ -e LMCACHE_CONFIG_FILE=/lmcache-config.yaml \ --entrypoint python3 \ lmcache-vllm:spark \ -m vllm.entrypoints.openai.api_server \ --model Qwen/Qwen2.5-3B-Instruct \ --max-model-len 20480 \ --gpu-memory-utilization 0.5 \ --kv-transfer-config '{"kv_connector":"LMCacheConnectorV1","kv_role":"kv_both"}' ``` You know LMCache is live when the startup logs show `Creating LMCacheEngine instance` and each request logs a `LMCache hit tokens:` line. **How I measured TTFT.** Time-to-first-token was measured on the box with a stdlib Python script that sends a streaming `/v1/chat/completions` request (an 11,342-token document plus a short question, `max_tokens` 64, temperature 0) and records the time until the first content chunk arrives. The restart test is `docker restart day5-lmcache`, wait for `/v1/models` to return 200, then repeat the same request. Cleanup afterward: `docker stop day5-lmcache`, and delete the `~/lmcache-disk` directory if you want the stored chunks gone. **The measured numbers.** This is not a general LMCache benchmark. This is one Spark, one model, and the in-process LMCacheConnectorV1 path. Same box, same model as my vLLM section: Qwen2.5-3B-Instruct BF16, one request at a time, an 11,342-token document plus a short question, TTFT measured on the box via the streaming API. These numbers are the current pairing, vLLM `0.25.1` with LMCache `0.5.1`: | Scenario | vLLM alone | vLLM + LMCache | |---|---|---| | First request after startup | 1.26 s | 1.31 to 1.36 s | | Same document, new question | 0.072 s | 0.080 s | | After a server restart | 1.24 s | 1.30 s | | Decode speed | ~30 tok/s | ~30 tok/s | Two readings jump out. First, prefill of 11.3k tokens on this box takes only about 1.3 seconds, roughly 9,000 tokens per second, so the absolute best a KV cache layer could save on this workload is about a second. Second, for the repeat-the-same-document case, vLLM's built-in prefix caching already answers in 0.07 seconds, and LMCache did not improve on that enough to matter. One genuine improvement over my first pass: on the older vLLM 0.19.0 pairing the connector cost roughly 10% of decode speed just by being on; on the current pairing that overhead was within noise. **The surprise: restart persistence did not work.** The thing I expected this pairing to add on a single box is a KV cache that survives restarts. The store side works: my document became 44 chunk files, 396 MB on disk, with LMCache-reported store throughput of 11 to 27 GB/s. But after a restart, the logs said `LMCache hit tokens: 0` and it re-prefilled everything. I chased down two separate causes: 1. LMCache cannot import vLLM's chunk hash function, warns `Could not load 'builtin' from vLLM`, and falls back to Python's built-in hash, which is randomized per process. Every restart names the same chunks differently. I confirmed it on the vLLM 0.19.0 pairing: the disk directory doubled from 44 to 88 chunks after one restart, same tokens under new names. 2. Pinning `PYTHONHASHSEED=0` makes the names deterministic, but lookups still return zero hits. The local-disk index lives only in memory and is never rebuilt from the files at startup. I then retested on the current pairing, vLLM 0.25.1 with LMCache 0.5.1. The `Could not load 'builtin'` warning still appears, and the restart lookup still returns `LMCache hit tokens: 0`. So in all three pairings I tested (LMCache 0.4.2 and 0.5.1 on vLLM 0.19.0, and LMCache 0.5.1 on vLLM 0.25.1), the local-disk tier behaved like a write-only tier across restarts. The KV files land on disk and are never found again. One more scoping note: LMCache's current documentation now labels this in-process connector approach "Legacy (In-Process Mode)" and is moving toward a separate multiprocess server architecture. So read this as a verdict on the in-process pairings I tested, not on LMCache as a whole. **What fits and what does not.** For this single-Spark workload and these legacy in-process pairings, LMCache did not earn its keep: the in-process win is already covered by vLLM's native prefix caching, and cross-restart reuse failed in all three pairings I tested. Current LMCache guidance points toward its multiprocess server mode instead, which I have not tested. Where it should shine is the setting it was actually built for: multiple serving instances sharing a cache tier, matched-version stacks like the project's own containers, and clusters where prefill is genuinely expensive. On a 3B model with a roughly 9,000 tok/s prefill rate, there is simply not much time to save. Recheck this when the ARM64 packaging and the vLLM pairing settle down, because the idea is right even though these pairings are not there yet. ### MAX by Modular [MAX](https://www.modular.com/open-source/max) is Modular's high-performance inference framework. The interesting idea is a compiler/runtime stack that targets different hardware through the Modular ecosystem. This is not a tested Spark path in this series. It belongs on the wider landscape for readers comparing vLLM, SGLang, TensorRT-LLM, and cross-hardware runtimes. ### ZML/LLMD alpha [ZML/LLMD](https://zml.ai/posts/llmd/) is a newer alpha inference server from the ZML team. Its pitch is ambitious: one self-contained LLM server across NVIDIA CUDA, AMD ROCm, Google TPU, Intel oneAPI, and Apple Metal. The interesting parts are the serving features it is trying to make cross-platform: continuous batching, paged attention, tensor parallel sharding, prefix caching, tool calling, Prometheus metrics, and DFlash speculative decoding for supported models. That belongs in this post because it attacks the same problem from a compiler-first, cross-hardware direction. After seeing the launch post, I did a quick Spark smoke test rather than just mentioning it. On this Spark, the CUDA image had an `arm64` manifest, pulled successfully, detected the GB10, loaded CUDA compatibility libraries, and served `Qwen/Qwen3-0.6B` through the OpenAI-compatible API: ```bash docker run -d --rm --name day5-zml-llmd \ --gpus all \ --shm-size=32GB \ -p 127.0.0.1:8011:8000 \ zmlai/llmd:cuda \ --model=hf://Qwen/Qwen3-0.6B \ --model-name=qwen3-0.6b \ --max-context-len=4096 \ --batch-size=1 \ --gpu-memory-fraction=0.40 ``` The useful startup lines were: ```text info(llmd): Devices: info(llmd): - NVIDIA GB10 (cuda:0) info(llama): Allocating 26238 pages for the KvCache: 44.840GiB info(llama): Compiled all models [18.796s] info(llama): Loaded weights [1.40GiB, 46.282s, 30.98MiB/s] info(llmd): Loaded a model of type qwen3 info(llmd): Listening on 0.0.0.0:8000 ``` `/v1/models` returned the local model: ```json {"id":"qwen3-0.6b","object":"model","owned_by":"local"} ``` And a tiny `/v1/chat/completions` request returned successfully: ```text elapsed_s 0.346 prompt_tokens: 28 completion_tokens: 49 total_tokens: 77 ``` The `/metrics` endpoint also worked and exposed Prometheus-style counters for `/v1/models` and `/v1/chat/completions`. Do not read that as a performance benchmark. It is a tiny Qwen3 0.6B compatibility smoke test, not a tuned Spark serving result. The important point is narrower and still useful: **ZML/LLMD alpha does run on the Spark's ARM64 + GB10 CUDA path, at least for a small model.** DFlash, larger Gemma/Qwen models, tensor parallelism, and real throughput numbers still need a separate proper test. ### EXO [EXO](https://exolabs.net/) is about local distributed inference across Macs and workstations. The project describes a local cluster that finds devices, reads the network topology, splits model work across memory, and serves normal APIs. This is not the same problem as "which engine should I run on one Spark?" But it matters for the future of deskside AI: multiple local machines acting like one inference pool. ### MLX and mlxcel [MLX](https://github.com/ml-explore/mlx) is Apple's machine-learning framework for Apple Silicon. `mlxcel` is a newer Rust-native MLX inference engine that I tested on an M1 Max in a separate post: [mlxcel: A Rust-Native Inference Engine for Apple Silicon](/blog/mlxcel-rust-native-inference-engine-tested-on-m1-max). This is not a DGX Spark runtime. It is an Apple Silicon runtime. But it is useful for the mental model because it shows the same story on different hardware: **hardware memory model plus runtime design decides real-world local LLM performance.** Spark has GB10, CUDA, Blackwell, and unified memory. Apple Silicon has MLX, Metal, and unified memory. The lesson is shared even if the code paths are different. ### Wandler and WebGPU-style local servers Wandler is another related local-inference direction: TypeScript, Transformers.js, ONNX, WebGPU, and OpenAI-compatible local serving. I tested it separately too: [Wandler: Local OpenAI-Compatible Inference With Transformers.js and WebGPU](/blog/wandler-local-openai-compatible-inference-transformersjs-webgpu). Again, not a Spark-first choice. But it belongs in the "the ecosystem is moving fast" paragraph because local inference is no longer only Python or CUDA. ## What's coming next Next we look at **the models**: which ones actually fit this box, which are worth running for which workload, and how to read benchmark numbers without fooling yourself. --- References and sources: - [Ollama: NVIDIA DGX Spark performance](https://ollama.com/blog/nvidia-spark-performance) - [llama.cpp speculative decoding docs](https://github.com/ggml-org/llama.cpp/blob/master/docs/speculative.md) - [llama.cpp PR #22105: DFlash speculative decoding support](https://github.com/ggml-org/llama.cpp/pull/22105) - [vLLM documentation](https://docs.vllm.ai/en/latest/) - [vLLM on the DGX Spark](https://vllm.ai/blog/2026-06-01-vllm-dgx-spark) - [vLLM for Inference on DGX Spark](https://build.nvidia.com/spark/vllm) - [Docker Model Runner docs](https://docs.docker.com/ai/model-runner/) - [Docker Model Runner inference engines](https://docs.docker.com/ai/model-runner/inference-engines/) - [SGLang on DGX Spark](https://build.nvidia.com/spark/sglang) - [LM Studio on DGX Spark](https://build.nvidia.com/spark/lm-studio) - [TensorRT-LLM on DGX Spark](https://build.nvidia.com/spark/trt-llm) - [NIM on DGX Spark](https://build.nvidia.com/spark/nim-llm) - [NIM model profiles and selection](https://docs.nvidia.com/nim/large-language-models/latest/deployment/model-profiles-and-selection.html) - [Hermes Agent with local models on DGX Spark](https://build.nvidia.com/spark/hermes-agent) - [NemoClaw with a local LLM on DGX Spark](https://build.nvidia.com/spark/nemoclaw) - [OpenClaw on DGX Spark](https://build.nvidia.com/spark/openclaw) - [NVIDIA Dynamo](https://developer.nvidia.com/dynamo) - [TensorRT-LLM overview](https://nvidia.github.io/TensorRT-LLM/overview.html) - [MAX by Modular](https://www.modular.com/open-source/max) - [ZML/LLMD alpha](https://zml.ai/posts/llmd/) - [zml/zml on GitHub](https://github.com/zml/zml) - [EXO](https://exolabs.net/) - [mlxcel on M1 Max](/blog/mlxcel-rust-native-inference-engine-tested-on-m1-max) - [Wandler local WebGPU inference](/blog/wandler-local-openai-compatible-inference-transformersjs-webgpu) --- # Bonsai 27B on RTX PRO 6000 vs DGX Spark: what actually works - Canonical: https://blog.kubesimplify.com/bonsai-27b-rtx-pro-6000-dgx-spark - Published: 2026-07-16 - Summary: Real Bonsai 27B benchmarks on an RTX PRO 6000 and a DGX Spark, including the supported llama.cpp setup, ternary vs 1-bit results, and speculative decoding. PrismML recently released [Bonsai 27B](https://github.com/PrismML-Eng/Bonsai-demo), a compressed version of Qwen3.6-27B designed to retain useful reasoning at an unusually small footprint. It comes in two main variants: - **Ternary Bonsai 27B**: the higher-quality option, with weights in `{-1, 0, +1}`. - **1-bit Bonsai 27B**: the smaller and faster option, with weights in `{-1, +1}`. Most early results focused on phones and laptops. I wanted to know what these models look like on two very different NVIDIA systems: - **RTX PRO 6000 Blackwell Server Edition**: 96 GB VRAM, approximately 1.8 TB/s memory bandwidth, `sm_120`. Thanks to [Utho Cloud](https://utho.com/) for sponsoring access to this system. - **NVIDIA DGX Spark**: GB10 Grace Blackwell, 128 GB unified memory (about 121 GB usable), 273 GB/s memory bandwidth, `sm_121`. The short version: Bonsai is extremely fast for a single user on the RTX PRO 6000, remains practical on the DGX Spark, and is much easier to run when you follow PrismML's supported setup instead of mixing model files and experimental llama.cpp branches. ## Results at a glance | Machine | Ternary Q2_0 | 1-bit Q1_0 | DSpark speculative decoding | |---|---:|---:|---:| | RTX PRO 6000 | 3,620 pp / **120.7 tg** | 3,600 pp / **145.5 tg** | 117.4 → **156.4 tok/s** in our code-generation test | | DGX Spark | 937.7 pp / **28.5 tg** | 991.6 pp / **42.8 tg** | 28.2 → **17.6 tok/s** in the same test | `pp` is prompt-processing speed: how quickly the model reads input. `tg` is token-generation speed: how quickly it writes the response. Both are tokens per second. The `llama-bench` results below use a 512-token prompt and generate 128 tokens, with five repetitions per cell. The practical choices are simple: - On the **RTX PRO 6000**, use ternary Q2_0 when quality is the priority and Q1_0 when raw decode speed matters more. - On the **DGX Spark**, Q1_0 is the better speed-first option. Q2_0 remains usable when you want the higher-quality ternary model. - Treat **DSpark speculative decoding as workload-specific**. It helped our single-user RTX code-generation test, but hurt the same test on the Spark. PrismML also marks it experimental because it forces one server slot and disables cross-request prompt-cache reuse. ![Batch-1 decode speed: Bonsai on llama.cpp vs Qwen3.6 NVFP4 on vLLM, both machines](/img/blog/bonsai-27b-rtx-pro-6000-dgx-spark/decode-speed.svg) ## What Bonsai 27B is Bonsai keeps the Qwen3.6-27B architecture but retrains the model so its language weights can use a binary or ternary representation. The base model has a hybrid-attention design: about 75% of its layers use linear attention, which helps keep long-context memory growth under control. ![How Bonsai 27B gets from 54 GB to your GPU](/img/blog/bonsai-27b-rtx-pro-6000-dgx-spark/bonsai-pipeline.svg) | Variant | Effective bits per weight | Ideal weight size | Published benchmark average | |---|---:|---:|---:| | Ternary Bonsai 27B | 1.71 | 5.9 GB | 80.49, or 94.6% of the FP16 reference | | 1-bit Bonsai 27B | 1.125 | 3.9 GB | 76.11, or 89.5% of the FP16 reference | There is an important size distinction. The 5.9 GB ternary figure is the ideal representation size. The current Q2_0 GGUF occupies about **7.17 GB on disk** because ternary values are stored in 2-bit slots with group scales. The Q1_0 GGUF is about **3.79 GB on disk**. Those deployed sizes are the useful numbers for planning downloads and memory. PrismML reports an average score of 72.73 for a conventional IQ2_XXS build of Qwen3.6-27B and 84.99 for Q4_K_XL. That makes the ternary model interesting: its published quality is much closer to the 4-bit result while using substantially less storage. These are PrismML's evaluations, not results I independently reproduced in full. ## Use the supported setup PrismML's [README](https://github.com/PrismML-Eng/Bonsai-demo) and [agent guide](https://github.com/PrismML-Eng/Bonsai-demo/blob/main/AGENTS.md) provide the intended installation path. On Linux or macOS, the basic flow is: ```bash git clone https://github.com/PrismML-Eng/Bonsai-demo.git cd Bonsai-demo ./setup.sh ./scripts/start_llama_server.sh ``` The default is Ternary Bonsai 27B. For the 1-bit model: ```bash BONSAI_FAMILY=bonsai ./setup.sh BONSAI_FAMILY=bonsai ./scripts/start_llama_server.sh ``` The setup script downloads the matching model and PrismML's prebuilt llama.cpp binaries. That is the path I would recommend to most readers. For reproducible benchmarking, I built the engines from source. The benchmark captures record these revisions: - Mainline llama.cpp: `12127de` - PrismML `pr/q2_0-cuda`: `87ff025` on the RTX PRO 6000 and `54e8e26` on the DGX Spark - PrismML `prism`: `62061f9`, the commit behind release `prism-b9591-62061f9` Branches move. If you want to reproduce these numbers, use the machine-specific commits above rather than cloning a branch tip and assuming it is unchanged. ### Commands used for the benchmark cells These are the `llama-bench` commands used for the result tables. They assume the GGUF files are in `models/` and use these local checkout names: - `llama.cpp-mainline`: mainline llama.cpp at `12127de` - `llama.cpp-prism`: PrismML `pr/q2_0-cuda` at the machine-specific revision listed above - `llama.cpp-prismbr`: PrismML `prism` at `62061f9` Create the three pinned checkouts: ```bash # Use 87ff025 on the RTX PRO 6000 or 54e8e26 on the DGX Spark. Q2_G64_COMMIT=87ff025 git clone https://github.com/ggml-org/llama.cpp.git llama.cpp-mainline git -C llama.cpp-mainline checkout 12127de git clone https://github.com/PrismML-Eng/llama.cpp.git llama.cpp-prism git -C llama.cpp-prism checkout "${Q2_G64_COMMIT}" git clone https://github.com/PrismML-Eng/llama.cpp.git llama.cpp-prismbr git -C llama.cpp-prismbr checkout 62061f9 ``` From the directory containing those checkouts, build `llama-bench` for the GPU you are testing: ```bash # Use 120 for the RTX PRO 6000 or 121 for the DGX Spark. CUDA_ARCH=120 for TREE in llama.cpp-mainline llama.cpp-prism llama.cpp-prismbr; do cmake -S "${TREE}" -B "${TREE}/build" \ -DGGML_CUDA=ON \ -DCMAKE_CUDA_ARCHITECTURES="${CUDA_ARCH}" \ -DLLAMA_BUILD_UI=OFF cmake --build "${TREE}/build" -j --target llama-bench done ``` From the directory containing the three checkouts and `models/`, the mainline Q1_0 run was: ```bash ./llama.cpp-mainline/build/bin/llama-bench \ -m models/Bonsai-27B-Q1_0.gguf \ -fa 0,1 -p 512 -n 128 -o md ``` On the DGX Spark, the Q2_g64 run used both flash-attention values in one command. This is the command that produced the `607.4` and `900.7` prefill rows shown later: ```bash ./llama.cpp-prism/build/bin/llama-bench \ -m models/Ternary-Bonsai-27B-Q2_g64.gguf \ -fa 0,1 -p 512 -n 128 -o md ``` The RTX Q2_g64 run used the same model and engine command with `-fa 1` only; I did not run an RTX Q2_g64 flash-attention A/B test. The PrismML `prism` runs for Q2_0 and Q1_0 were: ```bash BIN=./llama.cpp-prismbr/build/bin ${BIN}/llama-bench \ -m models/Ternary-Bonsai-27B-Q2_0.gguf \ -fa 1 -p 512 -n 128 -o md ${BIN}/llama-bench \ -m models/Bonsai-27B-Q1_0.gguf \ -fa 1 -p 512 -n 128 -o md ``` The commands did not explicitly set repetitions, GPU layers, threads, batch sizes, KV-cache types, or warm-up behavior. The pinned sources default to five repetitions, full GPU offload (`ngl = -1`), batch 2,048, microbatch 512, F16 K/V cache, warm-up enabled, and a machine-dependent CPU thread count. The resulting captures record `ngl = -1`, but not every default. On the multi-GPU RTX host, I set `CUDA_VISIBLE_DEVICES` to one GPU UUID before each command; do the same on any multi-GPU system to avoid accidentally spreading a run across devices. The execution record does not contain publishable checksums for every GGUF file, so this documents how the benchmark cells were produced rather than guaranteeing byte-for-byte reproduction from a future model download. The [pinned `llama-bench` documentation](https://github.com/ggml-org/llama.cpp/blob/12127de/tools/llama-bench/README.md) explains the flags and notes that these measurements exclude tokenization and sampling time. ## One compatibility detail worth knowing The repository currently publishes several ternary GGUF files because ternary support is moving into mainline llama.cpp. They are not interchangeable: | File | Intended use on CUDA at the tested revisions | |---|---| | `Ternary-Bonsai-27B-Q2_0.gguf` | PrismML's supported `prism` build; this is the recommended path | | `Ternary-Bonsai-27B-Q2_g64.gguf` | The group-64 migration format; fast with the tested `pr/q2_0-cuda` build | | `Ternary-Bonsai-27B-PQ2_0.gguf` | Not supported at the tested revisions | | `Bonsai-27B-Q1_0.gguf` | Supported by mainline llama.cpp and PrismML's build | In my tests, Q2_g64 loaded in the tested mainline CUDA build but ran through a very slow path: 8.8 pp and 2.7 tg, compared with 3,672 pp and 117.9 tg using the matching PrismML CUDA work. That is a useful diagnostic result, but it is not a reason to make readers navigate every development branch. Use the demo repository and its matching Q2_0 file unless you are specifically testing the upstream migration. ![Which file and which llama.cpp build to use](/img/blog/bonsai-27b-rtx-pro-6000-dgx-spark/which-build.svg) ## RTX PRO 6000 results These tests used one full RTX PRO 6000 GPU with flash attention enabled. | Model | Engine | pp512 | tg128 | |---|---|---:|---:| | Q1_0 | Mainline llama.cpp | 3,658.6 ± 242.2 | 133.7 ± 2.0 | | Q1_0 | PrismML `prism` | 3,599.7 ± 324.8 | **145.5 ± 3.2** | | Ternary Q2_g64 | PrismML `pr/q2_0-cuda` | 3,672.5 ± 293.6 | 117.9 ± 0.7 | | Ternary Q2_0 | PrismML `prism` | 3,620.6 ± 294.4 | **120.7 ± 0.7** | PrismML publishes an H100 SXM reference of 2,596 pp and 98 tg for ternary Q2_0. Our RTX PRO 6000 result is higher than that published reference, but this is not a controlled hardware comparison: the software revisions, flags, and environments are not identical. ### Tuning observations For ternary Q2_0, an RTX prompt-processing sweep using a 2,048-token prompt peaked at `-ub 1024`: | Microbatch | pp2048 | |---:|---:| | 256 | 3,519.0 | | 512 | 3,969.8 | | 1024 | **4,089.4** | | 2048 | 4,023.2 | Flash attention improved short-prompt prefill by about 7% in the Q1_0 test and was neutral for short-context decode. Longer context reduced decode speed gradually rather than collapsing it. Ternary Q2_0 measured 121.8 tok/s at 4K depth, 108.6 at 16K, and 90.2 at 64K depth. PrismML also offers experimental 4-bit KV cache support through `BONSAI_KV4=1`. Think of this as a memory-saving tool for long contexts, not a general speed optimization. The model's hybrid architecture already keeps its KV cache smaller than a full-attention 27B model. ### Concurrent users `llama-batched-bench` shows how aggregate throughput changes as more sequences are processed together: | Simultaneous sequences | Ternary Q2_0 total | Per sequence | Q1_0 total | Per sequence | |---:|---:|---:|---:|---:| | 1 | 119.3 | 119.3 | 140.2 | 140.2 | | 10 | 411.9 | 41.2 | 471.5 | 47.1 | | 32 | 671.8 | 21.0 | 786.1 | 24.6 | These are synthetic batched-benchmark results, not an end-to-end production load test. They show that the small weight footprint leaves substantial batching headroom on a 96 GB GPU. ### A note on MIG The RTX box initially exposed 32 MIG devices: four `1g.24gb` slices on each of eight GPUs. The tested llama.cpp build asserted during CUDA enumeration because it allows at most 16 visible devices. Restricting `CUDA_VISIBLE_DEVICES` to one slice avoided the startup failure. On one `1g.24gb` slice, Q1_0 decoded at 50.2 tok/s versus 133.7 tok/s on the full GPU. That result is useful when evaluating a cloud GPU slice, but it should not be treated as a performance estimate for a different consumer GPU with similar advertised bandwidth. ## DGX Spark results The DGX Spark has far more memory capacity than Bonsai needs, but much less memory bandwidth than the RTX PRO 6000. That difference is visible in decode performance. All rows below use flash attention (`-fa 1`) and report `llama-bench`'s mean ± standard deviation over five repetitions. | Model | Engine | pp512 | tg128 | |---|---|---:|---:| | Q1_0 | Mainline llama.cpp | 922.9 ± 11.8 | 40.9 ± 0.2 | | Q1_0 | PrismML `prism` | **991.6 ± 13.7** | **42.8 ± 0.1** | | Ternary Q2_g64 | PrismML `pr/q2_0-cuda` | 900.7 ± 19.1 | 26.6 ± 0.5 | | Ternary Q2_0 | PrismML `prism` | **937.7 ± 11.0** | **28.5 ± 0.1** | For Q2_g64, I also ran the same `54e8e26` build with flash attention disabled: | Q2_g64 setting | pp512 | tg128 | |---|---:|---:| | Flash attention off | 607.4 ± 129.5 | 25.9 ± 0.6 | | Flash attention on | 900.7 ± 19.1 | 26.6 ± 0.5 | Enabling flash attention increased mean prefill throughput by 48.3% in this test. The flash-attention-off prefill result had high run-to-run variation, and generation changed only slightly, so I would treat this as a result for this DGX Spark configuration rather than a general comparison with the RTX card. A Q2_g64 prompt-processing sweep peaked at `-ub 256` for a 2,048-token prompt. I did not repeat that complete microbatch sweep on Q2_0, so I would treat 256 as a starting point to measure rather than a universal best setting. At 16K context depth, Q1_0 still produced 31.5 tok/s. That is a meaningful result for a local workstation: the model remains interactive even as context grows. ## Speculative decoding depends on the workload PrismML ships a 1.95 GB Q4_1 DSpark drafter. It predicts short token blocks that the target model verifies. Accepted drafts preserve the target model's output distribution, but whether the technique is faster depends on the workload and hardware. I tested the same 512-token code-generation workload at temperature zero on both systems: | Machine | Without DSpark | With DSpark | Change | |---|---:|---:|---:| | RTX PRO 6000 | 117.4 tok/s | **156.4 tok/s** | +33% | | DGX Spark | 28.2 tok/s | **17.6 tok/s** | -37% | This makes DSpark attractive for this kind of single-user generation on the RTX PRO 6000, but not on the Spark in the tested configuration. It is not a free switch even on the RTX card. PrismML marks the feature experimental: it forces a single server slot and disables cross-request prompt-cache reuse. Leave it off for multi-user serving, multi-turn workloads that benefit from cached prefixes, or any workload you have not measured. ## A small vLLM comparison Bonsai's main advantage is quality per gigabyte. For context, I also ran the base Qwen3.6-27B model in NVFP4 with vLLM 0.25.1. | Machine | Bonsai Q2_0 decode | Bonsai Q1_0 decode | Qwen3.6-27B NVFP4 decode | |---|---:|---:|---:| | RTX PRO 6000 | 120.7 | 145.5 | 58.9 | | DGX Spark | 28.5 | 42.8 | 10.6 | The RTX vLLM run reached 858.8 output tok/s at batch 64, demonstrating why vLLM remains a better fit for a conventional model under heavy concurrent serving. Bonsai with llama.cpp was faster for the single-request tests shown here. On the Spark, I had to lower `--gpu-memory-utilization` to 0.5. The default 0.9 allocation left too little unified memory for the rest of the system in this environment. The resulting 10.6 tok/s was measured, but I did not isolate the kernel-level reason it fell below a simple bandwidth estimate. This is a context comparison, not an equal-quality model comparison. Bonsai and the NVFP4 base model have different weight representations and published evaluation scores. ## Quality smoke tests Speed is not useful if the model cannot complete ordinary tasks. I ran three small checks with temperature 0.7, top-p 0.95, top-k 20, and a 3,072-token generation budget: | Test | Ternary | 1-bit | |---|---|---| | Trick arithmetic question | Correct | Correct | | Longest-palindromic-substring implementation | Correct final answer | Correct approach in reasoning, but exhausted the token budget before the final answer | | Exact JSON tool call | Valid | Valid | These checks show that neither quant was obviously broken in basic use. They are not enough to claim that either model fully matches the original 27B model. The 1-bit coding result also shows why reasoning budgets matter: the model can spend a long time thinking before it produces a final answer. For an interactive deployment, use the web UI's reasoning-effort control or set a server-side cap with `--reasoning-budget`. A moderate cap is often more useful than disabling reasoning entirely. ## Recommendations | Use case | Recommendation | |---|---| | RTX PRO 6000, quality first | Ternary Q2_0 with the supported `prism` build | | RTX PRO 6000, fastest single-user decode | Q1_0 | | RTX PRO 6000, measured one-shot code generation | Test DSpark; our run improved by 33% | | DGX Spark, quality first | Ternary Q2_0, flash attention enabled | | DGX Spark, speed first | Q1_0, flash attention enabled | | DGX Spark speculative decoding | Leave off initially; our test was 37% slower | | Heavy concurrent serving of a conventional model | Use a serving engine such as vLLM and benchmark the actual workload | Bonsai 27B is a compelling single-user and small-team inference option on both machines. The RTX PRO 6000 exposes how fast a 27B-class model can decode when its weights occupy only a few gigabytes. The DGX Spark is slower, as expected from its lower memory bandwidth, but 28-43 tok/s remains practical for local use. The software support is still moving. Q1_0 is already the straightforward format; ternary CUDA users should follow PrismML's supported demo release until the upstream migration settles. Pin revisions when publishing benchmark numbers, and measure speculative decoding, KV-cache compression, and microbatch size against the workload you actually plan to run. ## Test environment - RTX system: Ubuntu 24.04, NVIDIA driver 610.43.02, CUDA 13.0, one RTX PRO 6000 Blackwell Server Edition used per benchmark. - DGX Spark: DGX OS, NVIDIA driver 580.159.03, CUDA 13.0, GB10 (`sm_121`). - llama.cpp revisions: mainline `12127de`; PrismML CUDA migration branch `87ff025` on the RTX system and `54e8e26` on the DGX Spark; PrismML release branch `62061f9`. - vLLM comparison: vLLM 0.25.1, transformers 5.13.1, `unsloth/Qwen3.6-27B-NVFP4`. - `llama-bench`: five repetitions per cell unless stated otherwise. - DSpark measurements: `llama-server` completion timings for the same code-generation prompt at temperature zero. --- # Introducing kiac: Real Kubernetes Nodes on Your Mac, Each Its Own Lightweight VM - Canonical: https://blog.kubesimplify.com/introducing-kiac-kubernetes-in-apple-containers - Published: 2026-07-06 - Summary: kiac runs local Kubernetes on macOS where every node is its own lightweight VM via apple/container: kubeadm or k3s flavors, Cilium on a custom kernel, built-in LoadBalancer, Grafana, Gateway API, and clusters that survive reboots. [kiac](https://github.com/saiyam1814/kiac) (Kubernetes in Apple Containers) runs local Kubernetes clusters where every node is its own lightweight virtual machine. Each node has its own kernel, its own cgroups, and its own IP that your Mac can reach directly. It is built on [apple/container](https://github.com/apple/container) and Apple's [Containerization](https://github.com/apple/containerization) framework, which went 1.0 in June 2026 and made this possible natively, with no Docker Desktop, no Lima, and no QEMU. And the per-node-VM idea goes further than you might expect: pick kubeadm or k3s as your flavor, run Cilium's eBPF datapath on a published full-featured kernel, get a working LoadBalancer and Grafana out of the box, survive host reboots, and practice real node failure, all from one small CLI. ![kiac v0.3 in one picture](/img/blog/introducing-kiac-kubernetes-in-apple-containers/wow-features.png) ```bash brew install saiyam1814/tap/kiac kiac create cluster --name dev --workers 2 ``` ![kiac creating a three-node cluster](/img/blog/introducing-kiac-kubernetes-in-apple-containers/kiac-demo.gif) This post explains the parts that make kiac interesting: how an Apple container actually works under the hood, how kiac assembles those into a Kubernetes cluster, and why that gives you a stronger isolation boundary than a node running inside a Docker container. --- ## A quick intro to Apple containers First, what `apple/container` actually is. It is Apple's own open-source container tool for macOS: a Swift-native CLI (`container run`, `container build`, `container images`, the workflow you already know) that Apple announced at WWDC 2025 and shipped as 1.0 in June 2026. It is built on a lower-level Swift framework called [Containerization](https://github.com/apple/containerization), and both are designed for Apple silicon, talking straight to the hypervisor built into macOS. There is no Docker daemon underneath, no hidden Linux VM you have to manage, and nothing to install besides a signed package from Apple. The interesting part is what it does differently. An Apple container is not a process sandboxed by namespaces. It is a full virtual machine. The Containerization framework boots a separate, minimal Linux VM for every single container, using Apple's `Virtualization.framework` on the silicon in your Mac. ![Anatomy of one Apple container](/img/blog/introducing-kiac-kubernetes-in-apple-containers/apple-container-anatomy.png) Here is what happens when you run a container: 1. **The image becomes a disk.** The runtime pulls the OCI image and builds an EXT4 filesystem from its layers. That filesystem is handed to the VM as its root block device. There is no overlay or union mount layered on top of a shared host kernel. The image is literally the VM's disk. 2. **A dedicated kernel boots.** The runtime asks `Virtualization.framework` to start a VM with a minimal, optimized Linux kernel that Apple bundles with the runtime (6.12 at the time of writing). This kernel belongs to that one container. It is not shared with the host or with any other container. It is also unusual: it is monolithic, with zero loadable modules. Everything it supports is compiled in, and anything left out simply does not exist inside the VM; there is no `modprobe` escape hatch. That is part of how it boots so fast, and it matters later when we talk about CNIs. 3. **`vminitd` comes up as PID 1.** Inside the VM, a tiny init system written in Swift, called `vminitd`, is the first process. It sets up the environment and launches and supervises your actual container process. The host runtime drives it through a gRPC API carried over `vsock`, the VM-to-host socket transport. 4. **Devices are virtio, networking is direct.** The VM uses virtio devices for block, network, and console. There is no BIOS and no legacy device emulation to slow the boot, which is why these VMs start in about a second rather than the half-minute a classic VM takes. On a supported macOS, each container also gets its own dedicated IP address, so you reach it directly without port forwarding. The payoff of this design is that you get the developer experience of containers (OCI images, registries, a `docker`-style CLI) on top of the isolation primitive of a virtual machine (a real, private kernel). That combination is exactly what makes it a good foundation for Kubernetes nodes. --- ## How a kiac cluster works A Kubernetes node wants to be a machine. It expects its own kernel, its own kubelet, its own cgroup hierarchy for resource accounting, and its own network identity. An Apple container gives it all of those. kiac is the glue that turns a handful of these VMs into a working cluster. ![How kiac builds a cluster](/img/blog/introducing-kiac-kubernetes-in-apple-containers/architecture.png) When you run `kiac create cluster`, the flow is: 1. **Boot the node VMs.** kiac drives the `apple/container` CLI to start one lightweight VM per node, each booted from the standard `kindest/node` image. That image already contains systemd, containerd, and kubeadm, so kiac is not reinventing the node, it is reusing a known-good one on a new runtime. 2. **Initialize the control plane.** kiac runs `kubeadm init` inside the first VM. This brings up etcd, the API server, the controller manager, and the scheduler. This step is the bulk of the create time, because it is real `kubeadm` doing real work. 3. **Join the workers.** Each worker VM runs `kubeadm join` against the control plane using a bootstrap token. Because every node has its own routable IP on the `vmnet` network, they talk to each other like machines on a small LAN. 4. **Install a usable default stack.** A bare cluster is not much fun, so every create installs four things by default: - **kindnet** for the pod network - **local-path-provisioner** as a default StorageClass, so PVCs bind and StatefulSets work immediately - **metrics-server**, configured for kubeadm's self-signed kubelet certs, so `kubectl top` works out of the box - **kiac-lb**, a purpose-built LoadBalancer controller, so `type: LoadBalancer` services get a real EXTERNAL-IP you can curl from your Mac kiac-lb deserves a sentence, because it is not what you expect. It is not a set of pods with webhooks and ARP speakers. It is a tiny systemd loop inside the control-plane VM that drives the node's own `kubectl` and assigns each Service a node IP local to its endpoints, in about two seconds. It shares one IP across Services on disjoint ports, and it re-checks its assignments, so it heals itself after node restarts. Since every node IP is already routable from your Mac, nothing needs to answer ARP at all. Why is kindnet the default and not Flannel or Cilium? This is where that minimal kernel comes back. Most CNIs build an overlay: they wrap pod traffic in VXLAN or Geneve tunnels, or run an eBPF datapath, and the bundled kernel compiles none of that in (no `CONFIG_VXLAN`, no `br_netfilter`, no BPF JIT, and with zero loadable modules nothing can be added at runtime). kindnet needs none of it. It is plain L3 routing: every node already has a routable IP on the same `vmnet` segment, so kindnet just installs a route on each node for every other node's pod CIDR, and pod traffic is forwarded as ordinary IP packets. No encapsulation, no tunnel device. The only kernel features it needs (veth pairs, a bridge, iptables NAT) are all compiled in. When you do want the fancy datapath, v0.3 has an answer: a published full kernel. More on that in a moment. 5. **Write the kubeconfig.** kiac merges a context named `kiac-` into your `~/.kube/config` (and backs up your existing config the first time). Your normal `kubectl` workflow just works. kiac talks only to the `apple/container` runtime. It never touches the Docker socket, so it coexists peacefully with Docker Desktop, Rancher Desktop, kind, and k3d if you have them. --- ## Why this is more isolated than a node in a Docker container This is the heart of it. When a local Kubernetes tool runs "nodes" as Docker containers, all of those nodes are processes sharing one Linux kernel, separated only by namespaces and cgroups. Namespaces are a software boundary inside a single shared kernel. With kiac, the boundary between nodes is the hypervisor itself, the same hardware-backed boundary that separates virtual machines. ![Where the isolation boundary sits](/img/blog/introducing-kiac-kubernetes-in-apple-containers/isolation.png) That difference is not academic. It changes what the cluster can actually do: - **Security blast radius.** A container escape is, at its core, a way to break out of the namespace boundary and reach the shared kernel. In a shared-kernel setup, reaching the kernel means reaching every other node on it. With a VM per node, an escape is contained to that one VM. To cross into another node, an attacker would have to break the hypervisor, which is a far harder boundary. - **Failure domains.** A shared kernel is a shared fate. One kernel panic, one runaway sysctl, one bad kernel module, and every node on that kernel goes down together. With kiac, a kernel problem stays inside the VM that caused it. The other nodes do not even notice. - **Real node failure.** Because each node is a separate VM, you can stop one and have it behave like an actual node going offline: the control plane detects NotReady, evicts the pods, and reschedules them elsewhere. kiac ships this as a first-class command pair, `kiac stop node` and `kiac start node`, so a chaos drill is two commands. You cannot meaningfully test that when "stopping a node" means killing one of several processes that share a kernel. - **Per-node kernel reality.** Each node has its own `/proc`, its own `/sys`, its own sysctls, and, with `--kernel`, its own kernel build. Node-level behavior is real, not simulated, which is exactly what you want when the thing you are testing is node-level behavior. None of this means containers are bad. For packaging and shipping software, the container model is excellent, and kiac depends on it. The point is narrower: when the workload you are isolating is itself a machine, a machine-grade boundary is the right tool, and that is what a lightweight VM gives you. --- ## Getting started ### Requirements - An Apple silicon Mac - macOS 26 or newer for multi-node clusters (single-node works on macOS 15, with limitations) - [apple/container](https://github.com/apple/container/releases) 1.0.0 or newer - `kubectl` ### Install and check ```bash brew install saiyam1814/tap/kiac kiac doctor ``` `kiac doctor` verifies your macOS version, that the `apple/container` CLI is present and recent, that its system service is running, and that `kubectl` is on your PATH. If the container service is not running, `kiac doctor --fix` starts it for you. ### Create a cluster ```bash kiac create cluster --name dev --workers 2 ``` ```text ⬢ kiac v0.3.0 · Kubernetes in Apple Containers ✓ Preflight checks (0.3s) ✓ Pulling node image kindest/node:v1.36.1 (8.2s) ✓ Booting 3 node VM(s) (9.6s) ✓ Initializing Kubernetes control plane (24.8s) ✓ Joining 2 worker(s) (8.4s) ✓ Installing CNI (kindnet) (0.4s) ✓ Installing addons (storage, metrics-server) (0.6s) ✓ Installing LoadBalancer (kiac-lb) (1.9s) ✓ Waiting for nodes to be Ready (7.1s) ✓ Labeling LoadBalancer primary node (0.3s) ✓ Writing kubeconfig (0.2s) Cluster "dev" is ready in 1m2s. Every node is its own lightweight VM. ``` About a minute for three nodes. The node VMs themselves boot in seconds; most of the time is real `kubeadm` initializing a real control plane. ### Useful flags on create | Flag | Default | What it does | |---|---|---| | `--name` | `dev` | cluster name | | `--workers` | `0` | worker count (the control plane is untainted when 0) | | `--k8s-version` | `1.36` | Kubernetes minor, pinned digests for 1.32 through 1.36 | | `--distro` | `kubeadm` | distribution per node VM: `kubeadm` (kindest/node) or `k3s` (rancher/k3s) | | `--cni` | `kindnet` | pod network: `kindnet`, `cilium` (needs `--kernel full`), or `none` to bring your own | | `--kernel` | Apple's bundled kernel | `full` downloads the published kiac kernel, or pass a path to your own kernel Image | | `--cpus` | `4` | vCPUs per node VM | | `--memory` | `2G` | memory per worker VM | | `--cp-memory` | `4G` | memory for the control-plane VM | | `--config` | | cluster config YAML; flags set explicitly on the command line override file values | | `--observability` / `--gateway` | off | opt-in stacks: Prometheus + Grafana, and Gateway API + Traefik | | `--no-metrics` / `--no-storage` / `--no-lb` | off | skip any default addon | --- ## Pick your flavor v0.3 gives you three ways to build the same per-node-VM cluster: ```bash # Default: kubeadm on kindest/node, the closest thing to a production cluster kiac create cluster --name dev --workers 2 # k3s: the lightest and fastest, batteries included kiac create cluster --name edge --distro k3s --workers 1 # Cilium on the full kernel (prereq: brew install cilium-cli) kiac create cluster --name lab --cni cilium --kernel full --workers 2 ``` **k3s** takes the VM-per-node idea to its logical extreme: `rancher/k3s` runs as PID 1 inside each VM, with a sqlite datastore instead of etcd and its bundled servicelb, local-path storage, and metrics-server. No systemd layer, no kubeadm. A two-node cluster is up in about 30 seconds and the whole thing idles around 3.7GB of host RSS. One kiac-specific twist: kiac applies kindnet instead of k3s's default flannel, because flannel's bridge backend without `br_netfilter` breaks same-node service traffic on the stock kernel. The [k3s guide](https://saiyam1814.github.io/kiac/docs/k3s.html) has the details. **Cilium** is the fun one, because it should not work here at all. Apple's bundled node kernel is monolithic with zero loadable modules, and it leaves out everything an eBPF CNI needs: no VXLAN or Geneve encapsulation, no `br_netfilter`, no BPF JIT, no BTF. There is no `modprobe`, so nothing can be added at runtime. On the stock kernel, Cilium's agent simply cannot bring up its datapath. The per-node-VM architecture is the way out. Since every node boots its own kernel, kiac can just boot a different one. `--kernel full` downloads a published kernel build (release `kernel-v6.12.28-full`, sha-pinned, cached under `~/.kiac/kernels`) that kiac builds in CI from kernel.org source: Apple's own config as the base, plus VXLAN, Geneve, `br_netfilter`, nf_tables, the BPF JIT with BTF, WireGuard, and kprobes compiled in. Same monolithic design, same fast boot, just with the networking features present. On that kernel, the full Cilium stack (Cilium plus the `--observability` and `--gateway` addons) comes up verified in 1m37s. The performance result was the surprise. Cilium's VXLAN overlay rides vmnet's fast path: around 285MB/s of cross-node pod-to-pod throughput, and about 1GB/s from the Mac straight to a pod. Counterintuitively, the encapsulated path is the fast one here; routed CNIs crawl on bulk cross-node transfers over vmnet, which kiac mitigates by assigning LoadBalancer IPs local to the pods they serve. If you want the full walkthrough, see the [Cilium guide](https://saiyam1814.github.io/kiac/docs/cilium.html) and [examples/cilium-cluster.md](https://github.com/saiyam1814/kiac/blob/main/examples/cilium-cluster.md). Whatever the flavor, `--observability` adds Prometheus v3.5.0 and Grafana 12.0.2 with two provisioned dashboards on a LoadBalancer IP at `:3000`, and `--gateway` adds Gateway API v1.5.1 CRDs plus Traefik v3.7.6 with a ready-to-use Gateway. Both are verified on kubeadm, k3s, and Cilium clusters. --- ### What `--observability` actually hands you Every flavor takes `--observability`, and this is the real thing, not a checkbox. Prometheus scrapes your nodes, kubelets, and kube-state-metrics from second one, and Grafana comes up on a LoadBalancer IP with dashboards already provisioned. This screenshot is a three-node cluster a few minutes after `kiac create cluster --workers 2 --observability`, untouched: ![Grafana Cluster Overview on a kiac cluster, live data](/img/blog/introducing-kiac-kubernetes-in-apple-containers/shot-grafana-overview.png) ## Seeing the isolation pay off Here is a real three-node cluster, with `kubectl get nodes -o wide`. ```text NAME STATUS ROLES VERSION INTERNAL-IP OS-IMAGE KERNEL-VERSION CONTAINER-RUNTIME kiac-dev-control-plane Ready control-plane v1.36.1 192.168.64.2 Debian GNU/Linux 13 (trixie) 6.12.28 (arm64) containerd://2.3.1 kiac-dev-worker-1 Ready v1.36.1 192.168.64.3 Debian GNU/Linux 13 (trixie) 6.12.28 (arm64) containerd://2.3.1 kiac-dev-worker-2 Ready v1.36.1 192.168.64.4 Debian GNU/Linux 13 (trixie) 6.12.28 (arm64) containerd://2.3.1 ``` Each node reports its own kernel version and its own `INTERNAL-IP` on the `vmnet` network, reachable straight from your Mac. ### `kubectl top` just works ```bash kubectl top nodes ``` ```text NAME CPU(cores) CPU(%) MEMORY(bytes) MEMORY(%) kiac-dev-control-plane 269m 5% 828Mi 20% kiac-dev-worker-1 35m 0% 288Mi 14% kiac-dev-worker-2 52m 1% 359Mi 18% ``` Real kubelet, real cAdvisor, real cgroups inside a real Linux VM. Give metrics-server about 60 seconds for its first scrape, and `kubectl top` reports actual numbers with no patching. ### `type: LoadBalancer` gets a real IP you can curl ```bash kubectl create deploy web --image=nginx --replicas=2 kubectl expose deploy web --port=80 --type=LoadBalancer kubectl get svc web ``` ```text NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE web LoadBalancer 10.104.197.79 192.168.64.3 80:30495/TCP 4s ``` ```bash curl http://192.168.64.3 # HTTP 200 in 0.004s, straight from your Mac. No tunnel, no . ``` That EXTERNAL-IP appeared about two seconds after the Service was created, courtesy of kiac-lb, and it is a node IP local to the pods behind it. ### Load a locally-built image into every node ```bash container build -t myapp:dev . kiac load image myapp:dev --name dev ``` When you are done, tear it all down: ```bash kiac delete cluster --name dev ``` --- ## Clusters that survive a reboot Restart your Mac, then run `kiac resume cluster --name dev`. kiac boots the node VMs back up, waits for the API server, and heals everything a reboot breaks: control-plane certificate SANs, the kubeconfigs inside the VMs and on your Mac, and kube-proxy, even if `vmnet` handed out a whole new subnet while the cluster was down (verified across a subnet change in 46 seconds). Resume works on kubeadm clusters today; the [persistence docs](https://saiyam1814.github.io/kiac/docs/persistence.html) cover exactly what it repairs and why. ## A web console when you want one `kiac ui` opens a local dashboard where you can create, watch, and delete clusters, running the same engine as the CLI: cluster cards with live per-node CPU and memory, stop and start buttons for node chaos, and one-click links to Grafana and your Gateway. ![The kiac ui dashboard](/img/blog/introducing-kiac-kubernetes-in-apple-containers/shot-kiac-ui.png) Each cluster also gets a kubectl Console drawer, so you can fire off commands against any cluster without leaving the browser: ![The embedded kubectl console](/img/blog/introducing-kiac-kubernetes-in-apple-containers/shot-kiac-console.png) The [dashboard docs](https://saiyam1814.github.io/kiac/docs/dashboard.html) have a full tour. --- ## Honest trade-offs Real isolation is not free, and I would rather you hear the limits from me than discover them mid-demo. - **RAM.** Each worker VM reserves 2G and the control plane 4G by default (tunable with `--memory` and `--cp-memory`). An idle worker only uses a few hundred MB inside the guest, as the `kubectl top` output above shows, but the reservation is real. A three-node cluster is not something you run on an 8GB machine. If memory is tight, `--distro k3s` keeps a two-node cluster around 3.7GB of host RSS. - **macOS 26+ for multi-node.** Node-to-node networking relies on `vmnet` container-to-container support. Single-node works further back, with limits. - **Apple silicon only.** This is an arm64 story end to end. - **Restarted VMs and host TCP.** After `kiac stop node` and `kiac start node`, new TCP connections from your Mac to that specific VM are dropped by a known `apple/container` vmnet issue. Traffic inside the cluster is unaffected, and a full host reboot followed by `kiac resume` does not hit it. It makes node chaos drills slightly less pretty than they should be, and it is tracked upstream. - **Bulk cross-node throughput on routed CNIs.** Large transfers between pods on different nodes are slow over vmnet with routed CNIs like kindnet. kiac mitigates it by assigning LoadBalancer IPs local to the serving pods, and the Cilium flavor avoids it entirely because its overlay rides vmnet's fast path. - **Resume is kubeadm-only today.** k3s clusters do not survive a host reboot yet. - **Create takes about a minute** for three nodes. The node VMs boot in seconds; most of the time is real `kubeadm` initializing the control plane. --- ## Where this is going kiac is deliberately small, and there is a clear list of what comes next: - **Persistent clusters** backed by `container machine` (the persistent Linux environments Apple shipped at WWDC26), so resume becomes a fast path instead of a repair job. - **HA control planes**, because multiple real control-plane VMs is exactly what this architecture is for. - **One-flag Calico and Flannel** now that the full kernel has the features they need. - **Hubble** wired into the Cilium flavor for flow observability. --- ## Come build it with me kiac is genuinely easy to contribute to: one Go binary with a clean `cmd/` + `pkg/` layout, a runtime you can poke at directly with the `container` CLI, and an [issues page](https://github.com/saiyam1814/kiac/issues) where items come scoped with context, file pointers, and acceptance criteria so a first PR has a clear finish line. Two other great ways in: - **Run the example guides and break them.** [cilium-cluster](https://github.com/saiyam1814/kiac/blob/main/examples/cilium-cluster.md), [k3s-cluster](https://github.com/saiyam1814/kiac/blob/main/examples/k3s-cluster.md), [chaos-drill](https://github.com/saiyam1814/kiac/blob/main/examples/chaos-drill.md), and [resume-drill](https://github.com/saiyam1814/kiac/blob/main/examples/resume-drill.md) are copy-pasteable end-to-end walkthroughs. If a step does not match what you see, that is an issue worth filing. - **Read the [contributing guide](https://saiyam1814.github.io/kiac/docs/contributing.html)** for the layout, test setup, and how a PR flows. And if you just run a cluster and something breaks, that is a contribution too: [open an issue](https://github.com/saiyam1814/kiac/issues) with the output of `kiac doctor`. --- ## Credit where it is due kiac stands on other people's work. The `apple/container` and Containerization teams at Apple built the runtime that makes any of this possible. Akihiro Suda's `kina` was the proof-of-concept that showed Kubernetes on `apple/container` was viable at all. The node experience reuses the `kindest/node` image from the kind project, and the k3s flavor reuses `rancher/k3s`. kiac is the packaging that turns those pieces into a one-command local cluster. ## Try it ```bash brew install saiyam1814/tap/kiac kiac doctor kiac create cluster --name dev --workers 2 # or the eBPF flavor kiac create cluster --name lab --cni cilium --kernel full --workers 2 ``` - GitHub: [github.com/saiyam1814/kiac](https://github.com/saiyam1814/kiac) - Docs and site: [saiyam1814.github.io/kiac](https://saiyam1814.github.io/kiac/) It is open source and MIT licensed. If you run a cluster on it, I would love to know what you break first. Issues and PRs welcome. --- # LLM Costs and Observability with agentgateway on Kubernetes (Part 2) - Canonical: https://blog.kubesimplify.com/llm-costs-and-observability-with-agentgateway-on-kubernetes - Published: 2026-06-30 - Summary: Part 2: scrape agentgateway with Prometheus, build a Grafana dashboard of token cost and per-tool usage, see blocked tool calls, and alert on spend. This is Part 2 of a two-part series. In [Part 1](https://blog.kubesimplify.com/controlling-mcp-tools-with-agentgateway-on-kubernetes) you put a Google ADK agent behind **agentgateway** on Kubernetes: the agent holds zero secrets, its model and tool calls flow through one proxy, and a policy blocks any tool you have not allowed. That is the governance half. This part is the question governance cannot answer on its own: what is all of this actually costing you, and can you see when an agent misbehaves? By the end you will have Prometheus and Grafana on the same `kind` cluster, a dashboard that shows token throughput and an estimated dollar figure as your agents run, and blocked tool-call attempts visible on the same screen. All the manifests and the dashboard JSON are in the companion repo: https://github.com/shkatara/agentgateway-security-observability What you'll build in Part 2: - Prometheus and Grafana running in the cluster via `kube-prometheus-stack`. - A `PodMonitor` that scrapes the gateway proxy's metrics. - A Grafana dashboard with token cost, per-tool call counts, and HTTP status, imported from a single file. - A view of blocked tool-call attempts, and an explanation of why the `405`s you will see are not blocked tools. - A `PrometheusRule` that alerts when estimated daily spend crosses a budget. > **Note:** agentgateway is a fast-moving project. These commands target the `v1.2.x` charts and the `agentgateway.dev/v1alpha1` API. Pin versions and expect metric names and fields to evolve. ## Prerequisites You need the setup from Part 1 up and running: - The `kind` cluster with the agentgateway control plane and proxy. - The LLM route (`/v1`) and the MCP route (`/mcp-github`), with the ADK agent working. - Ideally the `AgentgatewayPolicy` from Part 1 applied (allow only `get_me`), so blocked attempts show up on the dashboard. - `helm` v3.8+ and the port-forward on `localhost:8080` still available. ## Why agents need observability, specifically Two of the three questions from Part 1 are observability questions. Who spent the money, and on which model? When an agent goes off the rails, where is the trace? A normal service dashboard does not answer these, because it does not understand tokens or tool calls. agentgateway does. It counts tokens and MCP calls for every request and exposes them as Prometheus metrics, so the answers become PromQL queries instead of a shrug. ## The quick look: the metrics endpoint Before installing anything, confirm the gateway is already emitting what we need. Port-forward the proxy's metrics port and read it: ```sh kubectl port-forward deployment/agentgateway-proxy -n agentgateway-system 15020 & curl -s localhost:15020/metrics | grep gen_ai_client_token_usage ``` You will see the token-usage histogram that powers all cost tracking: ```console agentgateway_gen_ai_client_token_usage_sum{gen_ai_operation_name="chat",gen_ai_request_model="gpt-4o-mini",gen_ai_system="openai",gen_ai_token_type="input"} 342 agentgateway_gen_ai_client_token_usage_count{gen_ai_operation_name="chat",gen_ai_request_model="gpt-4o-mini",gen_ai_system="openai",gen_ai_token_type="input"} 5 ``` The labels (`gen_ai_request_model`, `gen_ai_system`, `gen_ai_token_type`) are what let you slice spend by model, provider, and direction. The metrics endpoint is fine for a peek. The payoff is a dashboard, so let us wire one up. ## The observability architecture Everything stays inside the same cluster. The proxy exposes metrics on port `15020`. We add Prometheus to scrape it and Grafana to draw it. ![AgentGateway Observability Architecture](/img/blog/llm-costs-and-observability-with-agentgateway-on-kubernetes/agw-monitoring.png) ## Step 1: Install Prometheus and Grafana The `kube-prometheus-stack` chart bundles Prometheus, Grafana, the Prometheus Operator, and a Grafana sidecar that auto-loads dashboards from labeled ConfigMaps. The three `NilUsesHelmValues=false` flags matter. Without them, the operator only picks up `PodMonitor` and `PrometheusRule` resources that carry its own release label, and the ones we create next would be silently ignored. ```sh helm upgrade --install kube-prometheus-stack kube-prometheus-stack \ --repo https://prometheus-community.github.io/helm-charts \ --namespace telemetry --create-namespace \ --set prometheus.prometheusSpec.podMonitorSelectorNilUsesHelmValues=false \ --set prometheus.prometheusSpec.serviceMonitorSelectorNilUsesHelmValues=false \ --set prometheus.prometheusSpec.ruleSelectorNilUsesHelmValues=false \ --wait ``` It pulls a few images, so give it a minute. Confirm the stack is up: ```sh kubectl get pods -n telemetry ``` ## Step 2: Make Prometheus scrape the gateway First, find the label that uniquely identifies the proxy pod. This one step saves the most debugging time. ```sh kubectl get pods -n agentgateway-system --show-labels | grep proxy ``` agentgateway's own scrape config keys on `kgateway=kube-gateway` for data plane proxies, which is what the `PodMonitor` below uses. If your output shows a different label (for example `app.kubernetes.io/name=agentgateway-proxy`), use that in the `selector`. ```sh kubectl apply -f- < /dev/null echo "request $i sent" sleep 2 done ``` You can also just chat with the ADK agent a few more times. Either way, every call increments the token metric. ## Step 4: Confirm the metric in Prometheus Before touching Grafana, confirm the data landed. With the port-forward on `9090`, open `http://localhost:9090/graph` and run: ```promql agentgateway_gen_ai_client_token_usage_sum ``` You should see series with labels like `gen_ai_request_model="gpt-4o-mini"` and `gen_ai_token_type="input"`. If they are here, Grafana is just drawing what Prometheus already has. ## Step 5: Import the cost dashboard The dashboard ships as [`agentgateway-cost-dashboard.json`](https://github.com/shkatara/agentgateway-security-observability/blob/main/agentgateway-cost-dashboard.json) in the repo. The Grafana sidecar auto-imports any ConfigMap in the `telemetry` namespace labeled `grafana_dashboard=1`, so from the repo root: ```sh kubectl -n telemetry create configmap agentgateway-cost-dashboard \ --from-file=agentgateway-cost-dashboard.json kubectl -n telemetry label configmap agentgateway-cost-dashboard grafana_dashboard=1 ``` Port-forward Grafana and log in: ```sh kubectl port-forward -n telemetry deployment/kube-prometheus-stack-grafana 3000 & # username: admin kubectl -n telemetry get secret kube-prometheus-stack-grafana -o jsonpath='{.data.admin-password}' | base64 -d ``` Open the **agentgateway: Agent LLM Cost and Usage** dashboard. With the traffic loop running, the panels fill in within a scrape interval or two. ![agentgateway cost and tool-usage dashboard in Grafana](/img/blog/llm-costs-and-observability-with-agentgateway-on-kubernetes/cost-dashboard.png) Top row: MCP tool calls over time, and totals per tool. Middle: MCP transport HTTP by method and status, where the `GET 405/406` lines are normal transport negotiation, not blocked tools (more on that below). Bottom: blocked tool-call attempts, everything outside the allow-list. ## The panels and the PromQL behind them If you would rather build the dashboard by hand, or you just want to understand what each panel asks Prometheus, here is every query. | Panel | What it shows | PromQL | | --- | --- | --- | | Token throughput (input vs output) | Tokens per second, split by direction | `sum by (gen_ai_token_type) (rate(agentgateway_gen_ai_client_token_usage_sum[5m]))` | | Token throughput by model | Which models are burning tokens | `sum by (gen_ai_request_model) (rate(agentgateway_gen_ai_client_token_usage_sum[5m]))` | | Estimated spend (last 1h) | A live dollar figure | `(sum(increase(agentgateway_gen_ai_client_token_usage_sum{gen_ai_token_type="input"}[1h])) / 1000000) * 0.15 + (sum(increase(agentgateway_gen_ai_client_token_usage_sum{gen_ai_token_type="output"}[1h])) / 1000000) * 0.60` | | LLM requests per second by status | HTTP status of LLM-route traffic only | `sum by (status) (rate(agentgateway_requests_total{route="agentgateway-system/openai"}[5m]))` | | MCP transport requests (by method and status) | Raw HTTP on the MCP route | `sum by (method, status) (rate(agentgateway_requests_total{route="agentgateway-system/mcp-github"}[5m]))` | | MCP tool calls over time (by tool) | Which tools are being called | `sum by (resource) (rate(agentgateway_mcp_requests_total{method="tools/call"}[5m]))` | | Total MCP tool calls (by tool) | How many times each tool was called | `sum by (resource) (agentgateway_mcp_requests_total{method="tools/call"})` | | Blocked tool-call attempts (total) | Calls to tools outside the allow-list | `sum(agentgateway_mcp_requests_total{method="tools/call", resource!="get_me"})` | | Blocked tool-call attempts over time (by tool) | When and which blocked tools were attempted | `sum by (resource) (rate(agentgateway_mcp_requests_total{method="tools/call", resource!="get_me"}[5m]))` | A note on the MCP metric. Tool activity is `agentgateway_mcp_requests_total`, with a `method` label (`tools/list`, `tools/call`, `initialize`) and, for tool calls, a `resource` label holding the tool name. So `...{method="tools/call", resource="get_me"}` is the per-tool call count. The gateway counts the attempt even when a tool is blocked, so a tool you have hidden by policy still shows a count here. The metric records that a call was attempted, not whether it was allowed, so the proxy logs are where you confirm the verdict. If names differ on your build, run `curl -s localhost:15020/metrics | grep mcp`. ## Turning tokens into dollars There is no magic in the spend panel. It multiplies token counts by your provider's price: ``` cost = (input_tokens / 1,000,000 × input_price) + (output_tokens / 1,000,000 × output_price) ``` The dashboard keeps the two prices in hidden variables (`price_in`, `price_out`), defaulting to example values for a small model. Prices change and vary by model, so treat them as placeholders and set your real rates in the dashboard's Variables settings. The `increase(...[1h])` window means the stat reads as spend over the last hour. ## See the blocked tool call on the dashboard This is where the two halves of the series meet. When you applied the `AgentgatewayPolicy` that allows only `get_me`, every attempt to call another tool is rejected. With the dashboard open, run a handful of blocked calls: ```sh for i in $(seq 1 5); do npx @modelcontextprotocol/inspector@0.21.2 \ --cli http://localhost:8080/mcp-github \ --transport http \ --method tools/call \ --tool-name search_repositories \ --tool-arg query="kubernetes" sleep 1 done ``` Each one comes back as `Unknown tool`, because the policy hides `search_repositories`. Even so, the gateway records the attempt, so `search_repositories` shows up on the per-tool panels with its own count, and on the "blocked tool-call attempts" panels because it is not in the allow-list. A blocked tool with a rising count is a useful signal in itself: something is trying to reach a tool it is not allowed to. ## Why you see 405s, and why they are not blocked tools Look at the MCP transport panel and you will see a lot of `GET 405` and `GET 406`. It is tempting to read those as blocked tools. They are not. A blocked tool call returns HTTP `200` with a JSON-RPC error in the body (`Unknown tool`). MCP errors are application-level, not HTTP status codes, so blocking never shows up as a `4xx`. The `405` and `406` responses are MCP Streamable HTTP transport: the client issues a `GET` to open an SSE stream, or a `DELETE` to end a session, and that method is not allowed on the endpoint. It is normal protocol negotiation. That is exactly why the dashboard scopes the LLM status panel to the OpenAI route and gives the MCP route its own transport panel. Otherwise the MCP transport noise leaks into your LLM error rate and you chase a problem that is not there. ## Alert on spend You do not want to watch a dashboard all day. A `PrometheusRule` fires when estimated daily spend crosses a threshold. The `release: kube-prometheus-stack` label is what makes the operator pick the rule up. ```sh kubectl apply -f- < 50 for: 5m labels: severity: warning annotations: summary: "Estimated LLM spend over the last 24h exceeded 50 USD" EOF ``` Adjust the prices and the `> 50` threshold to your budget. The alert appears in Prometheus under **Alerts**, and routes through AlertManager if you have wired up a receiver. ## Common Issues and How to Solve Them | Symptom | Likely cause | Fix | | --- | --- | --- | | Proxy target missing or `DOWN` in Prometheus | The `PodMonitor` selector does not match the proxy pod, or the operator ignores the monitor | Run `kubectl get pods -n agentgateway-system --show-labels` and fix the `selector`; confirm the `podMonitorSelectorNilUsesHelmValues=false` flag | | Target `UP` but no `agentgateway_gen_ai_*` series | No LLM traffic yet, or the wrong port | Run the traffic loop and confirm metrics on `:15020/metrics` | | Grafana panels say "datasource not found" | The dashboard datasource variable did not auto-select | Pick Prometheus from the datasource dropdown at the top of the dashboard | | Dashboard never appears in Grafana | The ConfigMap is unlabeled or in the wrong namespace | It must be in the `telemetry` namespace and labeled `grafana_dashboard=1` | | Estimated spend shows 0 | `increase()` needs at least two samples in the window | Send traffic and wait a couple of scrape intervals | | MCP panels are empty | The tool-call metric is named differently on your build | Run `curl -s localhost:15020/metrics | grep -E 'mcp|tool'` and adjust the query | | A lot of `GET 405/406` on the MCP route | Normal MCP Streamable HTTP transport negotiation, not blocked tools | Nothing to fix; blocked tools are HTTP `200` with `Unknown tool` in the body | ## Conclusion That completes the series. In Part 1 you made agents safe to run: no secrets in the runtime, and tools locked down by policy. In Part 2 you made them legible: token cost per model on a live graph, per-tool usage, blocked attempts you can watch, and an alert when spend runs away. Here's what you accomplished in Part 2: 1. Installed Prometheus and Grafana on the same cluster as the gateway. 2. Scraped the proxy with a `PodMonitor` and verified the target. 3. Imported a cost dashboard showing token throughput, per-model usage, per-tool calls, and an estimated dollar figure. 4. Made blocked tool-call attempts visible, and learned why the `405`s are transport noise, not policy denials. 5. Added an alert that fires when estimated daily spend crosses a budget. Put together, the two parts answer the questions you could not answer before: who spent what, who can call which tool, and where the trace is. That single control point is the difference between a pile of agents and a platform. All the manifests, the agent, and the dashboard are in the repo: https://github.com/shkatara/agentgateway-security-observability. The agentgateway project lives at [agentgateway.dev](https://agentgateway.dev/). --- # Controlling MCP Tools with agentgateway on Kubernetes (Part 1) - Canonical: https://blog.kubesimplify.com/controlling-mcp-tools-with-agentgateway-on-kubernetes - Published: 2026-06-29 - Summary: Run AI agents behind agentgateway on Kubernetes: route their LLM and MCP tool calls through one proxy, keep secrets out of the agent, and block tools by policy. This is Part 1 of a two-part series. In this part you stand up **agentgateway** on Kubernetes, put a Google ADK agent behind it so the agent holds zero secrets, and enforce tool-level access control that you can watch block a tool in real time. Part 2 adds cost and observability: Prometheus, Grafana, and a live token-spend dashboard. The available artifacts are available at https://github.com/shkatara/agentgateway-security-observability.git agentgateway is an open source, AI-native proxy. By the end of this part, your agent will talk to its tools (over MCP) and its model (over an OpenAI-compatible API) through the gateway, hold none of its own credentials, and be unable to call a tool you have not explicitly allowed. Who this is for: - Platform engineers, SREs, and backend engineers who are starting to run AI agents in production and are realizing that "the agent has every API key and can call every tool" is not a strategy. - Anyone who has shipped one agent, watched three more appear, and now needs a single place to enforce security across all of them. What you'll build in Part 1: - A `kind` cluster running the agentgateway control plane and proxy. - An LLM route, so the agent's model calls flow through the gateway and the provider key lives only in the gateway. - An MCP route to the remote GitHub MCP server, with the GitHub token injected by the gateway. - A Google ADK agent whose model and tools both go through the gateway and which carries no real secrets. - Tool-level access control: you allow one tool and watch the gateway hide another, so calling it comes back as an `Unknown tool` error. > **Note:** agentgateway is a fast-moving project (a Linux Foundation / Agentic AI Foundation project, currently around the v1.2 to v1.3 line). Pin the versions shown here, and expect custom resource fields to evolve. Every command below was written against the `v1.2.x` charts and the `agentgateway.dev/v1alpha1` API. ## Prerequisites For this tutorial, you'll need: - A machine with [Docker](https://docs.docker.com/get-docker/) and [`kind`](https://kind.sigs.k8s.io/) installed. - [`kubectl`](https://kubernetes.io/docs/tasks/tools/) and [`helm`](https://helm.sh/docs/intro/install/) (v3+). - Node.js (for `npx`, used to run the MCP Inspector verification tool). - Python 3.10+ (required by the Google ADK agent and the `mcp` SDK it uses). - An API key for an LLM provider. This guide uses OpenAI; an Anthropic key works too. A note for OpenAI users: the API is billed separately from a ChatGPT Plus or Pro subscription, so you need credit on the API account itself. - A [GitHub Personal Access Token](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens) (classic or fine-grained): the agent's tools will be the remote GitHub MCP server, which is perfect for a tool-access demo because it exposes many tools. Every manifest in this post is inline and copy-pasteable. There is no repo to clone. ## Why AI Agents Need Their Own Gateway Here's a scenario that is playing out in a lot of companies right now. A team ships their first agent. It needs an LLM, so they paste an OpenAI key into a Kubernetes Secret and mount it. It needs tools, so they wire it directly to a couple of MCP servers: one for GitHub, one for an internal database, one for filesystem access. It works. Everyone's happy. Then it scales. Now there are eight agents across four teams. Each one holds its own copy of the LLM key. Each one connects directly to whatever MCP servers it likes. Nobody can answer three questions that suddenly matter a lot: - **Who spent the $14,000 on OpenAI last month, and on which model?** - **Which agents can call the `delete_repository` tool, and who approved that?** - **When an agent does something dumb, where's the trace?** The first instinct is to put the existing API gateway in front of it all. That instinct is wrong, and it's worth understanding *why*, because it explains the entire reason agentgateway exists. Traditional API gateways are built for one shape of traffic: stateless, REST-style, one request in, pick a backend, one response out. Agent protocols like the **Model Context Protocol (MCP)** and **Agent-to-Agent (A2A)** are a completely different beast: | Traditional API Gateway | Agent traffic (MCP / A2A) | | ----------------------------- | ----------------------------------------------------------------- | | Stateless request/response | **Stateful JSON-RPC sessions** with long-lived connections | | One request to one backend | **Session fan-out** across multiple MCP servers at once | | Client-initiated only | **Bidirectional**: servers push events to clients over SSE | | Routing by path/header | **Protocol-aware routing** that parses JSON-RPC message bodies | | Static backend mapping | **Dynamic tool virtualization**: different clients see different tools | A normal gateway can't filter a `tools/list` response to hide a tool from one caller, because it has no idea what a `tools/list` response *is*. It can't authorize an individual tool call, because the tool name lives inside a JSON-RPC body it never parses. You'd be bolting agent-awareness onto something that was never designed for it. That's the gap. Agent traffic needs a data plane that speaks these protocols natively. ## What agentgateway Actually Is [agentgateway](https://agentgateway.dev/) is an open source proxy and control plane, written in Rust, that unifies **HTTP, gRPC, LLM, MCP, and A2A** traffic in a single data plane. It's a project under the Linux Foundation (and the Agentic AI Foundation), not a single vendor's closed product. The important framing is that it is a *full* HTTP and gRPC proxy with the things you'd expect, such as load balancing, retries, timeouts, TLS, rate limiting, and authorization. On top of that, it implements the pieces that traditional gateways are missing for agents. So you don't run a "normal gateway" and a separate "AI gateway." It's one data plane. It does three jobs: 1. **LLM Gateway.** It exposes a single, **OpenAI-compatible API** and routes to OpenAI, Anthropic, Gemini, Bedrock, Azure, Ollama, and many more. Your application code stops caring which provider is behind it. You get token metering, cost tracking, budgets, failover, and guardrails for free. 2. **MCP Gateway.** It connects agents to tools over MCP, with **tool federation** (aggregate many MCP servers behind one endpoint), OpenAPI-to-MCP conversion, and **per-tool authentication and authorization**. 3. **A2A Gateway.** It secures agent-to-agent communication so agents can discover and collaborate without exposing their internal tools and state. It runs anywhere: bare metal, VMs, Docker, or Kubernetes. And on Kubernetes it's built on the standard [Gateway API](https://gateway-api.sigs.k8s.io/), so `Gateway` and `HTTPRoute` resources work exactly as you'd expect, extended with a couple of custom resources (`AgentgatewayBackend`, `AgentgatewayPolicy`) for the agent-specific parts. ## The Three Reasons You'd Actually Reach for It There are a lot of features. But in practice, teams adopt agentgateway for three reasons. ### Reason 1: Security and governance you can enforce centrally This is the big one. With agentgateway in the path, you can: - **Stop handing LLM keys and tool tokens to agents.** The LLM provider key stays with the gateway. The GitHub token stays with the gateway. The agent talks to the gateway, and the gateway forwards the request to the LLM provider or MCP server with the real credentials. A compromised agent leaks nothing useful. - **Control which tools an agent can call** with fine-grained, CEL-based RBAC rules, down to the individual tool name, optionally keyed on JWT claims. If a tool isn't allowed, the gateway hides it from the tool list entirely and rejects any attempt to call it. - **Apply guardrails and prompt protection** on LLM and MCP traffic in one place. You'll see the tool control enforced live later in this part. ### Reason 2: Cost and token visibility Agent traffic is expensive and, by default, invisible. agentgateway tracks token consumption for every request and exposes it as Prometheus metrics, with labels for the model, provider, and direction. From there you can calculate cost per request, per model, or per user, graph spend in Grafana, and alert when a budget is crossed. That is the entire subject of Part 2. ### Reason 3: One data plane for any framework and any provider agentgateway sits at the network layer, so it is **framework-agnostic**. LangGraph, CrewAI, the OpenAI Agents SDK, and Google ADK all just point at a URL. And because the LLM side is OpenAI-compatible, **you can swap providers without touching agent code**. Change the backend from OpenAI to Anthropic in the gateway, and every agent follows, with no redeploys. ## How agentgateway Touches Cost, Time, and Developer Experience Here's how those capabilities map to the three things people actually budget for: | Concern | Without a gateway | With agentgateway | | ------------------------ | --------------------------------------------------------------------- | -------------------------------------------------------------------------------- | | **Cost** | Token spend is opaque; no per-model or per-team breakdown; no budgets | Per-request token metrics, cost dashboards, PromQL queries, budget alerts | | **Time** | Each team re-implements auth, secret handling, retries, and logging per agent | Implement it once at the gateway; new agents inherit it by pointing at a URL | | **Developer experience** | Devs juggle provider keys and tool tokens, and rewrite code to switch providers | Agents hold no secrets; one OpenAI-compatible endpoint; swap providers with zero code change | The developer-experience point is the one engineers feel immediately. In the demo below, the agent's code contains a fake API key (`sk-not-a-real-key`) and *no* GitHub token at all, and it still works, because the gateway owns the real credentials. ## The Architecture We'll Build Everything runs on one `kind` cluster. The agent runs on your laptop and reaches the gateway through a port-forward. The gateway is the only component that holds real secrets. ![Architecture of agentgateway running on a kind cluster with an ADK agent and MCP backend](/img/blog/controlling-mcp-tools-with-agentgateway-on-kubernetes/architecture-to-build.png) Let's build it. ## How to Spin Up a kind Cluster If you don't already have a cluster, `kind` gives you a real Kubernetes API in a Docker container in about thirty seconds: ```sh kind create cluster --name agentgateway-demo ``` Confirm it's up: ```sh kubectl cluster-info --context kind-agentgateway-demo ``` ## How to Install agentgateway agentgateway on Kubernetes has two parts: a **control plane** (it watches Gateway API and agentgateway custom resources and translates them into proxy config) and the **data plane** proxies it spins up for you. **Step 1: Install the Kubernetes Gateway API CRDs.** We install the *experimental* channel because it enables a few features (like CORS filters) that make local verification with the MCP Inspector easier. ```sh kubectl apply --server-side --force-conflicts \ -f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.5.0/experimental-install.yaml ``` **Step 2: Install the agentgateway CRDs with Helm.** ```sh helm upgrade -i agentgateway-crds oci://cr.agentgateway.dev/charts/agentgateway-crds \ --create-namespace --namespace agentgateway-system \ --version v1.2.1 \ --set controller.image.pullPolicy=Always ``` **Step 3: Install the agentgateway control plane.** The experimental feature gate matches the experimental CRDs from Step 1. ```sh helm upgrade -i agentgateway oci://cr.agentgateway.dev/charts/agentgateway \ --namespace agentgateway-system \ --version v1.2.1 \ --set controller.image.pullPolicy=Always \ --set controller.extraEnv.KGW_ENABLE_GATEWAY_API_EXPERIMENTAL_FEATURES=true \ --wait ``` Verify the control plane is running: ```sh kubectl get pods -n agentgateway-system ``` ```console NAME READY STATUS RESTARTS AGE agentgateway-5495d98459-46dpk 1/1 Running 0 19s ``` **Step 4: Create the gateway proxy.** This `Gateway` uses the `agentgateway` GatewayClass; the control plane sees it and deploys an actual proxy for you. ```sh kubectl apply -f- < **Want Anthropic instead?** Swap `provider.openai` for `provider.anthropic` and use an Anthropic key in the secret. The agent code does not change at all, because the agent only ever speaks the OpenAI-compatible API to the gateway. **Step 3: Route `/v1` to the LLM backend.** We use the `/v1` path prefix so the standard OpenAI path `/v1/chat/completions` lands here, and so it never collides with the `/mcp-github` route we add next. ```sh kubectl apply -f- <