fish-cpp: switch to resident s2 server + proxy shim — fix per-request CUDA init dominating wall time

Subprocess-per-request architecture forced CUDA + model load on every
/v1/tts call (~10-20s init, then 5-15s generation). Even though CUDA
is now actually being used (`-c 0` fix landed), 32s for "Verify."
proved per-request init was the bottleneck.

s2.cpp ships a built-in HTTP server (`--server -H -P`) that keeps the
model resident on the GPU. Refactor:

* entrypoint.sh — backgrounds `s2 --server -P 3030 -c 0 -m ... -t ...`,
  waits for it to bind 3030, then foregrounds uvicorn. tini supervises
  via `wait -n` so either child dying takes down the container.

* server.py — drops subprocess.run; instead httpx-POSTs Fish-shaped
  /v1/tts JSON to s2's localhost:3030/generate (multipart form: text
  + optional prompt_text/prompt_audio for cloning). Model load + CUDA
  init now happen once at container start, not per-request.

* Dockerfile — added httpx (shim dep), curl (entrypoint readiness
  probe), and the entrypoint.sh COPY+chmod. CMD now invokes
  entrypoint.sh instead of uvicorn directly.

* deploy-fish-cpp.yaml — uploads entrypoint.sh alongside server.py.
This commit is contained in:
2026-04-28 01:32:20 -07:00
parent 8a1d0bf709
commit 8c1088af1f
4 changed files with 118 additions and 85 deletions
+6
View File
@@ -66,6 +66,12 @@ steps:
dest: "{{ compose_dir }}/server.py" dest: "{{ compose_dir }}/server.py"
mode: "0644" mode: "0644"
- name: Upload entrypoint.sh (starts s2 server + uvicorn shim)
upload:
src: stacks/fish-cpp/entrypoint.sh
dest: "{{ compose_dir }}/entrypoint.sh"
mode: "0755"
- name: Seed .env from template (only if absent) - name: Seed .env from template (only if absent)
upload: upload:
src: stacks/fish-cpp/.env.example src: stacks/fish-cpp/.env.example
+10 -8
View File
@@ -50,18 +50,15 @@ ENV DEBIAN_FRONTEND=noninteractive \
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
python3 python3-pip python3-venv tini \ python3 python3-pip python3-venv tini \
libgomp1 \ libgomp1 curl \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# libgomp1 = GNU OpenMP runtime — required by the s2 binary at runtime. # libgomp1 = GNU OpenMP runtime — required by the s2 binary.
# CMake auto-enabled OpenMP at build time (gcc has -fopenmp), so the # curl = entrypoint uses it to wait for s2 server to bind :3030.
# binary dynamically links libgomp.so.1; the slim cuda:runtime base
# doesn't include it by default. Without this, s2 fails on first
# invocation with "error while loading shared libraries: libgomp.so.1".
# Pull the shim deps into an isolated venv so we don't fight system pip. # Pull the shim deps into an isolated venv so we don't fight system pip.
RUN python3 -m venv /opt/venv RUN python3 -m venv /opt/venv
ENV PATH="/opt/venv/bin:${PATH}" ENV PATH="/opt/venv/bin:${PATH}"
RUN pip install --no-cache-dir 'fastapi>=0.115' 'uvicorn[standard]>=0.30' 'pydantic>=2' RUN pip install --no-cache-dir 'fastapi>=0.115' 'uvicorn[standard]>=0.30' 'pydantic>=2' 'httpx>=0.27'
# Copy the s2 binary + GGML runtime libs from the builder stage. # Copy the s2 binary + GGML runtime libs from the builder stage.
COPY --from=builder /src/s2.cpp/build/s2 /usr/local/bin/s2 COPY --from=builder /src/s2.cpp/build/s2 /usr/local/bin/s2
@@ -71,11 +68,16 @@ RUN ldconfig
WORKDIR /app WORKDIR /app
COPY server.py /app/server.py COPY server.py /app/server.py
COPY entrypoint.sh /app/entrypoint.sh
RUN chmod +x /app/entrypoint.sh
# Bind-mounted at runtime: weights at /weights, references at /references. # Bind-mounted at runtime: weights at /weights, references at /references.
VOLUME /weights VOLUME /weights
VOLUME /references VOLUME /references
EXPOSE 8000 EXPOSE 8000
# tini supervises the entrypoint script which manages both s2 server
# (bound to localhost:3030, model resident) and the uvicorn shim
# (bound to 0.0.0.0:8000, proxies /v1/tts → s2's /generate).
ENTRYPOINT ["/usr/bin/tini", "--"] ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "8000", "--no-access-log"] CMD ["/app/entrypoint.sh"]
+55
View File
@@ -0,0 +1,55 @@
#!/bin/bash
# fish-cpp entrypoint — start s2 server + uvicorn shim.
#
# s2 server holds the model resident on the GPU; uvicorn proxies
# Fish-shaped /v1/tts JSON requests to s2's multipart /generate.
# Both processes share the container; tini supervises both via
# `wait` after backgrounding s2.
set -euo pipefail
MODEL_PATH="${WEIGHTS_DIR:-/weights}/${FISH_CPP_MODEL:-s2-pro-q6_k.gguf}"
TOKENIZER_PATH="${WEIGHTS_DIR:-/weights}/${FISH_CPP_TOKENIZER:-tokenizer.json}"
DEVICE="${FISH_CPP_DEVICE:-0}"
if [ ! -f "$MODEL_PATH" ]; then echo "missing model: $MODEL_PATH" >&2; exit 1; fi
if [ ! -f "$TOKENIZER_PATH" ]; then echo "missing tokenizer: $TOKENIZER_PATH" >&2; exit 1; fi
# Background s2 server. Loads model on GPU once, then accepts
# multipart POSTs on localhost:3030/generate.
echo "[entrypoint] starting s2 server on :3030 with CUDA device $DEVICE"
/usr/local/bin/s2 \
--server -H 127.0.0.1 -P 3030 \
-m "$MODEL_PATH" \
-t "$TOKENIZER_PATH" \
-c "$DEVICE" &
S2_PID=$!
# Wait for s2 to bind 3030 before starting the shim. Avoids the
# obvious /v1/health 502 race on first boot.
echo "[entrypoint] waiting for s2 server to bind :3030"
for i in $(seq 1 60); do
if curl -sf -o /dev/null --max-time 1 http://127.0.0.1:3030/ 2>/dev/null; then
echo "[entrypoint] s2 server up after ${i}s"
break
fi
if ! kill -0 "$S2_PID" 2>/dev/null; then
echo "[entrypoint] s2 server died during startup" >&2
exit 1
fi
sleep 1
done
# Foreground uvicorn. tini (PID 1) gets uvicorn signals; on SIGTERM
# uvicorn exits, then s2 gets reaped via `wait` below.
echo "[entrypoint] starting uvicorn shim on :8000"
uvicorn server:app --host 0.0.0.0 --port 8000 --no-access-log &
UV_PID=$!
# Block until either child exits; propagate exit code.
wait -n "$S2_PID" "$UV_PID"
EXIT=$?
echo "[entrypoint] one child exited (rc=$EXIT) — shutting down peer"
kill "$S2_PID" "$UV_PID" 2>/dev/null || true
wait || true
exit "$EXIT"
+47 -77
View File
@@ -1,46 +1,40 @@
"""fish-cpp — HTTP shim wrapping the s2.cpp CLI in Fish's /v1/tts API contract. """fish-cpp — HTTP shim wrapping s2.cpp's built-in server in Fish's
/v1/tts API contract.
s2.cpp is a pure C++/GGML inference engine for Fish s2-pro GGUFs. It's Architecture: an `s2 --server` instance runs IN THE SAME CONTAINER on
CLI-only (alpha), so this shim turns each HTTP POST into a subprocess localhost:3030 with the model loaded once on the GPU; this FastAPI
invocation that: shim translates each Fish-shape JSON POST into a multipart form POST
against localhost:3030/generate and streams the WAV back.
1. Writes the optional reference WAV to /tmp/<id>.wav The earlier subprocess-per-request design paid CUDA init + model load
2. Runs: s2 -m <model.gguf> -t <tokenizer.json> (~10-20s) on every call, which dominated wall time. Keeping s2 server
[-pa /tmp/ref.wav -pt "<transcript>"] resident moves the cost to startup (paid once) so per-request latency
-text "..." -o /tmp/<id>.out.wav matches what the model can actually do.
3. Streams the resulting WAV back to the client.
The contract matches Fish's wrapper close enough that bench harnesses The shim still accepts (and ignores) `streaming: true` in the request
written for fish-s2 work against fish-cpp without changes — same body for contract parity with fish-s2 — s2.cpp's /generate is
POST /v1/tts, same {text, references[{audio,text}], format} request body. synchronous, so TTFB ≈ total wall time. If/when s2.cpp grows
incremental output, wire it through here.
What's NOT supported (vs Fish HF):
* `streaming: true` — s2.cpp writes a complete WAV before returning.
The shim accepts the field but ignores it. TTFB ≈ total wall time.
If/when s2.cpp grows incremental output, wire it through here.
* Paralinguistic tags — s2.cpp uses the same model weights, so tags
SHOULD work, but quality may differ from the HF wrapper.
""" """
from __future__ import annotations from __future__ import annotations
import base64 import base64
import io
import os import os
import subprocess
import tempfile import tempfile
import uuid
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
import httpx
from fastapi import FastAPI, HTTPException from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse from fastapi.responses import Response
from pydantic import BaseModel from pydantic import BaseModel
S2_BINARY = os.environ.get("S2_BINARY", "/usr/local/bin/s2") S2_BASE_URL = os.environ.get("S2_BASE_URL", "http://127.0.0.1:3030")
WEIGHTS_DIR = Path(os.environ.get("WEIGHTS_DIR", "/weights")) WEIGHTS_DIR = Path(os.environ.get("WEIGHTS_DIR", "/weights"))
MODEL_FILE = os.environ.get("FISH_CPP_MODEL", "s2-pro-q6_k.gguf") MODEL_FILE = os.environ.get("FISH_CPP_MODEL", "s2-pro-q6_k.gguf")
TOKENIZER_FILE = os.environ.get("FISH_CPP_TOKENIZER", "tokenizer.json") TOKENIZER_FILE = os.environ.get("FISH_CPP_TOKENIZER", "tokenizer.json")
DEVICE_INDEX = os.environ.get("FISH_CPP_DEVICE", "0") # CUDA device id
MODEL_PATH = WEIGHTS_DIR / MODEL_FILE MODEL_PATH = WEIGHTS_DIR / MODEL_FILE
TOKENIZER_PATH = WEIGHTS_DIR / TOKENIZER_FILE TOKENIZER_PATH = WEIGHTS_DIR / TOKENIZER_FILE
@@ -49,7 +43,7 @@ app = FastAPI(title="fish-cpp")
class ReferenceAudio(BaseModel): class ReferenceAudio(BaseModel):
audio: str # base64-encoded WAV bytes (matches Fish HF schema) audio: str # base64-encoded WAV bytes
text: str # transcript of the reference clip text: str # transcript of the reference clip
@@ -57,7 +51,7 @@ class TTSRequest(BaseModel):
text: str text: str
references: list[ReferenceAudio] = [] references: list[ReferenceAudio] = []
format: str = "wav" format: str = "wav"
streaming: bool = False # accepted for contract parity; ignored streaming: bool = False # accepted for parity; ignored
@app.get("/v1/health") @app.get("/v1/health")
@@ -66,70 +60,46 @@ def health() -> dict:
raise HTTPException(503, f"model missing: {MODEL_PATH}") raise HTTPException(503, f"model missing: {MODEL_PATH}")
if not TOKENIZER_PATH.exists(): if not TOKENIZER_PATH.exists():
raise HTTPException(503, f"tokenizer missing: {TOKENIZER_PATH}") raise HTTPException(503, f"tokenizer missing: {TOKENIZER_PATH}")
return {"status": "ok", "model": MODEL_FILE} # Probe the s2 server backend — readiness, not just liveness.
try:
r = httpx.get(f"{S2_BASE_URL}/", timeout=2.0)
except httpx.HTTPError as exc:
raise HTTPException(503, f"s2 server unreachable: {exc}")
return {"status": "ok", "model": MODEL_FILE, "s2_status": r.status_code}
@app.post("/v1/tts") @app.post("/v1/tts")
def tts(req: TTSRequest) -> FileResponse: def tts(req: TTSRequest) -> Response:
if req.format not in ("wav",): if req.format not in ("wav",):
raise HTTPException(400, f"only wav format supported, got {req.format}") raise HTTPException(400, f"only wav format supported, got {req.format}")
job = uuid.uuid4().hex # Translate Fish-shaped JSON to s2.cpp's multipart form.
out_path = Path(tempfile.gettempdir()) / f"fish-cpp-{job}.wav" files: dict[str, tuple] = {
ref_path: Optional[Path] = None "text": (None, req.text),
}
cmd: list[str] = [
S2_BINARY,
"-m", str(MODEL_PATH),
"-t", str(TOKENIZER_PATH),
"-text", req.text,
"-o", str(out_path),
# -c <device> selects CUDA backend on the given device id. The
# README example uses `-v 0` (which is --vulkan 0 — easy to
# misread as "voice 0"); we want CUDA so that the work hits
# the A6000 tensor cores instead of falling back to CPU.
# Without a backend flag, s2 prints "NPU not compiled, falling
# back to CPU" and runs at single-digit RTF on a 4-core CPU.
"-c", "0",
]
if req.references: if req.references:
# s2.cpp accepts a single reference at a time via -pa/-pt. If the # s2.cpp accepts a single reference at a time. If the client
# client sent multiple, use the first; the upstream API doesn't # sent multiple, use the first.
# appear to support multi-reference cloning.
ref = req.references[0] ref = req.references[0]
ref_path = Path(tempfile.gettempdir()) / f"fish-cpp-{job}-ref.wav" files["prompt_text"] = (None, ref.text)
ref_path.write_bytes(base64.b64decode(ref.audio)) files["prompt_audio"] = ("ref.wav", base64.b64decode(ref.audio), "audio/wav")
cmd.extend(["-pa", str(ref_path), "-pt", ref.text])
env = os.environ.copy()
env["CUDA_VISIBLE_DEVICES"] = DEVICE_INDEX
try: try:
result = subprocess.run( # Long timeout — s2 generation is sub-realtime on q6_k. The
cmd, env=env, capture_output=True, text=True, timeout=180, # /v1/health probe upstream already vetted s2 server is live.
r = httpx.post(
f"{S2_BASE_URL}/generate", files=files, timeout=180.0,
) )
except subprocess.TimeoutExpired: except httpx.HTTPError as exc:
raise HTTPException(504, "s2 binary timed out (180s)") raise HTTPException(502, f"s2 server request failed: {exc}")
finally:
if ref_path and ref_path.exists():
ref_path.unlink()
if result.returncode != 0: if r.status_code != 200:
# s2 returns JSON `{"error": "..."}` on failure
raise HTTPException( raise HTTPException(
500, 500, f"s2 server returned {r.status_code}: {r.text[:500]}"
f"s2 binary failed (rc={result.returncode}): {result.stderr[-500:]}",
) )
if not out_path.exists() or out_path.stat().st_size == 0: return Response(
raise HTTPException(500, "s2 binary produced no output") content=r.content,
media_type=r.headers.get("content-type", "audio/wav"),
# FileResponse streams the file + cleans up after sending. Caller
# gets WAV bytes immediately; we reap the temp file via background
# task once the response finishes.
from fastapi import BackgroundTasks
bg = BackgroundTasks()
bg.add_task(out_path.unlink, missing_ok=True)
return FileResponse(
out_path, media_type="audio/wav", filename=f"{job}.wav", background=bg,
) )