fix: block sweep was running on every Settings panel open, stalling the server

block_sweep() is a 9-block permutation test (2000 trials each by
default) -- heavy enough on CPU that running it synchronously inside
the status route (which fires on every panel mount) blocked the same
event loop ComfyUI's own server runs on. Confirmed live: opening
Settings > Refinement & Taste froze an in-progress generation.

Split the same way trajectory_probe already is: status stays cheap
(no sweep), the sweep moves to its own POST route, run off the event
loop via asyncio.to_thread, triggered only by an explicit "Read"
button instead of automatically on mount.
This commit is contained in:
digital-garbage
2026-09-03 20:41:41 +03:00
parent 9c0dac7076
commit fdc75d1aed
3 changed files with 47 additions and 6 deletions
+1
View File
@@ -200,6 +200,7 @@
// H3 representation steering (Settings ▸ Learning) — per-KEY, unlike the probe above.
reinsStatus: (key) => j("GET", API("/h3_repr_steering") + `?key=${encodeURIComponent(key || "default")}`),
reinsSweep: (key, trials) => j("POST", API("/h3_repr_steering/sweep"), { key: key || "default", trials: trials || 2000 }),
reinsClear: (key) => j("POST", API("/h3_repr_steering/clear"), { key: key || "default" }),
async reinsExport(key) {
const k = key || "default";
+20 -4
View File
@@ -393,7 +393,13 @@
// ── H3 representation steering ──────────────────────────────────────────
// Per-KEY (unlike the probe above, which pools across every key): one sidecar per
// refinement key, so status/export/clear all act on whichever key this project is using.
let reinsState = null, reinsError = "";
//
// The block sweep is a 9-block permutation test -- heavy enough on CPU that running it on
// every panel mount (status was doing this) stalled the event loop ComfyUI's own server
// shares with this one, confirmed live as a generation freezing while the panel was open.
// Split the same way the probe already is: cheap status auto-fetched, the actual sweep
// only on explicit request (the "Read" button below).
let reinsState = null, reinsSweep = null, reinsSweepBusy = false, reinsError = "";
function reinsSweepTable(sweep) {
const entries = Object.entries(sweep || {}).sort((a, b) => a[1].p_value - b[1].p_value);
@@ -439,17 +445,27 @@
if (!window.confirm(`Throw away all REINS data for '${key}'?\n\nThis cannot be `
+ "undone. Download it first if you want to keep it.")) return;
reinsError = "";
try { reinsState = await window.MovieEditorAPI.reinsClear(key); }
try { reinsState = await window.MovieEditorAPI.reinsClear(key); reinsSweep = null; }
catch (e) { reinsError = String(e.message || e); }
paint();
}, { disabled: !nLiked && !nDisliked, danger: true }));
rows.append(actionRow("Read the block sweep",
"Runs a permutation test at every candidate block to see which one actually "
+ "separates liked from disliked. Heavy enough on CPU to notice — not run "
+ "automatically, only when you ask for it.",
reinsSweepBusy ? "Reading…" : "Read", async () => {
reinsSweepBusy = true; reinsError = ""; paint();
try { reinsSweep = await window.MovieEditorAPI.reinsSweep(key, 2000); }
catch (e) { reinsError = String(e.message || e); }
reinsSweepBusy = false; paint();
}, { disabled: reinsSweepBusy || (!nLiked && !nDisliked) }));
box.append(rows);
if (reinsError) box.append(el("div", "sw-hint", reinsError));
const sweepTbl = reinsState && reinsSweepTable(reinsState.sweep);
const sweepTbl = reinsSweep && reinsSweepTable(reinsSweep.sweep);
if (sweepTbl) {
box.append(el("div", "sw-rows-label",
`Block sweep — which of H3's blocks separates liked from disliked (block `
+ `${reinsState.default_block} is the one that actually steers)`));
+ `${reinsState ? reinsState.default_block : "?"} is the one that actually steers)`));
box.append(sweepTbl);
}
};
+26 -2
View File
@@ -2099,9 +2099,14 @@ if web is not None and PromptServer is not None:
return rs
def _repr_steer_state(key):
# Deliberately NOT block_sweep() -- that's a 9-block x trials permutation test, heavy
# enough on CPU to stall the event loop this route shares with ComfyUI's own server
# (confirmed live: opening the panel froze a generation in progress). Same split
# trajectory_probe already uses -- cheap status here, the actual analysis behind its
# own explicitly-triggered route below, so a status fetch (which fires on every panel
# mount, not just on request) can never be the expensive one.
rs = _repr_steer_module()
_direction, n_liked, n_disliked = rs.direction(key)
sweep = rs.block_sweep(key)
return rs, {
"key": key,
"n_liked": n_liked,
@@ -2109,7 +2114,6 @@ if web is not None and PromptServer is not None:
"ready": _direction is not None,
"min_per_group": rs.MIN_PER_GROUP,
"default_block": rs.DEFAULT_BLOCK,
"sweep": {str(b): r for b, r in sweep.items()},
}
@routes.get(UI_PREFIX + "/api/h3_repr_steering")
@@ -2122,6 +2126,26 @@ if web is not None and PromptServer is not None:
return web.json_response({"key": key, "n_liked": 0, "n_disliked": 0,
"ready": False, "error": str(e)})
@routes.post(UI_PREFIX + "/api/h3_repr_steering/sweep")
async def _repr_steer_sweep(req):
try:
body = await req.json()
except Exception:
body = {}
key = str(body.get("key") or "default").strip() or "default"
trials = max(100, min(20000, int(body.get("trials") or 2000)))
rs = _repr_steer_module()
try:
# Off the event loop -- this is the actual CPU-heavy permutation test, and even
# triggered explicitly (not on every panel mount, unlike before) it should not be
# able to stall other requests, including a generation's own progress messages,
# while it runs.
import asyncio
sweep = await asyncio.to_thread(rs.block_sweep, key, trials)
return web.json_response({"key": key, "sweep": {str(b): r for b, r in sweep.items()}})
except Exception as e:
return web.json_response({"key": key, "sweep": {}, "error": str(e)}, status=500)
# Export streams the sidecar FILE directly rather than reconstructing it from rows: this
# module's state already IS the export format (rows keyed by block + weight + prompt_hash),
# unlike the probe's aggregate-across-keys log.