Spaces:
Running on Zero
Running on Zero
File size: 8,546 Bytes
a162612 70b0743 a162612 70b0743 a162612 70b0743 a162612 70b0743 a162612 70b0743 a162612 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 | """LayaChess on a Hugging Face Space (Gradio + ZeroGPU).
The page is the local board (laya_chess/web/index.html) inside an iframe. Its API calls go to Python
through a hidden Gradio textbox, one game per visitor. Only Laya's search runs on the GPU; ZeroGPU runs
it in a separate process, so it gets the game as a list of moves and returns plain data.
"""
import html
import json
import os
import shutil
import threading
import time
import types
import spaces # before torch, as ZeroGPU requires
import chess
import chess.engine
import gradio as gr
from laya_chess import play
from laya_chess.model import DEFAULT_CHECKPOINT, LayaChessModel
from laya_chess.search import MCTS
MAX_THINK = float(os.environ.get("MAX_THINK", 30))
ON_SPACE = bool(os.environ.get("SPACE_ID"))
MODEL = LayaChessModel(os.environ.get("CHECKPOINT", DEFAULT_CHECKPOINT), device="cuda" if ON_SPACE else "auto")
SF_PATH = shutil.which("stockfish") or shutil.which("stockfish", path="/usr/games")
SF = chess.engine.SimpleEngine.popen_uci(SF_PATH) if SF_PATH else None
SF_LOCK = threading.Lock()
if SF:
SF.configure({"Threads": 1, "Hash": 64})
def gpu_duration(moves, seconds):
# reserve only what the chosen thinking time needs: shorter reservations get better queue priority
return int(seconds) + 20
@spaces.GPU(duration=gpu_duration)
def gpu_search(moves, seconds):
board = chess.Board()
for m in moves:
board.push_uci(m)
r = MCTS(MODEL, batch_leaves=8).search(board, nodes=0 if seconds <= 0 else None,
seconds=seconds if seconds > 0 else None)
return {"move": r.move.uci(), "value": r.value, "nodes": r.nodes, "seconds": r.seconds,
"pv": [m.uci() for m in r.pv], "scores": [(m.uci(), q, n) for m, q, n in r.scores]}
class RemoteSearch:
"""Stands in for the game's MCTS: each search is one GPU call from the move list."""
def search(self, board, nodes=None, seconds=None):
try:
d = gpu_search([m.uci() for m in board.move_stack], 0.0 if nodes == 0 else float(seconds or 0))
except Exception as e:
if "quota" in str(e).lower():
raise ValueError("You've used up today's free GPU time on Hugging Face (2 minutes a day logged out, "
"5 minutes logged in), so Laya can't think right now. Log in to Hugging Face, or "
"try again tomorrow.") from None
raise
mv = chess.Move.from_uci
return types.SimpleNamespace(move=mv(d["move"]), value=d["value"], nodes=d["nodes"], seconds=d["seconds"],
pv=[mv(m) for m in d["pv"]], scores=[(mv(m), q, n) for m, q, n in d["scores"]])
class SpaceGame(play.Game):
def __init__(self):
super().__init__(MODEL)
self.mcts, self.sf, self.sf_limit = RemoteSearch(), SF, chess.engine.Limit(time=0.3)
def new(self, color, think, use_book=True):
super().new(color, think, use_book)
self.mcts = RemoteSearch()
@property
def think(self):
return self._think
@think.setter
def think(self, seconds):
self._think = max(0.0, min(float(seconds), MAX_THINK))
def stockfish_view(self, board):
with SF_LOCK:
return super().stockfish_view(board)
def state(self):
s = super().state()
s["model"]["device"] = "ZeroGPU" if ON_SPACE else s["model"]["device"]
return s
GAMES, GAMES_LOCK = {}, threading.Lock()
def game_for(sid):
now = time.time()
with GAMES_LOCK:
for k in [k for k, (_, seen) in GAMES.items() if now - seen > 3600]:
del GAMES[k]
g = GAMES[sid][0] if sid in GAMES else SpaceGame()
GAMES[sid] = (g, now)
return g
def handle(req, request: gr.Request):
"""One board API call: {"id", "path", "body"} -> {"id", "data"} or {"id", "error"}."""
msg = json.loads(req or "{}")
rid, path, body = msg.get("id"), msg.get("path", ""), msg.get("body") or {}
g = game_for(request.session_hash or "local")
try:
with g.lock:
if path == "/api/new":
g.new(body.get("color", "white"), body.get("think", 0), body.get("book", True))
elif path == "/api/move":
g.human_move(body["uci"])
elif path == "/api/engine":
g.engine_move()
elif path == "/api/takeover":
g.takeover()
elif path == "/api/hint":
g.hint()
elif path == "/api/undo":
g.undo()
elif path == "/api/settings":
g.think = float(body.get("think", g.think))
elif path != "/api/state":
raise ValueError("unknown call " + path)
return json.dumps({"id": rid, "data": g.state()})
except Exception as e: # shown to the player as an alert, the board stays usable
return json.dumps({"id": rid, "error": str(e) or e.__class__.__name__})
# the board page, with fetch() replaced by messages to this Gradio page
BRIDGE = """<script>
let laya_n = 0; const laya_wait = {};
window.addEventListener("message", e => {
const m = e.data || {};
if (m.laya !== "resp" || !laya_wait[m.id]) return;
const p = laya_wait[m.id]; delete laya_wait[m.id];
m.error ? p.rej(new Error(m.error)) : p.res(m.data);
});
function api(path, body) {
return new Promise((res, rej) => { const id = ++laya_n; laya_wait[id] = { res, rej };
parent.postMessage({ laya: "req", id, path, body }, "*"); });
}
new ResizeObserver(() => parent.postMessage({ laya: "height", h: document.documentElement.scrollHeight }, "*"))
.observe(document.documentElement);
</script>"""
def board_page():
page = (play.WEB / "index.html").read_text()
start = page.index("async function api(")
end = page.index("\n}\n", start) + 3
page = page[:start] + page[end:] # the bridge above defines api()
page = page.replace("</head>", BRIDGE + "</head>", 1)
return page.replace('<option value="30">30 s (search)</option>',
'<option value="30">30 s (search, uses more GPU time)</option>')
PARENT_JS = """<script>
(() => {
const queue = []; let busy = false;
function send() {
if (busy || !queue.length) return;
const ta = document.querySelector("#laya-req textarea");
if (!ta) return setTimeout(send, 200);
busy = true;
ta.value = JSON.stringify(queue.shift());
ta.dispatchEvent(new Event("input", { bubbles: true }));
setTimeout(() => document.getElementById("laya-go").click(), 0);
}
window.layaReply = (v) => {
const frame = document.getElementById("laya-frame");
if (v) { try { frame.contentWindow.postMessage({ laya: "resp", ...JSON.parse(v) }, "*"); } catch (e) {} }
busy = false; send();
};
window.addEventListener("message", e => {
const m = e.data || {};
if (m.laya === "req") { queue.push({ id: m.id, path: m.path, body: m.body }); send(); }
if (m.laya === "height") { const f = document.getElementById("laya-frame"); if (f) f.style.height = (m.h + 8) + "px"; }
});
})();
</script>"""
CSS = ".laya-hidden { display: none !important; } #laya-frame { width: 100%; height: 900px; border: 0; }"
INTRO = """**LayaChess**: [Laya](https://huggingface.co/convaiinnovations/laya), a 421M System 1 decision model, fine-tuned
on 2M Stockfish-rated moves and wrapped in a tree search. Laya thinks on a shared free GPU, so the first move can take
a while, and you may wait in a queue. Hugging Face gives each visitor 2 minutes of GPU time a day (5 if you're logged
in), and Laya spends its thinking time from that: about 4 moves at 30 s, 12 at 10 s, many more at 3 s or instantly. [How it works](https://devroopsaha744.github.io/portfolio/blog/laya-chess/) ·
[Code](https://github.com/devroopsaha744/LayaChess) · [Video](https://www.youtube.com/watch?v=bPpAlWArs7E)"""
with gr.Blocks(title="LayaChess") as demo:
gr.Markdown(INTRO)
gr.HTML(f'<iframe id="laya-frame" srcdoc="{html.escape(board_page(), quote=True)}"></iframe>')
req = gr.Textbox(elem_id="laya-req", elem_classes="laya-hidden", show_label=False)
resp = gr.Textbox(elem_classes="laya-hidden", show_label=False)
go = gr.Button(elem_id="laya-go", elem_classes="laya-hidden")
go.click(handle, req, resp, concurrency_limit=16, show_progress="hidden")
resp.change(None, resp, None, js="(v) => { window.layaReply(v); }")
if __name__ == "__main__":
demo.launch(head=PARENT_JS, css=CSS)
|