Running the Workload Without a GPU on Google Cloud
Overlay the measured workload image with a stand-in model, so the node boots and serves inference on CPU alone.
Overview
Most of a node’s software is independent of inference running on GPUs. Attestation, the encrypted request path, configuration delivery, the execution allow list, and telemetry egress run on the CPU, and you can exercise all of them on an instance with no GPU attached. Instances with GPUs attached are scarce and come from a reservation, so this is the path open to a researcher who cannot reserve one: you build the image, create the instance yourself on a widely available machine type, and hold every credential involved. It works the same way if you do have a reservation. Two hard dependencies stop the shipped image from booting without a GPU: three components need a GPU driver to reach a ready state, and the model repositories the image ships load a back end that imports CUDA.
Rebuilding the workload image around a no-op model, a stand-in that streams synthetic tokens and computes nothing, removes both. The result reaches the same end state as a GPU node, less the GPU components: cpagent, cloudboard, cb-attestationd, cloudassetdownloader, ensemble-transport, and afm-runtime running, and a request from Issue inference requests returning a complete generation over an attested, encrypted connection.
Everything here is an overlay on the outer image. Unlike Google Cloud Workload Modification, you don’t extract, patch, or inject any embedded component, so you don’t need the Megacontainer build tool; an ordinary container build is enough. The code listings below take the base workload container image reference from the $MCIMAGE variable, as Understand what you need for the modification describe.
Three components share the work of executing an inference request:
afm-runtimeRuns the inference server, which is Triton in the configuration below.
- Model back end
causal_lm/1/model.pyis the code the inference server loads and calls per request.tie-cloud-appThe on-node process that turns a decrypted request into an inference call. It renders the prompt, loads the tokenizer, and asks the inference server for tokens.
Modify the workload image to run without a GPU
To adapt a published AFM workload container image for CPU execution, update these six components:
afm-runtime.serviceDrop
tie-model-ownerfrom itsRequires=andAfter=lines.tie-model-owneris the CUDA-IPC exporter that supplies the model back end’s GPU memory, and that entry is the only unit-level hard dependency on a GPU component. Remove it andafm-runtimestarts.gpuhealthd,tie-model-owner, andtie-controllerdStop all three from starting. None reaches a ready state without a driver:
gpuhealthdexits nonzero,tie-model-owneris the exporter itself, andtie-controllerdpolls the other two. No other unit listsgpuhealthdortie-controllerdin itsRequires=, so unlinking the three frommulti-user.target.wantsstops the restart loops and the log noise.- Model repository
Replace its model back end with a no-op. The image ships six repositories under
/opt/afm_runtime/triton_repos/. Each repository’scausal_lm/1/model.pydefines an inference back end, which imports the inference runtime, and with it torch and the CUDA-backed inference library. Replacing that one file is the main change you need to make.- Inference server
Select
tritonrather thanbones. Theafm-vrevariant’s measured configuration selectsbones, which loads model weights onto the accelerator. TheSERVER_TYPEenvironment variable overrides the choice.image_tokenizerLeave it unloaded. Its configuration declares a
KIND_GPUinstance group, so the model fails to load with no device present. Namingcausal_lmexplicitly leavesimage_tokenizerunloaded, and a text request doesn’t reach it.- Tokenizer
Stage a SentencePiece model at
/weights/tokenizer.model, and a resource bundle that declares it. Without the file, each request completes its warmup and then produces nothing, and the client sees an idle timeout rather than an error.Choose a tokenizercovers which file to use.
Every change above sits inside the image, so each one alters the workload container digest, and with it the measurement the node attests to.
Reuse the repository’s own tensor contract
The inference server rejects any request that names a tensor its model configuration doesn’t declare, and that declaration set moves between builds. When you write your own configuration, you need to keep it in sync by hand, and a mismatch appears one tensor at a time as unexpected inference input/output at request time.
Avoid that sync burden by replacing only model.py and leaving the repository’s configuration alone. Use v11_pro of the six repositories; its causal_lm configuration declares the tensors tie-cloud-app sends, including the prompt-cache-warmup input that the other repositories omit, and its instance group is already KIND_CPU. Point afm-runtime at that repository and bind-mount your model over the shipped one, preserving the tensor contract from that build.
Understand the no-op model
tie-cloud-app requests two output tensors, output_ids and request_metrics, so those are the only two the stand-in produces. It streams one response per token until it emits the requested count, and attaches a metrics tensor to the last response. Two directory names differ by one character throughout this article: the hyphenated causal-lm/ is the staging directory on your host, and the underscored causal_lm/ is the repository directory inside the image that you mount over. Write this as causal-lm/model.py:
import json
import numpy as np
import triton_python_backend_utils as pb_utils
# 50 and 106 are the stop-token ids the caller hardcodes. Emitting one ends
# the stream early and misreports why generation stopped.
FILLER = tuple(i for i in range(1000, 1016) if i not in (50, 106))
class TritonPythonModel:
def initialize(self, args):
self.model_config = json.loads(args["model_config"])
def execute(self, requests):
for request in requests:
sender = request.get_response_sender()
length = pb_utils.get_input_tensor_by_name(request, "request_output_len")
count = max(1, int(np.asarray(length.as_numpy()).reshape(-1)[0]))
for index in range(count):
last = index == count - 1
# (batch, hypotheses, tokens)
tensors = [pb_utils.Tensor(
"output_ids",
np.array([[[FILLER[index % len(FILLER)]]]], dtype=np.int32))]
if last:
metrics = json.dumps({"cached_token_count": 0}).encode()
tensors.append(pb_utils.Tensor(
"request_metrics", np.array([[metrics]], dtype=np.object_)))
sender.send(
pb_utils.InferenceResponse(output_tensors=tensors),
flags=(pb_utils.TRITONSERVER_RESPONSE_COMPLETE_FINAL if last else 0))
return NoneThis code imports only the standard library, numpy, and the Python back end’s helper module, all of which the image already provides. The model uses Triton’s decoupled transaction policy, which allows a single request to yield multiple responses through the response sender; therefore, execute returns None rather than a list.
The token IDs are arbitrary, so the client receives nonsense text. The metrics structure fields are optional, so any valid JSON object decodes. Generation stops once the model emits the number of tokens tie-cloud-app asked for, and tie-cloud-app reports that it reached the output-length limit.
Choose a tokenizer
tie-cloud-app resolves a tokenizer based on the identifier declared in a resource bundle, then loads the file from a hard-coded path, /weights/tokenizer.model. It doesn’t check the file against either the identifier or a digest. A SentencePiece model parses whichever file you stage. Because the no-op model discards the IDs it receives, the choice of tokenizer doesn’t affect whether generation completes. Use the tokenizer that ships with Gemma 4, one of Google’s open models; it’s a single tokenizer.model file in the model download. The setup doesn’t run a real model, so the specific tokens don’t matter; don’t read the resulting token stream as evidence of input tokenization.
You cannot bake the tokenizer file into the image at /weights. The container launcher mounts a tmpfs there, which shadows whatever the image holds at that path, so the file has to arrive at boot. The volume-initialization script is the earliest hook available, and it already runs as root before any component starts.
The bundle that declares the tokenizer is a separate requirement. A missing tokenizer file fails silently, leaving no tokenizer. A missing declaration leaves tie-cloud-app with no tokenizer and no fallback. Both failures look identical, so check the file first and the declaration second. The identifier has to be one the build hardcodes; the bundle in Assemble the overlay declares one that the verified build accepts.
Assemble the overlay
Four files come out of the base image. Read each one out of the image rather than from a reference copy, so they match the build you’re patching:
% mkdir -p orig overlays assets causal-lm
% for f in /mc/runc/config.afm-runtime.json \
/etc/systemd/system/afm-runtime.service \
/usr/bin/megacontainer-init-volumes \
/mc/eh-allowlists/components/afm-runtime.json; do
container run --rm --platform linux/amd64 "$MCIMAGE" cat "$f" \
> "orig/$(basename $f)"
donePoint afm-runtime at the v11_pro repository, load causal_lm alone, select the inference server explicitly, and mount the no-op model over the shipped one. The container launcher reads config.afm-runtime.json at boot and merges it into the component’s runtime configuration, so you change a component’s environment and mounts by editing this file:
% REPO=/opt/afm_runtime/triton_repos/v11_pro
% jq --arg repo "$REPO" '
.process.env += ["TRITON_MODEL_REPOSITORY=" + $repo,
"TRITON_LOAD_MODELS=causal_lm",
"SERVER_TYPE=triton"]
| .mounts += [{destination: ($repo + "/causal_lm/1"),
source: "/mc/supports/noop-causal-lm",
type: "bind", options: ["bind", "ro"]}]' \
orig/config.afm-runtime.json > overlays/config.afm-runtime.jsonSetting the repository explicitly also skips the manifest lookup the entry point otherwise does in /weights, so you need no model manifest and no version variable.
Drop the exporter from the unit’s dependencies:
% sed 's/ tie-model-owner\.service//' orig/afm-runtime.service \
> overlays/afm-runtime.serviceAppend the commands that stage the tokenizer to the volume-initialization script, then put the tokenizer and the bundle that declares it in assets/:
% cp orig/megacontainer-init-volumes overlays/megacontainer-init-volumes
% cat >> overlays/megacontainer-init-volumes <<'SH'
cp /mc/supports/noop-assets/tokenizer.model /weights/tokenizer.model
cp /mc/supports/noop-assets/resource_bundles_v2.json /weights/resource_bundles_v2.json
chown 1011:1011 /weights/tokenizer.model /weights/resource_bundles_v2.json
SH
% cp /path/to/gemma/tokenizer.model assets/tokenizer.model
% cat > assets/resource_bundles_v2.json <<'JSON'
{"resource_bundles": [{
"identifier": "com.apple.fm.language.instruct_server_v2.pcc_test_pro#cv10",
"tokenizer": "com.apple.fm.language.instruct_server_v2.tokenizer_pro.generic"}]}
JSON1011 is aea-stream-downloader, the component that populates /weights on a node whose configuration downloads model weights; the stock script this appends to already modifies the directory’s ownership, and matching it keeps the ownership consistent. Both readers of these files — afm-runtime and tie-cloud-app — mount /weights read-only, so they need only read access, which cp from a root-owned source gives.
Update the execution allow list. The path already has an entry, because you are replacing a file the image ships, so only the digest changes:
% jq --arg key "$REPO/causal_lm/1/model.py" \
--arg digest "sha256:$(shasum -a 256 causal-lm/model.py | cut -d' ' -f1)" \
'.scriptEntries[$key] = $digest' \
orig/afm-runtime.json > overlays/afm-runtime.jsonThen write the Dockerfile. The EXPOSE and VOLUME lines restate what the base image declares, which Restore dropped image metadata explains and shows how to read; the values below are the ones the verified build declared:
ARG MC
FROM ${MC}
EXPOSE 4442
EXPOSE 8080
EXPOSE 13987
EXPOSE 13999
VOLUME ["/var/lib/containers/overlays", "/var/lib/containers/run"]
COPY causal-lm /mc/supports/noop-causal-lm
COPY assets /mc/supports/noop-assets
COPY overlays/config.afm-runtime.json /mc/runc/config.afm-runtime.json
COPY overlays/afm-runtime.service /etc/systemd/system/afm-runtime.service
COPY overlays/afm-runtime.json /mc/eh-allowlists/components/afm-runtime.json
COPY --chmod=755 overlays/megacontainer-init-volumes /usr/bin/megacontainer-init-volumes
RUN rm -f /etc/systemd/system/multi-user.target.wants/gpuhealthd.service \
/etc/systemd/system/multi-user.target.wants/tie-model-owner.service \
/etc/systemd/system/multi-user.target.wants/tie-model-owner.path \
/etc/systemd/system/multi-user.target.wants/tie-controllerd.service \
/etc/systemd/system/multi-user.target.wants/tie-controllerd.pathBuild it, tagged as the reference you intend to deploy, then Push and deploy:
% container build --platform linux/amd64 --build-arg MC="$MCIMAGE" \
-t "$DESTIMAGE" .Bypass the external workload controller
cloudboardd doesn’t serve requests until a workload controller registers a workload with it. On a fleet node the control plane does that; on an instance you create yourself there is no control plane, so cloudboardd waits indefinitely and the node never answers. The workload image is intact in this state; the symptom is silence rather than an explicit error.
Set workload.controller.skipexternal to bypass it. cloudboardd then ignores the registerWorkload and setState calls it would otherwise wait for, and reads its workload configuration from local keys instead. The key is unattested, so it belongs in the unmeasured configuration override rather than in the image:
% OVERRIDE='{"checksum": "override-1", "values": {"preferences": [
{"applicationId": "com.apple.cloudboardd",
"key": "workload",
"value": {"controller": {"skipexternal": true}}}]}}'Pass the result as tee-env-PCC_UNMEASURED_CONFIG_OVERRIDE in the metadata below. The defaults cloudboardd falls back to are a service name of pcc-default-service and a concurrency of one request; the service name is deliberately not one the Node selection accepts, which costs nothing here because requests reach this instance directly rather than through the gateway.
Launch an instance without a GPU
A node with GPUs attached comes from a reservation, and the machine type it uses, A4 as of this writing, is scarce. The image you just built needs no GPU, so you can create an instance for it yourself, in a project you own, on a machine type that is widely available. c3-standard-192-metal is the one this section uses, and gcloud alone launches it.
Three things about that machine type differ from a node with GPUs attached, and each narrows what the instance can tell you:
- One network interface
The machine carries a single NIC rather than the multiple NICs a node with GPUs attached has.
- Stock firmware and boot chain
The machine type’s own firmware and boot chain expose no TPM, so the host contributes no measurements.
- No GPU
There are no GPU measurements, and nothing exercises the GPU components you removed.
The host, the Workload VM, and most of the workload container remain, which is enough to exercise attestation, the encrypted request path, configuration delivery, the execution allow list, and telemetry egress.
The machine type also constrains the host image and four metadata keys:
- Host image
A debug image; a hardened host image does not run on this machine family.
guest-memory-size-gibRequired on this machine family, with a value that depends on the machine type:
256forc3-standard-192-metal,1024forc3-highmem-192-metal.fleet-node-idRequired, and its absence is the hardest failure in this article to diagnose.
cpagentreads it from instance metadata at boot and treats a missing value as fatal, so it exits and systemd restarts it indefinitely. Every component that starts after it —cloudboard,cb-attestationd,cloudassetdownloader,ensemble-transport,afm-runtime— stays inactive, and because none of them ever started, none of them is failed:systemctl --failedreports nothing wrong while the node serves no requests. The value is a free-form node identifier.tee-env-PCC_VARIANTSpecifies a measured-configuration directory that your base image provides, so the image determines the valid values.
cpagentfails in the same manner as for a missing node ID when the directory is absent. Read the supported variants from the base image before you build, because the launcher fixes everytee-env-value when it creates the container: a wrong variant can’t be corrected on a running instance, only by recreating it.% container run --rm --platform linux/amd64 "$MCIMAGE" ls -1 /mc/configtee-install-gpu-driverLeave it unset. The guest can crash after failing to find a GPU that isn’t there.
One create call supplies everything the instance needs: the host image to boot ($BOOT_IMAGE) as the boot disk, and, in instance metadata, the Workload VM’s memory size, the workload container reference you pushed ($DESTIMAGE), the unmeasured configuration override ($OVERRIDE), and the capabilities, mounts, and shared-memory size the workload needs. The launcher reads the tee- prefixed keys; the host reads the rest before it starts the Workload VM. Two of the metadata keys have effects documented elsewhere: tee-image-reference is the same key that Replace the workload container updates, and tee-debug-ssh-enable opens the hop that Reach the host uses. The ^~^ prefix on the metadata string makes ~ the separator between entries, because the values themselves contain commas:
% CAPS='["CAP_SYS_ADMIN", "CAP_NET_ADMIN", "CAP_MKNOD", "CAP_SETUID", "CAP_SETGID", "CAP_CHOWN", "CAP_FOWNER", "CAP_DAC_OVERRIDE", "CAP_DAC_READ_SEARCH", "CAP_SETPCAP", "CAP_SYS_CHROOT", "CAP_SYS_PTRACE", "CAP_KILL", "CAP_AUDIT_WRITE", "CAP_SYS_RESOURCE", "CAP_FSETID", "CAP_NET_BIND_SERVICE", "CAP_NET_RAW", "CAP_SETFCAP", "CAP_BPF", "CAP_PERFMON"]'
% MOUNTS='type=tmpfs,source=tmpfs,destination=/var/run/cache,size=32000000000;type=tmpfs,source=tmpfs,destination=/var/lib/containers/run,size=32000000000;type=tmpfs,source=tmpfs,destination=/var/lib/containers/overlays,size=32000000000;type=tmpfs,source=tmpfs,destination=/weights,size=32000000000;type=tmpfs,source=tmpfs,destination=/private/var/MLModels/LLM/Prompts,size=32000000000'
% FLEET_NODE_ID="${INSTANCE_NAME}"
% METADATA="^~^\
guest-memory-size-gib=256~\
fleet-node-id=${FLEET_NODE_ID}~\
tee-image-reference=${DESTIMAGE}~\
tee-disable-gca-refresh=true~\
tee-container-log-redirect=serial~\
tee-cgroup-ns=true~\
tee-dev-shm-size-kb=1000000~\
tee-debug-ssh-enable=true~\
tee-env-PCC_VARIANT=afm-vre~\
tee-env-PCC_UNMEASURED_CONFIG_OVERRIDE=${OVERRIDE}~\
tee-added-capabilities=${CAPS}~\
tee-mount=${MOUNTS}"
% gcloud compute instances create "${INSTANCE_NAME}" \
--machine-type=c3-standard-192-metal \
--image="$BOOT_IMAGE" \
--boot-disk-size=32GB \
--boot-disk-type=hyperdisk-balanced \
--maintenance-policy=TERMINATE \
--scopes=cloud-platform \
--metadata="$METADATA"The launcher validates each size in $MOUNTS on its own against free memory in the Workload VM, and refuses to start the workload when any single entry exceeds it. It doesn’t check the sum, and a size buys no overcommit, so the much larger values a node with GPUs attached uses don’t carry over. tee-dev-shm-size-kb goes through the same check. An entry that is too large stops the boot before any component starts, and the error message gives both numbers:
got a /dev/shm size (357421875) larger than free memory (92397872) kBTake the free-memory figure from that message rather than deriving it from the machine’s memory or from guest-memory-size-gib, because it is smaller than either. The figure also moves between boots, so leave headroom rather than sizing to the number you read.
The machine type fixes three of the flags rather than leaving them to you: hyperdisk-balanced is the only boot-disk type it offers, a machine of this type cannot live-migrate, and the launcher needs cloud-platform scope to fetch an attestation token.
The network path to the instance is yours to configure, because the project is yours. The tunnel that Reach an instance describes works here, and so does any other path you set up. Expect the same wait either way: the instance reports RUNNING well before the workload answers.
The service account is yours to choose for the same reason, and it needs three grants: read on the registry holding $DESTIMAGE, roles/confidentialcomputing.workloadUser on the project so the launcher can fetch its attestation token, and read on any bucket a configuration override names. A project owner’s default compute service account usually already carries them; an account in a project with restrictive policy does not, and the launcher fails before it pulls the workload container.
Confirm the result
The afm-runtime container entry point reports the repository and server it resolved, and the server reports which models it loaded:
% journalctl -u afm-runtime | grep -E "Server Type|Model Repository|successfully loaded"
Server Type: triton (--server-type / SERVER_TYPE)
Model Repository: /opt/afm_runtime/triton_repos/v11_pro
"successfully loaded 'causal_lm'"Some log lines look like failures but are not:
The absence of
image_tokenizer, whichTRITON_LOAD_MODELS=causal_lmleaves unloaded.Cannot get CUDA device count, GPU metrics will not be available.No model manifest found path=/weights/manifest.json.Huge-page KV cache buffer allocation failed; falling back to normal pages.
Confirm the stand-in itself is what loaded, and that its digest matches the allow list entry you wrote. Read it from inside the component’s container: runc applies the bind mount in the container’s own mount namespace, so the same path read from the workload container shows the shipped file instead.
% runc exec "$(runc list -q | grep '^afm-runtime-' | head -1)" \
sha256sum /opt/afm_runtime/triton_repos/v11_pro/causal_lm/1/model.py
% jq -r '.scriptEntries["/opt/afm_runtime/triton_repos/v11_pro/causal_lm/1/model.py"]' \
/mc/eh-allowlists/components/afm-runtime.jsonThen Issue inference requests. Read the shape of the response rather than a token count: tie-cloud-app asks for the context length minus the prompt, so a complete stream runs to a six-figure number of token responses, and the exact figure moves with the prompt and the tokenizer. The request passes when the stream ends with one final response and a summary of ok. A single token response means generation stopped at the first token; read its text for the reason.
Understand what these changes don’t do
The node attests to a different measurement than the published build, because every change above is inside the image. That is the intended behavior, and the same property that Modify measured configuration relies on. It also means a result from this instance is not a result about the published image. Inspect an attestation bundle. Where a finding does not depend on the absence of a GPU, reproduce it against the unmodified image before you report it.
A machine type with no GPU narrows the attestation as well, and that limit comes from the machine rather than from the image. With no GPU present the attestation carries no GPU measurements, and with no TPM exposed the host contributes none either. An attestation from this instance therefore covers a smaller stack than one from a node with GPUs attached. Reproduce any finding about attestation coverage of the GPU or the host on a node booting the hardened host image with GPUs attached before you report it.
A standalone instance also carries no ensemble identity. cb-attestationd reads that identity from the optional FLEET_NODE_ID configuration key, which a control plane supplies. That key is distinct from the mandatory fleet-node-id instance-metadata attribute that cpagent reads. With the key unset, attestation still succeeds: cb-attestationd leaves the ensemble field out of the attestation AppData and populates the boot identifier as usual. When you compare bundles from this instance with a fleet node’s, treat the ensemble field as unset.
Two further limits follow from the overlay rather than from the node. The stand-in serializes requests, and bypassing the workload controller leaves cloudboardd with a local concurrency setting of one request, so this instance doesn’t exercise request concurrency on either count. The staged tokenizer splits control tokens and mismatches the stop-token IDs tie-cloud-app hardcodes, so the token stream is not evidence about tokenization.