| # 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_download` to 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_init` raising before your code runs is something the docs underplay. Five minutes in the source tells you more than the entire ZeroGPU page. |
|
|