Spaces:
Running
Add local GPU sidecars for tiny-aya + klein (off-ZeroGPU)
Browse filesRun the polats/tiny-army-{tiny-aya,klein}-zerogpu Spaces locally on the
3090 so the main app doesn't burn HF ZeroGPU quota. Each mirrors its
hosted Space's Gradio API contract, so the main app's gradio_client talks
to them unchanged — only the .env TINY_{AYA,KLEIN}_SPACE vars switch from
Space IDs to local URLs (.env is gitignored, so deploys are unaffected).
- spaces/tiny-aya-zerogpu: spaces.GPU -> no-op shim, 4-bit NF4 load, VRAM
cap, .env/token, host/port launch block.
- spaces/klein-zerogpu: sequential CPU offload (fits the desktop-shared
card; 4-bit OOMs at load-time warmup), weiner LoRA pre-applied + default.
- run_sidecars.sh launcher (start/stop/aya/klein; binds 0.0.0.0 for LAN).
- docs/local-sidecars.md.
This is a local-dev branch; it is not the deployed Space branch.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- docs/local-sidecars.md +65 -0
- run_sidecars.sh +75 -0
- spaces/klein-zerogpu/README.md +18 -0
- spaces/klein-zerogpu/app.py +162 -0
- spaces/klein-zerogpu/requirements.txt +9 -0
- spaces/tiny-aya-zerogpu/README.md +19 -0
- spaces/tiny-aya-zerogpu/app.py +182 -0
- spaces/tiny-aya-zerogpu/requirements.txt +10 -0
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Local model sidecars (no HuggingFace quota)
|
| 2 |
+
|
| 3 |
+
Two of the hosted ZeroGPU Spaces now run **locally on the 3090** so the main app doesn't burn
|
| 4 |
+
HF quota. They mirror each hosted Space's Gradio API exactly, so the main app's `gradio_client`
|
| 5 |
+
talks to them unchanged — only the `*_SPACE` env var changes from a Space ID to a local URL.
|
| 6 |
+
|
| 7 |
+
| Sidecar | Source dir | Port | Model | `.env` var |
|
| 8 |
+
|------------|-----------------------------|------|--------------------------------|-------------------|
|
| 9 |
+
| Tiny Aya | `spaces/tiny-aya-zerogpu/` | 7864 | `CohereLabs/tiny-aya-global` | `TINY_AYA_SPACE` |
|
| 10 |
+
| Klein | `spaces/klein-zerogpu/` | 7865 | `black-forest-labs/FLUX.2-klein-4B` + `polats/weiner-klein-lora` | `TINY_KLEIN_SPACE` |
|
| 11 |
+
|
| 12 |
+
Mirrors of `polats/tiny-army-tiny-aya-zerogpu` and `polats/tiny-army-klein-zerogpu`, adapted for
|
| 13 |
+
local hardware (no ZeroGPU). The hosted `app.py`s are unchanged in spirit; the local diffs are
|
| 14 |
+
documented in each file's header.
|
| 15 |
+
|
| 16 |
+
## Run
|
| 17 |
+
|
| 18 |
+
```bash
|
| 19 |
+
./run_sidecars.sh # start both, wait until ready (logs in logs/)
|
| 20 |
+
./run_sidecars.sh aya # just tiny-aya
|
| 21 |
+
./run_sidecars.sh klein # just klein
|
| 22 |
+
./run_sidecars.sh stop # stop both
|
| 23 |
+
```
|
| 24 |
+
|
| 25 |
+
`.env` already points `TINY_AYA_SPACE` / `TINY_KLEIN_SPACE` at the local URLs.
|
| 26 |
+
|
| 27 |
+
### Network access
|
| 28 |
+
|
| 29 |
+
The sidecars bind to `0.0.0.0` by default, so they're reachable from the **local network** at this
|
| 30 |
+
host's LAN IP (currently `10.0.0.36`, also the tailnet `100.88.3.43`):
|
| 31 |
+
|
| 32 |
+
- Tiny Aya: `http://10.0.0.36:7864`
|
| 33 |
+
- Klein: `http://10.0.0.36:7865`
|
| 34 |
+
|
| 35 |
+
The main app talks to them over loopback (`127.0.0.1`) for speed regardless. Set `BIND=127.0.0.1`
|
| 36 |
+
before `./run_sidecars.sh` to restrict them to this machine only. (They are NOT on the public
|
| 37 |
+
cloudflared/nginx path that exposes kimodo — LAN/tailnet only.) To go back to the
|
| 38 |
+
hosted ZeroGPU Spaces, comment those two lines back to the `polats/...` Space IDs.
|
| 39 |
+
|
| 40 |
+
## Why these settings (shared-GPU constraints)
|
| 41 |
+
|
| 42 |
+
The 3090 also drives the desktop **and** is shared with a separate ~15 GB text-encoder server
|
| 43 |
+
(`kimodo`), leaving only ~5 GB free. So:
|
| 44 |
+
|
| 45 |
+
- **Tiny Aya** loads in **4-bit NF4** (~7 GB → ~3.5 GB resident). `TINY_AYA_QUANT=bf16` for full
|
| 46 |
+
precision when the card is free.
|
| 47 |
+
- **Klein** loads in **bf16 with `enable_sequential_cpu_offload()`** — the pipeline stays on CPU
|
| 48 |
+
and streams to the GPU submodule-by-submodule, so **peak VRAM is only ~2.5 GB** and it never
|
| 49 |
+
contends with the shared card at load time. (4-bit quant was tried but its load-time GPU warmup
|
| 50 |
+
allocation OOMs against the tight free headroom; sequential offload sidesteps that and keeps full
|
| 51 |
+
bf16 quality.) ~11 s/image at 1024². `TINY_KLEIN_OFFLOAD=model` uses the faster
|
| 52 |
+
whole-component offload (~8 GB peak) when the card is free.
|
| 53 |
+
- The **weiner LoRA** is pre-applied at startup and is the default for the `lora` input, so the
|
| 54 |
+
main app's `/generate(prompt, seed)` calls (which don't pass a LoRA) get weiner portraits.
|
| 55 |
+
Other checkpoints: `weiner750`, `weiner500`, `weiner250`.
|
| 56 |
+
|
| 57 |
+
## Verify
|
| 58 |
+
|
| 59 |
+
```bash
|
| 60 |
+
# both endpoints, via the main app (which must be running on :7860)
|
| 61 |
+
curl -s -X POST localhost:7860/portrait -H 'content-type: application/json' \
|
| 62 |
+
-d '{"prompt":"portrait of a knight","engine":"klein"}' -o /tmp/p.webp
|
| 63 |
+
curl -sN -X POST localhost:7860/text/generate/stream -H 'content-type: application/json' \
|
| 64 |
+
-d '{"model":"tiny-aya-global-zerogpu","user":"hi","max_tokens":20}'
|
| 65 |
+
```
|
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
# Run the Tiny Army model sidecars LOCALLY on the 3090 instead of on ZeroGPU Spaces,
|
| 3 |
+
# so the main app doesn't burn HuggingFace quota.
|
| 4 |
+
#
|
| 5 |
+
# These mirror the hosted Spaces' Gradio API exactly, so the main app talks to them via
|
| 6 |
+
# gradio_client unchanged — just point TINY_AYA_SPACE / TINY_KLEIN_SPACE at the local URLs
|
| 7 |
+
# (already done in .env):
|
| 8 |
+
# TINY_AYA_SPACE=http://127.0.0.1:7864
|
| 9 |
+
# TINY_KLEIN_SPACE=http://127.0.0.1:7865
|
| 10 |
+
#
|
| 11 |
+
# Usage:
|
| 12 |
+
# ./run_sidecars.sh # start both, stream logs to logs/, wait until ready
|
| 13 |
+
# ./run_sidecars.sh aya # just tiny-aya
|
| 14 |
+
# ./run_sidecars.sh klein # just klein
|
| 15 |
+
# ./run_sidecars.sh stop # stop both
|
| 16 |
+
set -euo pipefail
|
| 17 |
+
cd "$(dirname "$0")"
|
| 18 |
+
|
| 19 |
+
PY="${PY:-.venv/bin/python}"
|
| 20 |
+
AYA_PORT="${AYA_PORT:-7864}"
|
| 21 |
+
KLEIN_PORT="${KLEIN_PORT:-7865}"
|
| 22 |
+
# Bind address. 0.0.0.0 = reachable from the LAN (other machines on the network) at this host's
|
| 23 |
+
# IP:PORT. Set BIND=127.0.0.1 to restrict to localhost (main app on the same box only).
|
| 24 |
+
BIND="${BIND:-0.0.0.0}"
|
| 25 |
+
mkdir -p logs
|
| 26 |
+
|
| 27 |
+
start_one() {
|
| 28 |
+
local name="$1" dir="$2" port="$3"
|
| 29 |
+
if [ -f "logs/$name.pid" ] && kill -0 "$(cat "logs/$name.pid")" 2>/dev/null; then
|
| 30 |
+
echo "[$name] already running (pid $(cat "logs/$name.pid")) on :$port"
|
| 31 |
+
return
|
| 32 |
+
fi
|
| 33 |
+
echo "[$name] starting on $BIND:$port (logs/$name.log)"
|
| 34 |
+
PORT="$port" GRADIO_SERVER_NAME="$BIND" nohup "$PY" "spaces/$dir/app.py" \
|
| 35 |
+
> "logs/$name.log" 2>&1 &
|
| 36 |
+
echo $! > "logs/$name.pid"
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
wait_ready() {
|
| 40 |
+
local name="$1" port="$2"
|
| 41 |
+
echo -n "[$name] loading model"
|
| 42 |
+
for _ in $(seq 1 600); do # up to ~10 min (cold model download/load)
|
| 43 |
+
if curl -fsS "http://127.0.0.1:$port/config" >/dev/null 2>&1; then
|
| 44 |
+
echo " — ready at http://127.0.0.1:$port"
|
| 45 |
+
return 0
|
| 46 |
+
fi
|
| 47 |
+
if [ -f "logs/$name.pid" ] && ! kill -0 "$(cat "logs/$name.pid")" 2>/dev/null; then
|
| 48 |
+
echo " — DIED, tail of logs/$name.log:"; tail -n 30 "logs/$name.log"; return 1
|
| 49 |
+
fi
|
| 50 |
+
echo -n "."; sleep 1
|
| 51 |
+
done
|
| 52 |
+
echo " — timed out"; return 1
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
stop_one() {
|
| 56 |
+
local name="$1"
|
| 57 |
+
if [ -f "logs/$name.pid" ]; then
|
| 58 |
+
kill "$(cat "logs/$name.pid")" 2>/dev/null || true
|
| 59 |
+
rm -f "logs/$name.pid"
|
| 60 |
+
echo "[$name] stopped"
|
| 61 |
+
fi
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
case "${1:-all}" in
|
| 65 |
+
stop) stop_one aya; stop_one klein ;;
|
| 66 |
+
aya) start_one aya tiny-aya-zerogpu "$AYA_PORT"; wait_ready aya "$AYA_PORT" ;;
|
| 67 |
+
klein) start_one klein klein-zerogpu "$KLEIN_PORT"; wait_ready klein "$KLEIN_PORT" ;;
|
| 68 |
+
all|*)
|
| 69 |
+
start_one aya tiny-aya-zerogpu "$AYA_PORT"
|
| 70 |
+
start_one klein klein-zerogpu "$KLEIN_PORT"
|
| 71 |
+
wait_ready aya "$AYA_PORT"
|
| 72 |
+
wait_ready klein "$KLEIN_PORT"
|
| 73 |
+
echo "both sidecars up. point the main app at them (already set in .env)."
|
| 74 |
+
;;
|
| 75 |
+
esac
|
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Tiny Army Klein ZeroGPU
|
| 3 |
+
emoji: 🤏
|
| 4 |
+
colorFrom: green
|
| 5 |
+
colorTo: gray
|
| 6 |
+
sdk: gradio
|
| 7 |
+
sdk_version: 5.49.1
|
| 8 |
+
app_file: app.py
|
| 9 |
+
suggested_hardware: zero-a10g
|
| 10 |
+
pinned: false
|
| 11 |
+
license: apache-2.0
|
| 12 |
+
models:
|
| 13 |
+
- black-forest-labs/FLUX.2-klein-4B
|
| 14 |
+
---
|
| 15 |
+
|
| 16 |
+
# Tiny Army Klein ZeroGPU
|
| 17 |
+
|
| 18 |
+
Private ZeroGPU sidecar for Tiny Army portrait generation.
|
|
@@ -0,0 +1,162 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
# LOCAL SIDECAR variant of the polats/tiny-army-klein-zerogpu Space.
|
| 4 |
+
#
|
| 5 |
+
# Same Gradio API contract as the hosted Space (so the main app's gradio_client talks to it
|
| 6 |
+
# unchanged):
|
| 7 |
+
# /generate(prompt, seed:int, lora:str) -> image # returns a PIL image / file path
|
| 8 |
+
#
|
| 9 |
+
# Differences from the hosted ZeroGPU app:
|
| 10 |
+
# * No ZeroGPU. `@spaces.GPU` is a no-op passthrough.
|
| 11 |
+
# * FLUX.2-klein is ~16 GB (transformer + text_encoder) and won't fit on the 3090, which is
|
| 12 |
+
# ALSO shared with a ~15 GB text-encoder server. We load in bf16 on CPU and stream to the GPU
|
| 13 |
+
# with `enable_sequential_cpu_offload()` (submodule granularity), so peak VRAM is only ~1-2 GB
|
| 14 |
+
# and the model never lands on the GPU all at once. (4-bit quantization was tried but its
|
| 15 |
+
# load-time GPU "warmup" allocation spikes above the ~5 GB of free headroom and OOMs; sequential
|
| 16 |
+
# offload sidesteps that AND keeps full bf16 quality. TINY_KLEIN_OFFLOAD=model uses the faster
|
| 17 |
+
# component-granularity offload instead — only safe when the card is mostly free.)
|
| 18 |
+
# * The weiner LoRA (polats/weiner-klein-lora) is loaded by DEFAULT and pre-applied at startup,
|
| 19 |
+
# so the main app's /generate calls (which don't pass a lora) get weiner portraits.
|
| 20 |
+
# * Loads the repo .env for HF_TOKEN.
|
| 21 |
+
import os
|
| 22 |
+
import random
|
| 23 |
+
import time
|
| 24 |
+
|
| 25 |
+
# Reduce CUDA fragmentation under the shared-card memory pressure (the offload churn allocates
|
| 26 |
+
# and frees repeatedly). Must be set before torch initializes CUDA.
|
| 27 |
+
os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
|
| 28 |
+
|
| 29 |
+
try:
|
| 30 |
+
from dotenv import load_dotenv
|
| 31 |
+
load_dotenv(os.path.join(os.path.dirname(__file__), "..", "..", ".env"))
|
| 32 |
+
load_dotenv()
|
| 33 |
+
except Exception: # noqa: BLE001
|
| 34 |
+
pass
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
# Off ZeroGPU there's no spaces.GPU; make the decorator a no-op passthrough.
|
| 38 |
+
def GPU(*dargs, **dkwargs): # noqa: N802
|
| 39 |
+
def wrap(fn):
|
| 40 |
+
return fn
|
| 41 |
+
if len(dargs) == 1 and callable(dargs[0]) and not dkwargs:
|
| 42 |
+
return dargs[0]
|
| 43 |
+
return wrap
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
import gradio as gr
|
| 47 |
+
import torch
|
| 48 |
+
from diffusers import Flux2KleinPipeline
|
| 49 |
+
|
| 50 |
+
MODEL_ID = os.environ.get("TINY_KLEIN_MODEL", "black-forest-labs/FLUX.2-klein-4B")
|
| 51 |
+
STEPS = int(os.environ.get("TINY_KLEIN_STEPS", "4"))
|
| 52 |
+
GUIDANCE = float(os.environ.get("TINY_KLEIN_GUIDANCE", "1.0"))
|
| 53 |
+
MAX_SEED = 2_147_483_647
|
| 54 |
+
_HF_TOKEN = os.environ.get("HF_TOKEN") or None
|
| 55 |
+
# Offload strategy. "sequential" (default) streams submodules → ~1-2 GB peak, fits alongside the
|
| 56 |
+
# shared text-encoder server, slower. "model" streams whole components → ~8 GB peak, faster, only
|
| 57 |
+
# safe when the card is mostly free.
|
| 58 |
+
OFFLOAD = os.environ.get("TINY_KLEIN_OFFLOAD", "sequential").strip().lower()
|
| 59 |
+
|
| 60 |
+
# Optional character/style LoRAs, loadable per-request via the `lora` argument.
|
| 61 |
+
# Trained on klein-base; serving on the distilled model is the documented fast path.
|
| 62 |
+
LORA_REPO = os.environ.get("KLEIN_LORA_REPO", "polats/weiner-klein-lora")
|
| 63 |
+
LORAS = {
|
| 64 |
+
"weiner": "weiner_klein_4b_v1.safetensors", # step 1000 (final)
|
| 65 |
+
"weiner750": "weiner_klein_4b_v1_000000750.safetensors",
|
| 66 |
+
"weiner500": "weiner_klein_4b_v1_000000500.safetensors",
|
| 67 |
+
"weiner250": "weiner_klein_4b_v1_000000250.safetensors",
|
| 68 |
+
}
|
| 69 |
+
# Default LoRA applied at startup (and the component default so no-arg /generate calls use it).
|
| 70 |
+
DEFAULT_LORA = os.environ.get("KLEIN_LORA_DEFAULT", "weiner").strip()
|
| 71 |
+
|
| 72 |
+
print(f"[klein] loading {MODEL_ID} (bf16, offload={OFFLOAD if torch.cuda.is_available() else 'cpu'})", flush=True)
|
| 73 |
+
_t0 = time.time()
|
| 74 |
+
# bf16 on CPU — no GPU allocation at load time, so it never contends with the shared card.
|
| 75 |
+
pipe = Flux2KleinPipeline.from_pretrained(MODEL_ID, torch_dtype=torch.bfloat16, token=_HF_TOKEN)
|
| 76 |
+
print(f"[klein] loaded in {time.time() - _t0:.1f}s", flush=True)
|
| 77 |
+
|
| 78 |
+
_lora_active: str | None = None
|
| 79 |
+
_offload_enabled = False
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def _load_lora(name: str) -> None:
|
| 83 |
+
pipe.load_lora_weights(LORA_REPO, weight_name=LORAS[name], token=_HF_TOKEN)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def _ensure_lora(name: str) -> None:
|
| 87 |
+
global _lora_active
|
| 88 |
+
name = (name or "").strip()
|
| 89 |
+
if name == (_lora_active or ""):
|
| 90 |
+
return
|
| 91 |
+
if _lora_active:
|
| 92 |
+
pipe.unload_lora_weights()
|
| 93 |
+
_lora_active = None
|
| 94 |
+
if name:
|
| 95 |
+
if name not in LORAS:
|
| 96 |
+
raise gr.Error(f"unknown lora '{name}' (have: {', '.join(LORAS)})")
|
| 97 |
+
_load_lora(name)
|
| 98 |
+
_lora_active = name
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
# Pre-apply the default LoRA, THEN install the offload hooks (load_lora before offload is the
|
| 102 |
+
# reliable order). After this the pipe manages its own device placement — never call .to(cuda).
|
| 103 |
+
if DEFAULT_LORA:
|
| 104 |
+
try:
|
| 105 |
+
_load_lora(DEFAULT_LORA)
|
| 106 |
+
_lora_active = DEFAULT_LORA
|
| 107 |
+
print(f"[klein] applied default LoRA: {DEFAULT_LORA}", flush=True)
|
| 108 |
+
except Exception as e: # noqa: BLE001
|
| 109 |
+
print(f"[klein] WARN: could not preload default LoRA {DEFAULT_LORA!r}: {e}", flush=True)
|
| 110 |
+
|
| 111 |
+
if torch.cuda.is_available():
|
| 112 |
+
if OFFLOAD == "model":
|
| 113 |
+
pipe.enable_model_cpu_offload() # whole-component granularity, ~8 GB peak, faster
|
| 114 |
+
print("[klein] enabled model CPU offload (peak ~ one component)", flush=True)
|
| 115 |
+
else:
|
| 116 |
+
pipe.enable_sequential_cpu_offload() # submodule granularity, ~1-2 GB peak, slower
|
| 117 |
+
print("[klein] enabled sequential CPU offload (peak ~1-2 GB)", flush=True)
|
| 118 |
+
_offload_enabled = True
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
@GPU(duration=60)
|
| 122 |
+
def generate(prompt: str, seed: int = 42, lora: str = DEFAULT_LORA):
|
| 123 |
+
if not prompt or not prompt.strip():
|
| 124 |
+
raise gr.Error("prompt required")
|
| 125 |
+
_ensure_lora(lora)
|
| 126 |
+
# With offload enabled the pipeline places its own modules; the generator must live on the
|
| 127 |
+
# device the latents end up on (cuda when offloading, else cpu).
|
| 128 |
+
dev = "cuda" if torch.cuda.is_available() else "cpu"
|
| 129 |
+
if seed is None or int(seed) < 0:
|
| 130 |
+
seed = random.randint(0, MAX_SEED)
|
| 131 |
+
img = pipe(
|
| 132 |
+
prompt=prompt.strip(),
|
| 133 |
+
width=1024,
|
| 134 |
+
height=1024,
|
| 135 |
+
num_inference_steps=STEPS,
|
| 136 |
+
guidance_scale=GUIDANCE,
|
| 137 |
+
generator=torch.Generator(device=dev).manual_seed(int(seed)),
|
| 138 |
+
).images[0]
|
| 139 |
+
if dev == "cuda":
|
| 140 |
+
torch.cuda.empty_cache()
|
| 141 |
+
return img
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
demo = gr.Interface(
|
| 145 |
+
fn=generate,
|
| 146 |
+
inputs=[
|
| 147 |
+
gr.Textbox(label="Prompt", lines=4),
|
| 148 |
+
gr.Number(label="Seed", value=42, precision=0),
|
| 149 |
+
gr.Textbox(label="LoRA", value=DEFAULT_LORA,
|
| 150 |
+
placeholder="blank = none; weiner / weiner750 / weiner500 / weiner250"),
|
| 151 |
+
],
|
| 152 |
+
outputs=gr.Image(type="pil", label="Portrait"),
|
| 153 |
+
api_name="generate",
|
| 154 |
+
title="Tiny Army Klein — local sidecar",
|
| 155 |
+
)
|
| 156 |
+
|
| 157 |
+
if __name__ == "__main__":
|
| 158 |
+
demo.queue().launch(
|
| 159 |
+
server_name=os.environ.get("GRADIO_SERVER_NAME", "127.0.0.1"),
|
| 160 |
+
server_port=int(os.environ.get("PORT", "7865")),
|
| 161 |
+
show_error=True,
|
| 162 |
+
)
|
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
git+https://github.com/huggingface/diffusers.git
|
| 2 |
+
torch
|
| 3 |
+
transformers
|
| 4 |
+
accelerate
|
| 5 |
+
sentencepiece
|
| 6 |
+
protobuf
|
| 7 |
+
spaces
|
| 8 |
+
peft
|
| 9 |
+
python-dotenv
|
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Tiny Army Tiny Aya ZeroGPU
|
| 3 |
+
emoji: 🌍
|
| 4 |
+
colorFrom: green
|
| 5 |
+
colorTo: blue
|
| 6 |
+
sdk: gradio
|
| 7 |
+
sdk_version: 6.15.2
|
| 8 |
+
app_file: app.py
|
| 9 |
+
python_version: "3.11"
|
| 10 |
+
suggested_hardware: zero-a10g
|
| 11 |
+
pinned: false
|
| 12 |
+
license: apache-2.0
|
| 13 |
+
models:
|
| 14 |
+
- CohereLabs/tiny-aya-global
|
| 15 |
+
---
|
| 16 |
+
|
| 17 |
+
# Tiny Army Tiny Aya ZeroGPU
|
| 18 |
+
|
| 19 |
+
ZeroGPU sidecar for Tiny Army text generation using Cohere Labs Tiny Aya Global.
|
|
@@ -0,0 +1,182 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
# LOCAL SIDECAR variant of the polats/tiny-army-tiny-aya-zerogpu Space.
|
| 4 |
+
#
|
| 5 |
+
# Same Gradio API contract as the hosted Space (so the main app's gradio_client talks to it
|
| 6 |
+
# unchanged):
|
| 7 |
+
# /generate(system, user, max_tokens:int, temperature:float) -> str
|
| 8 |
+
# /generate_stream(system, user, max_tokens:int, temperature:float) -> str # CUMULATIVE, streamed
|
| 9 |
+
#
|
| 10 |
+
# Differences from the hosted ZeroGPU app:
|
| 11 |
+
# * No ZeroGPU. `@spaces.GPU` is replaced by a no-op passthrough (the locally-installed
|
| 12 |
+
# `spaces` package has no `.GPU` off-platform), so generate() just runs on the local CUDA
|
| 13 |
+
# device the model is already resident on.
|
| 14 |
+
# * Loads a sibling/repo .env for HF_TOKEN (tiny-aya-global is a gated repo).
|
| 15 |
+
# * Caps this process's VRAM so a spike can't grab the whole 3090 and crash the desktop.
|
| 16 |
+
import os
|
| 17 |
+
import threading
|
| 18 |
+
|
| 19 |
+
os.environ.setdefault("OPENBLAS_NUM_THREADS", "4")
|
| 20 |
+
os.environ.setdefault("OMP_NUM_THREADS", "4")
|
| 21 |
+
os.environ.setdefault("MKL_NUM_THREADS", "4")
|
| 22 |
+
os.environ.setdefault("TOKENIZERS_PARALLELISM", "false")
|
| 23 |
+
os.environ.setdefault("GRADIO_SSR_MODE", "false")
|
| 24 |
+
|
| 25 |
+
try:
|
| 26 |
+
from dotenv import load_dotenv
|
| 27 |
+
# repo root .env (two dirs up: spaces/tiny-aya-zerogpu/ -> repo root)
|
| 28 |
+
load_dotenv(os.path.join(os.path.dirname(__file__), "..", "..", ".env"))
|
| 29 |
+
load_dotenv() # also any .env in CWD
|
| 30 |
+
except Exception: # noqa: BLE001
|
| 31 |
+
pass
|
| 32 |
+
|
| 33 |
+
import gradio as gr
|
| 34 |
+
import torch
|
| 35 |
+
from transformers import AutoModelForCausalLM, AutoTokenizer, TextIteratorStreamer
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
# Off ZeroGPU there's no spaces.GPU; make the decorator a no-op passthrough. The model is
|
| 39 |
+
# already on the local CUDA device, so the wrapped fn runs there directly.
|
| 40 |
+
def GPU(*dargs, **dkwargs): # noqa: N802
|
| 41 |
+
def wrap(fn):
|
| 42 |
+
return fn
|
| 43 |
+
if len(dargs) == 1 and callable(dargs[0]) and not dkwargs:
|
| 44 |
+
return dargs[0]
|
| 45 |
+
return wrap
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
MODEL_ID = os.environ.get("TINY_AYA_MODEL", "CohereLabs/tiny-aya-global")
|
| 49 |
+
DEFAULT_MAX_TOKENS = int(os.environ.get("TINY_AYA_MAX_TOKENS", "400"))
|
| 50 |
+
_HF_TOKEN = os.environ.get("HF_TOKEN") or None
|
| 51 |
+
# 4-bit by default: the 3090 is shared with a ~15 GB text-encoder server, so we NF4-quantize
|
| 52 |
+
# tiny-aya (~7 GB bf16 -> ~2.5 GB) to leave room. TINY_AYA_QUANT=bf16 disables it (best quality).
|
| 53 |
+
QUANT = os.environ.get("TINY_AYA_QUANT", "4bit").strip().lower()
|
| 54 |
+
|
| 55 |
+
# GUARDRAIL: the 3090 also drives the desktop. Cap THIS process's share of the card so an OOM
|
| 56 |
+
# errors out instead of taking down the display.
|
| 57 |
+
if torch.cuda.is_available():
|
| 58 |
+
try:
|
| 59 |
+
torch.cuda.set_per_process_memory_fraction(
|
| 60 |
+
float(os.environ.get("TINY_AYA_VRAM_FRAC", "0.5")), 0
|
| 61 |
+
)
|
| 62 |
+
except Exception: # noqa: BLE001
|
| 63 |
+
pass
|
| 64 |
+
|
| 65 |
+
tokenizer = AutoTokenizer.from_pretrained(MODEL_ID, token=_HF_TOKEN)
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def _load_model():
|
| 69 |
+
if not torch.cuda.is_available():
|
| 70 |
+
return AutoModelForCausalLM.from_pretrained(MODEL_ID, torch_dtype="auto", token=_HF_TOKEN)
|
| 71 |
+
if QUANT == "bf16":
|
| 72 |
+
m = AutoModelForCausalLM.from_pretrained(MODEL_ID, torch_dtype=torch.bfloat16, token=_HF_TOKEN)
|
| 73 |
+
return m.to("cuda")
|
| 74 |
+
# 4-bit NF4, loaded straight onto the GPU (bnb models can't be .to()'d afterwards).
|
| 75 |
+
from transformers import BitsAndBytesConfig
|
| 76 |
+
quant = BitsAndBytesConfig(
|
| 77 |
+
load_in_4bit=True,
|
| 78 |
+
bnb_4bit_quant_type="nf4",
|
| 79 |
+
bnb_4bit_compute_dtype=torch.bfloat16,
|
| 80 |
+
bnb_4bit_use_double_quant=True,
|
| 81 |
+
)
|
| 82 |
+
return AutoModelForCausalLM.from_pretrained(
|
| 83 |
+
MODEL_ID, quantization_config=quant, torch_dtype=torch.bfloat16,
|
| 84 |
+
device_map="cuda", token=_HF_TOKEN,
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
print(f"[tiny-aya] loading {MODEL_ID} quant={QUANT if torch.cuda.is_available() else 'cpu'}", flush=True)
|
| 89 |
+
model = _load_model()
|
| 90 |
+
model.eval()
|
| 91 |
+
print("[tiny-aya] model ready", flush=True)
|
| 92 |
+
|
| 93 |
+
_lock = threading.Lock()
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def _messages(system: str, user: str):
|
| 97 |
+
messages = []
|
| 98 |
+
if system and system.strip():
|
| 99 |
+
messages.append({"role": "system", "content": system.strip()})
|
| 100 |
+
messages.append({"role": "user", "content": (user or "").strip()})
|
| 101 |
+
return messages
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
@GPU(duration=120)
|
| 105 |
+
def generate(system: str, user: str, max_tokens: int = DEFAULT_MAX_TOKENS, temperature: float = 0.8):
|
| 106 |
+
if not user or not user.strip():
|
| 107 |
+
raise gr.Error("user prompt required")
|
| 108 |
+
max_tokens = max(1, min(int(max_tokens or DEFAULT_MAX_TOKENS), 1024))
|
| 109 |
+
temperature = max(0.0, min(float(temperature if temperature is not None else 0.8), 2.0))
|
| 110 |
+
inputs = tokenizer.apply_chat_template(
|
| 111 |
+
_messages(system, user),
|
| 112 |
+
tokenize=True,
|
| 113 |
+
add_generation_prompt=True,
|
| 114 |
+
return_dict=True,
|
| 115 |
+
return_tensors="pt",
|
| 116 |
+
).to(model.device)
|
| 117 |
+
with _lock, torch.inference_mode():
|
| 118 |
+
outputs = model.generate(
|
| 119 |
+
**inputs,
|
| 120 |
+
max_new_tokens=max_tokens,
|
| 121 |
+
do_sample=temperature > 0,
|
| 122 |
+
temperature=max(temperature, 1e-5),
|
| 123 |
+
top_p=0.95,
|
| 124 |
+
pad_token_id=tokenizer.eos_token_id,
|
| 125 |
+
)
|
| 126 |
+
return tokenizer.decode(outputs[0][inputs["input_ids"].shape[-1]:], skip_special_tokens=True).strip()
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
@GPU(duration=120)
|
| 130 |
+
def generate_stream(system: str, user: str, max_tokens: int = DEFAULT_MAX_TOKENS, temperature: float = 0.8):
|
| 131 |
+
if not user or not user.strip():
|
| 132 |
+
raise gr.Error("user prompt required")
|
| 133 |
+
max_tokens = max(1, min(int(max_tokens or DEFAULT_MAX_TOKENS), 1024))
|
| 134 |
+
temperature = max(0.0, min(float(temperature if temperature is not None else 0.8), 2.0))
|
| 135 |
+
inputs = tokenizer.apply_chat_template(
|
| 136 |
+
_messages(system, user),
|
| 137 |
+
tokenize=True,
|
| 138 |
+
add_generation_prompt=True,
|
| 139 |
+
return_dict=True,
|
| 140 |
+
return_tensors="pt",
|
| 141 |
+
).to(model.device)
|
| 142 |
+
streamer = TextIteratorStreamer(tokenizer, skip_prompt=True, skip_special_tokens=True)
|
| 143 |
+
|
| 144 |
+
def run():
|
| 145 |
+
with _lock, torch.inference_mode():
|
| 146 |
+
model.generate(
|
| 147 |
+
**inputs,
|
| 148 |
+
max_new_tokens=max_tokens,
|
| 149 |
+
do_sample=temperature > 0,
|
| 150 |
+
temperature=max(temperature, 1e-5),
|
| 151 |
+
top_p=0.95,
|
| 152 |
+
pad_token_id=tokenizer.eos_token_id,
|
| 153 |
+
streamer=streamer,
|
| 154 |
+
)
|
| 155 |
+
|
| 156 |
+
thread = threading.Thread(target=run, daemon=True)
|
| 157 |
+
thread.start()
|
| 158 |
+
acc = ""
|
| 159 |
+
for token in streamer:
|
| 160 |
+
acc += token
|
| 161 |
+
yield acc
|
| 162 |
+
thread.join(timeout=1)
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
with gr.Blocks(title="Tiny Army Tiny Aya (local)") as demo:
|
| 166 |
+
gr.Markdown("# Tiny Army Tiny Aya — local sidecar")
|
| 167 |
+
system = gr.Textbox(label="System", lines=5)
|
| 168 |
+
user = gr.Textbox(label="User", lines=5)
|
| 169 |
+
max_tokens = gr.Slider(1, 1024, value=DEFAULT_MAX_TOKENS, step=1, label="Max new tokens")
|
| 170 |
+
temperature = gr.Slider(0, 2, value=0.8, step=0.05, label="Temperature")
|
| 171 |
+
btn = gr.Button("Generate")
|
| 172 |
+
out = gr.Textbox(label="Output", lines=10)
|
| 173 |
+
btn.click(generate, inputs=[system, user, max_tokens, temperature], outputs=out, api_name="generate")
|
| 174 |
+
btn.click(generate_stream, inputs=[system, user, max_tokens, temperature], outputs=out, api_name="generate_stream")
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
if __name__ == "__main__":
|
| 178 |
+
demo.queue().launch(
|
| 179 |
+
server_name=os.environ.get("GRADIO_SERVER_NAME", "127.0.0.1"),
|
| 180 |
+
server_port=int(os.environ.get("PORT", "7864")),
|
| 181 |
+
show_error=True,
|
| 182 |
+
)
|
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
gradio==6.15.2
|
| 2 |
+
spaces
|
| 3 |
+
torch
|
| 4 |
+
transformers>=5.4.0
|
| 5 |
+
accelerate
|
| 6 |
+
sentencepiece
|
| 7 |
+
protobuf
|
| 8 |
+
huggingface_hub
|
| 9 |
+
python-dotenv
|
| 10 |
+
bitsandbytes
|