A newer version of the Gradio SDK is available: 6.20.0
Lessons learned: llama.cpp + ZeroGPU + a continuous simulation loop
Raw notes for the blog. The things I wish I had known on day one. They came out of a long debug session against the "RuntimeError: No CUDA GPUs are available" error.
1. ZeroGPU is designed for synchronous, user-clicks-a-button workloads
@spaces.GPU forks a worker process per call. The scheduler hands you a GPU slot if one is available; if the pool is busy, your fork's worker_init fails with No CUDA GPUs are available inside spaces/zero/wrappers.py:worker_init, before any of your code runs. This is the allocator backing off — not a bug, not anything you can catch from app code.
That's fine if your app makes ten GPU calls per recording session (chat demos, generate-an-image apps). It is not fine if your app makes hundreds (a simulation that polls every five seconds). The scheduler punishes high-frequency callers to keep the shared pool fair, and Townlet was the loudest neighbour on the block.
2. The spaces package is built around torch, not llama.cpp
import spaces monkey-patches torch's CUDA APIs so model.to("cuda") at module scope succeeds without a real GPU attached. On the first @spaces.GPU call the worker streams weights from disk → pinned memory → VRAM through a double-buffered pipeline — that's the cold-start optimisation the docs talk about.
llama-cpp-python is invisible to that pipeline. It's a separate C++ runtime; the monkey-patching does nothing for it. Every fork loads the GGUF from scratch every time. None of the warm-worker reuse benefits apply.
Surveying 50+ ZeroGPU Spaces in the same hackathon: nearly every team using LLMs ships transformers + torch on the GPU and keeps llama-cpp as a CPU-only side path. Two Spaces in the org actually ran llama-cpp on ZeroGPU (Dean's Rizz Therapy and 1000-Rooms) and both are click-triggered single-pipeline apps. None of them runs llama-cpp inside a continuous poll loop. Townlet was the only one.
3. Keep the Llama() constructor minimal
Dean's actual production code (he's the Discord OP whose recipe the whole hackathon copied) calls Llama() with five kwargs: model_path, n_gpu_layers=-1, n_ctx, flash_attn=True, verbose=False. That's it. No type_k/type_v for KV-cache quantisation, no use_mlock, no n_batch override, no use_mmap=True.
I had piled on those kwargs chasing performance. They added latency on every fork because some are version-gated and my try/except had to retry on rejection. Stripping back to the minimal five matched Dean's pattern and removed a whole class of failure modes.
4. hf_hub_download belongs at module scope, not inside @spaces.GPU
Llama.from_pretrained(repo_id=..., filename=...) is convenient, but it runs the cache-validation roundtrip inside the fork, where every second of wall time is precious. Dean's pattern, the 1000-Rooms pattern, and the long-running gokaygokay/Gemma-2-llamacpp reference all do model_path = hf_hub_download(...) in the parent process at import time, then Llama(model_path=model_path, ...) inside the decorated function. Same outcome, no network roundtrip per fork.
5. Remaining quota controls queue priority, so credits compound
From the docs: "Remaining quota directly impacts priority in ZeroGPU queues." Credits don't just extend daily runtime — they raise priority while you have them, which lowers the rate at which the allocator denies your forks. $20 of pre-paid credits buys 200 minutes of GPU time and moves you up the queue. It's the cheapest debugging tool a hackathon team has.
6. The error message doesn't tell you the cause
No CUDA GPUs are available is what the worker reports when torch.Tensor([0]).cuda() fails inside worker_init. The actual upstream causes can be: quota pre-check rejected the request, no slots in the shared pool, the allocator's fairness signal kicking in, or an HTTP-layer failure that the spaces decorator surfaces as a generic exception. We can't tell which from our side. The right escalation path is the spaces-team discussion board, not the app code.
7. The fix wasn't an architecture change. It was being less greedy.
Once diagnosed, the fix was small:
- Strip
Llama()to Dean's five kwargs - Move
hf_hub_downloadto module scope - Slow the JS poll interval (~25s — the world still feels alive because the snapshot tick at 800ms keeps characters animating between drains)
- Pre-paid quota as priority insurance
The simulation kept its continuous-loop architecture. It just stopped hammering the allocator.
Things I'd do differently next time
- Use transformers, not llama.cpp, on ZeroGPU. Llama Champion badge is nice but it cost a week of debug time. Most teams shipped transformers + LoRA, which is what the spaces package is actually built for.
- Measure quota burn before optimising decisions. I tuned drain duration and decision count before knowing that polling cadence was the dominant variable.
- Read the spaces source, not just the docs.
worker_initraising before your code runs is something the docs underplay. Five minutes in the source tells you more than the entire ZeroGPU page.