Benchmark $/token for a model on vLLM in Chalk
Produces a defensible cost-per-token number: serve the model on a GPU, drive it
to saturation, confirm the GPU is actually the limiter, then divide authoritative
cloud pricing by measured throughput.
The order matters. A throughput number without a saturation check is unfalsifiable,
and a $/hr figure pulled from a blog post is usually wrong (see step 5).
1. Serve the model
sh
chalk container run --dir <chalk-project> --json --no-spinners \
-i vllm/vllm-openai:latest -n bench-server \
--gpu nvidia-l4:1 --cpu 4 --memory 16Gi --port 8000 --lifetime 2h \
--env "HF_TOKEN=$HF_TOKEN" --env HF_HOME=/root/.cache/huggingface \
--await-running --await-timeout 20m \
-e vllm -- serve <MODEL> --port 8000 \
--dtype bfloat16 --max-model-len 16384 --gpu-memory-utilization 0.90
chalk container get --name bench-server
returns
(publicly reachable)
and
. Poll
— it returns 503 until weights are
loaded, then 200. Expect several minutes for a 7B download.
GPU selection. Only GPU pools labelled
chalk.ai/workload-type=compute
can
run container/sandbox workloads. On the staging cluster that is
(L4,
24GB, native bf16), capped at 2 nodes x 1 GPU. A pool that lacks the label
(e.g.
) will leave the pod Unschedulable and the controller gives up
after
300s — shorter than a cold GPU node autoscale, so a valid request can
fail purely on capacity. Free a GPU and retry.
Turing (T4) has no native bf16; use
there.
2. Serving flags that change the answer
Benchmark the config you intend to run, and say which flags you set. These
materially move the result:
| Flag | Why it matters |
|---|
| Caps concurrent sequences. Too high wastes scheduler time on short requests — throughput can fall as you raise it. Sweep it. |
| Prefill chunk budget. The binding constraint for prompt-heavy / short-output workloads. |
| On by default in vLLM V1. Inflates apparent prompt throughput (see pitfalls). |
--limit-mm-per-prompt '{"image":1}'
| Multimodal models profile peak activation memory against worst-case images per prompt, stealing KV cache. Pin it. |
| bf16 on Ada/Hopper; fp16 on Turing. |
| Skips CUDA graph capture: faster startup, slower decode. Fine for 1-token outputs, wrong for general serving. Don't leave it on in a benchmark you intend to generalize. |
| The container is publicly resolvable. An unauthenticated LLM endpoint is exposed. Pair with chalk container run --authenticated
. |
3. Drive load
Use
(bundled with this skill). It sweeps prompt length and
concurrency, and reads throughput from vLLM's
own Prometheus counters
(
,
vllm:generation_tokens_total
,
vllm:request_success_total
) across a steady-state window.
sh
./scripts/bench.py --base-url "$URL" --model "$MODEL" \
--input-lens 128,512 --concurrency 8,32,64 --duration 30
Methodology rules, each of which fixes a real failure mode:
- Sustained windows, not fixed request counts. Sub-10s windows are dominated
by jitter. Use >=20s and a warm ramp before the measured window.
- Server-side counters, not client timing. Removes client overhead and WAN
latency from the measurement.
- Sweep concurrency until throughput stops rising. One concurrency level
tells you nothing about where saturation is.
- Unique prompt prefixes. Identical prompts hit the prefix cache and inflate
results (bundled script seeds each request).
- Watch for client-side bottlenecks. If throughput drops at high
concurrency, suspect the load generator's CPU before the server. Re-run from a
bigger box, or from inside the cluster.
Where to run the driver. From a laptop is fine to start. To rule out the
network path, run it as a container inside the cluster (
, the vLLM image
already has Python + httpx) and point it at the server's
pod IP
(
kubectl get pod <podName> -o jsonpath='{.status.podIP}'
), then repeat against
the public
. If the two agree, the ingress is not your bottleneck —
measure this rather than assuming it.
4. Confirm the GPU is the limiter
A throughput number is only a hardware number if the hardware was saturated.
Check DCGM on the node running the model:
sh
NODE=$(kubectl -n ns-<env> get pod <podName> -o jsonpath='{.spec.nodeName}')
DCGM=$(kubectl -n chalk-telemetry get pods -o wide | grep "dcgm.*$NODE" | awk '{print $1}')
kubectl -n chalk-telemetry port-forward "$DCGM" 19400:9400 &
curl -s localhost:19400/metrics | grep -E '^DCGM_FI_DEV_(GPU_UTIL|POWER_USAGE|FB_USED)'
Sample it
during the run.
at 100 and
at the card's TDP
(72W for L4, 300W for A100 40GB) means you are GPU-bound and the number is real.
Utilization well under 100% means you measured your client, the network, or a
scheduler cap — not the GPU. Note the exporter scrapes on an interval, so
identical consecutive samples are expected.
5. Get the real $/hr
Do not trust third-party pricing pages or a web search summary. For GPU
machine families the accelerator is often a separate SKU from vCPU and RAM,
and aggregator sites frequently omit it — which understates the node by 3x.
Query GCP's Cloud Billing Catalog directly (Compute Engine service
), summing the SKUs that make up the node:
py
# paginate skus?pageSize=5000¤cyCode=USD with an Authorization: Bearer token
# from `gcloud auth print-access-token`, filter serviceRegions for your region,
# then match description against your machine family and accelerator.
Worked example,
(8 vCPU, 32 GiB, 1x L4) in
:
| SKU | Rate | Qty | Subtotal |
|---|
| G2 Instance Core running in Americas | $0.024988 /hr | 8 | $0.199904 |
| G2 Instance Ram running in Americas | $0.002927 /GiB-hr | 32 | $0.093664 |
| Nvidia L4 GPU running in Americas | $0.560040 /hr | 1 | $0.560040 |
| | | $0.853608 /hr |
Omitting the GPU SKU yields $0.2936/hr — a number that appears on public pricing
aggregators and is wrong. Sanity-check by computing spot the same way and
confirming it lands at the expected discount.
Also pull Spot and 1yr/3yr CUD rates; they are separate SKUs and are what a
production deployment would actually pay.
6. Compute the cost
$/sec = node_hourly / 3600
$/1M in-tok = $/sec / prompt_tok_per_s * 1e6
$/1M req = $/sec / rps * 1e6
Report $/1M input tokens and $/1M requests, plus the on-demand / spot /
CUD variants.
Pick the right unit. For a classifier or guardrail that emits a single token,
output tokens are identical to requests and "$/output token" is a degenerate
metric — lead with
$/classification. For a chat or coding model, decode
dominates and $/output token is the meaningful figure; set
to a
realistic completion length rather than 1.
Pitfalls
- Prefix caching inflates $/input-token. A shared system prompt gets cached,
so counts tokens that were never recomputed. The bundled
script reports the hit rate. It is a fair comparison against a vendor who
bills you for every input token, but it is not your real compute headroom —
say which one you are quoting.
- Saturation numbers are a floor, not a forecast. These are 100%-duty-cycle
figures. At 20% real utilization, multiply by 5. Idle GPU time is the dominant
cost in most production deployments.
- Tool-calling models need the right parser and the right checkpoint.
--enable-auto-tool-choice --tool-call-parser hermes
plus an instruct model.
Some Coder-tuned checkpoints emit tool calls as fenced JSON text that the parser
ignores, so returns null under while
works (guided decoding forces the shape).
- Completed container pods are garbage-collected, taking their logs. End a
one-shot driver script with so you can still it.
- is unimplemented for k8s containers
(
only implemented for host containers
). Drive work via the entrypoint, or use
/ the sandbox API.
- Piping a script into breaks silently if it invokes anything that
reads stdin — the child eats the rest of the script. Write it to a file and add
.
- Sandboxes have no network egress and no flag to grant it; even public IPs
are blocked. Use
chalk container run --route <CIDR>
when the workload must
reach anything.
Reporting
State the model, GPU, every non-default serving flag, the measured throughput,
the DCGM saturation evidence, and the SKU breakdown behind the $/hr. A
cost-per-token figure without those is not reproducible and should not be quoted.