DeskForge / web_models.py
Saidgurbuz's picture
Plan with Qwen3.5-9B instead of Qwen3.6-27B-FP8: as good on the demo tasks, about 4x faster per step
3767612 verified
Raw History Blame Contribute Delete
5.45 kB
"""The web agent's models on ZeroGPU: Qwen3.5-9B plans, DeskForge-Qwen3.5-4B clicks.
The planner is loaded here, next to the duel's four models from deskforge.py (about 65 GiB of
the 96 GB xlarge GPU), and DeskForge-Qwen3.5-4B is reused from deskforge.py. Each agent step
is ONE ZeroGPU call: the planner reads the screenshot and names an action, then DeskForge
locates every target the planner describes (web_plan.decide_with).
The paper's long-horizon study planned with Qwen3.6-27B. On our demo tasks Qwen3.5-9B did as well and
plans a step about four times faster, so it is the default; WEB_PLANNER=Qwen/Qwen3.6-27B-FP8 restores
the paper's planner (the FP8 loading below handles it).
"""
import os
import time
from pathlib import Path
import spaces # MUST come before torch / transformers (ZeroGPU)
import torch
from PIL import Image
from transformers import AutoConfig, AutoModelForImageTextToText, AutoProcessor
import deskforge
from web_plan import PLANNER_TOKENS, decide_with, planner_user_text
PLANNER_ID = os.environ.get("WEB_PLANNER", "Qwen/Qwen3.5-9B")
GROUNDER_FAMILY = "Qwen3.5-4B"
GPU_SECONDS = int(os.environ.get("WEB_GPU_SECONDS", "20")) # per step (~3 s with 9B); ZeroGPU checks quota against this
def _load_planner():
config = AutoConfig.from_pretrained(PLANNER_ID)
q = getattr(config, "quantization_config", None)
if not q: # the default Qwen3.5-9B: plain bf16 weights
model = AutoModelForImageTextToText.from_pretrained(PLANNER_ID, dtype=torch.bfloat16)
print(f"[web] planner {PLANNER_ID} loaded", flush=True)
return model.eval().to("cuda")
# An FP8 checkpoint (e.g. Qwen3.6-27B-FP8). Keep the FP8 weights: transformers dequantizes to bf16 when the
# device reports a compute capability below 8.9, which ZeroGPU's CUDA emulation may do outside a GPU call.
# The checkpoint also lists MoE-router modules (`mlp.gate`) as unquantized, and transformers matches those
# names as prefixes, so every dense `mlp.gate_proj` would lose its FP8 scales (garbage output).
q["modules_to_not_convert"] = [m for m in q["modules_to_not_convert"]
if not m.endswith((".mlp.gate", ".mlp.shared_expert_gate"))]
try:
fp8_ok = tuple(torch.cuda.get_device_capability()) >= (8, 9)
except Exception:
fp8_ok = False
real = torch.cuda.get_device_capability
if not fp8_ok:
torch.cuda.get_device_capability = lambda *a, **k: (12, 0) # ZeroGPU: RTX Pro 6000 Blackwell
try:
model = AutoModelForImageTextToText.from_pretrained(PLANNER_ID, config=config, dtype=torch.bfloat16)
finally:
torch.cuda.get_device_capability = real
print(f"[web] planner {PLANNER_ID} loaded as FP8 (capability patched: {not fp8_ok})", flush=True)
return model.eval().to("cuda")
PLANNER_PROCESSOR = AutoProcessor.from_pretrained(PLANNER_ID)
PLANNER = _load_planner()
def _plan(system, user_text, image):
messages = [{"role": "system", "content": system},
{"role": "user", "content": [{"type": "image"}, {"type": "text", "text": user_text}]}]
prompt = PLANNER_PROCESSOR.apply_chat_template(messages, tokenize=False, add_generation_prompt=True,
enable_thinking=False)
inputs = PLANNER_PROCESSOR(text=[prompt], images=[image], return_tensors="pt").to(PLANNER.device)
t0 = time.perf_counter()
with torch.inference_mode():
out = PLANNER.generate(**inputs, max_new_tokens=PLANNER_TOKENS, do_sample=False) # greedy, see web_plan
n_in = inputs["input_ids"].shape[1]
print(f"[web] plan {n_in} -> {out.shape[1] - n_in} tokens in {time.perf_counter() - t0:.2f}s", flush=True)
return PLANNER_PROCESSOR.decode(out[0, n_in:], skip_special_tokens=True).strip()
def _ground(instruction, image):
code, _ = deskforge._generate(GROUNDER_FAMILY, "deskforge", instruction, image, 128)
return code
@spaces.GPU(duration=GPU_SECONDS, size="xlarge")
def decide(user_text: str, image):
"""One agent step: plan with the planner (Qwen3.5-9B), then ground each target with DeskForge."""
t0 = time.perf_counter()
out = decide_with(_plan, _ground, user_text, image.convert("RGB"))
print(f"[web] step {time.perf_counter() - t0:.2f}s (plan {out['t_plan']:.2f}s, ground {out['t_ground']:.2f}s)",
flush=True)
return out
@spaces.GPU(duration=240, size="xlarge")
def _warm_up():
"""One step at startup: compiles the Triton kernels and fetches the FP8 kernel before the first visitor."""
image = Image.open(Path(__file__).with_name("examples") / "web_warmup.jpg").convert("RGB")
text = planner_user_text("Open the HTML version of the DeskForge paper.", 4, 30, "", [],
"https://arxiv.org/abs/2610.02320", "DeskForge on arXiv")
for attempt in ("cold", "warm"):
t0 = time.perf_counter()
out = decide_with(_plan, _ground, text, image)
print(f"[web] warm-up ({attempt}) {time.perf_counter() - t0:.2f}s: {out.get('plan') or out.get('error')} "
f"{out.get('codes')}", flush=True)
print(f"[web] GPU memory in use {torch.cuda.memory_allocated() / 2**30:.1f} GiB", flush=True)
if os.environ.get("WEB_WARMUP", "1") == "1":
try:
_warm_up()
except Exception as exc: # the app still starts; the first visitor then pays the compile time
print(f"[web] warm-up failed: {exc!r}", flush=True)