| … | … | @@ -27,7 +27,8 @@ PORT = 8819 |
| 27 | 27 | MODEL = "xandergos/terrain-diffusion-30m" |
| 28 | 28 | VENV = "/storage/projects/code/godot/terrain-diffusion/.venv/bin/python" |
| 29 | 29 | CWD = "/storage/projects/code/godot/terrain-diffusion" |
| 30 | ORIGIN = 21_000_000 # far from anything this session has touched | |
| 30 | ORIGIN = 51_000_000 | |
| 31 | CHECK_AT = 55_555_000 # one fixed region every config is validated on # far from anything this session has touched | |
| 31 | 32 | |
| 32 | 33 | |
| 33 | 34 | def wait_up(proc, timeout=600): |
| … | … | @@ -43,6 +44,38 @@ def wait_up(proc, timeout=600): |
| 43 | 44 | return False |
| 44 | 45 | |
| 45 | 46 | |
| 47 | REF = {} # reference tile from a config known to be correct | |
| 48 | ||
| 49 | ||
| 50 | def tile_data(i1, j1, n): | |
| 51 | """Elevation + climate, for CORRECTNESS checking.""" | |
| 52 | import numpy as np | |
| 53 | u = (f"http://127.0.0.1:{PORT}/terrain?i1={i1}&j1={j1}" | |
| 54 | f"&i2={i1+n}&j2={j1+n}&scale=1") | |
| 55 | with urllib.request.urlopen(u, timeout=3600) as r: | |
| 56 | h = int(r.headers["X-Height"]); w = int(r.headers["X-Width"]); b = r.read() | |
| 57 | e = np.frombuffer(b[:h*w*2], dtype="<i2").astype(float).reshape(h, w) | |
| 58 | c = np.frombuffer(b[h*w*2:], dtype="<f4").reshape(h, w, 4) if len(b) > h*w*2 else None | |
| 59 | return e, c | |
| 60 | ||
| 61 | ||
| 62 | def correctness(i1, j1, n=256): | |
| 63 | """A config that returns ZEROS is infinitely fast. This is the gate that was | |
| 64 | missing from the first three runs, and its absence shipped a 14x result that | |
| 65 | was 70% blank tile.""" | |
| 66 | import numpy as np | |
| 67 | e, c = tile_data(i1, j1, n) | |
| 68 | zeros = float((e == 0).mean()) | |
| 69 | nans = int(np.isnan(c).sum()) if c is not None else 0 | |
| 70 | corr = float("nan") | |
| 71 | if "e" in REF and REF["e"].shape == e.shape: | |
| 72 | a, b_ = REF["e"].ravel(), e.ravel() | |
| 73 | if a.std() > 0 and b_.std() > 0: | |
| 74 | corr = float(np.corrcoef(a, b_)[0, 1]) | |
| 75 | ok = zeros < 0.02 and nans == 0 and (corr != corr or corr > 0.98) | |
| 76 | return dict(zeros=zeros, nans=nans, corr=corr, ok=ok) | |
| 77 | ||
| 78 | ||
| 46 | 79 | def tile(i1, j1, n): |
| 47 | 80 | u = (f"http://127.0.0.1:{PORT}/terrain?i1={i1}&j1={j1}" |
| 48 | 81 | f"&i2={i1+n}&j2={j1+n}&scale=1") |
| … | … | @@ -103,11 +136,17 @@ def run(label, extra, n=512, reps=3, offset=0): |
| 103 | 136 | warm = tile(base, base, 256) # pay compile / lazy init here |
| 104 | 137 | ts = [tile(base + 20_000*(k+1), base + 20_000*(k+1), n) for k in range(reps)] |
| 105 | 138 | mem = gpu_mem_of(p.pid) |
| 139 | # ALWAYS the same coordinates, so every config is checked on one region. | |
| 140 | ck = correctness(CHECK_AT, CHECK_AT) | |
| 141 | if not REF and ck["zeros"] < 0.02 and ck["nans"] == 0: | |
| 142 | REF["e"], _ = tile_data(CHECK_AT, CHECK_AT, 256) | |
| 106 | 143 | best, avg = min(ts), sum(ts)/len(ts) |
| 107 | 144 | km2 = (n*30/1000)**2 |
| 108 | print(f" {label:<28} warm {warm:6.1f}s tile {avg:6.2f}s (best {best:5.2f})" | |
| 109 | f" {km2/avg:6.1f} km2/s {mem:5d} MiB") | |
| 110 | return dict(label=label, warm=warm, avg=avg, best=best, mem=mem) | |
| 145 | flag = "ok " if ck["ok"] else "BAD " | |
| 146 | print(f" {label:<26} {flag} tile {avg:6.2f}s {km2/avg:6.1f} km2/s " | |
| 147 | f"{mem:5d} MiB zeros {100*ck['zeros']:5.1f}% nan {ck['nans']:6d} " | |
| 148 | f"corr {ck['corr']:.4f}") | |
| 149 | return dict(label=label, avg=avg, best=best, mem=mem, **ck) | |
| 111 | 150 | finally: |
| 112 | 151 | try: os.killpg(os.getpgid(p.pid), signal.SIGTERM) |
| 113 | 152 | except Exception: pass |
| … | … | @@ -119,28 +158,31 @@ BASELINE_PIDS = set(gpu_apps().keys()) # the always-on service, not ours to wa |
| 119 | 158 | |
| 120 | 159 | if __name__ == "__main__": |
| 121 | 160 | print(f"512 tile = 15.36 km across = 236 km2. Fresh coordinates every time.\n") |
| 122 | print(f" {'config':<28} {'model load':>11} {'per tile':>22} {'throughput':>12} {'VRAM':>9}") | |
| 161 | print(f" {'config':<26} {'ok?':<4} {'per tile':>12} {'throughput':>11} " | |
| 162 | f"{'VRAM':>9} correctness") | |
| 123 | 163 | rows = [] |
| 164 | os.environ.pop("TD_COMPILE_MODE", None) | |
| 124 | 165 | F16 = ["--no-compile", "--dtype", "fp16", "--kwarg", "onestep_latent=True"] |
| 125 | D = lambda n: F16 + ["--kwarg", f"decoder_tile_size={n}"] | |
| 126 | C = lambda n: ["--compile", "--dtype", "fp16", "--kwarg", "onestep_latent=True", | |
| 127 | "--kwarg", f"decoder_tile_size={n}"] | |
| 128 | 166 | for i, (label, extra) in enumerate([ |
| 129 | ("decoder_tile 256 (winner)", D(256)), | |
| 130 | ("decoder_tile 192", D(192)), | |
| 131 | ("decoder_tile 128", D(128)), | |
| 132 | ("decoder_tile 64", D(64)), | |
| 133 | ("256 + compile default", C(256)), | |
| 134 | ("256 + compile reduce-ovh", C(256)), | |
| 167 | ("fp16+onestep (reference)", F16), # known good | |
| 168 | (" + decoder_tile 256", F16 + ["--kwarg", "decoder_tile_size=256"]), | |
| 169 | (" + decoder_tile 128", F16 + ["--kwarg", "decoder_tile_size=128"]), | |
| 170 | (" + compile only", ["--compile", "--dtype", "fp16", | |
| 171 | "--kwarg", "onestep_latent=True"]), | |
| 172 | (" + compile + dtile 256", ["--compile", "--dtype", "fp16", | |
| 173 | "--kwarg", "onestep_latent=True", | |
| 174 | "--kwarg", "decoder_tile_size=256"]), | |
| 175 | ("fp32 plain (control)", ["--no-compile"]), | |
| 135 | 176 | ]): |
| 136 | if "reduce-ovh" in label: | |
| 137 | os.environ["TD_COMPILE_MODE"] = "reduce-overhead" | |
| 138 | else: | |
| 139 | os.environ.pop("TD_COMPILE_MODE", None) | |
| 140 | 177 | r = run(label, extra, offset=i) |
| 141 | 178 | if r: rows.append(r) |
| 142 | 179 | if rows: |
| 143 | 180 | b = rows[0]["avg"] |
| 144 | print("\n speedup vs baseline:") | |
| 181 | print("\n speedup vs the reference config — ONLY meaningful where ok:") | |
| 145 | 182 | for r in rows: |
| 146 | print(f" {r['label']:<28} {b/r['avg']:5.2f}x (VRAM {r['mem']} MiB)") | |
| 183 | print(f" {r['label']:<26} {'ok ' if r['ok'] else 'BAD'} " | |
| 184 | f"{b/r['avg']:5.2f}x VRAM {r['mem']:5d} MiB") | |
| 185 | bad = [r["label"] for r in rows if not r["ok"]] | |
| 186 | if bad: | |
| 187 | print("\n DISCARD (wrong output, so the timing is meaningless): " | |
| 188 | + ", ".join(x.strip() for x in bad)) |
| … | … | @@ -100,8 +100,11 @@ kbd{font-family:"IBM Plex Mono",monospace;background:#0e1216;border:1px solid va |
| 100 | 100 | <label>Diffusion i1 (px)<input type="number" id="i1" value="0" step="256"></label> |
| 101 | 101 | <label>Diffusion j1 (px)<input type="number" id="j1" value="2048" step="256"></label> |
| 102 | 102 | <label>Diffusion seed<input type="number" id="seed" value="1234" step="1"> |
| 103 | <span class="d">a new seed is a whole new world. it rebuilds the pipeline and | |
| 104 | clears every cached tile, so it is much slower than moving i1/j1.</span></label> | |
| 103 | <span class="d">a whole new world, and it flushes every cached tile — so it | |
| 104 | is applied only on Enter or the button below, never by scrolling or | |
| 105 | arrow keys. Active seed is shown next to it.</span></label> | |
| 106 | <div class="row"><button id="applyseed">Apply seed</button> | |
| 107 | <span class="v" id="activeseed" style="align-self:center">1234</span></div> | |
| 105 | 108 | <div class="row"> |
| 106 | 109 | <button id="newloc">New location</button> |
| 107 | 110 | <button id="newseed">New seed</button> |
| … | … | @@ -153,6 +156,10 @@ kbd{font-family:"IBM Plex Mono",monospace;background:#0e1216;border:1px solid va |
| 153 | 156 | |
| 154 | 157 | <main> |
| 155 | 158 | <div class="bar"> |
| 159 | <label style="display:flex;gap:5px;align-items:center;font-size:11.5px"> | |
| 160 | <input type="checkbox" id="lockramp" style="width:auto"> lock ramp | |
| 161 | <span id="ramptxt" style="font-family:'IBM Plex Mono',monospace;font-size:10px;color:var(--dim)"></span> | |
| 162 | </label> | |
| 156 | 163 | <div class="views" id="modes"> |
| 157 | 164 | <button data-m="2d" aria-pressed="true">2D</button> |
| 158 | 165 | <button data-m="3d" aria-pressed="false">3D</button> |
| … | … | @@ -335,6 +342,8 @@ function updateDerived() { |
| 335 | 342 | } |
| 336 | 343 | let view = "shaded-height"; |
| 337 | 344 | let mode = "2d"; |
| 345 | let RAMP = null; // pinned colour ramp [lo, hi], null = fit per tile | |
| 346 | let ACTIVE_SEED = "1234"; // what the server is actually on; the field is a draft | |
| 338 | 347 | |
| 339 | 348 | for (const [grp, items] of Object.entries(GROUPS)) { |
| 340 | 349 | const host = $("grp_" + grp); |
| … | … | @@ -379,6 +388,12 @@ async function load(which, url, imgId, stId, busyId) { |
| 379 | 388 | if (old.startsWith("blob:")) URL.revokeObjectURL(old); |
| 380 | 389 | const h = r.headers; |
| 381 | 390 | const ms = (performance.now() - t0).toFixed(0); |
| 391 | // Capture the ramp the first time it is locked, so both panels and every | |
| 392 | // subsequent pan use the SAME scale. | |
| 393 | if ($("lockramp").checked && !RAMP && h.get("X-Ramp")) { | |
| 394 | RAMP = h.get("X-Ramp").split(",").map(Number); | |
| 395 | $("ramptxt").textContent = `${RAMP[0].toFixed(0)} .. ${RAMP[1].toFixed(0)} m`; | |
| 396 | } | |
| 382 | 397 | let s = `min ${h.get("X-Min")} max ${h.get("X-Max")} ` + |
| 383 | 398 | `mean ${h.get("X-Mean")} sd ${h.get("X-Sd")} m ${ms} ms`; |
| 384 | 399 | if (h.get("X-Climate")) s += `\n${h.get("X-Climate")}`; |
| … | … | @@ -546,12 +561,15 @@ function render() { |
| 546 | 561 | syncLabels(); |
| 547 | 562 | const p = erosionQuery(); |
| 548 | 563 | const q = new URLSearchParams({n: $("n").value, i1: $("i1").value, |
| 549 | j1: $("j1").value, seed: $("seed").value}); | |
| 564 | j1: $("j1").value, seed: ACTIVE_SEED}); | |
| 550 | 565 | if ($("erode").checked) { |
| 551 | 566 | q.set("erode", "1"); |
| 552 | 567 | for (const [key] of GROUPS.erode) q.set(key, $(key).value); |
| 553 | 568 | } |
| 554 | 569 | |
| 570 | if (RAMP && $("lockramp").checked) { | |
| 571 | for (const u of [p, q]) { u.set("vmin", RAMP[0]); u.set("vmax", RAMP[1]); } | |
| 572 | } | |
| 555 | 573 | if (mode === "3d") { |
| 556 | 574 | load3d("/api/erosion.bin?" + p, "cv_e", "st_e", "busy_e"); |
| 557 | 575 | load3d("/api/diffusion.bin?" + q, "cv_d", "st_d", "busy_d"); |
| … | … | @@ -571,9 +589,28 @@ function schedule() { |
| 571 | 589 | } |
| 572 | 590 | |
| 573 | 591 | $("erode").addEventListener("change", render); |
| 592 | $("lockramp").addEventListener("change", () => { | |
| 593 | if (!$("lockramp").checked) { RAMP = null; $("ramptxt").textContent = ""; } | |
| 594 | render(); | |
| 595 | }); | |
| 574 | 596 | document.addEventListener("input", e => { |
| 597 | if (e.target.id === "seed") return; // deliberate only — see below | |
| 575 | 598 | if (e.target.type === "range" || e.target.type === "number") schedule(); |
| 576 | 599 | }); |
| 600 | // Wheel over a number input changes its value in most browsers. For the seed | |
| 601 | // that means silently regenerating the world, so refuse the gesture outright. | |
| 602 | $("seed").addEventListener("wheel", e => { e.preventDefault(); $("seed").blur(); }, | |
| 603 | {passive: false}); | |
| 604 | $("seed").addEventListener("keydown", e => { | |
| 605 | if (e.key === "ArrowUp" || e.key === "ArrowDown") e.preventDefault(); | |
| 606 | if (e.key === "Enter") { e.preventDefault(); applySeed(); } | |
| 607 | }); | |
| 608 | function applySeed() { | |
| 609 | ACTIVE_SEED = $("seed").value; | |
| 610 | $("activeseed").textContent = ACTIVE_SEED; | |
| 611 | render(); | |
| 612 | } | |
| 613 | $("applyseed").onclick = applySeed; | |
| 577 | 614 | $("render").onclick = render; |
| 578 | 615 | // Two different ways to get different terrain, and they cost very differently. |
| 579 | 616 | $("newloc").onclick = () => { |
| … | … | @@ -583,7 +620,7 @@ $("newloc").onclick = () => { |
| 583 | 620 | }; |
| 584 | 621 | $("newseed").onclick = () => { |
| 585 | 622 | $("seed").value = Math.floor(Math.random() * 1e9); |
| 586 | render(); | |
| 623 | applySeed(); | |
| 587 | 624 | }; |
| 588 | 625 | $("reset").onclick = () => { |
| 589 | 626 | for (const items of Object.values(GROUPS)) |
| … | … | @@ -257,7 +257,18 @@ def to_png(rgb): |
| 257 | 257 | |
| 258 | 258 | |
| 259 | 259 | def colourise(h, mpp, view, vmin=None, vmax=None): |
| 260 | """view: shade | height | slope | shaded-height""" | |
| 260 | """view: shade | height | slope | shaded-height | |
| 261 | ||
| 262 | vmin/vmax PIN the ramp. Without them the ramp is fitted to this tile, so | |
| 263 | panning one step re-normalises and the colours jump even though the terrain | |
| 264 | is identical -- which is exactly what terrain-diffusion issue #19 reported | |
| 265 | as "tile inconsistencies" before the author identified it as the colour map. | |
| 266 | An instrument that changes its scale between readings is not an instrument. | |
| 267 | """ | |
| 268 | if vmin in ("", None): vmin = None | |
| 269 | if vmax in ("", None): vmax = None | |
| 270 | vmin = float(vmin) if vmin is not None else None | |
| 271 | vmax = float(vmax) if vmax is not None else None | |
| 261 | 272 | finite = h[np.isfinite(h)] |
| 262 | 273 | lo = float(vmin) if vmin is not None else (float(finite.min()) if finite.size else 0.0) |
| 263 | 274 | hi = float(vmax) if vmax is not None else (float(finite.max()) if finite.size else 1.0) |
| … | … | @@ -314,7 +325,8 @@ def api_erosion(): |
| 314 | 325 | steps = int(_clamp(float(a.get("steps", 256)), 32, 1024)) |
| 315 | 326 | knobs = parse_knobs(a) |
| 316 | 327 | h = render_erosion(ox, oz, n, mpp, knobs, steps, 40) |
| 317 | png, lo, hi = colourise(h, mpp, a.get("view", "shaded-height")) | |
| 328 | png, lo, hi = colourise(h, mpp, a.get("view", "shaded-height"), | |
| 329 | a.get("vmin"), a.get("vmax")) | |
| 318 | 330 | return stats_headers(Response(png, mimetype="image/png"), h, lo, hi, |
| 319 | 331 | {"X-Source": "erosion_render"}) |
| 320 | 332 | except Exception as e: |
| … | … | @@ -336,7 +348,8 @@ def api_diffusion(): |
| 336 | 348 | h, _ridge = erode_field(h, mpp_d, a) |
| 337 | 349 | extra["X-Source"] = "terrain-diffusion + erosion_filter" |
| 338 | 350 | extra["X-Mpp"] = repr(mpp_d) |
| 339 | png, lo, hi = colourise(h, mpp_d, a.get("view", "shaded-height")) | |
| 351 | png, lo, hi = colourise(h, mpp_d, a.get("view", "shaded-height"), | |
| 352 | a.get("vmin"), a.get("vmax")) | |
| 340 | 353 | if climate is not None: |
| 341 | 354 | extra["X-Climate"] = (f"{climate[...,0].mean():.1f}C," |
| 342 | 355 | f"{climate[...,1].mean():.0f}," |