← Commit history

Pyre: CUDA backend, placement manager, adaptive training, offline dashboard

lennoxrose committed on Oct 8, 2026 at 23:57 UTC

2531e3fparentb121127

82 files changed+5858−111

CUDA backend (NVIDIA only, optional): tiled fp64 matmul, ILP chaos kernel and an
integer-only Monte Carlo kernel in pyre/src/C/backends/cuda/*.cu, built when
pyre/cuda.mk finds nvcc; Vulkan now prefers a discrete GPU over an integrated one.

Manager: every kernel call is placed on CPU, CUDA or Vulkan from what this machine
has actually been seen to do at that workload size, learned online with no
calibration pass. A call never waits for a GPU to start (background warm-up while the
CPU runs). ash_gpu_plan_* queries expose the decision; PYRE_TRACE=1 traces it.

pyre tool (bin/extensions/pyre/pyre): info, plan, check, forget, train with a load
scale, and adaptive learning (--auto: grade on fresh held-out sizes, practise where
decisions are weakest, keep a round only if it helped) plus --forever retraining.

pyre dashboard: offline local web UI at http://127.0.0.1:1198 (React + TypeScript in
pyre/src/web, built with pnpm to bin/extensions/pyre/web). Dashboard, Training
(recommended / custom / forever), Performance, Devices and History pages, tendencies
and forecasts, live refresh without reloading.

Pyre output now builds to bin/extensions/pyre/ and the library is libpyre.a/.so; kiln's
--link=shared rpath and soname follow.

.gitignoremodified

+5−2
@@ -15,7 +15,8 @@ ashvm/benchmarks/native_jit
1515 pyre/benchmarks/*/run_benchmark
1616 pyre/tests/matrix_multiply_test
1717 pyre/tests/monte_carlo_test
18-pyre/lib/
18+pyre/tests/cuda_test
19+pyre/src/web/node_modules/
1920 # Generated from src/shaders/*.comp by glslc -mfmt=c (pyre/Makefile) --
2021 # the .comp source is tracked, the embedded-SPIR-V .h is not, same as
2122 # any other object code.
@@ -37,4 +38,6 @@ skills-lock.json
3738 goals.md
3839 rundown.md
3940 missing.md
40-test.ash
\ No newline at end of file
41+test.ash
42+# nvcc dependency files list this machine's absolute CUDA install paths; generated by the build, never committed
43+pyre/src/D/backends/cuda/*.d

CHANGELOG.mdmodified

+36−0
@@ -1,3 +1,39 @@
1+## 2026-10-09 (pyre: adaptive learning, forever retraining, recommended session, tendencies, live refresh)
2+- **Adaptive learning** (`pyre train --auto`, `pyre/tools/train.c`): instead of a fixed grid it learns the way a model does. Each round it measures real times on FRESH held-out sizes (a different set every round, so it cannot overfit one test set), finds where the manager's decisions are weakest (wrong pick, slow pick, bad prediction), practises exactly there (the neighbouring sizes on every target), and grades again. The previous and the candidate profile are scored against the SAME measured times, so timing noise cannot make a round look better or worse than it is; a round is kept only if it did not make decisions worse, otherwise reverted. Practice per round decays like a learning rate, and it stops by itself once two kept rounds in a row improve by under 0.3 points (`--rounds N` caps it, default 6). From an empty profile it seeds with a grid first. Example, empty profile: time lost 20.1% -> 6.4% and prediction error 67% -> 34% in 3.7 s.
3+- **Forever retraining** (`--forever`, `--round-pause S`): the same loop with no end; it keeps re-checking and re-practising (which also notices drift: a hotter GPU, a driver update) until stopped (Ctrl+C or Stop in the dashboard, both finish tidily), saving after every kept round so the dashboard sees learning as it happens.
4+- The plain grid mode now measures the held-out times once and scores before and after against them (cheaper and fairer than measuring twice). `train.c` split out of `pyre.c`; `json_out.h` shares the `--json` helpers.
5+- **Dashboard:** a **Recommended** session (Training page): picks depth, kernels and targets from what is thin or weak right now and says why, then runs `--auto`; **Forever** mode with kernels/targets/loads and the rest between rounds; **Custom** keeps every option. The live view has tabs (Rounds, Probe timings, Self-check results); the Rounds tab plots time lost per round, smoothed, with a dashed forecast.
6+- **Tendency:** KPI numbers and both gauges show which way they are heading and where a straight line through the recent sessions puts the next value ("Improving, next ~ 95%"; needs 3+ sessions and is labelled an estimate); History draws dashed forecast lines; Performance shows how the GPU-vs-CPU advantage moves with size.
7+- **Live refresh without reloading the page:** the server reports when the profile or history changed (`/api/stamp`, one cheap stat call) and every page refetches only then, so training from the dashboard, a terminal, or any program that learned something shows up within about 1.5 s. A header link ("Forever retraining running, round N") follows you to other pages while a session runs. Long sessions are bounded: the server keeps the newest events, the page keeps recent probes and checks.
8+- The logo (`logo.svg` from the repo root) replaces the blue box top-left; linear chart axes use round-number ticks.
9+
10+## 2026-10-09 (pyre: `pyre dashboard`, an offline web UI to analyse and run training)
11+- **`pyre dashboard`** serves a local, fully offline web UI at **http://127.0.0.1:1198** (a=1, s=19, h=8: "ash" by alphabet position; fixed, never hops to another port, `--port N` overrides, `--no-open` skips launching the browser). Binds 127.0.0.1 only, rejects wrong Host headers and cross-site POSTs, serves static files with path-traversal blocked. Server: `pyre/tools/dashboard.c` (single-threaded poll loop, no dependencies).
12+- **UI source in `pyre/src/web/`** (React + TypeScript/TSX, Vite, pnpm, plain hand-written CSS, light and dark theme, no CDN, no external requests); **built output goes to `bin/extensions/pyre/web/`**, next to the `pyre` executable. `make` in `pyre/` builds it (`make web` alone also works); Node and pnpm are needed at build time only. Without pnpm, or offline before the first `pnpm install`, Pyre still builds and only the dashboard UI is skipped with a warning.
13+- **Look and layout:** bordered tiles with centred bold titles, like a classic corporate KPI dashboard, light and dark. Five pages: **Dashboard** (analytics only, scaled to fill the window: speed-by-size chart with a kernel switch and winner shading, "Right picks" and "Time lost" KPI numbers, two heat-map matrices, Devices and Sessions tables, two needle gauges), **Training** (start a session with every `pyre train` option as plain buttons and number boxes, plus a live view: phase, probes, elapsed, before/after scores, verdict, probe chart, self-check results), **Performance** (one chart per kernel, the matrices, and where each GPU starts to pay off), **Devices** (hardware, what has been learned per kernel and target, profile file), **History** (trend chart, session durations, full session table). No sliders anywhere; no footer; no connection pill (only an "unreachable" notice if the server stops answering). Charts, gauges and matrices measure their tile and draw to fit it, tables are fixed-layout with ellipsis, so nothing spills out of a tile; verified by measuring every tile on all pages at 1920x950, 1673x860 and 1366x680 in light and dark (no tile overflow, no page scrolling), and by driving a real training session through the UI. Narrow windows stack into a scrolling column. `?theme=dark|light` and `#training`-style links work.
14+- Sessions are child `pyre <cmd> --json` processes, so the dashboard runs exactly the command-line code. New `--json` output mode (one event per line) on `train`, `check`, `info`, `forget`; every finished train/check is appended to `history.jsonl` beside the profile (also when run from the terminal).
15+- Fixed in the self-check score: a pick that had been skipped as too slow was charged 1e30 s (absurd percentages in history); it is now charged twice `--max-probe`.
16+
17+## 2026-10-09 (pyre: the manager -- places every call by what this machine was seen to do, `pyre` developer tool)
18+- **Manager** (`pyre/src/C/runtime/manager.c`, `cost_model.c`, `targets.c`; `device.c` now only describes each call's workload): every `ash_gpu_matrix_multiply` / `chaos_iterate` / `monte_carlo_risk` call is placed on CPU, CUDA or Vulkan from a table of measured seconds-per-work-unit by workload size (powers of two), then timed and fed back. No calibration pass and no machine-specific number ships in a program: a fresh machine starts empty and learns from the program's own work.
19+ - **Zero added delay:** a call never waits for a GPU to start. Until a GPU is up the CPU runs the work; when work is heavy (>= 1e8 units, or 0.25 s of Pyre CPU time used) a background thread brings up the best GPU (CUDA, else Vulkan) and preloads its kernels meanwhile.
20+ - A GPU is chosen only where it was measured, or can be inferred from a smaller measured size, to beat the CPU. An unmeasured size gets one real try, and only when the CPU would take >= 50 ms. Seen on this machine: call 0 on CPU 0.20 s while CUDA started, call 1 tried CUDA, then CUDA at 0.064 s for the rest (3x).
21+ - Learned rates persist to `~/.cache/pyre/profile.txt` (or `$PYRE_PROFILE`) as an optimisation for the next run on the same machine only; discarded when the thread count, CUDA build or GPU name differ. A one-call process learns nothing about the GPU (it exits before the GPU is up); `pyre train` pre-teaches.
22+ - New public queries `ash_gpu_plan_matrix_multiply/chaos_iterate/monte_carlo_risk` ("would you take this over, and how long?"). `PYRE_TRACE=1` prints one line per call with every target's prediction and the real time.
23+- **`pyre` developer tool** (`bin/extensions/pyre/pyre`, `pyre/tools/pyre.c`): `help [command]`, `info`, `plan ...`, `check`, `forget`, and `train` with `--level quick|normal|deep`, `--step`, `--repeats`, `--max-work`, `--max-probe`, `--max-mem`, `--ops`, `--targets`, `--reset`, `--quiet`, `--no-verify`, and a load scale: `--gpu-load PCT`, `--cpu-load PCT` (duty cycle; CPU also limits threads), `--pause MS`. Training grades itself on held-out sizes before and after (every target really runs them; score = share of time lost to picking a slower target, plus prediction error) and discards the training if decisions got worse. Example: empty profile 1027% time lost, after `train --level quick` 5.7%.
24+- The earlier fixed "always prefer the GPU" dispatch is gone, and so is the measured-in-advance calibration idea (rejected: it would tune an exe to its developer's machine and add start-up delay).
25+
26+## 2026-10-09 (pyre: NVIDIA-only CUDA backend, libpyre rename, bin/extensions/pyre)
27+- **CUDA backend** (`pyre/src/C/backends/cuda/*.cu`, header `H/backends/cuda/`): NVIDIA only, CUDA runtime API with static cudart (only the driver's libcuda is needed at run time). Built only when `pyre/cuda.mk` finds `nvcc`; without it Pyre is Vulkan + CPU exactly as before. Detection order is CUDA, then Vulkan, then CPU; a CUDA call that fails falls through to the next backend.
28+ - `matrix_multiply.cu`: 64x64 tiles, 4x4 outputs per thread in registers, A stored transposed with an odd stride (conflict-free), B read as `double2`, next k-slice prefetched into registers (one `__syncthreads` per slice), bounds-checked for any N, C split by row bands over 4 streams with the A upload and C download overlapped. 2000x2000 on an RTX 3050 Laptop: 0.155s (103 GFLOP/s, about the card's fp64 peak) vs 0.704s on the CPU backend.
29+ - `chaos.cu`: 4 independent elements per thread to overlap the dependent fp64 sqrt chains, coalesced access, chunked over 4 streams. `__dmul_rn`/`__dadd_rn` stop nvcc fusing into FMA, so results stay bit-identical to the CPU backend (benchmark checks, diff 0). 1.9s vs 3.8s CPU.
30+ - `monte_carlo.cu`: no fp64 in the loop: `(u64)s1*s2 > floor(threshold*2^64)` is the exact integer form of the CPU's double test. One atomic per block. 1e9 iterations: 4 ms vs 149 ms CPU, same risky count as the double version.
31+ - `ash_gpu_monte_carlo_risk` now dispatches (CUDA, else CPU); the CPU body is `ash_gpu_cpu_monte_carlo_risk`.
32+- **Vulkan device pick:** prefers a discrete GPU over an integrated one (hybrid laptops list the iGPU first).
33+- **Build output:** `libpyre.a` / `libpyre.so` (was `libashgpu.*`) now go to `bin/extensions/pyre/`; kiln's `--link=shared` rpath names that directory and `KILN_ASHGPU_SONAME` is `libpyre.so`.
34+- New `pyre/tests/cuda_test.c` (edge sizes vs the CPU backend).
35+- Known cost: first CUDA use in a process takes ~0.2s (context creation), so a one-shot short job can be slower end to end than the CPU -- input for the "Smart dispatch" task.
36+
137 ## 2026-10-08 (pyre: the GPU is real now -- one loss, one 34x win, and the dispatcher gap that exposes)
238 Follow-up to the kiln-integration entry below, same day: this sandbox turns out to be WSL2 on the user's actual machine, which has GPU passthrough infrastructure (`/dev/dxg`, Microsoft's D3D12 translation libraries) already present but no Vulkan driver installed. Installing `vulkan-dzn` (Mesa's Vulkan-over-D3D12 driver) plus `mesa` made `vulkaninfo` and `ash_gpu_detect()` both find the real **AMD Radeon RX 9070 XT** ("Microsoft Direct3D12 (AMD Radeon RX 9070 XT)", driver `DRIVER_ID_MESA_DOZEN`, `shaderFloat64 = true`) -- the Vulkan path written two entries ago has now actually run, for the first time ever.
339

ashvm/Makefilemodified

+9−7
@@ -1,21 +1,23 @@
11 CC = gcc
22 .DEFAULT_GOAL := all
3-# -I../pyre/src, -fopenmp and the ../pyre/lib/libashgpu.a/-ldl/-lvulkan/-lstdc++
3+# -I../pyre/src, -fopenmp and the ../bin/extensions/pyre/libpyre.a/-ldl/-lvulkan/-lstdc++
44 # below: matrix_mul (src/builtins/C/matrix.c) links against Pyre's CPU +
55 # Vulkan backends, STATICALLY (the explicit .a path, not -lashgpu --
6-# pyre/lib/ also holds libashgpu.so for kiln's --link=shared to find at
6+# bin/extensions/pyre/ also holds libpyre.so for kiln's --link=shared to find at
77 # runtime, and a bare -lashgpu would silently prefer that .so over the
8-# .a, turning ashvm from self-contained into "needs libashgpu.so
8+# .a, turning ashvm from self-contained into "needs libpyre.so
99 # findable at runtime with no rpath to say where" -- found exactly that
1010 # way when pyre/Makefile grew the .so target). ashvm already links
1111 # against libc/libm normally, unlike kiln's hand-rolled, zero-dependency
1212 # ELF/PE output, so this one extra build-time dependency doesn't break
1313 # any existing promise (see ideas/assigned.md). -lstdc++ because
14-# libashgpu.a's Vulkan backend is C++ (RAII) internally, even though
14+# libpyre.a's Vulkan backend is C++ (RAII) internally, even though
1515 # ash_gpu.h itself is a plain C ABI -- plain gcc as the final linker
1616 # needs that spelled out, g++ would do it automatically.
17+include ../pyre/cuda.mk
18+
1719 CFLAGS = -Wall -Wextra -std=c11 -O2 -Isrc -I../pyre/src -fopenmp
18-LDFLAGS = -lm ../pyre/lib/libashgpu.a -fopenmp -ldl -lvulkan -lstdc++
20+LDFLAGS = -lm ../bin/extensions/pyre/libpyre.a -fopenmp -ldl -lvulkan -lstdc++ $(CUDA_LIBS)
1921
2022 ASHVM_SRC = \
2123 src/app/C/main.c \
@@ -75,10 +77,10 @@ BIN = ../bin/ashvm
7577
7678 all: $(BIN)
7779
78-../pyre/lib/libashgpu.a:
80+../bin/extensions/pyre/libpyre.a:
7981 $(MAKE) -C ../pyre lib
8082
81-$(BIN): $(ASHVM_OBJ) ../pyre/lib/libashgpu.a
83+$(BIN): $(ASHVM_OBJ) ../bin/extensions/pyre/libpyre.a
8284 mkdir -p ../bin
8385 $(CC) $(CFLAGS) -o $(BIN) $(ASHVM_OBJ) $(LDFLAGS)
8486

ashvm/src/builtins/C/matrix.cmodified

+1−1
@@ -14,7 +14,7 @@
1414 // dependency here isn't the architecture problem it would be for kiln --
1515 // see assigned.md for why kiln's own matrix_mul (codegen/C/collections/matrix_mul.c)
1616 // instead ports the same loop-order technique natively rather than
17-// linking against libashgpu.
17+// linking against libpyre.
1818 //
1919 // Below ASH_GPU_MIN_N, a plain local loop skips Pyre entirely: OpenMP's
2020 // own thread-launch overhead would dominate a handful of multiply-adds,

ideas/assigned.mdmodified

+5−5
@@ -34,7 +34,7 @@ this file directly if any rule below doesn't match intent.
3434 constructs.
3535 4. **Kiln integration** -- "Kiln emits calls to ash-gpu for suitable
3636 workloads," *only after the runtime is solid*. **Done**: kiln's
37- generated code really does call into `libashgpu.so` through a GOT
37+ generated code really does call into `libpyre.so` through a GOT
3838 slot now -- see "kiln" below for how. Found and fixed one real,
3939 previously-invisible bug this way (kiln's own `exit`-syscall choice),
4040 which is exactly the value of actually running this instead of
@@ -52,7 +52,7 @@ this file directly if any rule below doesn't match intent.
5252 program, so one more build-time dependency (Pyre's CPU backend needs
5353 OpenMP/libgomp, `runtime/device.c` needs libdl for its detection probe)
5454 doesn't break any promise ashvm makes. ashvm's own Makefile links
55-`../pyre/lib/libashgpu.a` unconditionally -- there's no "Pyre not
55+`../pyre/lib/libpyre.a` unconditionally -- there's no "Pyre not
5656 installed" fallback path today; if that matters later (packaging ashvm
5757 without Pyre), add a `HAVE_PYRE` build switch then, not speculatively now.
5858
@@ -82,8 +82,8 @@ How: `--link=shared` already had a real, working example of exactly
8282 this shape -- it emits a dynamically-linked ELF with a `PT_INTERP`, so
8383 the real system `ld.so` resolves calls into a second binary
8484 (`libkilnrt.so`) at load time (`elf/C/elf_dynamic.c`, `elf/C/elf_so_writer.c`).
85-Extended that same machinery to also declare `libashgpu.so` as a SECOND
86-needed library: one more `DT_NEEDED`, and `libashgpu.so`'s one export
85+Extended that same machinery to also declare `libpyre.so` as a SECOND
86+needed library: one more `DT_NEEDED`, and `libpyre.so`'s one export
8787 (`ash_gpu_matrix_multiply`) folded into the SAME combined hash/dynsym/GOT/rela
8888 table as kiln's own runtime imports (`KILN_DYN_TOTAL_IMPORTS`,
8989 `elf/H/elf_dynamic.h`) rather than a second parallel set of tables --
@@ -150,7 +150,7 @@ having more than two data points.
150150
151151 - ashvm: always, unconditionally, once `N >= ASH_GPU_MIN_N` -- no
152152 separate opt-in, because ashvm never promised zero dependencies.
153-- kiln: always under `--link=shared` (once `libashgpu.so` is installed
153+- kiln: always under `--link=shared` (once `libpyre.so` is installed
154154 at `KILN_RPATH`, same as `libkilnrt.so`); never under the default
155155 `--link=static`, which keeps working with nothing installed beyond
156156 kiln itself, unconditionally, forever.

kiln/Makefilemodified

+2−1
@@ -1,6 +1,7 @@
11 CC = gcc
22 .DEFAULT_GOAL := all
3-CFLAGS = -Wall -Wextra -std=c11 -O2 -Isrc
3+KILN_RPATH_LIST = $(abspath ../bin/extensions/pyre):/usr/local/lib/kiln
4+CFLAGS = -Wall -Wextra -std=c11 -O2 -Isrc -DKILN_RPATH='"$(KILN_RPATH_LIST)"'
45 LDFLAGS =
56
67 KILN_SRC = \

kiln/src/codegen/C/collections/matrix_mul.cmodified

+2−2
@@ -9,7 +9,7 @@
99 #include "app/H/target.h"
1010
1111 // matrix_mul(a, b) -- real Phase 4 integration (ideas/gpu_acceleration.md,
12-// ideas/assigned.md): calls INTO libashgpu.so's ash_gpu_matrix_multiply
12+// ideas/assigned.md): calls INTO libpyre.so's ash_gpu_matrix_multiply
1313 // through the GOT slot elf/C/elf_dynamic.c's --link=shared writer lays
1414 // out, rather than reimplementing the algorithm in kiln's own hand-rolled
1515 // x86-64 the way the CPU backend itself does -- that distinction is the
@@ -223,7 +223,7 @@ static void unflatten_result(CodeBuf *code) {
223223
224224 void codegen_builtin_matrix_mul(void) {
225225 if (kiln_get_link_mode() != KILN_LINK_SHARED) {
226- parse_error("matrix_mul requires --link=shared (it calls into libashgpu.so; "
226+ parse_error("matrix_mul requires --link=shared (it calls into libpyre.so; "
227227 "--link=static stays kiln's zero-dependency default -- see ideas/assigned.md)");
228228 }
229229

kiln/src/codegen/C/runtime/print_int.cmodified

+1−1
@@ -433,7 +433,7 @@ void codegen_exit0(CodeBuf *code) {
433433 // CALLING thread, leaving the process alive until every thread
434434 // exits -- invisible as long as a kiln program is single-threaded,
435435 // which was every one of them until matrix_mul's --link=shared path
436- // started calling into libashgpu.so's OpenMP-based CPU backend
436+ // started calling into libpyre.so's OpenMP-based CPU backend
437437 // (found by actually running that: the process hung forever after
438438 // printing its correct result, waiting on ghost worker threads).
439439 emit_mov_reg_imm64(code, REG_RAX, 231);

kiln/src/codegen/H/collections/matrix_mul.hmodified

+1−1
@@ -4,7 +4,7 @@
44 // matrix_mul(a, b): only available under --link=shared (ideas/assigned.md
55 // -- a --link=static binary stays the hand-rolled, zero-dependency ELF/PE
66 // kiln otherwise always produces; this builtin's whole implementation is
7-// a call through the GOT into libashgpu.so, which only exists under
7+// a call through the GOT into libpyre.so, which only exists under
88 // --link=shared at all). codegen/C/builtins/builtins.c's dispatch calls
99 // this after parsing matrix_mul(...)'s arguments, same convention as
1010 // every other builtin in that file -- a and b already pushed

kiln/src/codegen/H/emit/ash_gpu_exports.defmodified

+1−1
@@ -1,4 +1,4 @@
1-// x-macro list of libashgpu.so's exported symbols kiln's --link=shared
1+// x-macro list of libpyre.so's exported symbols kiln's --link=shared
22 // codegen calls (elf/C/elf_dynamic.c's executable-side GOT/hash/dynsym,
33 // alongside kiln's own runtime_exports.def entries -- see that file's
44 // header comment and ideas/assigned.md for why this is a SECOND needed

kiln/src/codegen/H/emit/ash_gpu_import_enum.hmodified

+1−1
@@ -1,7 +1,7 @@
11 #ifndef KILN_ASH_GPU_IMPORT_ENUM_H
22 #define KILN_ASH_GPU_IMPORT_ENUM_H
33
4-// Every libashgpu.so function kiln's --link=shared codegen calls --
4+// Every libpyre.so function kiln's --link=shared codegen calls --
55 // single list in ash_gpu_exports.def, shared by this enum and
66 // elf_dynamic.c's actual table bytes, so the two can never silently
77 // drift out of sync (same pattern as runtime_import_enum.h).

kiln/src/codegen/H/platform/linux_call.hmodified

+1−1
@@ -4,7 +4,7 @@
44 #include "elf/H/elf_dynamic_call.h"
55
66 // SysV x86-64 calling convention wrapper for calling into a SECOND
7-// needed library's real, normally-compiled function (libashgpu.so --
7+// needed library's real, normally-compiled function (libpyre.so --
88 // see elf/H/elf_dynamic.h) -- new territory the same way win_call.h was
99 // for libkilnrt.dll's imports, but for a different reason: kiln's OWN
1010 // runtime routines (heap_alloc, print_top, ...), the only things

kiln/src/elf/C/elf_dynamic.cmodified

+2−2
@@ -162,8 +162,8 @@ int elf_write_dynamic_executable(const char *path, const CodeBuf *machine_code)
162162 // ---- .dynamic ----
163163 #define DT(tag, val) do { put64(&f, (uint64_t)(tag)); put64(&f, (uint64_t)(val)); } while (0)
164164 DT(1, soname_off); // DT_NEEDED (index into .dynstr) -- libkilnrt.so
165- DT(1, ashgpu_soname_off); // DT_NEEDED -- libashgpu.so (ideas/assigned.md)
166- DT(15, rpath_off); // DT_RPATH -- shared by both NEEDED entries (libashgpu.so installs to the same rpath)
165+ DT(1, ashgpu_soname_off); // DT_NEEDED -- libpyre.so (ideas/assigned.md)
166+ DT(15, rpath_off); // DT_RPATH -- shared by both NEEDED entries (libpyre.so installs to the same rpath)
167167 DT(4, KILN_DYN_HASH_ADDR); // DT_HASH
168168 DT(5, dynstr_addr); // DT_STRTAB (after the code -- depends on its length)
169169 DT(6, KILN_DYN_SYMTAB_ADDR); // DT_SYMTAB

kiln/src/elf/H/elf_dynamic.hmodified

+9−4
@@ -19,22 +19,27 @@
1919 // "executable" with PT_INTERP/PT_DYNAMIC/GOT/RELA, loaded and resolved
2020 // correctly by /lib64/ld-linux-x86-64.so.2) -- this header encodes that
2121 // same validated structure, not a first attempt.
22+// Colon-separated search list. kiln/Makefile overrides it to also name the
23+// repo's bin/extensions/pyre (where Pyre builds libpyre.so) ahead of the
24+// system install dir.
25+#ifndef KILN_RPATH
2226 #define KILN_RPATH "/usr/local/lib/kiln"
27+#endif
2328 #define KILN_LIBKILNRT_SONAME "libkilnrt.so"
2429 // ideas/assigned.md: a SECOND needed library, Pyre's compute runtime --
2530 // kept as a parallel DT_NEEDED next to libkilnrt.so's, never merged into
26-// it (libkilnrt.so is kiln's own hand-rolled routines; libashgpu.so is a
31+// it (libkilnrt.so is kiln's own hand-rolled routines; libpyre.so is a
2732 // normal gcc/g++-built shared object this project doesn't build). ld.so
2833 // resolves each imported symbol name against ALL needed libraries, so
2934 // the two don't need separate hash/dynsym/GOT regions -- one combined
3035 // table (KILN_DYN_TOTAL_IMPORTS below) covering both libraries' symbols
3136 // is correct and the much smaller change.
32-#define KILN_ASHGPU_SONAME "libashgpu.so"
37+#define KILN_ASHGPU_SONAME "libpyre.so"
3338 #define KILN_INTERP_STR "/lib64/ld-linux-x86-64.so.2"
3439
3540 // Every symbol this executable needs resolved, from EITHER needed
3641 // library -- kiln's own (RUNTIME_IMPORT_COUNT_, from libkilnrt.so) then
37-// Pyre's (ASH_GPU_IMPORT_COUNT_, from libashgpu.so), in that order.
42+// Pyre's (ASH_GPU_IMPORT_COUNT_, from libpyre.so), in that order.
3843 #define KILN_DYN_TOTAL_IMPORTS ((unsigned)RUNTIME_IMPORT_COUNT_ + (unsigned)ASH_GPU_IMPORT_COUNT_)
3944
4045 // nsyms includes the mandatory null 0th symbol.
@@ -67,7 +72,7 @@
6772 #define KILN_DYN_GOT_SIZE (8u * KILN_DYN_TOTAL_IMPORTS)
6873
6974 #define KILN_DYN_ENTRY_SIZE 16u // Elf64_Dyn: {Elf64_Sxword d_tag; union d_val/d_ptr;}
70-// NEEDED(libkilnrt.so),NEEDED(libashgpu.so),RPATH,HASH,STRTAB,SYMTAB,STRSZ,SYMENT,RELA,RELASZ,RELAENT,NULL(12, see .c)
75+// NEEDED(libkilnrt.so),NEEDED(libpyre.so),RPATH,HASH,STRTAB,SYMTAB,STRSZ,SYMENT,RELA,RELASZ,RELAENT,NULL(12, see .c)
7176 #define KILN_DYN_TABLE_NENT_TOTAL 11u
7277 #define KILN_DYN_TABLE_ADDR (KILN_DYN_GOT_ADDR + KILN_DYN_GOT_SIZE)
7378 #define KILN_DYN_TABLE_SIZE ((KILN_DYN_TABLE_NENT_TOTAL + 1u) * KILN_DYN_ENTRY_SIZE) // +1 for the DT_NULL terminator

kiln/src/elf/H/elf_dynamic_call.hmodified

+1−1
@@ -14,7 +14,7 @@
1414 // falls through -- harmless, and not worth a second code path here.
1515 void elf_dynamic_call(CodeBuf *code, RuntimeImport which);
1616
17-// Same mechanism, for the second needed library (libashgpu.so, see
17+// Same mechanism, for the second needed library (libpyre.so, see
1818 // elf_dynamic.h's header comment) -- `which`'s own SysV C calling
1919 // convention applies past the call (args in RDI/RSI/RDX/RCX/R8/R9),
2020 // unlike kiln's own runtime routines above which each define their own

kiln/tests/scripts/shared_link_check.shmodified

+3−3
@@ -4,10 +4,10 @@
44 # --link=shared had NO automated coverage at all before this (run_regression.sh
55 # only ever compiles with kiln's default --link=static) -- this fills that
66 # gap generally, and specifically covers matrix_mul (codegen/C/collections/matrix_mul.c,
7-# elf/H/elf_dynamic.h's second DT_NEEDED for libashgpu.so), the one builtin
7+# elf/H/elf_dynamic.h's second DT_NEEDED for libpyre.so), the one builtin
88 # that only exists under --link=shared at all.
99 #
10-# 1. Rebuilds kiln, libkilnrt.so, and libashgpu.so, installing both at
10+# 1. Rebuilds kiln, libkilnrt.so, and libpyre.so, installing both at
1111 # kiln's hardcoded rpath (KILN_RPATH, elf/H/elf_dynamic.h) -- there is
1212 # no env-var override, so this writes to /usr/local/lib/kiln for real,
1313 # same as doing it by hand.
@@ -27,7 +27,7 @@ cd "$(dirname "$0")/../.." # -> kiln/
2727
2828 make -s || { echo "build failed"; exit 1; }
2929 ../bin/kiln --emit-runtime -o /usr/local/lib/kiln/libkilnrt.so || { echo "emit-runtime failed"; exit 1; }
30-(cd ../pyre && make -s install) || { echo "pyre install failed"; exit 1; }
30+(cd ../pyre && make -s lib) || { echo "pyre build failed"; exit 1; }
3131
3232 OUTDIR=/tmp/kiln_shared_check
3333 rm -rf "$OUTDIR"

pyre/Makefilemodified

+83−20
@@ -17,30 +17,52 @@
1717 # pyre/tools/install_deps.sh, never run end-to-end -- this sandbox has
1818 # no GPU at all).
1919
20+include cuda.mk
21+
2022 CC = gcc
2123 CXX = g++
2224 .DEFAULT_GOAL := all
23-CFLAGS = -Wall -Wextra -std=c11 -O3 -march=native -fopenmp -Isrc -fPIC
24-CXXFLAGS = -Wall -Wextra -std=c++17 -O3 -Isrc -fPIC
25-LDFLAGS = -fopenmp -lm -ldl -lvulkan
25+CFLAGS = -Wall -Wextra -std=c11 -O3 -march=native -fopenmp -Isrc -fPIC -pthread $(CUDA_DEFINE)
26+CXXFLAGS = -Wall -Wextra -std=c++17 -O3 -Isrc -fPIC -pthread
27+LDFLAGS = -fopenmp -lm -ldl -lvulkan $(CUDA_LIBS)
28+NVCCFLAGS = -std=c++17 -O3 $(CUDA_ARCH) -Isrc -Xcompiler -fPIC -Xcompiler -Wall
29+
30+# Built libraries land in the repo's top-level bin/, next to ashvm and kiln,
31+# under extensions/ (Pyre is an optional add-on, not a core tool). ashvm links
32+# the .a from here; kiln's --link=shared binaries find the .so here via an
33+# rpath entry (see kiln/Makefile).
34+EXT_DIR = ../bin/extensions/pyre
2635
2736 PYRE_C_SRC = \
2837 src/C/runtime/device.c \
38+ src/C/runtime/targets.c \
39+ src/C/runtime/cost_model.c \
40+ src/C/runtime/manager.c \
2941 src/C/backends/cpu/matrix_multiply.c \
3042 src/C/backends/cpu/monte_carlo.c \
3143 src/C/backends/cpu/chaos.c
3244 PYRE_CPP_SRC = \
3345 src/C/backends/vulkan/context.cpp \
3446 src/C/backends/vulkan/matrix_multiply.cpp \
3547 src/C/backends/vulkan/chaos.cpp
48+# NVIDIA-only CUDA backend (src/C/backends/cuda/*.cu), built only when
49+# cuda.mk found nvcc; without it Pyre is Vulkan + CPU exactly as before.
50+PYRE_CU_SRC = \
51+ src/C/backends/cuda/context.cu \
52+ src/C/backends/cuda/matrix_multiply.cu \
53+ src/C/backends/cuda/chaos.cu \
54+ src/C/backends/cuda/monte_carlo.cu
55+ifeq ($(NVCC),)
56+PYRE_CU_SRC =
57+endif
3658
3759 # Objects and dependency files live in trees parallel to C/:
3860 # src/C/<feature>/x.c -> src/O/<feature>/x.o src/D/<feature>/x.d
3961 # src/C/<feature>/x.cpp -> src/O/<feature>/x.o src/D/<feature>/x.d
4062 obj_of = $(subst /C/,/O/,$(basename $(1)).o)
4163 dep_of = $(subst /C/,/D/,$(basename $(1)).d)
42-PYRE_OBJ = $(foreach s,$(PYRE_C_SRC) $(PYRE_CPP_SRC),$(call obj_of,$(s)))
43-PYRE_DEP = $(foreach s,$(PYRE_C_SRC) $(PYRE_CPP_SRC),$(call dep_of,$(s)))
64+PYRE_OBJ = $(foreach s,$(PYRE_C_SRC) $(PYRE_CPP_SRC) $(PYRE_CU_SRC),$(call obj_of,$(s)))
65+PYRE_DEP = $(foreach s,$(PYRE_C_SRC) $(PYRE_CPP_SRC) $(PYRE_CU_SRC),$(call dep_of,$(s)))
4466
4567 # The Vulkan backend's one kernel so far needs its shader compiled to
4668 # SPIR-V and embedded as a C array (glslc -mfmt=c) rather than loaded
@@ -82,37 +104,73 @@ $(call obj_of,$(1)): $(1)
82104 endef
83105 $(foreach s,$(PYRE_CPP_SRC),$(eval $(call cpp_compile_rule,$(s))))
84106
107+define cu_compile_rule
108+$(call obj_of,$(1)): $(1)
109+ @mkdir -p $$(dir $$@) $$(dir $(call dep_of,$(1)))
110+ $$(NVCC) $$(NVCCFLAGS) -MMD -MF $(call dep_of,$(1)) -MT $$@ -c $$< -o $$@
111+endef
112+$(foreach s,$(PYRE_CU_SRC),$(eval $(call cu_compile_rule,$(s))))
113+
85114 -include $(PYRE_DEP)
86115
87-.PHONY: all clean test benchmark lib install
116+.PHONY: all clean test benchmark lib install tool web
88117
89-all: test benchmark lib
118+all: test benchmark lib tool web
90119
91-# Consumed by ashvm's Makefile (-Lpyre/lib -lashgpu) -- see
120+# Consumed by ashvm's Makefile (../bin/extensions/pyre/libpyre.a) -- see
92121 # ashvm/src/builtins/C/matrix.c and ideas/assigned.md for why ashvm links
93122 # against this directly while kiln, whose own output is a hand-rolled,
94123 # zero-dependency ELF/PE binary, does not.
95-lib/libashgpu.a: $(PYRE_OBJ)
96- mkdir -p lib
124+$(EXT_DIR)/libpyre.a: $(PYRE_OBJ)
125+ mkdir -p $(EXT_DIR)
97126 ar rcs $@ $(PYRE_OBJ)
98127
99128 # Consumed by kiln's --link=shared mode (elf/H/elf_dynamic.h's
100129 # KILN_ASHGPU_SONAME, same rpath as libkilnrt.so -- see ideas/assigned.md):
101130 # a real DT_NEEDED on a shared object built by the normal system
102131 # toolchain, not another hand-rolled kiln-side writer like libkilnrt.so.
103-lib/libashgpu.so: $(PYRE_OBJ)
104- mkdir -p lib
132+$(EXT_DIR)/libpyre.so: $(PYRE_OBJ)
133+ mkdir -p $(EXT_DIR)
105134 $(CXX) -shared -o $@ $(PYRE_OBJ) $(LDFLAGS)
106135
107-lib: lib/libashgpu.a lib/libashgpu.so
136+lib: $(EXT_DIR)/libpyre.a $(EXT_DIR)/libpyre.so
108137
109-# Installs libashgpu.so at kiln's own rpath (KILN_RPATH, elf/H/elf_dynamic.h)
138+# `pyre info|calibrate|plan ...`: inspect the manager (tools/pyre.c).
139+tools/%.o: tools/%.c tools/dashboard.h tools/json_out.h tools/train.h
140+ $(CC) $(CFLAGS) -D_GNU_SOURCE -c $< -o $@
141+
142+$(EXT_DIR)/pyre: tools/pyre.o tools/dashboard.o tools/train.o $(PYRE_OBJ)
143+ mkdir -p $(EXT_DIR)
144+ $(CXX) $(CXXFLAGS) -fopenmp $^ -o $@ $(LDFLAGS)
145+
146+tool: $(EXT_DIR)/pyre
147+
148+# The dashboard UI (`pyre dashboard`): React + TypeScript in src/web, built
149+# with pnpm to <bin>/extensions/pyre/web, which the pyre executable serves
150+# fully offline. Node and pnpm are needed at BUILD time only (and network the
151+# first time, to fetch packages); without them Pyre still builds and only the
152+# dashboard UI is skipped, with a warning.
153+WEB_SRC = src/web
154+WEB_OUT = $(EXT_DIR)/web
155+WEB_DEPS = $(shell find $(WEB_SRC)/src -type f) $(addprefix $(WEB_SRC)/,index.html package.json pnpm-lock.yaml vite.config.ts tsconfig.json)
156+
157+$(WEB_OUT)/index.html: $(WEB_DEPS)
158+ @if command -v pnpm >/dev/null 2>&1; then \
159+ (cd $(WEB_SRC) && pnpm install --frozen-lockfile && pnpm build) \
160+ || echo "WARNING: dashboard UI build failed (offline first build? run 'pnpm install' in pyre/$(WEB_SRC) once with network); 'pyre dashboard' will say it is not built"; \
161+ else \
162+ echo "WARNING: pnpm not found, skipping the dashboard UI (install Node + pnpm, then 'make web')"; \
163+ fi
164+
165+web: $(WEB_OUT)/index.html
166+
167+# Installs libpyre.so at kiln's own rpath (KILN_RPATH, elf/H/elf_dynamic.h)
110168 # so a kiln --link=shared executable that imports an ash_gpu_* symbol can
111169 # actually find it at load time -- same spot `kiln --emit-runtime` installs
112170 # libkilnrt.so to by hand today (there's no kiln "make install" yet either).
113-install: lib/libashgpu.so
171+install: $(EXT_DIR)/libpyre.so
114172 mkdir -p /usr/local/lib/kiln
115- cp lib/libashgpu.so /usr/local/lib/kiln/libashgpu.so
173+ cp $(EXT_DIR)/libpyre.so /usr/local/lib/kiln/libpyre.so
116174
117175 # Each test/benchmark main is plain C -- compiled with $(CC) into its own
118176 # .o (g++ would compile a .c file as C++ instead, which is stricter than
@@ -132,9 +190,13 @@ tests/matrix_multiply_test: tests/matrix_multiply_test.o $(PYRE_OBJ)
132190 tests/monte_carlo_test: tests/monte_carlo_test.o $(PYRE_OBJ)
133191 $(CXX) $(CXXFLAGS) -fopenmp $^ -o $@ $(LDFLAGS)
134192
135-test: tests/matrix_multiply_test tests/monte_carlo_test
193+tests/cuda_test: tests/cuda_test.o $(PYRE_OBJ)
194+ $(CXX) $(CXXFLAGS) -fopenmp $^ -o $@ $(LDFLAGS)
195+
196+test: tests/matrix_multiply_test tests/monte_carlo_test tests/cuda_test
136197 tests/matrix_multiply_test
137198 tests/monte_carlo_test
199+ tests/cuda_test
138200
139201 benchmarks/matrix/run_benchmark: benchmarks/matrix/run_benchmark.o $(PYRE_OBJ)
140202 $(CXX) $(CXXFLAGS) -fopenmp $^ -o $@ $(LDFLAGS)
@@ -154,7 +216,8 @@ clean:
154216 find src -name '*.o' -delete
155217 find src -name '*.d' -delete
156218 rm -f src/H/backends/vulkan/matrix_multiply_spv.h src/H/backends/vulkan/chaos_spv.h
157- rm -f tests/*.o benchmarks/*/*.o
158- rm -f tests/matrix_multiply_test tests/monte_carlo_test
219+ rm -f tests/*.o benchmarks/*/*.o tools/*.o $(EXT_DIR)/pyre
220+ rm -rf $(WEB_OUT)
221+ rm -f tests/matrix_multiply_test tests/monte_carlo_test tests/cuda_test
159222 rm -f benchmarks/matrix/run_benchmark benchmarks/monte_carlo_no_pi/run_benchmark benchmarks/chaos/run_benchmark
160- rm -rf lib
223+ rm -f $(EXT_DIR)/libpyre.a $(EXT_DIR)/libpyre.so

pyre/benchmarks/chaos/run_benchmark.cmodified

+41−12
@@ -14,6 +14,9 @@
1414 #include "H/runtime/ash_gpu.h"
1515 #include "H/backends/cpu/chaos.h"
1616 #include "H/backends/vulkan/context.h"
17+#ifdef ASH_GPU_HAVE_CUDA
18+#include "H/backends/cuda/context.h"
19+#endif
1720 #include <stdio.h>
1821 #include <stdlib.h>
1922 #include <time.h>
@@ -44,33 +47,59 @@ int main(void) {
4447 double cpu_seconds = now_seconds() - t0;
4548 printf("CPU backend: %.3fs\n", cpu_seconds);
4649
47- AshGpuInfo info = ash_gpu_detect();
48- if (info.backend == ASH_GPU_BACKEND_NONE) {
49- printf("GPU backend: unavailable (%s)\n", info.device_name);
50+ int exit_code = 0;
51+
52+#ifdef ASH_GPU_HAVE_CUDA
53+ /* CUDA first (NVIDIA only): a warm-up call so context creation and the
54+ * first kernel's JIT/load aren't charged to the measurement. */
55+ if (ash_gpu_cuda_init()) {
56+ double warm = 0.5;
57+ ash_gpu_cuda_chaos_iterate(&warm, 1, 1);
58+
59+ double *data_cuda = malloc((size_t)n * sizeof(double));
60+ for (int i = 0; i < n; i++) data_cuda[i] = (double)(i % 1000) * 0.001;
61+ t0 = now_seconds();
62+ int ran = ash_gpu_cuda_chaos_iterate(data_cuda, n, iterations);
63+ double cuda_seconds = now_seconds() - t0;
64+ if (ran) {
65+ double max_diff = 0.0;
66+ for (int i = 0; i < n; i++) {
67+ double d = data_cpu[i] - data_cuda[i];
68+ if (d < 0) d = -d;
69+ if (d > max_diff) max_diff = d;
70+ }
71+ printf("CUDA backend: %.3fs (%s) speedup: %.2fx max |CPU - CUDA|: %g\n",
72+ cuda_seconds, ash_gpu_cuda_device_name(), cpu_seconds / cuda_seconds, max_diff);
73+ if (max_diff != 0.0) exit_code = 1;
74+ } else {
75+ printf("CUDA backend: launch failed\n");
76+ }
77+ free(data_cuda);
78+ }
79+#endif
80+
81+ if (!ash_gpu_vulkan_init()) {
82+ printf("Vulkan backend: unavailable\n");
5083 free(data_cpu);
5184 free(data_gpu);
52- return 0;
85+ return exit_code;
5386 }
5487
5588 t0 = now_seconds();
5689 ash_gpu_vulkan_chaos_iterate(data_gpu, n, iterations);
5790 double gpu_seconds = now_seconds() - t0;
58- printf("GPU backend: %.3fs (%s)\n", gpu_seconds, info.device_name);
59- printf("speedup: %.2fx\n", cpu_seconds / gpu_seconds);
91+ printf("Vulkan backend: %.3fs (%s) speedup: %.2fx\n",
92+ gpu_seconds, ash_gpu_vulkan_device_name(), cpu_seconds / gpu_seconds);
6093
61- /* Both backends run the identical recurrence -- results must match
62- * (within fp rounding-order slack, since the GPU evaluates per
63- * thread independently same as the CPU's OpenMP loop does, both
64- * single-precision-free, double throughout). */
6594 double max_diff = 0.0;
6695 for (int i = 0; i < n; i++) {
6796 double diff = data_cpu[i] - data_gpu[i];
6897 if (diff < 0) diff = -diff;
6998 if (diff > max_diff) max_diff = diff;
7099 }
71- printf("max |CPU - GPU| difference: %g\n", max_diff);
100+ printf("max |CPU - Vulkan| difference: %g\n", max_diff);
72101
73102 free(data_cpu);
74103 free(data_gpu);
75- return 0;
104+ return exit_code;
76105 }

pyre/benchmarks/matrix/run_benchmark.cmodified

+52−0
@@ -7,8 +7,56 @@
77 * finish in under ~2.88s on this machine.
88 */
99 #include "H/runtime/ash_gpu.h"
10+#include "H/backends/cpu/matrix_multiply.h"
11+#ifdef ASH_GPU_HAVE_CUDA
12+#include "H/backends/cuda/context.h"
13+#endif
14+#include <math.h>
1015 #include <stdio.h>
1116 #include <stdlib.h>
17+#include <time.h>
18+
19+static double now_seconds(void) {
20+ struct timespec ts;
21+ clock_gettime(CLOCK_MONOTONIC, &ts);
22+ return (double)ts.tv_sec + (double)ts.tv_nsec / 1e9;
23+}
24+
25+#ifdef ASH_GPU_HAVE_CUDA
26+/* Backend-by-backend timing at the benchmark's own N, on non-constant data
27+ * (the challenge's constant fill can't catch a wrong index), CUDA checked
28+ * against the CPU backend's result. */
29+static void compare_backends(int n) {
30+ size_t cnt = (size_t)n * n;
31+ double *a = malloc(cnt * sizeof(double)), *b = malloc(cnt * sizeof(double));
32+ double *c_cpu = malloc(cnt * sizeof(double)), *c_cuda = malloc(cnt * sizeof(double));
33+ for (size_t i = 0; i < cnt; i++) {
34+ a[i] = (double)((i % 13) - 6) * 0.5;
35+ b[i] = (double)((i % 7) - 3) * 1.5;
36+ }
37+ double t0 = now_seconds();
38+ ash_gpu_cpu_matrix_multiply(a, b, c_cpu, n);
39+ double cpu_s = now_seconds() - t0;
40+
41+ if (ash_gpu_cuda_init()) {
42+ ash_gpu_cuda_matrix_multiply(a, b, c_cuda, 64); /* warm-up: context + kernel load */
43+ t0 = now_seconds();
44+ int ran = ash_gpu_cuda_matrix_multiply(a, b, c_cuda, n);
45+ double cuda_s = now_seconds() - t0;
46+ double max_err = 0.0;
47+ for (size_t i = 0; i < cnt; i++) {
48+ double e = fabs(c_cpu[i] - c_cuda[i]);
49+ if (e > max_err) max_err = e;
50+ }
51+ printf(" CPU : %.3fs\n CUDA : %.3fs (%s, %.1f GFLOP/s) max |CPU - CUDA| = %g%s\n",
52+ cpu_s, cuda_s, ash_gpu_cuda_device_name(),
53+ 2.0 * n * n * (double)n / cuda_s / 1e9, max_err, ran ? "" : " [LAUNCH FAILED]");
54+ } else {
55+ printf(" CPU : %.3fs\n CUDA : unavailable\n", cpu_s);
56+ }
57+ free(a); free(b); free(c_cpu); free(c_cuda);
58+}
59+#endif
1260
1361 int main(void) {
1462 const int n = 2000;
@@ -25,6 +73,10 @@ int main(void) {
2573 ash_gpu_matrix_multiply(a, b, c, n);
2674 printf("Done!\n");
2775 printf("Sample element C[0][0]: %g\n", c[0]);
76+#ifdef ASH_GPU_HAVE_CUDA
77+ printf("Backends at N=%d:\n", n);
78+ compare_backends(n);
79+#endif
2880
2981 double expected = (double)n * 1.5 * 2.0; /* every term of the dot product is 1.5*2.0, N of them */
3082 int ok = c[0] == expected;

pyre/cuda.mkadded

+23−0
@@ -0,0 +1,23 @@
1+# CUDA backend detection, shared by pyre/Makefile and ashvm/Makefile so the
2+# "is CUDA here" decision and its link flags live in exactly one place.
3+#
4+# The CUDA backend is NVIDIA-only and optional: with no nvcc on this machine
5+# every variable below is empty and Pyre builds exactly as before (Vulkan +
6+# CPU). Override NVCC= or CUDA_ARCH= on the make command line if needed.
7+#
8+# CUDA_ARCH defaults to `native` (the GPU in this machine, so the kernels are
9+# compiled for exactly the hardware they will run on). Pass something like
10+# CUDA_ARCH='-gencode arch=compute_86,code=sm_86 -gencode arch=compute_89,code=sm_89'
11+# when building on one machine for another.
12+NVCC ?= $(firstword $(wildcard /opt/cuda/bin/nvcc /usr/local/cuda/bin/nvcc) $(shell command -v nvcc 2>/dev/null))
13+ifneq ($(NVCC),)
14+CUDA_HOME := $(abspath $(dir $(NVCC))/..)
15+CUDA_ARCH ?= -arch=native
16+# Static cudart: libpyre.so / ashvm then need only the NVIDIA driver's
17+# libcuda at run time, not a CUDA toolkit install.
18+CUDA_LIBS = -L$(CUDA_HOME)/lib64 -lcudart_static -lrt -lpthread
19+CUDA_DEFINE = -DASH_GPU_HAVE_CUDA
20+else
21+CUDA_LIBS =
22+CUDA_DEFINE =
23+endif

pyre/src/C/backends/cpu/monte_carlo.cmodified

+2−1
@@ -1,4 +1,5 @@
11 #include "H/runtime/ash_gpu.h"
2+#include "H/backends/cpu/monte_carlo.h"
23 #ifdef _OPENMP
34 #include <omp.h>
45 #endif
@@ -23,7 +24,7 @@ static unsigned int splitmix32(unsigned int x) {
2324 return x ^ (x >> 16);
2425 }
2526
26-void ash_gpu_monte_carlo_risk(long long iterations, double threshold, unsigned int seed,
27+void ash_gpu_cpu_monte_carlo_risk(long long iterations, double threshold, unsigned int seed,
2728 long long *out_risky, double *out_probability) {
2829 long long risky = 0;
2930

pyre/src/C/backends/cuda/chaos.cuadded

+93−0
@@ -0,0 +1,93 @@
1+// CUDA kernel for ash_gpu_chaos_iterate (ash_gpu.h): data[i] =
2+// iterate(data[i]), x = sqrt(|x|+1)*0.99999 + 0.00001, `iterations` times.
3+//
4+// Why this is fast on NVIDIA:
5+// * The recurrence is a serial dependency chain per element, dominated by
6+// the fp64 sqrt (long latency, low throughput on consumer parts). Each
7+// thread therefore advances ILP independent elements in lock step so the
8+// chains overlap instead of the warp stalling on one.
9+// * Elements of a block are contiguous and a thread's ILP elements are one
10+// block-width apart, so every load/store is a fully coalesced 256 B warp
11+// access.
12+// * Host<->device traffic is split into chunks over several streams, so
13+// chunk k's kernel overlaps chunk k+1's upload and chunk k-1's download.
14+//
15+// __dmul_rn / __dadd_rn instead of `*` and `+`: nvcc would otherwise fuse
16+// them into one FMA, which rounds once instead of twice and would make the
17+// result differ from the CPU backend (compiled -std=c11, no contraction) in
18+// the last bit. Paying one extra DADD per step keeps CPU and GPU results
19+// bit-identical, which the benchmark checks.
20+#include "H/backends/cuda/context.h"
21+#include "H/backends/cuda/context_internal.cuh"
22+#include <algorithm>
23+
24+namespace {
25+
26+constexpr int BLOCK = 256;
27+constexpr int ILP = 4;
28+// Chunks smaller than this are not worth a separate stream round trip.
29+constexpr int MIN_CHUNK = 1 << 18;
30+
31+__global__ void __launch_bounds__(BLOCK) chaos_kernel(double *data, int n, int iterations) {
32+ const int base = blockIdx.x * (BLOCK * ILP) + threadIdx.x;
33+
34+ double x[ILP];
35+ bool live[ILP];
36+#pragma unroll
37+ for (int j = 0; j < ILP; j++) {
38+ const int i = base + j * BLOCK;
39+ live[j] = i < n;
40+ x[j] = live[j] ? data[i] : 0.0;
41+ }
42+
43+ for (int k = 0; k < iterations; k++) {
44+#pragma unroll
45+ for (int j = 0; j < ILP; j++) {
46+ x[j] = __dadd_rn(__dmul_rn(sqrt(__dadd_rn(fabs(x[j]), 1.0)), 0.99999), 0.00001);
47+ }
48+ }
49+
50+#pragma unroll
51+ for (int j = 0; j < ILP; j++) {
52+ const int i = base + j * BLOCK;
53+ if (live[j]) data[i] = x[j];
54+ }
55+}
56+
57+} // namespace
58+
59+extern "C" int ash_gpu_cuda_chaos_iterate(double *data, int n, int iterations) {
60+ if (!ash_gpu_cuda_init() || n <= 0) return 0;
61+
62+ const size_t bytes = (size_t)n * sizeof(double);
63+ double *dev = (double *)ash_cuda_scratch(0, bytes);
64+ if (!dev) return 0;
65+
66+ const int pinned = ash_cuda_pin(data, bytes);
67+
68+ int streams = std::min(ASH_CUDA_STREAMS, std::max(1, n / MIN_CHUNK));
69+ const int chunk = (n + streams - 1) / streams;
70+
71+ bool ok = true;
72+ for (int s = 0; s < streams && ok; s++) {
73+ const int off = s * chunk;
74+ const int len = std::min(chunk, n - off);
75+ if (len <= 0) break;
76+ cudaStream_t st = ash_cuda_stream(s);
77+ ok = ash_cuda_ok(cudaMemcpyAsync(dev + off, data + off, (size_t)len * sizeof(double),
78+ cudaMemcpyHostToDevice, st));
79+ if (!ok) break;
80+ const int blocks = (len + BLOCK * ILP - 1) / (BLOCK * ILP);
81+ chaos_kernel<<<blocks, BLOCK, 0, st>>>(dev + off, len, iterations);
82+ ok = ash_cuda_ok(cudaGetLastError());
83+ if (!ok) break;
84+ ok = ash_cuda_ok(cudaMemcpyAsync(data + off, dev + off, (size_t)len * sizeof(double),
85+ cudaMemcpyDeviceToHost, st));
86+ }
87+
88+ for (int s = 0; s < streams; s++) {
89+ if (!ash_cuda_ok(cudaStreamSynchronize(ash_cuda_stream(s)))) ok = false;
90+ }
91+ ash_cuda_unpin(data, pinned);
92+ return ok ? 1 : 0;
93+}

pyre/src/C/backends/cuda/context.cuadded

+104−0
@@ -0,0 +1,104 @@
1+// Pyre's CUDA backend (NVIDIA only): process-lifetime device, streams and
2+// scratch-buffer state, shared by the kernel files next to this one.
3+// See src/H/backends/cuda/context.h for the contract.
4+#include "H/backends/cuda/context.h"
5+#include "H/backends/cuda/context_internal.cuh"
6+#include <stdio.h>
7+
8+namespace {
9+
10+struct CudaState {
11+ int tried = 0;
12+ int ready = 0;
13+ int sm_count = 0;
14+ char name[256] = {0};
15+ unsigned long long vram = 0;
16+ cudaStream_t streams[ASH_CUDA_STREAMS] = {};
17+ void *scratch[ASH_CUDA_SCRATCH_SLOTS] = {};
18+ size_t scratch_bytes[ASH_CUDA_SCRATCH_SLOTS] = {};
19+};
20+
21+CudaState g;
22+
23+// Registration (page-locking) costs roughly a millisecond per few MB; below
24+// this size the staging copy it would save is cheaper.
25+const size_t PIN_MIN_BYTES = 1u << 20;
26+
27+int init_once() {
28+ int count = 0;
29+ if (cudaGetDeviceCount(&count) != cudaSuccess || count == 0) {
30+ (void)cudaGetLastError();
31+ return 0;
32+ }
33+
34+ // Most multiprocessors wins: on a multi-GPU box that is the one the
35+ // user bought for compute, not the one driving the display.
36+ int best = -1, best_sms = -1;
37+ for (int d = 0; d < count; d++) {
38+ cudaDeviceProp p;
39+ if (cudaGetDeviceProperties(&p, d) != cudaSuccess) continue;
40+ if (p.multiProcessorCount > best_sms) { best_sms = p.multiProcessorCount; best = d; }
41+ }
42+ if (best < 0 || cudaSetDevice(best) != cudaSuccess) return 0;
43+
44+ cudaDeviceProp props;
45+ if (cudaGetDeviceProperties(&props, best) != cudaSuccess) return 0;
46+ snprintf(g.name, sizeof(g.name), "%s", props.name);
47+ g.vram = (unsigned long long)props.totalGlobalMem;
48+ g.sm_count = props.multiProcessorCount;
49+
50+ for (int i = 0; i < ASH_CUDA_STREAMS; i++) {
51+ if (cudaStreamCreateWithFlags(&g.streams[i], cudaStreamNonBlocking) != cudaSuccess) return 0;
52+ }
53+ // Touch the context now so a broken driver fails detection, not the
54+ // first real dispatch.
55+ if (cudaFree(0) != cudaSuccess) return 0;
56+ return 1;
57+}
58+
59+} // namespace
60+
61+extern "C" int ash_gpu_cuda_init(void) {
62+ if (!g.tried) {
63+ g.tried = 1;
64+ g.ready = init_once();
65+ if (!g.ready) (void)cudaGetLastError();
66+ }
67+ return g.ready;
68+}
69+
70+extern "C" const char *ash_gpu_cuda_device_name(void) { return g.name; }
71+extern "C" unsigned long long ash_gpu_cuda_vram_bytes(void) { return g.vram; }
72+
73+cudaStream_t ash_cuda_stream(int index) { return g.streams[index]; }
74+int ash_cuda_sm_count(void) { return g.sm_count; }
75+
76+void *ash_cuda_scratch(int slot, size_t bytes) {
77+ if (g.scratch_bytes[slot] >= bytes) return g.scratch[slot];
78+ if (g.scratch[slot]) {
79+ cudaFree(g.scratch[slot]);
80+ g.scratch[slot] = NULL;
81+ g.scratch_bytes[slot] = 0;
82+ }
83+ void *p = NULL;
84+ if (cudaMalloc(&p, bytes) != cudaSuccess) {
85+ (void)cudaGetLastError();
86+ return NULL;
87+ }
88+ g.scratch[slot] = p;
89+ g.scratch_bytes[slot] = bytes;
90+ return p;
91+}
92+
93+int ash_cuda_pin(const void *ptr, size_t bytes) {
94+ if (bytes < PIN_MIN_BYTES) return 0;
95+ if (cudaHostRegister(const_cast<void *>(ptr), bytes, cudaHostRegisterDefault) != cudaSuccess) {
96+ (void)cudaGetLastError();
97+ return 0;
98+ }
99+ return 1;
100+}
101+
102+void ash_cuda_unpin(const void *ptr, int pinned) {
103+ if (pinned) cudaHostUnregister(const_cast<void *>(ptr));
104+}

pyre/src/C/backends/cuda/matrix_multiply.cuadded

+190−0
@@ -0,0 +1,190 @@
1+// CUDA kernel for ash_gpu_matrix_multiply (ash_gpu.h): C = A * B, all N x N,
2+// row-major, fp64 (the ABI is `double` throughout).
3+//
4+// Honest hardware note: GeForce parts run fp64 at 1/64 of their fp32 rate
5+// (a laptop RTX 3050 peaks near 100 GFLOP/s of fp64), and fp64 has no tensor
6+// core path on consumer Ampere/Ada. This kernel's job is to keep that small
7+// fp64 pipe saturated, not to beat a many-core CPU on a card that
8+// physically cannot; the benchmark reports both numbers.
9+//
10+// Structure (the classic high-end SGEMM shape, adapted to fp64):
11+// * 64x64 output tile per 16x16-thread block, 4x4 outputs per thread held
12+// in registers: each loaded value feeds 4 FMAs, so shared-memory
13+// traffic per FMA is a quarter of the naive kernel's.
14+// * BK=16 deep k-slices staged through shared memory. A is stored
15+// transposed with a 65-double row stride so the transposing store is
16+// bank-conflict free; B is read as double2 (16 B) vectors.
17+// * Software-pipelined: the next k-slice is fetched into registers while
18+// the current one is multiplied, then written to the other shared
19+// buffer -- one __syncthreads per slice, global-load latency hidden.
20+// * Tile loads are bounds-checked and zero-filled, so any N works, not
21+// just multiples of 64.
22+// * Host side splits C by rows over streams: B uploads once, then each
23+// stream uploads its rows of A, multiplies, and downloads its rows of C
24+// while the other streams compute.
25+#include "H/backends/cuda/context.h"
26+#include "H/backends/cuda/context_internal.cuh"
27+#include <algorithm>
28+
29+namespace {
30+
31+constexpr int BM = 64, BN = 64, BK = 16;
32+constexpr int TM = 4, TN = 4;
33+constexpr int THREADS_X = BN / TN; // 16
34+constexpr int THREADS_Y = BM / TM; // 16
35+constexpr int THREADS = THREADS_X * THREADS_Y;
36+constexpr int A_STRIDE = BM + 1; // odd stride: conflict-free transposed store
37+constexpr int A_LOADS = (BM * BK) / THREADS; // 4
38+constexpr int B_LOADS = (BK * BN) / THREADS; // 4
39+
40+// C[m x n] = A[m x n] * B[n x n] (A and C are a horizontal band of rows).
41+__global__ void __launch_bounds__(THREADS)
42+gemm_kernel(const double *__restrict__ A, const double *__restrict__ B,
43+ double *__restrict__ C, int m, int n) {
44+ __shared__ double As[2][BK][A_STRIDE];
45+ __shared__ __align__(16) double Bs[2][BK][BN];
46+
47+ const int tx = threadIdx.x, ty = threadIdx.y;
48+ const int tid = ty * THREADS_X + tx;
49+ const int row0 = blockIdx.y * BM;
50+ const int col0 = blockIdx.x * BN;
51+
52+ double acc[TM][TN];
53+#pragma unroll
54+ for (int i = 0; i < TM; i++)
55+#pragma unroll
56+ for (int j = 0; j < TN; j++) acc[i][j] = 0.0;
57+
58+ double ra[A_LOADS], rb[B_LOADS];
59+
60+ // Fetch k-slice `k0` of A and B into registers (zero outside the matrix).
61+ auto fetch = [&](int k0) {
62+#pragma unroll
63+ for (int i = 0; i < A_LOADS; i++) {
64+ const int idx = tid + i * THREADS;
65+ const int r = row0 + (idx / BK), k = k0 + (idx % BK);
66+ ra[i] = (r < m && k < n) ? A[(size_t)r * n + k] : 0.0;
67+ }
68+#pragma unroll
69+ for (int i = 0; i < B_LOADS; i++) {
70+ const int idx = tid + i * THREADS;
71+ const int k = k0 + (idx / BN), c = col0 + (idx % BN);
72+ rb[i] = (k < n && c < n) ? B[(size_t)k * n + c] : 0.0;
73+ }
74+ };
75+ auto commit = [&](int buf) {
76+#pragma unroll
77+ for (int i = 0; i < A_LOADS; i++) {
78+ const int idx = tid + i * THREADS;
79+ As[buf][idx % BK][idx / BK] = ra[i];
80+ }
81+#pragma unroll
82+ for (int i = 0; i < B_LOADS; i++) {
83+ const int idx = tid + i * THREADS;
84+ Bs[buf][idx / BN][idx % BN] = rb[i];
85+ }
86+ };
87+
88+ const int tiles = (n + BK - 1) / BK;
89+ fetch(0);
90+ commit(0);
91+ __syncthreads();
92+
93+ for (int t = 0; t < tiles; t++) {
94+ const int cur = t & 1;
95+ const bool more = t + 1 < tiles;
96+ if (more) fetch((t + 1) * BK);
97+
98+#pragma unroll
99+ for (int kk = 0; kk < BK; kk++) {
100+ double a[TM], b[TN];
101+#pragma unroll
102+ for (int i = 0; i < TM; i++) a[i] = As[cur][kk][ty * TM + i];
103+ const double2 b01 = *reinterpret_cast<const double2 *>(&Bs[cur][kk][tx * TN]);
104+ const double2 b23 = *reinterpret_cast<const double2 *>(&Bs[cur][kk][tx * TN + 2]);
105+ b[0] = b01.x; b[1] = b01.y; b[2] = b23.x; b[3] = b23.y;
106+#pragma unroll
107+ for (int i = 0; i < TM; i++)
108+#pragma unroll
109+ for (int j = 0; j < TN; j++) acc[i][j] = fma(a[i], b[j], acc[i][j]);
110+ }
111+
112+ if (more) commit(cur ^ 1);
113+ __syncthreads();
114+ }
115+
116+#pragma unroll
117+ for (int i = 0; i < TM; i++) {
118+ const int r = row0 + ty * TM + i;
119+ if (r >= m) continue;
120+#pragma unroll
121+ for (int j = 0; j < TN; j++) {
122+ const int c = col0 + tx * TN + j;
123+ if (c < n) C[(size_t)r * n + c] = acc[i][j];
124+ }
125+ }
126+}
127+
128+} // namespace
129+
130+extern "C" int ash_gpu_cuda_matrix_multiply(const double *a, const double *b, double *c, int n) {
131+ if (!ash_gpu_cuda_init() || n <= 0) return 0;
132+
133+ const size_t mat_bytes = (size_t)n * n * sizeof(double);
134+ double *dA = (double *)ash_cuda_scratch(0, mat_bytes);
135+ double *dB = (double *)ash_cuda_scratch(1, mat_bytes);
136+ double *dC = (double *)ash_cuda_scratch(2, mat_bytes);
137+ if (!dA || !dB || !dC) return 0;
138+
139+ const int pin_a = ash_cuda_pin(a, mat_bytes);
140+ const int pin_b = ash_cuda_pin(b, mat_bytes);
141+ const int pin_c = ash_cuda_pin(c, mat_bytes);
142+
143+ // Row bands are multiples of the tile height so no tile straddles two
144+ // streams. Tiny matrices stay on one stream.
145+ const int bands_wanted = n >= 512 ? ASH_CUDA_STREAMS : 1;
146+ const int band = ((n + bands_wanted - 1) / bands_wanted + BM - 1) / BM * BM;
147+
148+ bool ok = true;
149+
150+ // B is needed by every band: upload it once on stream 0 and make the
151+ // other streams wait for that event.
152+ cudaStream_t s0 = ash_cuda_stream(0);
153+ ok = ash_cuda_ok(cudaMemcpyAsync(dB, b, mat_bytes, cudaMemcpyHostToDevice, s0));
154+ cudaEvent_t b_ready = NULL;
155+ if (ok) ok = ash_cuda_ok(cudaEventCreateWithFlags(&b_ready, cudaEventDisableTiming));
156+ if (ok) ok = ash_cuda_ok(cudaEventRecord(b_ready, s0));
157+
158+ int used = 0;
159+ for (int s = 0; s < bands_wanted && ok; s++) {
160+ const int r0 = s * band;
161+ const int rows = std::min(band, n - r0);
162+ if (rows <= 0) break;
163+ used = s + 1;
164+ cudaStream_t st = ash_cuda_stream(s);
165+ const size_t off = (size_t)r0 * n;
166+ const size_t band_bytes = (size_t)rows * n * sizeof(double);
167+
168+ ok = ash_cuda_ok(cudaMemcpyAsync(dA + off, a + off, band_bytes, cudaMemcpyHostToDevice, st));
169+ if (!ok) break;
170+ ok = ash_cuda_ok(cudaStreamWaitEvent(st, b_ready, 0));
171+ if (!ok) break;
172+
173+ const dim3 block(THREADS_X, THREADS_Y);
174+ const dim3 grid((n + BN - 1) / BN, (rows + BM - 1) / BM);
175+ gemm_kernel<<<grid, block, 0, st>>>(dA + off, dB, dC + off, rows, n);
176+ ok = ash_cuda_ok(cudaGetLastError());
177+ if (!ok) break;
178+
179+ ok = ash_cuda_ok(cudaMemcpyAsync(c + off, dC + off, band_bytes, cudaMemcpyDeviceToHost, st));
180+ }
181+
182+ for (int s = 0; s < std::max(used, 1); s++) {
183+ if (!ash_cuda_ok(cudaStreamSynchronize(ash_cuda_stream(s)))) ok = false;
184+ }
185+ if (b_ready) cudaEventDestroy(b_ready);
186+ ash_cuda_unpin(a, pin_a);
187+ ash_cuda_unpin(b, pin_b);
188+ ash_cuda_unpin(c, pin_c);
189+ return ok ? 1 : 0;
190+}

pyre/src/C/backends/cuda/monte_carlo.cuadded

+110−0
@@ -0,0 +1,110 @@
1+// CUDA kernel for ash_gpu_monte_carlo_risk (ash_gpu.h): two LCG draws per
2+// iteration, count how often their product exceeds `threshold`.
3+//
4+// Same recurrence and per-thread seed derivation as the CPU backend
5+// (src/C/backends/cpu/monte_carlo.c): every GPU thread owns an independent
6+// splitmix32-seeded LCG stream, which is why ash_gpu.h already documents
7+// that the exact risky count differs from a single-threaded run.
8+//
9+// Why this is fast on NVIDIA:
10+// * Zero memory traffic inside the loop: the whole state of a thread is
11+// one 32-bit seed in a register; the only global write is one atomic
12+// per block.
13+// * No fp64 at all in the loop. The CPU backend tests
14+// (s1/2^32) * (s2/2^32) > threshold in doubles; the same test in exact
15+// integers is (u64)s1 * s2 > floor(threshold * 2^64), because the left
16+// side is an integer and the right side's fractional part cannot change
17+// which integers exceed it. That is one IMAD.WIDE plus a 64-bit
18+// compare, at full integer rate, instead of two fp64 conversions, a
19+// multiply and a compare at the card's 1/64 fp64 rate -- and it is the
20+// exact comparison, free of the double product's rounding.
21+// * The LCG step is a single IMAD (integer multiply-add, full rate).
22+// * Enough resident threads (SMs x 2048) to hide the dependent-IMAD
23+// latency of each chain; iterations are divided evenly with the
24+// remainder spread one-per-thread, so no thread idles at the end.
25+// * Reduction is warp shuffle -> shared -> one 64-bit atomicAdd per block.
26+#include "H/backends/cuda/context.h"
27+#include "H/backends/cuda/context_internal.cuh"
28+#include <math.h>
29+
30+namespace {
31+
32+constexpr int BLOCK = 256;
33+constexpr int BLOCKS_PER_SM = 8; // 8 x 256 = 2048 threads/SM: full occupancy on Turing..Ada
34+
35+__device__ __forceinline__ unsigned int splitmix32(unsigned int x) {
36+ x += 0x9E3779B9u;
37+ x = (x ^ (x >> 16)) * 0x85EBCA6Bu;
38+ x = (x ^ (x >> 13)) * 0xC2B2AE35u;
39+ return x ^ (x >> 16);
40+}
41+
42+__global__ void __launch_bounds__(BLOCK)
43+risk_kernel(long long iterations, unsigned long long cutoff, unsigned int seed,
44+ unsigned long long *out_risky) {
45+ const long long total_threads = (long long)gridDim.x * BLOCK;
46+ const long long tid = (long long)blockIdx.x * BLOCK + threadIdx.x;
47+
48+ // Even split, remainder spread one extra iteration to the first threads.
49+ const long long mine = iterations / total_threads + (tid < iterations % total_threads ? 1 : 0);
50+
51+ unsigned int s = splitmix32(seed + (unsigned int)tid * 2654435761u);
52+ unsigned int local = 0; // per-thread count fits 32 bits for any sane split
53+
54+ for (long long i = 0; i < mine; i++) {
55+ s = s * 1664525u + 1013904223u;
56+ const unsigned int s1 = s;
57+ s = s * 1664525u + 1013904223u;
58+ local += ((unsigned long long)s1 * s > cutoff) ? 1u : 0u;
59+ }
60+
61+ unsigned long long v = local;
62+#pragma unroll
63+ for (int d = 16; d > 0; d >>= 1) v += __shfl_down_sync(0xffffffffu, v, d);
64+
65+ __shared__ unsigned long long warp_sums[BLOCK / 32];
66+ if ((threadIdx.x & 31) == 0) warp_sums[threadIdx.x >> 5] = v;
67+ __syncthreads();
68+ if (threadIdx.x == 0) {
69+ unsigned long long block_sum = 0;
70+ for (int w = 0; w < BLOCK / 32; w++) block_sum += warp_sums[w];
71+ atomicAdd(out_risky, block_sum);
72+ }
73+}
74+
75+} // namespace
76+
77+extern "C" int ash_gpu_cuda_monte_carlo_risk(long long iterations, double threshold, unsigned int seed,
78+ long long *out_risky, double *out_probability) {
79+ if (!ash_gpu_cuda_init() || iterations <= 0) return 0;
80+
81+ unsigned long long *dev = (unsigned long long *)ash_cuda_scratch(0, sizeof(unsigned long long));
82+ if (!dev) return 0;
83+ cudaStream_t st = ash_cuda_stream(0);
84+
85+ // Map the double threshold onto the integer test in risk_kernel.
86+ // Products n1*n2 lie in [0, 1): a threshold >= 1 (or NaN) is never
87+ // exceeded, a threshold < 0 always is (even by a product of 0). In
88+ // between, scaling by 2^64 is an exact exponent shift and the cast
89+ // floors it.
90+ if (!(threshold < 1.0) || threshold < 0.0) {
91+ *out_risky = threshold < 0.0 ? iterations : 0;
92+ *out_probability = (double)*out_risky / (double)iterations;
93+ return 1;
94+ }
95+ const unsigned long long cutoff = (unsigned long long)ldexp(threshold, 64);
96+
97+ unsigned long long host_count = 0;
98+ bool ok = ash_cuda_ok(cudaMemsetAsync(dev, 0, sizeof(unsigned long long), st));
99+ if (ok) {
100+ risk_kernel<<<ash_cuda_sm_count() * BLOCKS_PER_SM, BLOCK, 0, st>>>(iterations, cutoff, seed, dev);
101+ ok = ash_cuda_ok(cudaGetLastError());
102+ }
103+ if (ok) ok = ash_cuda_ok(cudaMemcpyAsync(&host_count, dev, sizeof(host_count), cudaMemcpyDeviceToHost, st));
104+ if (ok) ok = ash_cuda_ok(cudaStreamSynchronize(st));
105+ if (!ok) return 0;
106+
107+ *out_risky = (long long)host_count;
108+ *out_probability = (double)host_count / (double)iterations;
109+ return 1;
110+}

pyre/src/C/backends/vulkan/context.cppmodified

+16−4
@@ -25,21 +25,31 @@ void destroy_partial(AshGpuVulkanContext &ctx) {
2525 ctx = AshGpuVulkanContext{};
2626 }
2727
28-// First physical device with both a compute-capable queue family and
29-// the shaderFloat64 feature (ash_gpu's ABI is `double` throughout, see
28+// Best physical device with both a compute-capable queue family and the
29+// shaderFloat64 feature (ash_gpu's ABI is `double` throughout, see
3030 // ash_gpu.h) -- a device that can only do fp32 compute isn't a match.
31+// Among matches a discrete GPU beats an integrated one: Vulkan lists
32+// devices in driver order, which on a hybrid laptop puts the iGPU first.
3133 bool pick_physical_device(VkInstance instance, VkPhysicalDevice *out_device, uint32_t *out_queue_family) {
3234 uint32_t count = 0;
3335 vkEnumeratePhysicalDevices(instance, &count, nullptr);
3436 if (count == 0) return false;
3537 std::vector<VkPhysicalDevice> devices(count);
3638 vkEnumeratePhysicalDevices(instance, &count, devices.data());
3739
40+ bool found = false;
41+ int best_rank = -1;
3842 for (VkPhysicalDevice dev : devices) {
3943 VkPhysicalDeviceFeatures features;
4044 vkGetPhysicalDeviceFeatures(dev, &features);
4145 if (!features.shaderFloat64) continue;
4246
47+ VkPhysicalDeviceProperties props;
48+ vkGetPhysicalDeviceProperties(dev, &props);
49+ int rank = props.deviceType == VK_PHYSICAL_DEVICE_TYPE_DISCRETE_GPU ? 2
50+ : props.deviceType == VK_PHYSICAL_DEVICE_TYPE_INTEGRATED_GPU ? 1 : 0;
51+ if (rank <= best_rank) continue;
52+
4353 uint32_t qf_count = 0;
4454 vkGetPhysicalDeviceQueueFamilyProperties(dev, &qf_count, nullptr);
4555 std::vector<VkQueueFamilyProperties> qfs(qf_count);
@@ -49,11 +59,13 @@ bool pick_physical_device(VkInstance instance, VkPhysicalDevice *out_device, uin
4959 if (qfs[i].queueFlags & VK_QUEUE_COMPUTE_BIT) {
5060 *out_device = dev;
5161 *out_queue_family = i;
52- return true;
62+ best_rank = rank;
63+ found = true;
64+ break;
5365 }
5466 }
5567 }
56- return false;
68+ return found;
5769 }
5870
5971 bool init_once(AshGpuVulkanContext &ctx) {

pyre/src/C/runtime/cost_model.cadded

+110−0
@@ -0,0 +1,110 @@
1+#define _POSIX_C_SOURCE 200809L
2+#include "H/runtime/cost_model.h"
3+#include <math.h>
4+#include <stdio.h>
5+#include <string.h>
6+#include <unistd.h>
7+
8+#define OBSERVE_RATE 0.3
9+
10+int pyre_work_bucket(double work) {
11+ if (work < 1.0) return 0;
12+ int b = (int)floor(log2(work));
13+ return b >= PYRE_BUCKETS ? PYRE_BUCKETS - 1 : b;
14+}
15+
16+void pyre_rates_observe(PyreRates *r, double work, double seconds) {
17+ if (work <= 0.0 || seconds <= 0.0) return;
18+ int b = pyre_work_bucket(work);
19+ double rate = seconds / work;
20+ if (!r->seen[b]) { r->rate[b] = rate; r->seen[b] = 1; }
21+ else r->rate[b] += (rate - r->rate[b]) * OBSERVE_RATE;
22+}
23+
24+double pyre_rates_known(const PyreRates *r, double work) {
25+ int b = pyre_work_bucket(work);
26+ return r->seen[b] ? r->rate[b] * work : -1.0;
27+}
28+
29+double pyre_rates_from_below(const PyreRates *r, double work) {
30+ for (int b = pyre_work_bucket(work); b >= 0; b--) {
31+ if (r->seen[b]) return r->rate[b] * work;
32+ }
33+ return -1.0;
34+}
35+
36+double pyre_rates_nearest(const PyreRates *r, double work) {
37+ int home = pyre_work_bucket(work);
38+ for (int d = 0; d < PYRE_BUCKETS; d++) {
39+ if (home - d >= 0 && r->seen[home - d]) return r->rate[home - d] * work;
40+ if (home + d < PYRE_BUCKETS && r->seen[home + d]) return r->rate[home + d] * work;
41+ }
42+ return -1.0;
43+}
44+
45+int pyre_profile_load(PyreProfile *p, const char *path) {
46+ memset(p, 0, sizeof(*p));
47+ FILE *f = fopen(path, "r");
48+ if (!f) return 0;
49+ int version = 0;
50+ char line[512];
51+ while (fgets(line, sizeof(line), f)) {
52+ char word[32], opn[32], name[128];
53+ int present, bucket;
54+ double v;
55+ if (sscanf(line, "version %d", &version) == 1) continue;
56+ if (sscanf(line, "cpu_threads %d", &p->cpu_threads) == 1) continue;
57+ if (sscanf(line, "cuda_built %d", &p->cuda_built) == 1) continue;
58+ name[0] = '\0';
59+ // "target <name> <present> <init_seconds> <device...>"
60+ int got = sscanf(line, "target %31s %d %lf %127[^\n]", word, &present, &v, name);
61+ if (got >= 3) {
62+ for (int t = 0; t < PYRE_TARGET_COUNT; t++) {
63+ if (strcmp(word, pyre_target_name(t))) continue;
64+ p->present[t] = present;
65+ p->init_seconds[t] = v;
66+ snprintf(p->device[t], sizeof(p->device[t]), "%s", got == 4 ? name : "");
67+ }
68+ continue;
69+ }
70+ // "rate <op> <target> <bucket> <seconds_per_work>"
71+ if (sscanf(line, "rate %31s %31s %d %lf", opn, word, &bucket, &v) == 4 && bucket >= 0 && bucket < PYRE_BUCKETS) {
72+ for (int o = 0; o < PYRE_OP_COUNT; o++) {
73+ if (strcmp(opn, pyre_op_name(o))) continue;
74+ for (int t = 0; t < PYRE_TARGET_COUNT; t++) {
75+ if (strcmp(word, pyre_target_name(t))) continue;
76+ p->rates[o][t].rate[bucket] = v;
77+ p->rates[o][t].seen[bucket] = 1;
78+ }
79+ }
80+ }
81+ }
82+ fclose(f);
83+ return version == PYRE_PROFILE_VERSION;
84+}
85+
86+int pyre_profile_save(const PyreProfile *p, const char *path) {
87+ char tmp[1100];
88+ snprintf(tmp, sizeof(tmp), "%s.%d.tmp", path, (int)getpid());
89+ FILE *f = fopen(tmp, "w");
90+ if (!f) return 0;
91+ fprintf(f, "# Pyre: what this machine was seen to do, learned from real calls.\n"
92+ "# Local to this machine, safe to delete (it is simply relearned).\n");
93+ fprintf(f, "version %d\ncpu_threads %d\ncuda_built %d\n", PYRE_PROFILE_VERSION, p->cpu_threads, p->cuda_built);
94+ for (int t = 0; t < PYRE_TARGET_COUNT; t++) {
95+ fprintf(f, "target %s %d %.9g %s\n", pyre_target_name(t), p->present[t], p->init_seconds[t], p->device[t]);
96+ }
97+ for (int o = 0; o < PYRE_OP_COUNT; o++) {
98+ for (int t = 0; t < PYRE_TARGET_COUNT; t++) {
99+ for (int b = 0; b < PYRE_BUCKETS; b++) {
100+ if (p->rates[o][t].seen[b]) {
101+ fprintf(f, "rate %s %s %d %.9g\n", pyre_op_name(o), pyre_target_name(t), b, p->rates[o][t].rate[b]);
102+ }
103+ }
104+ }
105+ }
106+ int ok = fclose(f) == 0;
107+ if (ok && rename(tmp, path) != 0) ok = 0;
108+ if (!ok) remove(tmp);
109+ return ok;
110+}

pyre/src/C/runtime/device.cmodified

+63−32
@@ -1,28 +1,29 @@
1+#define _POSIX_C_SOURCE 200809L
12 #include "H/runtime/ash_gpu.h"
2-#include "H/backends/vulkan/context.h"
3-#include "H/backends/cpu/matrix_multiply.h"
4-#include "H/backends/cpu/chaos.h"
3+#include "H/runtime/manager.h"
54 #include <string.h>
65
76 /*
8- * Real detection: tries to actually bring up a Vulkan device (not just
9- * check a loader is present -- see ash_gpu_vulkan_init(), src/C/backends/vulkan/context.cpp).
10- * This turned out to be WSL2 with GPU passthrough available but no
11- * Vulkan driver installed at first -- installing vulkan-dzn (Mesa's
12- * Vulkan-over-D3D12 driver) made this report ASH_GPU_BACKEND_VULKAN for
13- * real, naming the user's actual AMD Radeon RX 9070 XT. See
14- * src/C/backends/vulkan/context.cpp's header comment.
7+ * Real detection: tries to actually bring up a device, best first -- CUDA
8+ * (NVIDIA only, present only when pyre/cuda.mk found nvcc at build time),
9+ * then Vulkan (any vendor), else the CPU fallback. Not a loader-presence
10+ * check: each init really creates a device. This reports what EXISTS; which
11+ * of them a given call actually uses is the manager's decision
12+ * (src/C/runtime/manager.c), per call, from measured cost.
1513 */
1614 AshGpuInfo ash_gpu_detect(void) {
1715 AshGpuInfo info;
18- info.vram_bytes = 0;
16+ memset(&info, 0, sizeof(info));
17+ info.backend = ASH_GPU_BACKEND_NONE;
18+ strncpy(info.device_name, "CPU fallback", sizeof(info.device_name) - 1);
1919
20- if (ash_gpu_vulkan_init()) {
20+ if (pyre_target_init(PYRE_CUDA)) {
21+ info.backend = ASH_GPU_BACKEND_CUDA;
22+ info.vram_bytes = pyre_target_vram(PYRE_CUDA);
23+ strncpy(info.device_name, pyre_target_device(PYRE_CUDA), sizeof(info.device_name) - 1);
24+ } else if (pyre_target_init(PYRE_VULKAN)) {
2125 info.backend = ASH_GPU_BACKEND_VULKAN;
22- strncpy(info.device_name, ash_gpu_vulkan_device_name(), sizeof(info.device_name) - 1);
23- } else {
24- info.backend = ASH_GPU_BACKEND_NONE;
25- strncpy(info.device_name, "CPU fallback", sizeof(info.device_name) - 1);
26+ strncpy(info.device_name, pyre_target_device(PYRE_VULKAN), sizeof(info.device_name) - 1);
2627 }
2728 info.device_name[sizeof(info.device_name) - 1] = '\0';
2829 return info;
@@ -32,24 +33,54 @@ int ash_gpu_available(void) {
3233 return ash_gpu_detect().backend != ASH_GPU_BACKEND_NONE;
3334 }
3435
35-// Both dispatchers unconditionally prefer GPU-when-available over CPU,
36-// without comparing measured cost first -- matrix_multiply's own real
37-// measurement says that's the WRONG call for that kernel on this
38-// hardware (ash_gpu.h's comment on it); chaos_iterate exists precisely
39-// because it's shaped to actually win on GPU instead. Neither dispatcher
40-// has been taught to tell the difference yet -- see ideas/assigned.md.
36+// ---- placement queries and the three kernels: describe the work, let the
37+// manager place it ----
38+
39+static AshGpuBackend backend_of(PyreTarget t) {
40+ return t == PYRE_CUDA ? ASH_GPU_BACKEND_CUDA : t == PYRE_VULKAN ? ASH_GPU_BACKEND_VULKAN : ASH_GPU_BACKEND_NONE;
41+}
42+
43+static AshGpuPlan plan_for(PyreOp op, PyreWorkload w) {
44+ double est[PYRE_TARGET_COUNT];
45+ PyreTarget t = pyre_manager_estimate(op, w, est);
46+ AshGpuPlan plan = {backend_of(t), est[t], est[PYRE_CPU]};
47+ return plan;
48+}
49+
50+AshGpuPlan ash_gpu_plan_matrix_multiply(int n) { return plan_for(PYRE_OP_MATMUL, pyre_workload_matmul(n)); }
51+AshGpuPlan ash_gpu_plan_chaos_iterate(int n, int iterations) { return plan_for(PYRE_OP_CHAOS, pyre_workload_chaos(n, iterations)); }
52+AshGpuPlan ash_gpu_plan_monte_carlo_risk(long long iterations) { return plan_for(PYRE_OP_MONTE_CARLO, pyre_workload_monte_carlo(iterations)); }
53+
54+typedef struct { const double *a, *b; double *c; int n; } MatmulArgs;
55+static int run_matmul(PyreTarget t, void *p) {
56+ MatmulArgs *x = p;
57+ return pyre_run_matmul(t, x->a, x->b, x->c, x->n);
58+}
4159 void ash_gpu_matrix_multiply(const double *a, const double *b, double *c, int n) {
42- if (ash_gpu_available()) {
43- ash_gpu_vulkan_matrix_multiply(a, b, c, n);
44- return;
45- }
46- ash_gpu_cpu_matrix_multiply(a, b, c, n);
60+ MatmulArgs args = {a, b, c, n};
61+ pyre_manager_execute(PYRE_OP_MATMUL, pyre_workload_matmul(n), run_matmul, &args);
4762 }
4863
64+typedef struct { double *data; int n, iterations; } ChaosArgs;
65+static int run_chaos(PyreTarget t, void *p) {
66+ ChaosArgs *x = p;
67+ return pyre_run_chaos(t, x->data, x->n, x->iterations);
68+}
4969 void ash_gpu_chaos_iterate(double *data, int n, int iterations) {
50- if (ash_gpu_available()) {
51- ash_gpu_vulkan_chaos_iterate(data, n, iterations);
52- return;
53- }
54- ash_gpu_cpu_chaos_iterate(data, n, iterations);
70+ ChaosArgs args = {data, n, iterations};
71+ pyre_manager_execute(PYRE_OP_CHAOS, pyre_workload_chaos(n, iterations), run_chaos, &args);
72+}
73+
74+typedef struct {
75+ long long iterations; double threshold; unsigned int seed;
76+ long long *risky; double *probability;
77+} MonteCarloArgs;
78+static int run_monte_carlo(PyreTarget t, void *p) {
79+ MonteCarloArgs *x = p;
80+ return pyre_run_monte_carlo(t, x->iterations, x->threshold, x->seed, x->risky, x->probability);
81+}
82+void ash_gpu_monte_carlo_risk(long long iterations, double threshold, unsigned int seed,
83+ long long *out_risky, double *out_probability) {
84+ MonteCarloArgs args = {iterations, threshold, seed, out_risky, out_probability};
85+ pyre_manager_execute(PYRE_OP_MONTE_CARLO, pyre_workload_monte_carlo(iterations), run_monte_carlo, &args);
5586 }

pyre/src/C/runtime/manager.cadded

+215−0
@@ -0,0 +1,215 @@
1+#define _POSIX_C_SOURCE 200809L
2+#include "H/runtime/manager.h"
3+#include <stdio.h>
4+#include <stdlib.h>
5+#include <string.h>
6+#include <sys/stat.h>
7+
8+// Policy constants. These are about the SHAPE of a workload, not about any
9+// machine: a job under HEAVY_WORK units (1e8 multiply-adds, element
10+// iterations, ...) finishes in a fraction of a second on any current CPU, so
11+// it is not worth waking a GPU for; a job whose CPU time is under
12+// EXPLORE_MIN_S is too cheap to be worth an experiment on an unmeasured size.
13+#define HEAVY_WORK 1e8
14+#define WARM_AFTER_S 0.25
15+#define EXPLORE_MIN_S 0.05
16+
17+static PyreProfile profile;
18+static int loaded;
19+static int dirty;
20+static int atexit_registered;
21+static int disabled[PYRE_TARGET_COUNT];
22+static double cpu_seconds_used; // total CPU time Pyre has spent in this process
23+static int trace = -1;
24+static char path[1024];
25+static int path_resolved;
26+
27+PyreWorkload pyre_workload_matmul(int n) { return (PyreWorkload){3.0 * n * n * 8.0, (double)n * n * n}; }
28+PyreWorkload pyre_workload_chaos(int n, int iterations) { return (PyreWorkload){2.0 * n * 8.0, (double)n * iterations}; }
29+PyreWorkload pyre_workload_monte_carlo(long long iterations) { return (PyreWorkload){0.0, (double)iterations}; }
30+
31+// $PYRE_PROFILE, else $XDG_CACHE_HOME/pyre/profile.txt, else
32+// ~/.cache/pyre/profile.txt. Empty = no home, learn in memory only.
33+static void resolve_path(void) {
34+ if (path_resolved) return;
35+ path_resolved = 1;
36+ const char *override = getenv("PYRE_PROFILE");
37+ if (override && *override) { snprintf(path, sizeof(path), "%s", override); return; }
38+ const char *xdg = getenv("XDG_CACHE_HOME");
39+ const char *home = getenv("HOME");
40+ char dir[900];
41+ if (xdg && *xdg) snprintf(dir, sizeof(dir), "%s", xdg);
42+ else if (home && *home) snprintf(dir, sizeof(dir), "%s/.cache", home);
43+ else return;
44+ mkdir(dir, 0755);
45+ char sub[960];
46+ snprintf(sub, sizeof(sub), "%s/pyre", dir);
47+ mkdir(sub, 0755);
48+ snprintf(path, sizeof(path), "%s/profile.txt", sub);
49+}
50+
51+int pyre_manager_save(void) {
52+ resolve_path();
53+ if (!path[0] || !pyre_profile_save(&profile, path)) return 0;
54+ dirty = 0;
55+ return 1;
56+}
57+
58+static void save_at_exit(void) {
59+ if (dirty) pyre_manager_save();
60+}
61+
62+static void note_dirty(void) {
63+ dirty = 1;
64+ if (!atexit_registered) { atexit_registered = 1; atexit(save_at_exit); }
65+}
66+
67+static int tracing(void) {
68+ if (trace < 0) { const char *e = getenv("PYRE_TRACE"); trace = e && *e && *e != '0'; }
69+ return trace;
70+}
71+
72+static void reset_profile(void) {
73+ memset(&profile, 0, sizeof(profile));
74+ profile.cpu_threads = pyre_cpu_threads();
75+ profile.cuda_built = pyre_cuda_built();
76+ memset(disabled, 0, sizeof(disabled));
77+}
78+
79+static void ensure_loaded(void) {
80+ if (loaded) return;
81+ loaded = 1;
82+ resolve_path();
83+ if (!(path[0] && pyre_profile_load(&profile, path)) ||
84+ profile.cpu_threads != pyre_cpu_threads() || profile.cuda_built != pyre_cuda_built()) {
85+ reset_profile(); // other machine / other build / nothing yet: start empty
86+ }
87+}
88+
89+const char *pyre_manager_profile_path(void) { resolve_path(); return path; }
90+const PyreProfile *pyre_manager_profile(void) { ensure_loaded(); return &profile; }
91+PyreProfile *pyre_manager_profile_edit(void) { ensure_loaded(); note_dirty(); return &profile; }
92+
93+void pyre_manager_forget(void) {
94+ resolve_path();
95+ loaded = 1;
96+ reset_profile();
97+ if (path[0]) remove(path);
98+ dirty = 0;
99+}
100+
101+// Once a GPU target is up, make sure the profile's idea of it is this
102+// device: a different GPU (or driver reinstall onto other hardware) than
103+// the one the saved rates describe means those rates are not ours.
104+static void sync_target(PyreTarget t) {
105+ if (t == PYRE_CPU || !pyre_target_is_up(t)) return;
106+ const char *dev = pyre_target_device(t);
107+ if (profile.present[t] && strcmp(profile.device[t], dev) == 0) return;
108+ for (int o = 0; o < PYRE_OP_COUNT; o++) memset(&profile.rates[o][t], 0, sizeof(PyreRates));
109+ profile.present[t] = 1;
110+ snprintf(profile.device[t], sizeof(profile.device[t]), "%s", dev);
111+ profile.init_seconds[t] = pyre_target_init_seconds(t);
112+ note_dirty();
113+}
114+
115+typedef enum { SRC_NONE, SRC_KNOWN, SRC_INFERRED } Source;
116+
117+static PyreTarget decide(PyreOp op, PyreWorkload w, double est[PYRE_TARGET_COUNT], Source src[PYRE_TARGET_COUNT]) {
118+ double cpu = pyre_rates_nearest(&profile.rates[op][PYRE_CPU], w.work);
119+ est[PYRE_CPU] = cpu;
120+ src[PYRE_CPU] = cpu >= 0 ? SRC_KNOWN : SRC_NONE;
121+
122+ for (int t = PYRE_CPU + 1; t < PYRE_TARGET_COUNT; t++) {
123+ est[t] = -1.0;
124+ src[t] = SRC_NONE;
125+ if (disabled[t] || pyre_target_failed(t) || !pyre_target_supports(t, op)) continue;
126+ sync_target(t);
127+ double known = pyre_rates_known(&profile.rates[op][t], w.work);
128+ if (known >= 0) { est[t] = known; src[t] = SRC_KNOWN; continue; }
129+ // A rate measured at a smaller size is pessimistic here (fixed costs
130+ // amortise as work grows): if even that beats the CPU, the GPU wins.
131+ double below = pyre_rates_from_below(&profile.rates[op][t], w.work);
132+ if (below >= 0 && cpu >= 0 && below < cpu) { est[t] = below; src[t] = SRC_INFERRED; }
133+ }
134+
135+ if (cpu < 0) return PYRE_CPU; // nothing learned about the CPU yet: just run it
136+
137+ PyreTarget best = PYRE_CPU;
138+ double best_cost = cpu;
139+ for (int t = PYRE_CPU + 1; t < PYRE_TARGET_COUNT; t++) {
140+ if (est[t] < 0) continue;
141+ double cost = est[t];
142+ if (!pyre_target_is_up(t)) {
143+ if (pyre_target_warming()) continue; // never wait for a start-up in flight
144+ // Starting it in the foreground only when we already KNOW (from a
145+ // previous start on this machine) that the saving beats the start.
146+ if (profile.init_seconds[t] <= 0 || cpu - est[t] <= profile.init_seconds[t]) continue;
147+ cost += profile.init_seconds[t];
148+ }
149+ if (cost < best_cost) { best = t; best_cost = cost; }
150+ }
151+ if (best != PYRE_CPU) return best;
152+
153+ // Nothing measured beats the CPU. If the job is big enough to make one
154+ // experiment cheap, try an unmeasured size on a GPU that is already up.
155+ if (cpu >= EXPLORE_MIN_S) {
156+ for (int t = PYRE_CPU + 1; t < PYRE_TARGET_COUNT; t++) {
157+ if (!disabled[t] && pyre_target_is_up(t) && pyre_target_supports(t, op) &&
158+ pyre_rates_known(&profile.rates[op][t], w.work) < 0) {
159+ return t;
160+ }
161+ }
162+ }
163+ return PYRE_CPU;
164+}
165+
166+PyreTarget pyre_manager_estimate(PyreOp op, PyreWorkload w, double seconds[PYRE_TARGET_COUNT]) {
167+ ensure_loaded();
168+ Source src[PYRE_TARGET_COUNT];
169+ return decide(op, w, seconds, src);
170+}
171+
172+static void maybe_warm_gpu(PyreWorkload w) {
173+ if (pyre_target_warming()) return;
174+ for (int t = PYRE_CPU + 1; t < PYRE_TARGET_COUNT; t++) if (pyre_target_is_up(t)) return;
175+ if (w.work >= HEAVY_WORK || cpu_seconds_used >= WARM_AFTER_S) pyre_target_warm_async();
176+}
177+
178+void pyre_manager_execute(PyreOp op, PyreWorkload w, PyreRunFn run, void *args) {
179+ ensure_loaded();
180+ for (int attempt = 0; attempt < PYRE_TARGET_COUNT + 2; attempt++) {
181+ double est[PYRE_TARGET_COUNT];
182+ Source src[PYRE_TARGET_COUNT];
183+ PyreTarget t = decide(op, w, est, src);
184+
185+ if (!pyre_target_is_up(t)) {
186+ // Only reached when the profile already proved the start-up pays.
187+ if (!pyre_target_init(t)) { disabled[t] = 1; continue; }
188+ sync_target(t);
189+ continue; // re-decide: a different device voids the old rates
190+ }
191+ if (t == PYRE_CPU) maybe_warm_gpu(w);
192+
193+ double t0 = pyre_now_seconds();
194+ int ok = run(t, args);
195+ double dt = pyre_now_seconds() - t0;
196+ if (!ok) { disabled[t] = 1; continue; }
197+
198+ if (t == PYRE_CPU) cpu_seconds_used += dt;
199+ pyre_rates_observe(&profile.rates[op][t], w.work, dt);
200+ note_dirty();
201+
202+ if (tracing()) {
203+ fprintf(stderr, "pyre: %s work=%.3g -> %s %.4gs [", pyre_op_name(op), w.work, pyre_target_name(t), dt);
204+ for (int o = 0; o < PYRE_TARGET_COUNT; o++) {
205+ if (o) fputs(", ", stderr);
206+ if (est[o] >= 0) fprintf(stderr, "%s est %.4gs%s", pyre_target_name(o), est[o], src[o] == SRC_INFERRED ? " (inferred)" : "");
207+ else fprintf(stderr, "%s %s", pyre_target_name(o),
208+ pyre_target_warming() && o != PYRE_CPU ? "starting" : "unmeasured");
209+ }
210+ fputs("]\n", stderr);
211+ }
212+ return;
213+ }
214+ run(PYRE_CPU, args);
215+}

pyre/src/C/runtime/targets.cadded

+180−0
@@ -0,0 +1,180 @@
1+#define _POSIX_C_SOURCE 200809L
2+#include "H/runtime/targets.h"
3+#include "H/backends/cpu/matrix_multiply.h"
4+#include "H/backends/cpu/chaos.h"
5+#include "H/backends/cpu/monte_carlo.h"
6+#include "H/backends/vulkan/context.h"
7+#ifdef ASH_GPU_HAVE_CUDA
8+#include "H/backends/cuda/context.h"
9+#endif
10+#ifdef _OPENMP
11+#include <omp.h>
12+#endif
13+#include <pthread.h>
14+#include <stdatomic.h>
15+#include <stdio.h>
16+#include <stdlib.h>
17+
18+// up[] is read lock-free by the manager while the warm-up thread may be
19+// writing it; init itself is serialised by init_lock.
20+static atomic_int up[PYRE_TARGET_COUNT];
21+static atomic_int tried[PYRE_TARGET_COUNT];
22+static double init_seconds[PYRE_TARGET_COUNT];
23+static pthread_mutex_t init_lock = PTHREAD_MUTEX_INITIALIZER;
24+static atomic_int warming;
25+static atomic_int warm_started;
26+
27+const char *pyre_op_name(PyreOp op) {
28+ static const char *names[PYRE_OP_COUNT] = {"matrix_multiply", "chaos_iterate", "monte_carlo_risk"};
29+ return names[op];
30+}
31+
32+const char *pyre_target_name(PyreTarget t) {
33+ static const char *names[PYRE_TARGET_COUNT] = {"cpu", "cuda", "vulkan"};
34+ return names[t];
35+}
36+
37+int pyre_cpu_threads(void) {
38+#ifdef _OPENMP
39+ return omp_get_max_threads();
40+#else
41+ return 1;
42+#endif
43+}
44+
45+int pyre_cuda_built(void) {
46+#ifdef ASH_GPU_HAVE_CUDA
47+ return 1;
48+#else
49+ return 0;
50+#endif
51+}
52+
53+int pyre_target_supports(PyreTarget t, PyreOp op) {
54+ switch (t) {
55+ case PYRE_CPU: return 1;
56+#ifdef ASH_GPU_HAVE_CUDA
57+ case PYRE_CUDA: return 1;
58+#endif
59+ case PYRE_VULKAN: return op != PYRE_OP_MONTE_CARLO;
60+ default: return 0;
61+ }
62+}
63+
64+int pyre_target_init(PyreTarget t) {
65+ if (t == PYRE_CPU) return 1;
66+ if (atomic_load(&tried[t])) return atomic_load(&up[t]);
67+ pthread_mutex_lock(&init_lock);
68+ if (!atomic_load(&tried[t])) {
69+ double t0 = pyre_now_seconds();
70+ int ok = 0;
71+ if (t == PYRE_VULKAN) ok = ash_gpu_vulkan_init();
72+#ifdef ASH_GPU_HAVE_CUDA
73+ else if (t == PYRE_CUDA) ok = ash_gpu_cuda_init();
74+#endif
75+ init_seconds[t] = ok ? pyre_now_seconds() - t0 : 0.0;
76+ atomic_store(&up[t], ok);
77+ atomic_store(&tried[t], 1);
78+ }
79+ pthread_mutex_unlock(&init_lock);
80+ return atomic_load(&up[t]);
81+}
82+
83+int pyre_target_is_up(PyreTarget t) { return t == PYRE_CPU || atomic_load(&up[t]); }
84+int pyre_target_failed(PyreTarget t) { return t != PYRE_CPU && atomic_load(&tried[t]) && !atomic_load(&up[t]); }
85+double pyre_target_init_seconds(PyreTarget t) { return init_seconds[t]; }
86+
87+const char *pyre_target_device(PyreTarget t) {
88+ static char cpu[64];
89+ switch (t) {
90+ case PYRE_CPU:
91+ snprintf(cpu, sizeof(cpu), "CPU (%d threads)", pyre_cpu_threads());
92+ return cpu;
93+#ifdef ASH_GPU_HAVE_CUDA
94+ case PYRE_CUDA: return up[t] ? ash_gpu_cuda_device_name() : "";
95+#endif
96+ case PYRE_VULKAN: return up[t] ? ash_gpu_vulkan_device_name() : "";
97+ default: return "";
98+ }
99+}
100+
101+unsigned long long pyre_target_vram(PyreTarget t) {
102+#ifdef ASH_GPU_HAVE_CUDA
103+ if (t == PYRE_CUDA && up[t]) return ash_gpu_cuda_vram_bytes();
104+#endif
105+ (void)t;
106+ return 0;
107+}
108+
109+int pyre_run_matmul(PyreTarget t, const double *a, const double *b, double *c, int n) {
110+ switch (t) {
111+ case PYRE_CPU: ash_gpu_cpu_matrix_multiply(a, b, c, n); return 1;
112+#ifdef ASH_GPU_HAVE_CUDA
113+ case PYRE_CUDA: return ash_gpu_cuda_matrix_multiply(a, b, c, n);
114+#endif
115+ case PYRE_VULKAN: ash_gpu_vulkan_matrix_multiply(a, b, c, n); return 1;
116+ default: return 0;
117+ }
118+}
119+
120+int pyre_run_chaos(PyreTarget t, double *data, int n, int iterations) {
121+ switch (t) {
122+ case PYRE_CPU: ash_gpu_cpu_chaos_iterate(data, n, iterations); return 1;
123+#ifdef ASH_GPU_HAVE_CUDA
124+ case PYRE_CUDA: return ash_gpu_cuda_chaos_iterate(data, n, iterations);
125+#endif
126+ case PYRE_VULKAN: ash_gpu_vulkan_chaos_iterate(data, n, iterations); return 1;
127+ default: return 0;
128+ }
129+}
130+
131+int pyre_run_monte_carlo(PyreTarget t, long long iterations, double threshold, unsigned int seed,
132+ long long *out_risky, double *out_probability) {
133+ switch (t) {
134+ case PYRE_CPU:
135+ ash_gpu_cpu_monte_carlo_risk(iterations, threshold, seed, out_risky, out_probability);
136+ return 1;
137+#ifdef ASH_GPU_HAVE_CUDA
138+ case PYRE_CUDA:
139+ return ash_gpu_cuda_monte_carlo_risk(iterations, threshold, seed, out_risky, out_probability);
140+#endif
141+ default: return 0;
142+ }
143+}
144+
145+// One tiny run of every kernel the target has, so lazy loading (CUDA modules,
146+// Vulkan pipelines) is done before real work arrives.
147+static void preload(PyreTarget t) {
148+ double a[16 * 16], c[16 * 16], data[1000];
149+ for (int i = 0; i < 16 * 16; i++) a[i] = 1.0;
150+ for (int i = 0; i < 1000; i++) data[i] = 0.5;
151+ pyre_run_matmul(t, a, a, c, 16);
152+ pyre_run_chaos(t, data, 1000, 2);
153+ if (pyre_target_supports(t, PYRE_OP_MONTE_CARLO)) {
154+ long long risky;
155+ double p;
156+ pyre_run_monte_carlo(t, 1000, 0.75, 1u, &risky, &p);
157+ }
158+}
159+
160+static void *warm_thread(void *arg) {
161+ (void)arg;
162+ // Best first: CUDA, else Vulkan. A machine with a working CUDA device
163+ // gains nothing from also starting Vulkan on the same card.
164+ static const PyreTarget order[] = {PYRE_CUDA, PYRE_VULKAN};
165+ for (unsigned i = 0; i < sizeof(order) / sizeof(order[0]); i++) {
166+ if (pyre_target_init(order[i])) { preload(order[i]); break; }
167+ }
168+ atomic_store(&warming, 0);
169+ return NULL;
170+}
171+
172+void pyre_target_warm_async(void) {
173+ if (atomic_exchange(&warm_started, 1)) return;
174+ atomic_store(&warming, 1);
175+ pthread_t th;
176+ if (pthread_create(&th, NULL, warm_thread, NULL) == 0) pthread_detach(th);
177+ else atomic_store(&warming, 0);
178+}
179+
180+int pyre_target_warming(void) { return atomic_load(&warming); }

pyre/src/D/backends/cpu/monte_carlo.dmodified

+2−1
@@ -1,3 +1,4 @@
11 src/O/backends/cpu/monte_carlo.o: src/C/backends/cpu/monte_carlo.c \
2- src/H/runtime/ash_gpu.h
2+ src/H/runtime/ash_gpu.h src/H/backends/cpu/monte_carlo.h
33 src/H/runtime/ash_gpu.h:
4+src/H/backends/cpu/monte_carlo.h:

pyre/src/D/runtime/cost_model.dadded

+4−0
@@ -0,0 +1,4 @@
1+src/O/runtime/cost_model.o: src/C/runtime/cost_model.c \
2+ src/H/runtime/cost_model.h src/H/runtime/targets.h
3+src/H/runtime/cost_model.h:
4+src/H/runtime/targets.h:

pyre/src/D/runtime/device.dmodified

+5−5
@@ -1,7 +1,7 @@
11 src/O/runtime/device.o: src/C/runtime/device.c src/H/runtime/ash_gpu.h \
2- src/H/backends/vulkan/context.h src/H/backends/cpu/matrix_multiply.h \
3- src/H/backends/cpu/chaos.h
2+ src/H/runtime/manager.h src/H/runtime/cost_model.h \
3+ src/H/runtime/targets.h
44 src/H/runtime/ash_gpu.h:
5-src/H/backends/vulkan/context.h:
6-src/H/backends/cpu/matrix_multiply.h:
7-src/H/backends/cpu/chaos.h:
5+src/H/runtime/manager.h:
6+src/H/runtime/cost_model.h:
7+src/H/runtime/targets.h:

pyre/src/D/runtime/manager.dadded

+5−0
@@ -0,0 +1,5 @@
1+src/O/runtime/manager.o: src/C/runtime/manager.c src/H/runtime/manager.h \
2+ src/H/runtime/cost_model.h src/H/runtime/targets.h
3+src/H/runtime/manager.h:
4+src/H/runtime/cost_model.h:
5+src/H/runtime/targets.h:

pyre/src/D/runtime/targets.dadded

+10−0
@@ -0,0 +1,10 @@
1+src/O/runtime/targets.o: src/C/runtime/targets.c src/H/runtime/targets.h \
2+ src/H/backends/cpu/matrix_multiply.h src/H/backends/cpu/chaos.h \
3+ src/H/backends/cpu/monte_carlo.h src/H/backends/vulkan/context.h \
4+ src/H/backends/cuda/context.h
5+src/H/runtime/targets.h:
6+src/H/backends/cpu/matrix_multiply.h:
7+src/H/backends/cpu/chaos.h:
8+src/H/backends/cpu/monte_carlo.h:
9+src/H/backends/vulkan/context.h:
10+src/H/backends/cuda/context.h:

pyre/src/H/backends/cpu/monte_carlo.hadded

+7−0
@@ -0,0 +1,7 @@
1+#ifndef ASH_GPU_CPU_MONTE_CARLO_H
2+#define ASH_GPU_CPU_MONTE_CARLO_H
3+// Internal to the dispatcher (src/C/runtime/device.c) -- not part of
4+// ash_gpu.h's public ABI. Same contract as ash_gpu_monte_carlo_risk.
5+void ash_gpu_cpu_monte_carlo_risk(long long iterations, double threshold, unsigned int seed,
6+ long long *out_risky, double *out_probability);
7+#endif

pyre/src/H/backends/cuda/context.hadded

+38−0
@@ -0,0 +1,38 @@
1+#ifndef ASH_GPU_CUDA_CONTEXT_H
2+#define ASH_GPU_CUDA_CONTEXT_H
3+// Internal to the dispatcher (src/C/runtime/device.c) -- not part of
4+// ash_gpu.h's public ABI. NVIDIA-only backend, CUDA runtime API, kernels
5+// in src/C/backends/cuda/*.cu. Only compiled when pyre/cuda.mk finds
6+// nvcc (ASH_GPU_HAVE_CUDA); device.c never references this header
7+// otherwise.
8+//
9+// Every kernel entry point returns 1 when the GPU did the work and 0 when
10+// it could not (out of device memory, launch failure, ...): the
11+// dispatcher then runs the CPU backend, so "no GPU" and "GPU failed" are
12+// both a normal answer, never an error (design principle 1,
13+// ideas/gpu_acceleration.md). Not thread-safe: one caller at a time, like
14+// the Vulkan backend's singleton.
15+#ifdef __cplusplus
16+extern "C" {
17+#endif
18+
19+// Brings up the best CUDA device (most multiprocessors) once; later calls
20+// return the cached result. 1 = a CUDA device is ready, 0 = none (no
21+// driver, no NVIDIA GPU, runtime error).
22+int ash_gpu_cuda_init(void);
23+
24+// Valid after ash_gpu_cuda_init() returned 1.
25+const char *ash_gpu_cuda_device_name(void);
26+unsigned long long ash_gpu_cuda_vram_bytes(void);
27+
28+// Same contracts as ash_gpu_matrix_multiply / ash_gpu_chaos_iterate /
29+// ash_gpu_monte_carlo_risk in ash_gpu.h.
30+int ash_gpu_cuda_matrix_multiply(const double *a, const double *b, double *c, int n);
31+int ash_gpu_cuda_chaos_iterate(double *data, int n, int iterations);
32+int ash_gpu_cuda_monte_carlo_risk(long long iterations, double threshold, unsigned int seed,
33+ long long *out_risky, double *out_probability);
34+
35+#ifdef __cplusplus
36+}
37+#endif
38+#endif

pyre/src/H/backends/cuda/context_internal.cuhadded

+34−0
@@ -0,0 +1,34 @@
1+#ifndef ASH_GPU_CUDA_CONTEXT_INTERNAL_CUH
2+#define ASH_GPU_CUDA_CONTEXT_INTERNAL_CUH
3+// Shared by the CUDA backend's .cu files only (context.cu owns the state).
4+#include <cuda_runtime.h>
5+#include <stddef.h>
6+
7+// True when a CUDA call succeeded; on failure also clears the sticky
8+// error so the next call starts clean.
9+static inline bool ash_cuda_ok(cudaError_t e) {
10+ if (e == cudaSuccess) return true;
11+ (void)cudaGetLastError();
12+ return false;
13+}
14+
15+// Streams the pipelined kernels spread chunks over. Index < ASH_CUDA_STREAMS.
16+#define ASH_CUDA_STREAMS 4
17+cudaStream_t ash_cuda_stream(int index);
18+
19+// Grow-only device scratch buffers, reused across calls so repeated
20+// dispatches (ashvm calling matrix_mul in a loop) pay cudaMalloc once.
21+// Slot 0..2. Returns NULL if the allocation fails.
22+#define ASH_CUDA_SCRATCH_SLOTS 3
23+void *ash_cuda_scratch(int slot, size_t bytes);
24+
25+// Page-locks a caller's malloc'd buffer so cudaMemcpyAsync can DMA straight
26+// from/to it (no staging copy) and overlap with kernels. Skipped for small
27+// buffers where registration costs more than it saves. Failure is harmless:
28+// async copies from pageable memory still work, just without the overlap.
29+int ash_cuda_pin(const void *ptr, size_t bytes);
30+void ash_cuda_unpin(const void *ptr, int pinned);
31+
32+int ash_cuda_sm_count(void);
33+
34+#endif

pyre/src/H/runtime/ash_gpu.hmodified

+26−0
@@ -47,6 +47,32 @@ AshGpuInfo ash_gpu_detect(void);
4747 /* True iff ash_gpu_detect() found a usable (non-CPU-fallback) device. */
4848 int ash_gpu_available(void);
4949
50+/*
51+ * The manager's answer to "would you like me to take this over?", without
52+ * doing the work. Every kernel call below asks the same question itself and
53+ * acts on the answer (src/C/runtime/manager.c); these let a caller ask first,
54+ * e.g. to skip converting its own data into flat buffers for a job Pyre
55+ * would only hand straight back to the CPU.
56+ *
57+ * The prediction comes from what this machine has actually been seen to do
58+ * at this workload size, learned from real calls (no calibration pass, no
59+ * fixed threshold, nothing shipped; see H/runtime/manager.h). `backend` is
60+ * where Pyre would run it right now (ASH_GPU_BACKEND_NONE = the CPU
61+ * backend); est_seconds is the predicted time there and est_cpu_seconds the
62+ * CPU backend's, both negative until that size has been measured. A GPU
63+ * that is not up yet is never chosen unless the saving is known to beat
64+ * its start-up; calls never wait for a start-up in flight.
65+ */
66+typedef struct {
67+ AshGpuBackend backend;
68+ double est_seconds;
69+ double est_cpu_seconds;
70+} AshGpuPlan;
71+
72+AshGpuPlan ash_gpu_plan_matrix_multiply(int n);
73+AshGpuPlan ash_gpu_plan_chaos_iterate(int n, int iterations);
74+AshGpuPlan ash_gpu_plan_monte_carlo_risk(long long iterations);
75+
5076 /*
5177 * C = A * B, all three N x N, row-major, contiguous (flat, not an array
5278 * of row pointers -- see todo.md 2.A's "Contiguous Buffer Type": this is

pyre/src/H/runtime/cost_model.hadded

+59−0
@@ -0,0 +1,59 @@
1+#ifndef PYRE_COST_MODEL_H
2+#define PYRE_COST_MODEL_H
3+// What this machine has actually been seen to do, learned from real calls.
4+//
5+// There is no calibration pass and no shipped number: a program compiled on
6+// one machine and run on another starts knowing nothing and learns THAT
7+// machine from the work it really does. For every (kernel, target) the model
8+// is a table indexed by workload size (powers of two of the kernel's work
9+// units: n^3 for matmul, n*iterations for chaos, iterations for Monte
10+// Carlo) holding the measured seconds-per-work-unit at that size. Rates are
11+// kept per size bucket, not as one formula, because both transfer overhead
12+// and cache effects change the effective rate with size and a table simply
13+// records what happened.
14+#include "H/runtime/targets.h"
15+
16+#define PYRE_BUCKETS 48
17+
18+typedef struct {
19+ double rate[PYRE_BUCKETS]; // measured seconds per work unit, EMA
20+ unsigned char seen[PYRE_BUCKETS]; // 1 once a real call landed in the bucket
21+} PyreRates;
22+
23+typedef struct { double bytes, work; } PyreWorkload; // bytes: informational (trace/plan)
24+
25+int pyre_work_bucket(double work);
26+
27+// Fold one real, timed call in (first sample sets the rate, later ones
28+// move it 30% of the way).
29+void pyre_rates_observe(PyreRates *r, double work, double seconds);
30+
31+// Predicted seconds for `work`, only from a bucket measured at exactly this
32+// size; < 0 if that size was never seen.
33+double pyre_rates_known(const PyreRates *r, double work);
34+
35+// Predicted seconds from the nearest measured size at or BELOW this one
36+// (a rate measured at a smaller size is pessimistic for a larger one,
37+// since fixed overheads amortise), or < 0 if nothing smaller was seen.
38+double pyre_rates_from_below(const PyreRates *r, double work);
39+
40+// Predicted seconds from the nearest measured size on either side, or < 0.
41+double pyre_rates_nearest(const PyreRates *r, double work);
42+
43+// Everything learned so far. Persisting it is an optimisation for the
44+// next run on the SAME machine -- it is never required, never shipped, and
45+// is discarded when the hardware it describes is not the hardware found.
46+#define PYRE_PROFILE_VERSION 2
47+typedef struct {
48+ int cpu_threads;
49+ int cuda_built;
50+ int present[PYRE_TARGET_COUNT];
51+ char device[PYRE_TARGET_COUNT][128];
52+ double init_seconds[PYRE_TARGET_COUNT];
53+ PyreRates rates[PYRE_OP_COUNT][PYRE_TARGET_COUNT];
54+} PyreProfile;
55+
56+int pyre_profile_load(PyreProfile *p, const char *path); // 0 = missing/foreign: start empty
57+int pyre_profile_save(const PyreProfile *p, const char *path);
58+
59+#endif

pyre/src/H/runtime/manager.hadded

+47−0
@@ -0,0 +1,47 @@
1+#ifndef PYRE_MANAGER_H
2+#define PYRE_MANAGER_H
3+// The manager: Pyre's resident decision-maker. For every kernel call it
4+// looks up what this machine has actually been seen to do at this workload
5+// size (cost_model.h), runs the work on the fastest target, times it, and
6+// records the result -- so it keeps learning from the program's own work
7+// with no calibration step and no added delay:
8+//
9+// * A call NEVER waits for a GPU to start. Until one is up the CPU runs the
10+// work; when the work is heavy, a background thread brings the GPU up
11+// meanwhile (targets.h: pyre_target_warm_async).
12+// * A GPU is only chosen where it was measured -- or can be inferred from a
13+// smaller measured size -- to beat the CPU. A size it has never run at
14+// gets one real try, and only when the job is big enough
15+// (EXPLORE_MIN_S) that one slow try is cheap.
16+// * What it learns is saved for the next run on the same machine, as an
17+// optimisation only (it is never shipped, never required).
18+#include "H/runtime/cost_model.h"
19+
20+typedef int (*PyreRunFn)(PyreTarget t, void *args); // 1 = ran, 0 = could not
21+
22+// How much each kernel call costs in bytes moved and work done.
23+PyreWorkload pyre_workload_matmul(int n);
24+PyreWorkload pyre_workload_chaos(int n, int iterations);
25+PyreWorkload pyre_workload_monte_carlo(long long iterations);
26+
27+// The profile in force (loaded on first use; empty if none). Never NULL.
28+const PyreProfile *pyre_manager_profile(void);
29+const char *pyre_manager_profile_path(void); // "" if persistence is off
30+
31+// Developer tooling (tools/pyre.c): edit the profile directly, persist it,
32+// or throw it away.
33+PyreProfile *pyre_manager_profile_edit(void);
34+int pyre_manager_save(void);
35+void pyre_manager_forget(void);
36+
37+// The "should I take this over?" question without doing the work: predicted
38+// seconds per target (< 0 = not known / can't run it), returning the target
39+// the manager would use right now.
40+PyreTarget pyre_manager_estimate(PyreOp op, PyreWorkload w, double seconds[PYRE_TARGET_COUNT]);
41+
42+// Runs the workload on the best target; a target that fails to start or to
43+// run is taken out of rotation and the work falls through, ending at the
44+// CPU, which always runs.
45+void pyre_manager_execute(PyreOp op, PyreWorkload w, PyreRunFn run, void *args);
46+
47+#endif

pyre/src/H/runtime/targets.hadded

+55−0
@@ -0,0 +1,55 @@
1+#ifndef PYRE_TARGETS_H
2+#define PYRE_TARGETS_H
3+// Internal to the runtime (device.c, manager.c, calibrate.c, tools/pyre.c):
4+// one uniform way to bring up and run each place a kernel can execute, so
5+// the manager can reason about "CPU / CUDA / Vulkan" without knowing any
6+// backend's API. Not part of ash_gpu.h's public ABI.
7+#include <time.h>
8+
9+typedef enum { PYRE_OP_MATMUL, PYRE_OP_CHAOS, PYRE_OP_MONTE_CARLO, PYRE_OP_COUNT } PyreOp;
10+typedef enum { PYRE_CPU, PYRE_CUDA, PYRE_VULKAN, PYRE_TARGET_COUNT } PyreTarget;
11+
12+const char *pyre_op_name(PyreOp op);
13+const char *pyre_target_name(PyreTarget t);
14+
15+// The two build/machine facts a measured profile is only valid for.
16+int pyre_cpu_threads(void);
17+int pyre_cuda_built(void);
18+
19+// Whether this target can run this op at all (Vulkan has no Monte Carlo
20+// kernel; CUDA is absent unless pyre/cuda.mk found nvcc at build time).
21+int pyre_target_supports(PyreTarget t, PyreOp op);
22+
23+// Brings the target up (idempotent, remembers the answer). 1 = usable.
24+// The first successful call is timed: pyre_target_init_seconds() is the
25+// one-off price of using that target in a fresh process, which the
26+// manager charges to the first job that goes there.
27+int pyre_target_init(PyreTarget t);
28+int pyre_target_is_up(PyreTarget t); // already initialised and usable (never blocks)
29+int pyre_target_failed(PyreTarget t); // init was attempted and did not work
30+double pyre_target_init_seconds(PyreTarget t);
31+const char *pyre_target_device(PyreTarget t);
32+unsigned long long pyre_target_vram(PyreTarget t);
33+
34+// Run a kernel on a target. 1 = it ran, 0 = it could not (caller falls
35+// back). CPU always runs.
36+// Starts a background thread that brings up the best available GPU
37+// (CUDA, else Vulkan) and runs one tiny kernel of each kind on it so lazy
38+// module / pipeline loading is paid there, not by a real call. Returns at
39+// once; at most one warm-up ever runs. A call never waits for it -- the
40+// manager just keeps using the CPU until pyre_target_is_up() turns true.
41+void pyre_target_warm_async(void);
42+int pyre_target_warming(void);
43+
44+int pyre_run_matmul(PyreTarget t, const double *a, const double *b, double *c, int n);
45+int pyre_run_chaos(PyreTarget t, double *data, int n, int iterations);
46+int pyre_run_monte_carlo(PyreTarget t, long long iterations, double threshold, unsigned int seed,
47+ long long *out_risky, double *out_probability);
48+
49+static inline double pyre_now_seconds(void) {
50+ struct timespec ts;
51+ clock_gettime(CLOCK_MONOTONIC, &ts);
52+ return (double)ts.tv_sec + (double)ts.tv_nsec / 1e9;
53+}
54+
55+#endif

pyre/src/O/backends/cuda/.gitkeepadded

+0−0

No content changes. View the file on GitHub

pyre/src/web/index.htmladded

+21−0
@@ -0,0 +1,21 @@
1+<!doctype html>
2+<html lang="en">
3+ <head>
4+ <meta charset="UTF-8" />
5+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+ <title>Pyre Dashboard</title>
7+ <script>
8+ // Apply the saved theme before first paint (no light/dark flash).
9+ // ?theme=dark|light in the URL wins over the saved choice.
10+ try {
11+ var q = new URLSearchParams(location.search).get('theme');
12+ var t = q || localStorage.getItem('pyre-theme');
13+ if (t === 'light' || t === 'dark') document.documentElement.dataset.theme = t;
14+ } catch (e) {}
15+ </script>
16+ </head>
17+ <body>
18+ <div id="root"></div>
19+ <script type="module" src="/src/main.tsx"></script>
20+ </body>
21+</html>

pyre/src/web/package.jsonadded

+21−0
@@ -0,0 +1,21 @@
1+{
2+ "name": "pyre-dashboard",
3+ "private": true,
4+ "version": "1.0.0",
5+ "type": "module",
6+ "scripts": {
7+ "dev": "vite",
8+ "build": "tsc --noEmit && vite build"
9+ },
10+ "dependencies": {
11+ "react": "^19.3.0",
12+ "react-dom": "^19.3.0"
13+ },
14+ "devDependencies": {
15+ "@types/react": "^19.3.0",
16+ "@types/react-dom": "^19.3.0",
17+ "@vitejs/plugin-react": "^6.1.2",
18+ "typescript": "^7.0.2",
19+ "vite": "^8.3.3"
20+ }
21+}

pyre/src/web/pnpm-lock.yamladded

+730−0
Show diff (730 changed lines)
@@ -0,0 +1,730 @@
1+lockfileVersion: '9.0'
2+
3+settings:
4+ autoInstallPeers: true
5+ excludeLinksFromLockfile: false
6+
7+importers:
8+
9+ .:
10+ dependencies:
11+ react:
12+ specifier: ^19.3.0
13+ version: 19.3.0
14+ react-dom:
15+ specifier: ^19.3.0
16+ version: 19.3.0(react@19.3.0)
17+ devDependencies:
18+ '@types/react':
19+ specifier: ^19.3.0
20+ version: 19.3.0
21+ '@types/react-dom':
22+ specifier: ^19.3.0
23+ version: 19.3.0(@types/react@19.3.0)
24+ '@vitejs/plugin-react':
25+ specifier: ^6.1.2
26+ version: 6.1.2(vite@8.3.3)
27+ typescript:
28+ specifier: ^7.0.2
29+ version: 7.0.2
30+ vite:
31+ specifier: ^8.3.3
32+ version: 8.3.3
33+
34+packages:
35+
36+ '@oxc-project/types@0.153.0':
37+ resolution: {integrity: sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==}
38+
39+ '@rolldown/binding-android-arm-eabi@1.2.13':
40+ resolution: {integrity: sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA==}
41+ engines: {node: ^20.19.0 || >=22.12.0}
42+ cpu: [arm]
43+ os: [android]
44+
45+ '@rolldown/binding-android-arm64@1.2.13':
46+ resolution: {integrity: sha512-6LEoIrGy5vvCd2boNnv2soPSUZj/PRvhp8R/t9KlX+mIZxThIE/ZqxrGXxmuNX0FuvQFvLdZr7S4HrYpYBVhEQ==}
47+ engines: {node: ^20.19.0 || >=22.12.0}
48+ cpu: [arm64]
49+ os: [android]
50+
51+ '@rolldown/binding-darwin-arm64@1.2.13':
52+ resolution: {integrity: sha512-CaJSRzlQ6Q+zkDMVA9OkVxBjVoPRCDEE3v0FSJ9kIQBgRjFApA3Si4ijhbNVUji7ZJt+PfO0e46qy4nPInHUPA==}
53+ engines: {node: ^20.19.0 || >=22.12.0}
54+ cpu: [arm64]
55+ os: [darwin]
56+
57+ '@rolldown/binding-darwin-x64@1.2.13':
58+ resolution: {integrity: sha512-e4r340I5hxSzJkD3YJBxgzq/rJ3wwxwdLLb2FPt1Zpyem20nrQPT11DDEEkazwxaHumH8wpglsp/Ozgj/wT6mw==}
59+ engines: {node: ^20.19.0 || >=22.12.0}
60+ cpu: [x64]
61+ os: [darwin]
62+
63+ '@rolldown/binding-freebsd-x64@1.2.13':
64+ resolution: {integrity: sha512-hoBWDZ4/K/mJKVMbsgUd1jfpA2DiOrJ54mpegObU9TBHBBrtX4FLIXJJQRDPZ/2Dn91GvPob+Z2hwKJzf4E5SQ==}
65+ engines: {node: ^20.19.0 || >=22.12.0}
66+ cpu: [x64]
67+ os: [freebsd]
68+
69+ '@rolldown/binding-linux-arm-gnueabihf@1.2.13':
70+ resolution: {integrity: sha512-oF9fZu3vTWnTiVAkDFxU9PJLt1nh20K9RRpuHsR7ZnE2L7XookBbDBVLojCVSLGbvtSlxeUNnjAj6DbNsKyzbg==}
71+ engines: {node: ^20.19.0 || >=22.12.0}
72+ cpu: [arm]
73+ os: [linux]
74+
75+ '@rolldown/binding-linux-arm64-gnu@1.2.13':
76+ resolution: {integrity: sha512-DTpb/+jMiqWMPgphn4ksW1zW+XLJ6dJJ9FMvnIk9I291LhCctMPhMYJrP5t9p6C9qa3KXadDdUEZHBaPh3kNxQ==}
77+ engines: {node: ^20.19.0 || >=22.12.0}
78+ cpu: [arm64]
79+ os: [linux]
80+ libc: [glibc]
81+
82+ '@rolldown/binding-linux-arm64-musl@1.2.13':
83+ resolution: {integrity: sha512-xU+Kvv9N2A3SY35zFh1FWWNjxbadE/BKeqnkXe7vScU4GZsw757BlVmDLITZEq8DPpk6m7uoDYkK7P+x/uU5Ow==}
84+ engines: {node: ^20.19.0 || >=22.12.0}
85+ cpu: [arm64]
86+ os: [linux]
87+ libc: [musl]
88+
89+ '@rolldown/binding-linux-ppc64-gnu@1.2.13':
90+ resolution: {integrity: sha512-XMk/hnhh25oQ756Lclb1Q11h4ll0xC0Esa5w4Vzp+mAE2S3AGJgeM0SonrIgY8fHhjarb6hcoTuwoFG0VVP2ig==}
91+ engines: {node: ^20.19.0 || >=22.12.0}
92+ cpu: [ppc64]
93+ os: [linux]
94+ libc: [glibc]
95+
96+ '@rolldown/binding-linux-s390x-gnu@1.2.13':
97+ resolution: {integrity: sha512-U1BoTHYmlywE2n3HUWjzZ+8EQsKr+OwpxYuDWaubx42tUL8QSL7nOqRz/NVHanibbFP3M1Rxuprz/wNcbMKjqw==}
98+ engines: {node: ^20.19.0 || >=22.12.0}
99+ cpu: [s390x]
100+ os: [linux]
101+ libc: [glibc]
102+
103+ '@rolldown/binding-linux-x64-gnu@1.2.13':
104+ resolution: {integrity: sha512-J4cm80Pc6wEwV5AZ42o62sIQgDk+Y7R2s646jIwuvO+4V46QB58N4vVuMPWCBaNgursXLpmUWJV//WQAkPvHBQ==}
105+ engines: {node: ^20.19.0 || >=22.12.0}
106+ cpu: [x64]
107+ os: [linux]
108+ libc: [glibc]
109+
110+ '@rolldown/binding-linux-x64-musl@1.2.13':
111+ resolution: {integrity: sha512-W0euZS40U4V6JkmEnVCIX9mcsL6cbXQ4X8c2w+MMOIjFg68TXUW+u3RT+2e1t1pv9mg/EP3pDtK3POXdQ7Rxyw==}
112+ engines: {node: ^20.19.0 || >=22.12.0}
113+ cpu: [x64]
114+ os: [linux]
115+ libc: [musl]
116+
117+ '@rolldown/binding-openharmony-arm64@1.2.13':
118+ resolution: {integrity: sha512-do/AF/4n9MRshia3a5aXoy8xM2c6mgfT6D7H/dY6my5z+hfCIR4ab7mk4IRj++nsEQpKy39kd0DQnDq+r692tw==}
119+ engines: {node: ^20.19.0 || >=22.12.0}
120+ cpu: [arm64]
121+ os: [openharmony]
122+
123+ '@rolldown/binding-win32-arm64-msvc@1.2.13':
124+ resolution: {integrity: sha512-6FtUf2cJZobutJdxnGYX8z0vtbgqU0b2v8vfBhkXb9wp2MppMFtkunE+IaOntyi01rkIQoPW2GHABtfzsC0BRQ==}
125+ engines: {node: ^20.19.0 || >=22.12.0}
126+ cpu: [arm64]
127+ os: [win32]
128+
129+ '@rolldown/binding-win32-x64-msvc@1.2.13':
130+ resolution: {integrity: sha512-UKhFaZTeTcbKrb3iU82m17jVcPEAoqDkCN5MBqgPyZSwp/cQmib21crTS0DdH7T1B7wZRWEI89YQAOJMet5yhg==}
131+ engines: {node: ^20.19.0 || >=22.12.0}
132+ cpu: [x64]
133+ os: [win32]
134+
135+ '@rolldown/pluginutils@1.0.1':
136+ resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
137+
138+ '@types/react-dom@19.3.0':
139+ resolution: {integrity: sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==}
140+ peerDependencies:
141+ '@types/react': ^19.3.0
142+
143+ '@types/react@19.3.0':
144+ resolution: {integrity: sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==}
145+
146+ '@typescript/typescript-aix-ppc64@7.0.2':
147+ resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==}
148+ engines: {node: '>=16.20.0'}
149+ cpu: [ppc64]
150+ os: [aix]
151+
152+ '@typescript/typescript-darwin-arm64@7.0.2':
153+ resolution: {integrity: sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==}
154+ engines: {node: '>=16.20.0'}
155+ cpu: [arm64]
156+ os: [darwin]
157+
158+ '@typescript/typescript-darwin-x64@7.0.2':
159+ resolution: {integrity: sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==}
160+ engines: {node: '>=16.20.0'}
161+ cpu: [x64]
162+ os: [darwin]
163+
164+ '@typescript/typescript-freebsd-arm64@7.0.2':
165+ resolution: {integrity: sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==}
166+ engines: {node: '>=16.20.0'}
167+ cpu: [arm64]
168+ os: [freebsd]
169+
170+ '@typescript/typescript-freebsd-x64@7.0.2':
171+ resolution: {integrity: sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==}
172+ engines: {node: '>=16.20.0'}
173+ cpu: [x64]
174+ os: [freebsd]
175+
176+ '@typescript/typescript-linux-arm64@7.0.2':
177+ resolution: {integrity: sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==}
178+ engines: {node: '>=16.20.0'}
179+ cpu: [arm64]
180+ os: [linux]
181+
182+ '@typescript/typescript-linux-arm@7.0.2':
183+ resolution: {integrity: sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==}
184+ engines: {node: '>=16.20.0'}
185+ cpu: [arm]
186+ os: [linux]
187+
188+ '@typescript/typescript-linux-loong64@7.0.2':
189+ resolution: {integrity: sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==}
190+ engines: {node: '>=16.20.0'}
191+ cpu: [loong64]
192+ os: [linux]
193+
194+ '@typescript/typescript-linux-mips64el@7.0.2':
195+ resolution: {integrity: sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==}
196+ engines: {node: '>=16.20.0'}
197+ cpu: [mips64el]
198+ os: [linux]
199+
200+ '@typescript/typescript-linux-ppc64@7.0.2':
201+ resolution: {integrity: sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==}
202+ engines: {node: '>=16.20.0'}
203+ cpu: [ppc64]
204+ os: [linux]
205+
206+ '@typescript/typescript-linux-riscv64@7.0.2':
207+ resolution: {integrity: sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==}
208+ engines: {node: '>=16.20.0'}
209+ cpu: [riscv64]
210+ os: [linux]
211+
212+ '@typescript/typescript-linux-s390x@7.0.2':
213+ resolution: {integrity: sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==}
214+ engines: {node: '>=16.20.0'}
215+ cpu: [s390x]
216+ os: [linux]
217+
218+ '@typescript/typescript-linux-x64@7.0.2':
219+ resolution: {integrity: sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==}
220+ engines: {node: '>=16.20.0'}
221+ cpu: [x64]
222+ os: [linux]
223+
224+ '@typescript/typescript-netbsd-arm64@7.0.2':
225+ resolution: {integrity: sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==}
226+ engines: {node: '>=16.20.0'}
227+ cpu: [arm64]
228+ os: [netbsd]
229+
230+ '@typescript/typescript-netbsd-x64@7.0.2':
231+ resolution: {integrity: sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==}
232+ engines: {node: '>=16.20.0'}
233+ cpu: [x64]
234+ os: [netbsd]
235+
236+ '@typescript/typescript-openbsd-arm64@7.0.2':
237+ resolution: {integrity: sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==}
238+ engines: {node: '>=16.20.0'}
239+ cpu: [arm64]
240+ os: [openbsd]
241+
242+ '@typescript/typescript-openbsd-x64@7.0.2':
243+ resolution: {integrity: sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==}
244+ engines: {node: '>=16.20.0'}
245+ cpu: [x64]
246+ os: [openbsd]
247+
248+ '@typescript/typescript-sunos-x64@7.0.2':
249+ resolution: {integrity: sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==}
250+ engines: {node: '>=16.20.0'}
251+ cpu: [x64]
252+ os: [sunos]
253+
254+ '@typescript/typescript-win32-arm64@7.0.2':
255+ resolution: {integrity: sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==}
256+ engines: {node: '>=16.20.0'}
257+ cpu: [arm64]
258+ os: [win32]
259+
260+ '@typescript/typescript-win32-x64@7.0.2':
261+ resolution: {integrity: sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==}
262+ engines: {node: '>=16.20.0'}
263+ cpu: [x64]
264+ os: [win32]
265+
266+ '@vitejs/plugin-react@6.1.2':
267+ resolution: {integrity: sha512-fhJNGUdscQa4HkI0xFxwonWyhnVReCPDbXI7FH7jmzpnVCrFQI7PRrhWecO263610vOrs9tr2Y4xcf8uAdFxbw==}
268+ engines: {node: ^20.19.0 || >=22.12.0}
269+ peerDependencies:
270+ '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0
271+ babel-plugin-react-compiler: ^1.0.0
272+ oxc-transform-react: ^0.152.0
273+ vite: ^8.0.0
274+ peerDependenciesMeta:
275+ '@rolldown/plugin-babel':
276+ optional: true
277+ babel-plugin-react-compiler:
278+ optional: true
279+ oxc-transform-react:
280+ optional: true
281+
282+ csstype@3.2.3:
283+ resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
284+
285+ detect-libc@2.1.2:
286+ resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
287+ engines: {node: '>=8'}
288+
289+ fdir@6.5.0:
290+ resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
291+ engines: {node: '>=12.0.0'}
292+ peerDependencies:
293+ picomatch: ^3 || ^4
294+ peerDependenciesMeta:
295+ picomatch:
296+ optional: true
297+
298+ fsevents@2.3.3:
299+ resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
300+ engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
301+ os: [darwin]
302+
303+ lightningcss-android-arm64@1.33.0:
304+ resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==}
305+ engines: {node: '>= 12.0.0'}
306+ cpu: [arm64]
307+ os: [android]
308+
309+ lightningcss-darwin-arm64@1.33.0:
310+ resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==}
311+ engines: {node: '>= 12.0.0'}
312+ cpu: [arm64]
313+ os: [darwin]
314+
315+ lightningcss-darwin-x64@1.33.0:
316+ resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==}
317+ engines: {node: '>= 12.0.0'}
318+ cpu: [x64]
319+ os: [darwin]
320+
321+ lightningcss-freebsd-x64@1.33.0:
322+ resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==}
323+ engines: {node: '>= 12.0.0'}
324+ cpu: [x64]
325+ os: [freebsd]
326+
327+ lightningcss-linux-arm-gnueabihf@1.33.0:
328+ resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==}
329+ engines: {node: '>= 12.0.0'}
330+ cpu: [arm]
331+ os: [linux]
332+
333+ lightningcss-linux-arm64-gnu@1.33.0:
334+ resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==}
335+ engines: {node: '>= 12.0.0'}
336+ cpu: [arm64]
337+ os: [linux]
338+ libc: [glibc]
339+
340+ lightningcss-linux-arm64-musl@1.33.0:
341+ resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==}
342+ engines: {node: '>= 12.0.0'}
343+ cpu: [arm64]
344+ os: [linux]
345+ libc: [musl]
346+
347+ lightningcss-linux-x64-gnu@1.33.0:
348+ resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==}
349+ engines: {node: '>= 12.0.0'}
350+ cpu: [x64]
351+ os: [linux]
352+ libc: [glibc]
353+
354+ lightningcss-linux-x64-musl@1.33.0:
355+ resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==}
356+ engines: {node: '>= 12.0.0'}
357+ cpu: [x64]
358+ os: [linux]
359+ libc: [musl]
360+
361+ lightningcss-win32-arm64-msvc@1.33.0:
362+ resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==}
363+ engines: {node: '>= 12.0.0'}
364+ cpu: [arm64]
365+ os: [win32]
366+
367+ lightningcss-win32-x64-msvc@1.33.0:
368+ resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==}
369+ engines: {node: '>= 12.0.0'}
370+ cpu: [x64]
371+ os: [win32]
372+
373+ lightningcss@1.33.0:
374+ resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==}
375+ engines: {node: '>= 12.0.0'}
376+
377+ nanoid@3.3.20:
378+ resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==}
379+ engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
380+ hasBin: true
381+
382+ picocolors@1.1.1:
383+ resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
384+
385+ picomatch@4.0.7:
386+ resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==}
387+ engines: {node: '>=12'}
388+
389+ postcss@8.5.29:
390+ resolution: {integrity: sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==}
391+ engines: {node: ^10 || ^12 || >=14}
392+
393+ react-dom@19.3.0:
394+ resolution: {integrity: sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==}
395+ peerDependencies:
396+ react: ^19.3.0
397+
398+ react@19.3.0:
399+ resolution: {integrity: sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==}
400+ engines: {node: '>=0.10.0'}
401+
402+ rolldown@1.2.13:
403+ resolution: {integrity: sha512-/5apOkwWxtACcQtZ5WlrAsKS4JwXVThQPJZ3RqIruRY9UG9M8e0VlXN068xum/JRKA4aIUoJ13ghe/kGCvidiQ==}
404+ engines: {node: ^20.19.0 || >=22.12.0}
405+ hasBin: true
406+
407+ scheduler@0.28.0:
408+ resolution: {integrity: sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==}
409+
410+ source-map-js@1.2.2:
411+ resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==}
412+ engines: {node: '>=0.10.0'}
413+
414+ tinyglobby@0.2.17:
415+ resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
416+ engines: {node: '>=12.0.0'}
417+
418+ typescript@7.0.2:
419+ resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==}
420+ engines: {node: '>=16.20.0'}
421+ hasBin: true
422+
423+ vite@8.3.3:
424+ resolution: {integrity: sha512-cTAldKPImjg6c+gk48U19POPn3GCBzZwpdsN8ZMEEcbpes+6/wvfqUd0C2y3qYY4wsj8PwgFwrZ/1jBPVttMSg==}
425+ engines: {node: ^20.19.0 || >=22.12.0}
426+ hasBin: true
427+ peerDependencies:
428+ '@types/node': ^20.19.0 || >=22.12.0
429+ '@vitejs/devtools': ^0.7.1
430+ esbuild: ^0.27.0 || ^0.28.0
431+ jiti: '>=1.21.0'
432+ less: ^4.0.0
433+ sass: ^1.70.0
434+ sass-embedded: ^1.70.0
435+ stylus: '>=0.54.8'
436+ sugarss: ^5.0.0
437+ terser: ^5.16.0
438+ tsx: ^4.8.1
439+ yaml: ^2.4.2
440+ peerDependenciesMeta:
441+ '@types/node':
442+ optional: true
443+ '@vitejs/devtools':
444+ optional: true
445+ esbuild:
446+ optional: true
447+ jiti:
448+ optional: true
449+ less:
450+ optional: true
451+ sass:
452+ optional: true
453+ sass-embedded:
454+ optional: true
455+ stylus:
456+ optional: true
457+ sugarss:
458+ optional: true
459+ terser:
460+ optional: true
461+ tsx:
462+ optional: true
463+ yaml:
464+ optional: true
465+
466+snapshots:
467+
468+ '@oxc-project/types@0.153.0': {}
469+
470+ '@rolldown/binding-android-arm-eabi@1.2.13':
471+ optional: true
472+
473+ '@rolldown/binding-android-arm64@1.2.13':
474+ optional: true
475+
476+ '@rolldown/binding-darwin-arm64@1.2.13':
477+ optional: true
478+
479+ '@rolldown/binding-darwin-x64@1.2.13':
480+ optional: true
481+
482+ '@rolldown/binding-freebsd-x64@1.2.13':
483+ optional: true
484+
485+ '@rolldown/binding-linux-arm-gnueabihf@1.2.13':
486+ optional: true
487+
488+ '@rolldown/binding-linux-arm64-gnu@1.2.13':
489+ optional: true
490+
491+ '@rolldown/binding-linux-arm64-musl@1.2.13':
492+ optional: true
493+
494+ '@rolldown/binding-linux-ppc64-gnu@1.2.13':
495+ optional: true
496+
497+ '@rolldown/binding-linux-s390x-gnu@1.2.13':
498+ optional: true
499+
500+ '@rolldown/binding-linux-x64-gnu@1.2.13':
501+ optional: true
502+
503+ '@rolldown/binding-linux-x64-musl@1.2.13':
504+ optional: true
505+
506+ '@rolldown/binding-openharmony-arm64@1.2.13':
507+ optional: true
508+
509+ '@rolldown/binding-win32-arm64-msvc@1.2.13':
510+ optional: true
511+
512+ '@rolldown/binding-win32-x64-msvc@1.2.13':
513+ optional: true
514+
515+ '@rolldown/pluginutils@1.0.1': {}
516+
517+ '@types/react-dom@19.3.0(@types/react@19.3.0)':
518+ dependencies:
519+ '@types/react': 19.3.0
520+
521+ '@types/react@19.3.0':
522+ dependencies:
523+ csstype: 3.2.3
524+
525+ '@typescript/typescript-aix-ppc64@7.0.2':
526+ optional: true
527+
528+ '@typescript/typescript-darwin-arm64@7.0.2':
529+ optional: true
530+
531+ '@typescript/typescript-darwin-x64@7.0.2':
532+ optional: true
533+
534+ '@typescript/typescript-freebsd-arm64@7.0.2':
535+ optional: true
536+
537+ '@typescript/typescript-freebsd-x64@7.0.2':
538+ optional: true
539+
540+ '@typescript/typescript-linux-arm64@7.0.2':
541+ optional: true
542+
543+ '@typescript/typescript-linux-arm@7.0.2':
544+ optional: true
545+
546+ '@typescript/typescript-linux-loong64@7.0.2':
547+ optional: true
548+
549+ '@typescript/typescript-linux-mips64el@7.0.2':
550+ optional: true
551+
552+ '@typescript/typescript-linux-ppc64@7.0.2':
553+ optional: true
554+
555+ '@typescript/typescript-linux-riscv64@7.0.2':
556+ optional: true
557+
558+ '@typescript/typescript-linux-s390x@7.0.2':
559+ optional: true
560+
561+ '@typescript/typescript-linux-x64@7.0.2':
562+ optional: true
563+
564+ '@typescript/typescript-netbsd-arm64@7.0.2':
565+ optional: true
566+
567+ '@typescript/typescript-netbsd-x64@7.0.2':
568+ optional: true
569+
570+ '@typescript/typescript-openbsd-arm64@7.0.2':
571+ optional: true
572+
573+ '@typescript/typescript-openbsd-x64@7.0.2':
574+ optional: true
575+
576+ '@typescript/typescript-sunos-x64@7.0.2':
577+ optional: true
578+
579+ '@typescript/typescript-win32-arm64@7.0.2':
580+ optional: true
581+
582+ '@typescript/typescript-win32-x64@7.0.2':
583+ optional: true
584+
585+ '@vitejs/plugin-react@6.1.2(vite@8.3.3)':
586+ dependencies:
587+ '@rolldown/pluginutils': 1.0.1
588+ vite: 8.3.3
589+
590+ csstype@3.2.3: {}
591+
592+ detect-libc@2.1.2: {}
593+
594+ fdir@6.5.0(picomatch@4.0.7):
595+ optionalDependencies:
596+ picomatch: 4.0.7
597+
598+ fsevents@2.3.3:
599+ optional: true
600+
601+ lightningcss-android-arm64@1.33.0:
602+ optional: true
603+
604+ lightningcss-darwin-arm64@1.33.0:
605+ optional: true
606+
607+ lightningcss-darwin-x64@1.33.0:
608+ optional: true
609+
610+ lightningcss-freebsd-x64@1.33.0:
611+ optional: true
612+
613+ lightningcss-linux-arm-gnueabihf@1.33.0:
614+ optional: true
615+
616+ lightningcss-linux-arm64-gnu@1.33.0:
617+ optional: true
618+
619+ lightningcss-linux-arm64-musl@1.33.0:
620+ optional: true
621+
622+ lightningcss-linux-x64-gnu@1.33.0:
623+ optional: true
624+
625+ lightningcss-linux-x64-musl@1.33.0:
626+ optional: true
627+
628+ lightningcss-win32-arm64-msvc@1.33.0:
629+ optional: true
630+
631+ lightningcss-win32-x64-msvc@1.33.0:
632+ optional: true
633+
634+ lightningcss@1.33.0:
635+ dependencies:
636+ detect-libc: 2.1.2
637+ optionalDependencies:
638+ lightningcss-android-arm64: 1.33.0
639+ lightningcss-darwin-arm64: 1.33.0
640+ lightningcss-darwin-x64: 1.33.0
641+ lightningcss-freebsd-x64: 1.33.0
642+ lightningcss-linux-arm-gnueabihf: 1.33.0
643+ lightningcss-linux-arm64-gnu: 1.33.0
644+ lightningcss-linux-arm64-musl: 1.33.0
645+ lightningcss-linux-x64-gnu: 1.33.0
646+ lightningcss-linux-x64-musl: 1.33.0
647+ lightningcss-win32-arm64-msvc: 1.33.0
648+ lightningcss-win32-x64-msvc: 1.33.0
649+
650+ nanoid@3.3.20: {}
651+
652+ picocolors@1.1.1: {}
653+
654+ picomatch@4.0.7: {}
655+
656+ postcss@8.5.29:
657+ dependencies:
658+ nanoid: 3.3.20
659+ picocolors: 1.1.1
660+ source-map-js: 1.2.2
661+
662+ react-dom@19.3.0(react@19.3.0):
663+ dependencies:
664+ react: 19.3.0
665+ scheduler: 0.28.0
666+
667+ react@19.3.0: {}
668+
669+ rolldown@1.2.13:
670+ dependencies:
671+ '@oxc-project/types': 0.153.0
672+ '@rolldown/pluginutils': 1.0.1
673+ optionalDependencies:
674+ '@rolldown/binding-android-arm-eabi': 1.2.13
675+ '@rolldown/binding-android-arm64': 1.2.13
676+ '@rolldown/binding-darwin-arm64': 1.2.13
677+ '@rolldown/binding-darwin-x64': 1.2.13
678+ '@rolldown/binding-freebsd-x64': 1.2.13
679+ '@rolldown/binding-linux-arm-gnueabihf': 1.2.13
680+ '@rolldown/binding-linux-arm64-gnu': 1.2.13
681+ '@rolldown/binding-linux-arm64-musl': 1.2.13
682+ '@rolldown/binding-linux-ppc64-gnu': 1.2.13
683+ '@rolldown/binding-linux-s390x-gnu': 1.2.13
684+ '@rolldown/binding-linux-x64-gnu': 1.2.13
685+ '@rolldown/binding-linux-x64-musl': 1.2.13
686+ '@rolldown/binding-openharmony-arm64': 1.2.13
687+ '@rolldown/binding-win32-arm64-msvc': 1.2.13
688+ '@rolldown/binding-win32-x64-msvc': 1.2.13
689+
690+ scheduler@0.28.0: {}
691+
692+ source-map-js@1.2.2: {}
693+
694+ tinyglobby@0.2.17:
695+ dependencies:
696+ fdir: 6.5.0(picomatch@4.0.7)
697+ picomatch: 4.0.7
698+
699+ typescript@7.0.2:
700+ optionalDependencies:
701+ '@typescript/typescript-aix-ppc64': 7.0.2
702+ '@typescript/typescript-darwin-arm64': 7.0.2
703+ '@typescript/typescript-darwin-x64': 7.0.2
704+ '@typescript/typescript-freebsd-arm64': 7.0.2
705+ '@typescript/typescript-freebsd-x64': 7.0.2
706+ '@typescript/typescript-linux-arm': 7.0.2
707+ '@typescript/typescript-linux-arm64': 7.0.2
708+ '@typescript/typescript-linux-loong64': 7.0.2
709+ '@typescript/typescript-linux-mips64el': 7.0.2
710+ '@typescript/typescript-linux-ppc64': 7.0.2
711+ '@typescript/typescript-linux-riscv64': 7.0.2
712+ '@typescript/typescript-linux-s390x': 7.0.2
713+ '@typescript/typescript-linux-x64': 7.0.2
714+ '@typescript/typescript-netbsd-arm64': 7.0.2
715+ '@typescript/typescript-netbsd-x64': 7.0.2
716+ '@typescript/typescript-openbsd-arm64': 7.0.2
717+ '@typescript/typescript-openbsd-x64': 7.0.2
718+ '@typescript/typescript-sunos-x64': 7.0.2
719+ '@typescript/typescript-win32-arm64': 7.0.2
720+ '@typescript/typescript-win32-x64': 7.0.2
721+
722+ vite@8.3.3:
723+ dependencies:
724+ lightningcss: 1.33.0
725+ picomatch: 4.0.7
726+ postcss: 8.5.29
727+ rolldown: 1.2.13
728+ tinyglobby: 0.2.17
729+ optionalDependencies:
730+ fsevents: 2.3.3

pyre/src/web/src/App.tsxadded

+101−0
@@ -0,0 +1,101 @@
1+import { useCallback, useEffect, useState } from 'react';
2+import { getHistory, getStamp, getState } from './api';
3+import logoUrl from '../../../../logo.svg';
4+import { Button } from './components/ui';
5+import { DashboardPage, DevicesPage, HistoryPage, PerformancePage, TrainingPage } from './pages';
6+import { useSession } from './session';
7+import { useTheme } from './theme';
8+import type { HistoryEntry, Info } from './types';
9+
10+const PAGES = [
11+ { id: 'dashboard', label: 'Dashboard' },
12+ { id: 'training', label: 'Training' },
13+ { id: 'performance', label: 'Performance' },
14+ { id: 'devices', label: 'Devices' },
15+ { id: 'history', label: 'History' },
16+] as const;
17+type Page = (typeof PAGES)[number]['id'];
18+
19+function pageFromHash(): Page {
20+ const h = window.location.hash.slice(1);
21+ return PAGES.some(p => p.id === h) ? (h as Page) : 'dashboard';
22+}
23+
24+export function App() {
25+ const [page, setPage] = useState<Page>(pageFromHash);
26+ const [info, setInfo] = useState<Info | null>(null);
27+ const [history, setHistory] = useState<HistoryEntry[]>([]);
28+ const [online, setOnline] = useState(true);
29+ const [theme, toggleTheme] = useTheme();
30+
31+ useEffect(() => {
32+ const onHash = () => setPage(pageFromHash());
33+ window.addEventListener('hashchange', onHash);
34+ return () => window.removeEventListener('hashchange', onHash);
35+ }, []);
36+
37+ const refresh = useCallback(async () => {
38+ try {
39+ const [i, h] = await Promise.all([getState(), getHistory()]);
40+ setInfo(i);
41+ setHistory(h);
42+ setOnline(true);
43+ } catch {
44+ setOnline(false);
45+ }
46+ }, []);
47+
48+ const { view, error, start, stop } = useSession(() => void refresh());
49+
50+ // Live refresh without reloading the page: ask the server (cheaply) whether the profile or the
51+ // history changed -- training, a forever session, or any program that learned something -- and
52+ // refetch only then. A slow full refresh is the fallback if stamps ever stop changing.
53+ useEffect(() => {
54+ let last = '';
55+ let ticks = 0;
56+ void refresh();
57+ const id = window.setInterval(async () => {
58+ try {
59+ const st = await getStamp();
60+ const key = `${st.profile}/${st.history}`;
61+ ticks++;
62+ if (key !== last || ticks % 40 === 0) { last = key; await refresh(); }
63+ else setOnline(true);
64+ } catch {
65+ setOnline(false);
66+ }
67+ }, 1500);
68+ return () => window.clearInterval(id);
69+ }, [refresh]);
70+
71+ const running = view?.running ?? false;
72+ const lastRound = view && view.rounds.length ? view.rounds[view.rounds.length - 1].round : 0;
73+
74+ return (
75+ <div className="shell">
76+ <header className="topbar">
77+ <div className="brand"><img className="logo" src={logoUrl} alt="" /><h1>Pyre</h1></div>
78+ <nav aria-label="Pages">
79+ {PAGES.map(p => (
80+ <a key={p.id} href={`#${p.id}`} className={page === p.id ? 'on' : ''}>
81+ {p.label}{p.id === 'training' && running ? ' ●' : ''}
82+ </a>
83+ ))}
84+ </nav>
85+ <div className="topbar-right">
86+ {running && page !== 'training' && (
87+ <a className="running-link" href="#training">● {view?.mode === 'forever' ? 'Forever retraining' : 'Training'} running{lastRound ? ` · round ${lastRound}` : ''}</a>
88+ )}
89+ {!online && <span className="offline">Server unreachable</span>}
90+ <Button onClick={toggleTheme} aria-label="Toggle light or dark theme">{theme === 'dark' ? 'Light mode' : 'Dark mode'}</Button>
91+ </div>
92+ </header>
93+
94+ {page === 'dashboard' && <DashboardPage info={info} history={history} />}
95+ {page === 'training' && <TrainingPage info={info} history={history} view={view} error={error} onStart={(c, a) => void start(c, a)} onStop={() => void stop()} />}
96+ {page === 'performance' && <PerformancePage info={info} />}
97+ {page === 'devices' && <DevicesPage info={info} />}
98+ {page === 'history' && <HistoryPage history={history} />}
99+ </div>
100+ );
101+}

pyre/src/web/src/analytics.tsadded

+102−0
@@ -0,0 +1,102 @@
1+import { OPS, TARGETS, type HistoryEntry, type RatePoint, type Target } from './types';
2+
3+/** Workload sizes grouped into five classes (powers of two of the kernel's work units). */
4+export const SIZE_CLASSES = [
5+ { id: 'tiny', label: 'Tiny', from: 0, to: 16 },
6+ { id: 'small', label: 'Small', from: 17, to: 22 },
7+ { id: 'medium', label: 'Medium', from: 23, to: 28 },
8+ { id: 'large', label: 'Large', from: 29, to: 32 },
9+ { id: 'huge', label: 'Huge', from: 33, to: 99 },
10+] as const;
11+export type SizeClass = (typeof SIZE_CLASSES)[number];
12+
13+/** Geometric mean of the measured speeds of one target inside a size class. */
14+function classRate(rates: RatePoint[], op: string, target: Target, cls: SizeClass): number | undefined {
15+ const r = rates.filter(x => x.op === op && x.target === target && x.bucket >= cls.from && x.bucket <= cls.to);
16+ if (!r.length) return undefined;
17+ return Math.exp(r.reduce((s, x) => s + Math.log(x.per_second), 0) / r.length);
18+}
19+
20+export interface Cell {
21+ winner: Target | null;
22+ /** Best GPU speed ÷ CPU speed (> 1: a GPU beats the CPU), when both were measured. */
23+ speedup?: number;
24+}
25+
26+export function cellFor(rates: RatePoint[], op: string, cls: SizeClass): Cell {
27+ const at: Partial<Record<Target, number>> = {};
28+ for (const t of TARGETS) {
29+ const v = classRate(rates, op, t, cls);
30+ if (v !== undefined) at[t] = v;
31+ }
32+ const entries = Object.entries(at) as [Target, number][];
33+ if (!entries.length) return { winner: null };
34+ const winner = entries.reduce((a, b) => (b[1] > a[1] ? b : a))[0];
35+ const gpu = Math.max(at.cuda ?? 0, at.vulkan ?? 0);
36+ return { winner, speedup: at.cpu && gpu ? gpu / at.cpu : undefined };
37+}
38+
39+export function heatRows(rates: RatePoint[]) {
40+ return OPS.map(o => ({ op: o, cells: SIZE_CLASSES.map(c => cellFor(rates, o.name, c)) }));
41+}
42+
43+export interface LatestScore { hits: number; shapes: number; lost_pct: number; error_pct: number | null; time: number }
44+
45+/** The most recent session that produced a self-check score ("after" if it trained, else "before"). */
46+export function latestScore(history: HistoryEntry[]): LatestScore | null {
47+ for (let i = history.length - 1; i >= 0; i--) {
48+ const s = history[i].after ?? history[i].before;
49+ if (s && s.shapes) return { ...s, time: history[i].time };
50+ }
51+ return null;
52+}
53+
54+/** Smallest size bucket from which the best GPU stays faster than the CPU at every larger measured size. */
55+export function crossover(rates: RatePoint[], op: string): { bucket: number | null; measured: boolean } {
56+ const buckets = [...new Set(rates.filter(r => r.op === op).map(r => r.bucket))].sort((a, b) => a - b);
57+ const wins = buckets.map(b => {
58+ const at = (t: Target) => rates.find(r => r.op === op && r.target === t && r.bucket === b)?.per_second;
59+ const cpu = at('cpu'), gpu = Math.max(at('cuda') ?? 0, at('vulkan') ?? 0);
60+ return cpu !== undefined && gpu > 0 ? gpu > cpu : null; // null: cannot compare at this size
61+ });
62+ const comparable = buckets.map((b, i) => ({ b, win: wins[i] })).filter(x => x.win !== null);
63+ if (!comparable.length) return { bucket: null, measured: false };
64+ let from: number | null = null;
65+ for (let i = comparable.length - 1; i >= 0 && comparable[i].win; i--) from = comparable[i].b;
66+ return { bucket: from, measured: true };
67+}
68+
69+/** Number of measured sizes and the smallest/largest, per kernel and target. */
70+export function coverage(rates: RatePoint[]) {
71+ return OPS.flatMap(o => TARGETS.map(t => {
72+ const b = rates.filter(r => r.op === o.name && r.target === t).map(r => r.bucket);
73+ return { op: o, target: t, count: b.length, min: b.length ? Math.min(...b) : null, max: b.length ? Math.max(...b) : null };
74+ }));
75+}
76+
77+/** Per-session numbers (oldest first) for tendencies: the score each scored session ended with. */
78+export function sessionSeries(history: HistoryEntry[]) {
79+ const scored = history.map(h => h.after ?? h.before).filter((s): s is NonNullable<typeof s> => !!s && s.shapes > 0);
80+ return {
81+ lost: scored.map(s => s.lost_pct),
82+ hit: scored.map(s => (s.hits / s.shapes) * 100),
83+ accuracy: scored.filter(s => s.error_pct !== null).map(s => Math.max(0, 100 - (s.error_pct as number))),
84+ };
85+}
86+
87+/** Which way the GPU-vs-CPU advantage moves as work grows, and (if the GPU never wins yet) where it might start to. */
88+export function speedupTendency(rates: RatePoint[], op: string): string {
89+ const pts: [number, number][] = [];
90+ for (const b of new Set(rates.filter(r => r.op === op).map(r => r.bucket))) {
91+ const at = (t: Target) => rates.find(r => r.op === op && r.target === t && r.bucket === b)?.per_second;
92+ const cpu = at('cpu'), gpu = Math.max(at('cuda') ?? 0, at('vulkan') ?? 0);
93+ if (cpu && gpu) pts.push([b, Math.log2(gpu / cpu)]);
94+ }
95+ if (pts.length < 4) return 'needs more sizes measured';
96+ const recent = pts.sort((a, b) => a[0] - b[0]).slice(-6);
97+ const n = recent.length, mx = recent.reduce((s, p) => s + p[0], 0) / n, my = recent.reduce((s, p) => s + p[1], 0) / n;
98+ const slope = recent.reduce((s, p) => s + (p[0] - mx) * (p[1] - my), 0) / recent.reduce((s, p) => s + (p[0] - mx) ** 2, 0);
99+ if (Math.abs(slope) < 0.03) return '→ GPU advantage flat with size';
100+ if (slope > 0) return `↗ GPU gains ×2 every ~${Math.round(1 / slope)} sizes`;
101+ return '↘ GPU advantage shrinking with size';
102+}

pyre/src/web/src/api.tsadded

+20−0
@@ -0,0 +1,20 @@
1+import type { HistoryEntry, Info, JobStatus } from './types';
2+
3+async function request<T>(path: string, init?: RequestInit): Promise<T> {
4+ const res = await fetch(path, init);
5+ if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
6+ return (await res.json()) as T;
7+}
8+
9+export const getState = () => request<Info>('/api/state');
10+export const getHistory = () => request<HistoryEntry[]>('/api/history');
11+export interface Stamp { profile: number; history: number }
12+export const getStamp = () => request<Stamp>('/api/stamp');
13+export const getJob = (since: number) => request<JobStatus>(`/api/job?since=${since}`);
14+export const stopJob = () => request<{ stopping: boolean }>('/api/job/stop', { method: 'POST' });
15+export const startJob = (cmd: 'train' | 'check' | 'forget', args: string[]) =>
16+ request<{ started: boolean }>('/api/job', {
17+ method: 'POST',
18+ headers: { 'Content-Type': 'application/json' },
19+ body: JSON.stringify({ cmd, args }),
20+ });

pyre/src/web/src/colors.tsadded

+5−0
@@ -0,0 +1,5 @@
1+import type { Target } from './types';
2+
3+// Same hues in light and dark (the series stay recognisable when the theme flips).
4+export const TARGET_COLOR: Record<Target, string> = { cpu: '#64748b', cuda: '#16a34a', vulkan: '#d97706' };
5+export const TARGET_LABEL: Record<Target, string> = { cpu: 'CPU', cuda: 'CUDA', vulkan: 'Vulkan' };

pyre/src/web/src/components/Gauge.tsxadded

+32−0
@@ -0,0 +1,32 @@
1+// Half-circle gauge with five colour zones and a needle, in the style of the reference dashboard.
2+const ZONES = ['#f26b6b', '#f9a561', '#f6e27f', '#c5e1a5', '#8bbf6e'];
3+const CX = 100, CY = 96, R = 78, R_IN = 50;
4+
5+function polar(r: number, pct: number): [number, number] {
6+ const a = Math.PI * (1 - pct / 100); // 0% = left, 100% = right
7+ return [CX + r * Math.cos(a), CY - r * Math.sin(a)];
8+}
9+
10+function zonePath(from: number, to: number): string {
11+ const [x1, y1] = polar(R, from), [x2, y2] = polar(R, to), [x3, y3] = polar(R_IN, to), [x4, y4] = polar(R_IN, from);
12+ return `M${x1} ${y1} A${R} ${R} 0 0 1 ${x2} ${y2} L${x3} ${y3} A${R_IN} ${R_IN} 0 0 0 ${x4} ${y4} Z`;
13+}
14+
15+export function Gauge({ value, caption }: { value: number | null; caption: string }) {
16+ const v = value === null ? null : Math.max(0, Math.min(100, value));
17+ const [nx, ny] = polar(R - 6, v ?? 0);
18+ return (
19+ <figure className="gauge">
20+ <svg viewBox="0 0 200 124" preserveAspectRatio="xMidYMid meet" role="img" aria-label={`${caption}: ${v === null ? 'no data' : `${v.toFixed(0)}%`}`}>
21+ {ZONES.map((c, i) => <path key={c} d={zonePath(i * 20, (i + 1) * 20)} fill={c} stroke="var(--tile)" strokeWidth="1" />)}
22+ {[0, 20, 40, 60, 80, 100].map(t => {
23+ const [x, y] = polar(R + 10, t);
24+ return <text key={t} className="gauge-tick" x={x} y={y + 3} textAnchor="middle">{t}</text>;
25+ })}
26+ {v !== null && <line x1={CX} y1={CY} x2={nx} y2={ny} stroke="var(--ink)" strokeWidth="3.4" strokeLinecap="round" />}
27+ <circle cx={CX} cy={CY} r="5.5" fill="var(--ink)" />
28+ <text className="gauge-value" x={CX} y={CY + 22} textAnchor="middle">{v === null ? '–' : `${v.toFixed(0)}%`}</text>
29+ </svg>
30+ </figure>
31+ );
32+}

pyre/src/web/src/components/HeatMap.tsxadded

+27−0
@@ -0,0 +1,27 @@
1+import type { ReactNode } from 'react';
2+
3+export interface HeatCell { color: string; text?: string; title?: string }
4+
5+/** Small coloured matrix with row and column labels (the reference's risk-matrix tiles). */
6+export function HeatMap({ rows, cols, cells, xLabel, legend }: {
7+ rows: string[]; cols: string[]; cells: HeatCell[][]; xLabel: string; legend?: ReactNode;
8+}) {
9+ return (
10+ <div className="heat">
11+ <div className="heat-grid" style={{ gridTemplateColumns: `auto repeat(${cols.length}, minmax(0, 1fr))`, gridTemplateRows: `auto repeat(${rows.length}, minmax(26px, 1fr))` }}>
12+ <span />
13+ {cols.map(c => <span key={c} className="heat-col">{c}</span>)}
14+ {rows.map((r, i) => (
15+ <div key={r} style={{ display: 'contents' }}>
16+ <span className="heat-row">{r}</span>
17+ {cells[i].map((c, j) => (
18+ <span key={j} className="heat-cell" style={{ background: c.color }} title={c.title}>{c.text}</span>
19+ ))}
20+ </div>
21+ ))}
22+ </div>
23+ <div className="heat-x">{xLabel}</div>
24+ {legend && <div className="heat-legend">{legend}</div>}
25+ </div>
26+ );
27+}

pyre/src/web/src/components/LineChart.tsxadded

+110−0
@@ -0,0 +1,110 @@
1+import { useEffect, useId, useRef, useState } from 'react';
2+
3+export interface Series { name: string; color: string; points: [number, number][]; dashed?: boolean }
4+
5+interface Props {
6+ series: Series[];
7+ xLabel: string;
8+ yLabel: string;
9+ logY?: boolean;
10+ height?: number; // fixed pixel height; omit to fill the tile
11+ xTick?: (x: number) => string;
12+ yTick?: (y: number) => string;
13+ yMin?: number;
14+ bands?: { from: number; to: number; color: string }[]; // shaded x-ranges behind the lines
15+ yMax?: number; // fixed top of a linear axis (e.g. 100 for percentages): no padding above it
16+}
17+
18+const M = { top: 10, right: 14, bottom: 34, left: 58 };
19+
20+/** Plain SVG line chart: linear X, linear or log10 Y, legend, native tooltips. No dependencies. */
21+export function LineChart({ series, xLabel, yLabel, logY = false, height, xTick = x => String(x), yTick = y => String(y), yMin, yMax, bands }: Props) {
22+ const id = useId();
23+ const box = useRef<HTMLDivElement>(null);
24+ // Drawn at the tile's real pixel size so text keeps its size and nothing overflows.
25+ const [size, setSize] = useState({ w: 520, h: 240 });
26+ useEffect(() => {
27+ const el = box.current;
28+ if (!el) return;
29+ const measure = () => setSize({ w: Math.max(240, Math.round(el.clientWidth)), h: Math.max(120, Math.round(el.clientHeight)) });
30+ const ro = new ResizeObserver(measure);
31+ ro.observe(el);
32+ measure();
33+ return () => ro.disconnect();
34+ }, []);
35+ const W = size.w;
36+ const H = height ?? size.h;
37+ const pts = series.flatMap(s => s.points).filter(([, y]) => (logY ? y > 0 : true));
38+ if (pts.length === 0) return <div className="chart"><div className="chart-area" ref={box} style={height ? { height } : undefined}><div className="chart-empty">No data yet</div></div></div>;
39+
40+ const xs = pts.map(p => p[0]);
41+ const ys = pts.map(p => p[1]);
42+ const xMin = Math.min(...xs), xMax = Math.max(...xs);
43+ const fy = (y: number) => (logY ? Math.log10(y) : y);
44+ let lo = fy(yMin ?? Math.min(...ys)), hi = fy(Math.max(...ys));
45+ let niceStep = 0;
46+ if (logY) { lo = Math.floor(lo); hi = Math.ceil(hi); if (hi === lo) hi = lo + 1; }
47+ else {
48+ if (yMin === undefined) lo = Math.min(0, lo);
49+ if (yMax !== undefined) hi = yMax;
50+ if (hi === lo) hi = lo + 1;
51+ // Round-number ticks (1, 2, 5 x 10^n), so axes read 0 / 50 / 100 / 150, not 0 / 51 / 102.
52+ const raw = (hi - lo) / 4, mag = 10 ** Math.floor(Math.log10(raw));
53+ niceStep = [1, 2, 5, 10].map(m => m * mag).find(st => st >= raw) ?? raw;
54+ lo = Math.floor(lo / niceStep) * niceStep;
55+ hi = Math.ceil(hi / niceStep) * niceStep;
56+ }
57+
58+ const iw = W - M.left - M.right, ih = H - M.top - M.bottom;
59+ const px = (x: number) => M.left + (xMax === xMin ? iw / 2 : ((x - xMin) / (xMax - xMin)) * iw);
60+ const py = (y: number) => M.top + ih - ((fy(y) - lo) / (hi - lo)) * ih;
61+
62+ const yTicks: number[] = [];
63+ if (logY) for (let e = lo; e <= hi; e++) yTicks.push(10 ** e);
64+ else for (let v = lo; v <= hi + niceStep / 2; v += niceStep) yTicks.push(v);
65+ const xCount = Math.min(xMax - xMin, 8);
66+ const xTicks = Array.from(new Set(Array.from({ length: xCount + 1 }, (_, i) => Math.round(xMin + ((xMax - xMin) * i) / Math.max(1, xCount)))));
67+
68+ return (
69+ <div className="chart">
70+ <div className="chart-area" ref={box} style={height ? { height } : undefined}>
71+ <svg width={W} height={H} viewBox={`0 0 ${W} ${H}`} role="img" aria-labelledby={id}>
72+ <title id={id}>{`${yLabel} by ${xLabel}`}</title>
73+ {bands?.map((b, i) => (
74+ <rect key={i} x={px(b.from)} y={M.top} width={Math.max(0, px(b.to) - px(b.from))} height={ih} fill={b.color} opacity={0.16} />
75+ ))}
76+ {yTicks.map(t => (
77+ <g key={t}>
78+ <line className="grid" x1={M.left} x2={W - M.right} y1={py(t)} y2={py(t)} />
79+ <text className="tick" x={M.left - 8} y={py(t) + 4} textAnchor="end">{yTick(t)}</text>
80+ </g>
81+ ))}
82+ {xTicks.map(t => (
83+ <text key={t} className="tick" x={px(t)} y={H - M.bottom + 18} textAnchor="middle">{xTick(t)}</text>
84+ ))}
85+ <text className="axis" x={M.left + iw / 2} y={H - 4} textAnchor="middle">{xLabel}</text>
86+ <text className="axis" transform={`translate(14 ${M.top + ih / 2}) rotate(-90)`} textAnchor="middle">{yLabel}</text>
87+ {series.map(s => {
88+ const p = s.points.filter(([, y]) => (logY ? y > 0 : true)).sort((a, b) => a[0] - b[0]);
89+ if (!p.length) return null;
90+ return (
91+ <g key={s.name}>
92+ <polyline fill="none" stroke={s.color} strokeWidth={2} strokeLinejoin="round" strokeDasharray={s.dashed ? '6 5' : undefined} opacity={s.dashed ? 0.8 : 1} points={p.map(([x, y]) => `${px(x)},${py(y)}`).join(' ')} />
93+ {p.map(([x, y]) => (
94+ <circle key={x} cx={px(x)} cy={py(y)} r={3.2} fill={s.dashed ? 'var(--tile)' : s.color} stroke={s.color} strokeWidth={s.dashed ? 1.6 : 0}>
95+ <title>{`${s.name}: ${xTick(x)} → ${yTick(y)}`}</title>
96+ </circle>
97+ ))}
98+ </g>
99+ );
100+ })}
101+ </svg>
102+ </div>
103+ <ul className="legend">
104+ {series.map(s => (
105+ <li key={s.name}><i style={{ background: s.dashed ? 'transparent' : s.color, borderTop: s.dashed ? `3px dotted ${s.color}` : undefined }} />{s.name}</li>
106+ ))}
107+ </ul>
108+ </div>
109+ );
110+}

pyre/src/web/src/components/TrendNote.tsxadded

+15−0
@@ -0,0 +1,15 @@
1+import type { Trend } from '../trend';
2+
3+const GLYPH = { improving: '↗', worsening: '↘', steady: '→' } as const;
4+const WORD = { improving: 'Improving', worsening: 'Getting worse', steady: 'Holding steady' } as const;
5+
6+/** One line: which way a measure is heading and where the fitted line says it goes next (a guess). */
7+export function TrendNote({ t, format }: { t: Trend | null; format: (v: number) => string }) {
8+ if (!t) return <div className="trend trend-none" title="Needs at least three sessions to guess a direction">Tendency: needs 3+ sessions</div>;
9+ const tone = t.direction === 'improving' ? 'good' : t.direction === 'worsening' ? 'bad' : 'neutral';
10+ return (
11+ <div className={`trend trend-${tone}`} title={`Straight line through the last ${t.points} values; an estimate, not a promise`}>
12+ <span aria-hidden>{GLYPH[t.direction]}</span> {WORD[t.direction]} · next ≈ {format(t.next)}
13+ </div>
14+ );
15+}

pyre/src/web/src/components/ui.tsxadded

+70−0
@@ -0,0 +1,70 @@
1+import type { ButtonHTMLAttributes, ReactNode } from 'react';
2+
3+/** The one container everything sits in: bordered tile, centered bold title. */
4+export function Tile({ title, children, className = '', actions }: { title?: string; children: ReactNode; className?: string; actions?: ReactNode }) {
5+ return (
6+ <section className={`tile ${className}`}>
7+ {title && <h2 className="tile-title">{title}</h2>}
8+ {actions && <div className="tile-actions">{actions}</div>}
9+ <div className="tile-body">{children}</div>
10+ </section>
11+ );
12+}
13+
14+export function Button({ variant = 'secondary', className = '', ...props }: ButtonHTMLAttributes<HTMLButtonElement> & { variant?: 'primary' | 'secondary' | 'danger' }) {
15+ return <button className={`btn btn-${variant} ${className}`} {...props} />;
16+}
17+
18+export function Badge({ children, tone = 'neutral' }: { children: ReactNode; tone?: 'neutral' | 'good' | 'bad' | 'warn' | 'info' }) {
19+ return <span className={`badge badge-${tone}`}>{children}</span>;
20+}
21+
22+/** Row of mutually exclusive choices (replaces sliders/selects with plain buttons). */
23+export function Segmented<T extends string | number>({ options, value, onChange, label }: {
24+ options: { value: T; label: string }[]; value: T; onChange: (v: T) => void; label: string;
25+}) {
26+ return (
27+ <div className="segmented" role="group" aria-label={label}>
28+ {options.map(o => (
29+ <button key={String(o.value)} type="button" aria-pressed={o.value === value} className={o.value === value ? 'on' : ''} onClick={() => onChange(o.value)}>
30+ {o.label}
31+ </button>
32+ ))}
33+ </div>
34+ );
35+}
36+
37+/** Toggle chip for multi-select (kernels, targets). */
38+export function Chip({ label, on, onClick, hint, disabled }: { label: string; on: boolean; onClick: () => void; hint?: string; disabled?: boolean }) {
39+ return (
40+ <button type="button" aria-pressed={on} disabled={disabled} title={hint} className={`chip ${on ? 'on' : ''}`} onClick={onClick}>
41+ {label}
42+ </button>
43+ );
44+}
45+
46+export function Check({ label, checked, onChange, hint }: { label: string; checked: boolean; onChange: (v: boolean) => void; hint?: string }) {
47+ return (
48+ <label className="check">
49+ <input type="checkbox" checked={checked} onChange={e => onChange(e.target.checked)} />
50+ <span>{label}{hint && <small>{hint}</small>}</span>
51+ </label>
52+ );
53+}
54+
55+export function Empty({ children }: { children: ReactNode }) {
56+ return <div className="empty">{children}</div>;
57+}
58+
59+/** Compact table in the same grid style as the tiles. */
60+export function DataTable({ head, children, className = '', widths }: { head: ReactNode[]; children: ReactNode; className?: string; widths?: string[] }) {
61+ return (
62+ <div className="table-wrap">
63+ <table className={`data ${className}`}>
64+ {widths && <colgroup>{widths.map((w, i) => <col key={i} style={{ width: w }} />)}</colgroup>}
65+ <thead><tr>{head.map((h, i) => <th key={i}>{h}</th>)}</tr></thead>
66+ <tbody>{children}</tbody>
67+ </table>
68+ </div>
69+ );
70+}

pyre/src/web/src/format.tsadded

+33−0
@@ -0,0 +1,33 @@
1+export function fmtRate(perSecond: number): string {
2+ const units = [[1e12, 'T'], [1e9, 'G'], [1e6, 'M'], [1e3, 'k']] as const;
3+ for (const [v, s] of units) if (perSecond >= v) return `${(perSecond / v).toFixed(perSecond / v >= 100 ? 0 : 1)} ${s}`;
4+ return perSecond.toFixed(0);
5+}
6+
7+export function fmtMs(ms: number): string {
8+ if (ms >= 1000) return `${(ms / 1000).toFixed(2)} s`;
9+ if (ms >= 1) return `${ms.toFixed(ms >= 100 ? 0 : 1)} ms`;
10+ return `${(ms * 1000).toFixed(0)} µs`;
11+}
12+
13+export function fmtPct(v: number | null | undefined): string {
14+ if (v === null || v === undefined) return 'n/a';
15+ return `${v >= 100 ? v.toFixed(0) : v.toFixed(1)}%`;
16+}
17+
18+export function fmtDuration(seconds: number): string {
19+ if (seconds < 60) return `${seconds.toFixed(1)} s`;
20+ return `${Math.floor(seconds / 60)} min ${Math.round(seconds % 60)} s`;
21+}
22+
23+export function fmtDate(epoch: number): string {
24+ return new Date(epoch * 1000).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' });
25+}
26+
27+/** Axis label for a percentage that may be tiny (0.04) or large (208). */
28+export function pctTick(v: number): string {
29+ if (v === 0) return '0%';
30+ if (Math.abs(v) < 1) return `${v.toFixed(2)}%`;
31+ if (Math.abs(v) < 10) return `${v.toFixed(1)}%`;
32+ return `${v.toFixed(0)}%`;
33+}

pyre/src/web/src/main.tsxadded

+10−0
@@ -0,0 +1,10 @@
1+import { StrictMode } from 'react';
2+import { createRoot } from 'react-dom/client';
3+import { App } from './App';
4+import './styles.css';
5+
6+createRoot(document.getElementById('root')!).render(
7+ <StrictMode>
8+ <App />
9+ </StrictMode>,
10+);

pyre/src/web/src/pages.tsxadded

+164−0
@@ -0,0 +1,164 @@
1+import { crossover, coverage, latestScore, sessionSeries, speedupTendency } from './analytics';
2+import { Gauge } from './components/Gauge';
3+import { TrendNote } from './components/TrendNote';
4+import { trend } from './trend';
5+import { LineChart } from './components/LineChart';
6+import { DataTable, Empty, Tile } from './components/ui';
7+import { fmtDate, fmtDuration, fmtPct, pctTick } from './format';
8+import type { SessionView } from './session';
9+import { ControlTile } from './tiles/Control';
10+import { SpeedupTile, WinnerTile } from './tiles/Heat';
11+import { KpiTile } from './tiles/Kpi';
12+import { LiveTile } from './tiles/Live';
13+import { KernelTile, SpeedTile } from './tiles/Speed';
14+import { DevicesTile, SessionsTile, TimelineTile } from './tiles/Tables';
15+import { TARGET_COLOR, TARGET_LABEL } from './colors';
16+import { OPS, type HistoryEntry, type Info } from './types';
17+
18+interface Data { info: Info | null; history: HistoryEntry[] }
19+
20+/** Landing page: analytics only, scaled to fill the screen. */
21+export function DashboardPage({ info, history }: Data) {
22+ const score = latestScore(history);
23+ const series = sessionSeries(history);
24+ const lostTrend = trend(series.lost, 'lower', [0, Infinity]);
25+ const hitTrend = trend(series.hit, 'higher', [0, 100]);
26+ const accTrend = trend(series.accuracy, 'higher', [0, 100]);
27+ const hitRate = score ? (score.hits / Math.max(1, score.shapes)) * 100 : null;
28+ const accuracy = score && score.error_pct !== null ? Math.max(0, 100 - score.error_pct) : null;
29+ const lostTone = !score ? 'ink' : score.lost_pct < 5 ? 'good' : score.lost_pct < 25 ? 'warn' : 'bad';
30+ const pct = (v: number) => (v < 10 ? `${v.toFixed(1)}%` : `${v.toFixed(0)}%`);
31+ return (
32+ <main className="wall">
33+ <SpeedTile info={info} />
34+ <KpiTile title="Right picks" value={score ? (hitRate ?? 0).toFixed(0) : '–'} unit={score ? '%' : undefined} tone={hitRate === null ? 'ink' : hitRate >= 80 ? 'good' : hitRate >= 50 ? 'warn' : 'bad'} trend={hitTrend} format={pct} />
35+ <KpiTile title="Time lost" value={score ? (score.lost_pct < 10 ? score.lost_pct.toFixed(1) : score.lost_pct.toFixed(0)) : '–'} unit={score ? '%' : undefined} tone={lostTone} spark={series.lost.slice(-12)} trend={lostTrend} format={pct} />
36+ <WinnerTile info={info} />
37+ <SpeedupTile info={info} />
38+ <DevicesTile info={info} />
39+ <SessionsTile history={history} />
40+ <Tile title="Picked the best target" className="gauge-tile"><Gauge value={hitRate} caption="Picked the best target" /><TrendNote t={hitTrend} format={pct} /></Tile>
41+ <Tile title="Prediction accuracy" className="gauge-tile"><Gauge value={accuracy} caption="Prediction accuracy" /><TrendNote t={accTrend} format={pct} /></Tile>
42+ </main>
43+ );
44+}
45+
46+/** Start, watch and stop sessions. */
47+export function TrainingPage({ info, history, view, error, onStart, onStop }: {
48+ info: Info | null; history: HistoryEntry[]; view: SessionView | null; error: string;
49+ onStart: (cmd: 'train' | 'check' | 'forget', args: string[]) => void; onStop: () => void;
50+}) {
51+ return (
52+ <main className="split">
53+ <ControlTile info={info} history={history} running={view?.running ?? false} error={error} onStart={onStart} onStop={onStop} />
54+ <LiveTile view={view} />
55+ </main>
56+ );
57+}
58+
59+/** Per-kernel charts, the two matrices, and where each GPU starts to pay off. */
60+export function PerformancePage({ info }: { info: Info | null }) {
61+ const rates = info?.rates ?? [];
62+ return (
63+ <main className="perf">
64+ {OPS.map(o => <KernelTile key={o.key} info={info} op={o.key} />)}
65+ <WinnerTile info={info} />
66+ <SpeedupTile info={info} />
67+ <Tile title="Where the GPU starts to pay off" className="table-tile">
68+ <DataTable head={['Kernel', 'GPU faster from', 'Tendency']} widths={['24%', '30%', '46%']}>
69+ {OPS.map(o => {
70+ const c = crossover(rates, o.name);
71+ return (
72+ <tr key={o.key}>
73+ <td>{o.label}</td>
74+ <td>{!c.measured ? 'not measured' : c.bucket === null ? 'not at any size' : `2^${c.bucket} and up`}</td>
75+ <td title="Straight line through GPU-vs-CPU speed ratio across sizes; an estimate">{speedupTendency(rates, o.name)}</td>
76+ </tr>
77+ );
78+ })}
79+ </DataTable>
80+ </Tile>
81+ </main>
82+ );
83+}
84+
85+/** What hardware Pyre found and how much it has learned about each device. */
86+export function DevicesPage({ info }: { info: Info | null }) {
87+ const cov = coverage(info?.rates ?? []);
88+ return (
89+ <main className="devices">
90+ <DevicesTile info={info} detailed />
91+ <Tile title="What has been learned" className="table-tile">
92+ <DataTable head={['Kernel', 'Target', 'Sizes measured', 'Smallest', 'Largest']} widths={['26%', '20%', '22%', '16%', '16%']}>
93+ {cov.map((c, i) => (
94+ <tr key={i}>
95+ <td>{c.op.label}</td>
96+ <td><i className="swatch" style={{ background: TARGET_COLOR[c.target] }} />{TARGET_LABEL[c.target]}</td>
97+ <td className="num">{c.count}</td>
98+ <td className="num">{c.min === null ? '–' : `2^${c.min}`}</td>
99+ <td className="num">{c.max === null ? '–' : `2^${c.max}`}</td>
100+ </tr>
101+ ))}
102+ </DataTable>
103+ </Tile>
104+ <Tile title="Profile" className="table-tile">
105+ {!info ? <Empty>Waiting for the Pyre server…</Empty> : (
106+ <DataTable head={['Setting', 'Value']} widths={['32%', '68%']}>
107+ <tr><td>Profile file</td><td className="mono" title={info.profile}>{info.profile || '(memory only)'}</td></tr>
108+ <tr><td>CPU threads</td><td>{info.cpu_threads}</td></tr>
109+ <tr><td>CUDA support</td><td>{info.cuda_built ? 'built in' : 'built without CUDA'}</td></tr>
110+ <tr><td>Learned data points</td><td>{info.rates.length}</td></tr>
111+ </DataTable>
112+ )}
113+ </Tile>
114+ </main>
115+ );
116+}
117+
118+const V: Record<string, string> = { better: 'v-good', worse: 'v-bad', same: 'v-neutral', unknown: 'v-warn', unverified: 'v-neutral', check: 'v-neutral' };
119+
120+/** Every recorded session, with trends over time. */
121+export function HistoryPage({ history }: { history: HistoryEntry[] }) {
122+ const scored = history.map((h, i) => ({ i: i + 1, s: h.after ?? h.before })).filter(x => x.s);
123+ const sr = sessionSeries(history);
124+ const lostT = trend(sr.lost, 'lower', [0, Infinity]);
125+ const hitT = trend(sr.hit, 'higher', [0, 100]);
126+ const lastX = scored.length ? scored[scored.length - 1].i : 0;
127+ const fc = (t: ReturnType<typeof trend>, last?: number) => (t && last !== undefined ? [[lastX, last] as [number, number], ...t.forecast(3).map((v, k) => [lastX + k + 1, v] as [number, number])] : []);
128+ return (
129+ <main className="hist">
130+ <Tile title="Time lost and right picks, by session" className="hist-chart">
131+ {scored.length ? (
132+ <LineChart
133+ series={[{ name: 'Time lost (%)', color: '#c62828', points: scored.map(x => [x.i, x.s!.lost_pct] as [number, number]) },
134+ { name: 'Right picks (%)', color: '#2e7d32', points: scored.map(x => [x.i, (x.s!.hits / Math.max(1, x.s!.shapes)) * 100] as [number, number]) },
135+ ...(lostT ? [{ name: 'Time lost, tendency', color: '#c62828', dashed: true, points: fc(lostT, sr.lost[sr.lost.length - 1]) }] : []),
136+ ...(hitT ? [{ name: 'Right picks, tendency', color: '#2e7d32', dashed: true, points: fc(hitT, sr.hit[sr.hit.length - 1]) }] : [])]}
137+ xLabel="session #" yLabel="%" yMin={0} yMax={Math.max(100, ...scored.map(x => x.s!.lost_pct))} yTick={pctTick}
138+ />
139+ ) : <Empty>No scored sessions yet.</Empty>}
140+ </Tile>
141+ <TimelineTile history={history} />
142+ <Tile title="All sessions" className="hist-table">
143+ {history.length === 0 ? <Empty>No sessions recorded yet.</Empty> : (
144+ <div className="scroll fill">
145+ <DataTable head={['#', 'When', 'Session', 'Loads', 'Probes', 'Took', 'Lost before', 'Lost after', 'Right', 'Result']} widths={['5%', '17%', '14%', '10%', '8%', '8%', '10%', '10%', '8%', '10%']}>
146+ {history.map((h, i) => (
147+ <tr key={i}>
148+ <td className="num">{i + 1}</td><td>{fmtDate(h.time)}</td>
149+ <td>{h.kind === 'train' ? `Train · ${h.level}` : 'Self-check'}</td>
150+ <td className="num">{h.kind === 'train' ? `${h.gpu_load}/${h.cpu_load}%` : '–'}</td>
151+ <td className="num">{h.probes || '–'}</td><td className="num">{fmtDuration(h.seconds)}</td>
152+ <td className="num">{h.before ? fmtPct(h.before.lost_pct) : '–'}</td>
153+ <td className="num">{h.after ? fmtPct(h.after.lost_pct) : '–'}</td>
154+ <td className="num">{h.after ? `${h.after.hits}/${h.after.shapes}` : '–'}</td>
155+ <td className={V[h.verdict] ?? 'v-neutral'}>{h.verdict}</td>
156+ </tr>
157+ )).reverse()}
158+ </DataTable>
159+ </div>
160+ )}
161+ </Tile>
162+ </main>
163+ );
164+}

pyre/src/web/src/recommend.tsadded

+59−0
@@ -0,0 +1,59 @@
1+import { OPS, TARGETS, type HistoryEntry, type Info } from './types';
2+import { latestScore } from './analytics';
3+
4+export interface Recommendation {
5+ /** `pyre train` arguments for the recommended session. */
6+ args: string[];
7+ headline: string;
8+ reasons: string[];
9+}
10+
11+/** Sizes a kernel is expected to cover when it is well learned (2^12 .. about 2^34, every other size). */
12+const EXPECTED_SIZES = 10;
13+
14+/**
15+ * Picks a session from what is known right now: which kernels have the least data, which targets
16+ * exist on this machine, and how the last self-check went. It always runs the adaptive learner
17+ * (`--auto`), which then finds the weak sizes by itself; this only chooses the depth and scope.
18+ */
19+export function recommend(info: Info | null, history: HistoryEntry[]): Recommendation {
20+ const rates = info?.rates ?? [];
21+ const targets = TARGETS.filter(t => t === 'cpu' || info?.targets.find(x => x.name === t)?.present);
22+
23+ const coverage = OPS.map(o => ({
24+ op: o,
25+ sizes: new Set(rates.filter(r => r.op === o.name).map(r => r.bucket)).size,
26+ }));
27+ const learnedAnything = rates.length > 0;
28+ const thin = coverage.filter(c => c.sizes < EXPECTED_SIZES);
29+ const focus = (thin.length ? thin : coverage).map(c => c.op);
30+
31+ const score = latestScore(history);
32+ const reasons: string[] = [];
33+ let level: 'quick' | 'normal' | 'deep' = 'normal';
34+
35+ if (!learnedAnything) {
36+ reasons.push('Nothing is learned on this machine yet: it starts with a grid of probes, then switches to practising where it is weakest.');
37+ } else if (score && (score.lost_pct > 25 || (score.error_pct ?? 0) > 40)) {
38+ level = 'deep';
39+ reasons.push(`The last self-check lost ${score.lost_pct.toFixed(1)}% of time to wrong placement (prediction error ${score.error_pct === null ? 'n/a' : `${score.error_pct.toFixed(0)}%`}), so it practises more per round.`);
40+ } else if (score && score.lost_pct < 2 && (score.error_pct ?? 100) < 20) {
41+ level = 'quick';
42+ reasons.push(`Decisions are already sharp (${score.lost_pct.toFixed(1)}% time lost), so this is a light tune-up.`);
43+ } else {
44+ reasons.push('Decisions are decent but not sharp: a normal-depth adaptive pass.');
45+ }
46+
47+ if (thin.length && learnedAnything) {
48+ reasons.push(`Least data so far: ${thin.map(c => `${c.op.label} (${c.sizes} sizes)`).join(', ')}.`);
49+ }
50+ const missing = TARGETS.filter(t => !targets.includes(t));
51+ reasons.push(`Targets: ${targets.map(t => t.toUpperCase()).join(', ')}${missing.length ? `. Not seen on this machine: ${missing.map(t => t.toUpperCase()).join(', ')}.` : '.'}`);
52+ reasons.push('Runs at full speed to learn fastest (lower the loads under Custom if you need the machine). It stops by itself once decisions stop improving.');
53+
54+ return {
55+ headline: level === 'deep' ? 'Deep adaptive session' : level === 'quick' ? 'Quick tune-up' : 'Adaptive session',
56+ reasons,
57+ args: ['--auto', '--level', level, '--ops', focus.map(o => o.key).join(','), '--targets', targets.join(','), '--rounds', level === 'deep' ? '10' : '6'],
58+ };
59+}

pyre/src/web/src/session.tsadded

+112−0
@@ -0,0 +1,112 @@
1+import { useCallback, useEffect, useRef, useState } from 'react';
2+import { getJob, startJob, stopJob } from './api';
3+import type { DoneEvent, EvalRow, JobStatus, ProbeEvent, PyreEvent, RoundEvent, ScoreEvent, VerdictEvent } from './types';
4+
5+export interface SessionView {
6+ cmd: string;
7+ running: boolean;
8+ exit: number | null;
9+ started: number;
10+ events: PyreEvent[];
11+ phase: string;
12+ probes: ProbeEvent[];
13+ rows: EvalRow[];
14+ rounds: RoundEvent[];
15+ mode?: 'auto' | 'forever';
16+ idle: boolean; // forever mode, resting between rounds
17+ converged: boolean;
18+ scores: Record<string, ScoreEvent>;
19+ verdict?: VerdictEvent;
20+ done?: DoneEvent;
21+ logs: string[];
22+}
23+
24+export function summarize(status: Pick<JobStatus, 'running' | 'cmd' | 'started' | 'exit'>, events: PyreEvent[], rounds: RoundEvent[] = []): SessionView {
25+ const view: SessionView = {
26+ cmd: status.cmd, running: status.running, exit: status.exit, started: status.started,
27+ events, phase: '', probes: [], rows: [], rounds, idle: false, converged: false, scores: {}, logs: [],
28+ };
29+ for (const e of events) {
30+ switch (e.event) {
31+ case 'phase': view.phase = e.name; break;
32+ case 'probe': view.probes.push(e); view.idle = false; break;
33+ case 'round': view.idle = false; break;
34+ case 'mode': view.mode = e.mode; break;
35+ case 'idle': view.idle = true; break;
36+ case 'converged': view.converged = true; break;
37+ case 'eval_row': view.rows.push(e); break;
38+ case 'score': view.scores[e.phase] = e; break;
39+ case 'verdict': view.verdict = e; break;
40+ case 'done': view.done = e; break;
41+ case 'log': view.logs.push(e.message); break;
42+ case 'skipped': view.logs.push(`${e.target} is not available and was skipped`); break;
43+ }
44+ }
45+ // A forever session can run for hours: keep the page light by showing only the recent probes and checks.
46+ view.probes = view.probes.slice(-600);
47+ view.rows = view.rows.slice(-120);
48+ return view;
49+}
50+
51+/** Polls the dashboard server for the running (or last) session and exposes start / stop. */
52+export function useSession(onFinished: () => void) {
53+ const [view, setView] = useState<SessionView | null>(null);
54+ const [error, setError] = useState('');
55+ const since = useRef(0);
56+ const events = useRef<PyreEvent[]>([]);
57+ const rounds = useRef<RoundEvent[]>([]);
58+ const startedAt = useRef(0);
59+ const wasRunning = useRef(false);
60+ const finished = useRef(onFinished);
61+ finished.current = onFinished;
62+
63+ const poll = useCallback(async () => {
64+ try {
65+ const s = await getJob(since.current);
66+ if (s.started !== startedAt.current || s.next < since.current) { // a different session: start over
67+ startedAt.current = s.started;
68+ events.current = [];
69+ rounds.current = [];
70+ since.current = 0;
71+ if (s.next > s.lines.length) return poll();
72+ }
73+ events.current = events.current.concat(s.lines);
74+ for (const l of s.lines) if (l.event === 'round') rounds.current.push(l);
75+ if (rounds.current.length > 400) rounds.current = rounds.current.slice(-300);
76+ if (events.current.length > 6000) events.current = events.current.slice(-4000);
77+ since.current = s.next;
78+ setView(s.started ? summarize(s, events.current, rounds.current) : null);
79+ if (wasRunning.current && !s.running) finished.current();
80+ wasRunning.current = s.running;
81+ setError('');
82+ } catch (e) {
83+ setError(e instanceof Error ? e.message : String(e));
84+ }
85+ }, []);
86+
87+ useEffect(() => {
88+ void poll();
89+ const id = window.setInterval(() => void poll(), view?.running ? 500 : 2500);
90+ return () => window.clearInterval(id);
91+ }, [poll, view?.running]);
92+
93+ const start = useCallback(async (cmd: 'train' | 'check' | 'forget', args: string[]) => {
94+ try {
95+ await startJob(cmd, args);
96+ events.current = [];
97+ rounds.current = [];
98+ since.current = 0;
99+ startedAt.current = 0;
100+ wasRunning.current = true;
101+ await poll();
102+ } catch (e) {
103+ setError(e instanceof Error ? e.message : String(e));
104+ }
105+ }, [poll]);
106+
107+ const stop = useCallback(async () => {
108+ try { await stopJob(); } catch (e) { setError(e instanceof Error ? e.message : String(e)); }
109+ }, []);
110+
111+ return { view, error, start, stop };
112+}

pyre/src/web/src/styles.cssadded

+235−0
@@ -0,0 +1,235 @@
1+/* Pyre dashboard: tile wall (bordered tiles, centred bold titles), light and dark.
2+ Every page is laid out to fit the window; charts, gauges and matrices measure their tile
3+ and draw to fit it, so nothing spills out of a tile. System fonts only: fully offline. */
4+:root {
5+ --page: #ffffff; --tile: #ffffff; --ink: #111827; --muted: #6b7280;
6+ --line: #cfd4dc; --line-soft: #e6e9ee; --stripe: #f5f6f8;
7+ --blue: #2457d6; --good: #2e7d32; --warn: #b26a00; --bad: #c62828; --cell-empty: #eef0f3;
8+ color-scheme: light;
9+}
10+:root[data-theme='dark'] {
11+ --page: #0e1117; --tile: #151a23; --ink: #e6e9ef; --muted: #9aa4b5;
12+ --line: #2c3442; --line-soft: #222a37; --stripe: #1a2130;
13+ --blue: #6d9bff; --good: #6fcf7a; --warn: #f0b54a; --bad: #ff7b72; --cell-empty: #212938;
14+ color-scheme: dark;
15+}
16+@media (prefers-color-scheme: dark) {
17+ :root:not([data-theme]) {
18+ --page: #0e1117; --tile: #151a23; --ink: #e6e9ef; --muted: #9aa4b5; --line: #2c3442; --line-soft: #222a37; --stripe: #1a2130;
19+ --blue: #6d9bff; --good: #6fcf7a; --warn: #f0b54a; --bad: #ff7b72; --cell-empty: #212938; color-scheme: dark;
20+ }
21+}
22+
23+* { box-sizing: border-box; }
24+html, body { margin: 0; min-height: 100%; }
25+body { background: var(--page); color: var(--ink); font: 13px/1.45 system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; overflow-x: hidden; }
26+h1, h2, h3 { margin: 0; }
27+.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; }
28+.num { text-align: right; font-variant-numeric: tabular-nums; }
29+.note { color: var(--muted); font-size: 12px; margin: 0; }
30+:focus-visible { outline: 2px solid var(--blue); outline-offset: 2px; }
31+
32+/* shell: the page is exactly the window tall; the active page fills what is left under the top bar */
33+.shell { min-height: 100vh; display: flex; flex-direction: column; padding: 0 14px 14px; }
34+.shell > main { flex: 1; min-height: 0; display: grid; gap: 12px; }
35+.topbar { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 12px; padding: 10px 0; }
36+.brand { display: flex; align-items: center; gap: 10px; }
37+.brand h1 { font-size: 17px; }
38+.logo { width: 30px; height: 30px; display: block; }
39+.topbar nav { display: flex; gap: 2px; justify-content: center; flex-wrap: wrap; }
40+.topbar nav a { padding: 6px 14px; color: var(--muted); text-decoration: none; font-weight: 600; border-bottom: 2px solid transparent; }
41+.topbar nav a:hover { color: var(--ink); }
42+.topbar nav a.on { color: var(--ink); border-bottom-color: var(--blue); }
43+.topbar-right { display: flex; align-items: center; gap: 10px; justify-content: flex-end; }
44+.offline { color: var(--bad); font-weight: 600; }
45+
46+@media (min-width: 1101px) { .shell { height: 100vh; } } /* definite height: tile rows split the window exactly */
47+
48+/* ---- Dashboard: four columns, three rows, everything stretches to the window ---- */
49+.wall { grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-rows: minmax(auto, 1fr) minmax(auto, 1.3fr) minmax(auto, 1.05fr); }
50+.wall > :nth-child(1) { grid-area: 1 / 1 / 3 / 3; } /* speed chart: two columns, two rows */
51+.wall > :nth-child(2) { grid-area: 1 / 3; }
52+.wall > :nth-child(3) { grid-area: 1 / 4; }
53+.wall > :nth-child(4) { grid-area: 2 / 3; }
54+.wall > :nth-child(5) { grid-area: 2 / 4; }
55+.wall > :nth-child(6) { grid-area: 3 / 1; }
56+.wall > :nth-child(7) { grid-area: 3 / 2; }
57+.wall > :nth-child(8) { grid-area: 3 / 3; }
58+.wall > :nth-child(9) { grid-area: 3 / 4; }
59+
60+/* ---- other pages ---- */
61+.split { grid-template-columns: minmax(360px, 460px) minmax(0, 1fr); grid-template-rows: minmax(0, 1fr); }
62+.split .control { overflow-y: auto; }
63+.perf { grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-rows: minmax(260px, 1.5fr) minmax(auto, 1fr); }
64+.devices { grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr); grid-template-rows: auto minmax(0, 1fr); }
65+.devices > :nth-child(1) { grid-area: 1 / 1; }
66+.devices > :nth-child(2) { grid-area: 1 / 2 / 3 / 3; }
67+.devices > :nth-child(3) { grid-area: 2 / 1; }
68+.hist { grid-template-columns: minmax(0, 1fr) minmax(0, 1.5fr); grid-template-rows: minmax(0, 1.3fr) minmax(auto, 1fr); }
69+.hist > :nth-child(1) { grid-area: 1 / 1; }
70+.hist > :nth-child(2) { grid-area: 2 / 1; }
71+.hist > :nth-child(3) { grid-area: 1 / 2 / 3 / 3; }
72+
73+@media (max-width: 1100px) {
74+ .topbar { grid-template-columns: 1fr auto; }
75+ .topbar nav { grid-column: 1 / -1; grid-row: 2; }
76+ .wall { grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-rows: none; grid-auto-rows: minmax(190px, auto); }
77+ .wall > :nth-child(n) { grid-area: auto; }
78+ .wall > :nth-child(1) { grid-column: span 2; min-height: 340px; }
79+ .wall > :nth-child(6), .wall > :nth-child(7) { grid-column: span 2; }
80+ .split, .devices, .hist { grid-template-columns: minmax(0, 1fr); grid-template-rows: none; }
81+ .split .live { min-height: 520px; }
82+ .devices > :nth-child(n), .hist > :nth-child(n) { grid-area: auto; }
83+ .perf { grid-template-columns: minmax(0, 1fr); grid-template-rows: none; grid-auto-rows: minmax(260px, auto); }
84+ .hist > :nth-child(1) { min-height: 280px; }
85+}
86+@media (max-width: 600px) {
87+ .wall { grid-template-columns: minmax(0, 1fr); }
88+ .wall > :nth-child(n) { grid-column: auto; }
89+}
90+
91+/* ---- tile ---- */
92+.tile { background: var(--tile); border: 1px solid var(--line); padding: 10px 12px 12px; position: relative; min-width: 0; min-height: 0; display: flex; flex-direction: column; gap: 8px; }
93+.tile-title { font-size: 13px; font-weight: 700; text-align: center; padding: 0 60px; flex: none; }
94+.tile-actions { display: flex; gap: 8px; align-items: center; justify-content: center; flex: none; }
95+.tile-body { display: flex; flex-direction: column; gap: 8px; flex: 1; min-width: 0; min-height: 0; }
96+
97+/* KPI */
98+.kpi .tile-body { align-items: center; justify-content: center; }
99+.kpi-value { font-size: clamp(34px, 6.5vh, 76px); font-weight: 500; line-height: 1.1; }
100+.kpi-unit { font-size: 0.5em; margin-left: 6px; }
101+.spark { width: 70%; height: 26px; flex: none; }
102+.tone-good { color: var(--good); } .tone-warn { color: var(--warn); } .tone-bad { color: var(--bad); }
103+
104+/* chart: the SVG sits absolutely inside its area, so it can never push the tile bigger */
105+.chart { flex: 1; min-height: 0; min-width: 0; display: flex; flex-direction: column; }
106+.chart-area { flex: 1; min-height: 130px; position: relative; }
107+.chart-area svg { position: absolute; left: 0; top: 0; }
108+.chart .grid { stroke: var(--line-soft); stroke-width: 1; }
109+.chart .tick { fill: var(--muted); font-size: 11px; }
110+.chart .axis { fill: var(--muted); font-size: 11px; }
111+.chart-empty { color: var(--muted); padding: 24px; text-align: center; }
112+.legend { list-style: none; display: flex; gap: 16px; margin: 4px 0 0; padding: 0; justify-content: center; color: var(--ink); font-size: 12px; flex: none; }
113+.legend i { display: inline-block; width: 18px; height: 3px; margin-right: 6px; vertical-align: middle; }
114+
115+/* heat map: cells share the tile's height */
116+.heat { flex: 1; min-height: 0; display: flex; flex-direction: column; gap: 4px; }
117+.heat-grid { flex: 1; display: grid; gap: 2px; min-height: 0; }
118+.heat-col { font-size: 10px; color: var(--muted); text-align: center; padding-bottom: 2px; }
119+.heat-row { font-size: 11px; padding-right: 6px; display: flex; align-items: center; }
120+.heat-cell { display: flex; align-items: center; justify-content: center; font-size: 10px; font-weight: 600; color: #1f2937; overflow: hidden; min-width: 0; }
121+.heat-x { text-align: center; font-size: 11px; flex: none; }
122+.heat-legend { display: flex; gap: 12px; justify-content: center; flex-wrap: wrap; font-size: 11px; flex: none; }
123+.heat-legend i { display: inline-block; width: 11px; height: 11px; margin-right: 5px; vertical-align: -1px; border: 1px solid var(--line); }
124+
125+/* gauge */
126+.gauge { margin: 0; flex: 1; min-height: 90px; position: relative; }
127+.gauge svg { position: absolute; inset: 0; width: 100%; height: 100%; }
128+.gauge-tick { fill: var(--ink); font-size: 8.5px; }
129+.gauge-value { fill: var(--ink); font-size: 14px; font-weight: 600; }
130+
131+/* tables: fixed layout, long text is ellipsised instead of overflowing */
132+.table-wrap { min-width: 0; }
133+table.data { width: 100%; border-collapse: collapse; table-layout: fixed; }
134+table.data th { text-align: left; font-weight: 700; padding: 6px 8px; border: 1px solid var(--line); background: var(--stripe); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
135+table.data td { padding: 6px 8px; border: 1px solid var(--line); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
136+table.data tbody tr:nth-child(even) td { background: var(--stripe); }
137+.swatch { display: inline-block; width: 11px; height: 11px; margin-right: 8px; vertical-align: -1px; }
138+.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 7px; }
139+.dot-good { background: #3ea85a; } .dot-idle { background: var(--line); }
140+.v-good { color: var(--good); font-weight: 600; } .v-bad { color: var(--bad); font-weight: 600; }
141+.v-warn { color: var(--warn); font-weight: 600; } .v-neutral { color: var(--muted); font-weight: 600; }
142+.scroll { overflow: auto; border: 1px solid var(--line); flex: 1; min-height: 0; }
143+.scroll table.data th { position: sticky; top: 0; z-index: 1; }
144+.empty { color: var(--muted); text-align: center; padding: 18px 8px; margin: auto; }
145+
146+/* session durations (gantt) */
147+.gantt { list-style: none; margin: 0; padding: 0; }
148+.gantt li { display: grid; grid-template-columns: minmax(80px, 130px) minmax(0, 1fr) 64px; gap: 10px; align-items: center; padding: 7px 4px; }
149+.gantt li:nth-child(even) { background: var(--stripe); }
150+.gantt-label { font-size: 12px; font-weight: 600; text-align: right; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
151+.gantt-track { height: 14px; position: relative; }
152+.gantt-bar { position: absolute; left: 0; top: 0; bottom: 0; border-radius: 3px; }
153+.gantt-value { font-size: 12px; color: var(--muted); font-variant-numeric: tabular-nums; }
154+.gantt-axis { display: flex; justify-content: space-between; color: var(--muted); font-size: 11px; padding: 0 64px 0 140px; }
155+
156+/* training controls: plain buttons and number boxes, no sliders */
157+.ctl { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 10px 14px; align-items: center; }
158+.ctl-label { color: var(--muted); font-size: 12px; }
159+.segmented { display: inline-flex; border: 1px solid var(--line); width: fit-content; justify-self: start; }
160+.segmented button { font: inherit; padding: 4px 12px; border: 0; border-right: 1px solid var(--line); background: var(--tile); color: var(--ink); cursor: pointer; }
161+.segmented button:last-child { border-right: 0; }
162+.segmented button.on { background: var(--blue); color: var(--page); font-weight: 600; }
163+.chips { display: flex; gap: 6px; flex-wrap: wrap; }
164+.chip { font: inherit; padding: 3px 11px; border: 1px solid var(--line); background: var(--tile); color: var(--muted); cursor: pointer; border-radius: 999px; }
165+.chip.on { border-color: var(--blue); color: var(--blue); background: color-mix(in srgb, var(--blue) 10%, var(--tile)); font-weight: 600; }
166+.num-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px 10px; }
167+.num-field { display: flex; flex-direction: column; gap: 3px; font-size: 12px; color: var(--muted); min-width: 0; }
168+.num-field input { font: inherit; padding: 5px 8px; border: 1px solid var(--line); background: var(--page); color: var(--ink); width: 100%; min-width: 0; }
169+.checks { display: flex; flex-direction: column; gap: 8px; }
170+.check { display: flex; gap: 8px; align-items: flex-start; }
171+.check input { margin-top: 3px; accent-color: var(--blue); }
172+.check small { display: block; color: var(--muted); font-size: 12px; }
173+.btn-row { display: flex; gap: 8px; flex-wrap: wrap; }
174+.control .btn-row { margin-top: auto; }
175+.danger-row { display: flex; gap: 12px; align-items: center; justify-content: space-between; border-top: 1px solid var(--line); padding-top: 10px; font-size: 12px; color: var(--muted); }
176+.btn { font: inherit; font-weight: 600; padding: 6px 12px; border: 1px solid var(--line); background: var(--tile); color: var(--ink); cursor: pointer; }
177+.btn:hover:not(:disabled) { background: var(--stripe); }
178+.btn:disabled { opacity: 0.5; cursor: not-allowed; }
179+.btn-primary { background: var(--blue); border-color: var(--blue); color: var(--page); }
180+.btn-primary:hover:not(:disabled) { background: var(--blue); filter: brightness(1.1); }
181+.btn-danger { color: var(--bad); border-color: var(--bad); }
182+.msg { margin: 0; padding: 6px 8px; font-size: 12px; border: 1px solid var(--line); background: var(--stripe); }
183+.msg-warn { color: var(--warn); } .msg-bad { color: var(--bad); }
184+.badge { display: inline-block; padding: 1px 8px; border: 1px solid var(--line); font-size: 11px; font-weight: 600; color: var(--muted); border-radius: 999px; }
185+.badge-good { color: var(--good); border-color: var(--good); } .badge-bad { color: var(--bad); border-color: var(--bad); }
186+.badge-info { color: var(--blue); border-color: var(--blue); } .badge-warn { color: var(--warn); border-color: var(--warn); }
187+
188+/* live session */
189+.live .tile-actions { position: absolute; top: 8px; right: 10px; }
190+.bar { height: 6px; background: var(--stripe); border: 1px solid var(--line); overflow: hidden; flex: none; }
191+.bar span { display: block; height: 100%; width: 100%; background: var(--good); }
192+.bar-run span { width: 35%; background: var(--blue); animation: slide 1.2s ease-in-out infinite; }
193+@keyframes slide { 0% { margin-left: -35%; } 100% { margin-left: 100%; } }
194+@media (prefers-reduced-motion: reduce) { .bar-run span { animation: none; margin-left: 30%; } }
195+.strip { display: flex; flex-wrap: wrap; gap: 8px 26px; justify-content: center; flex: none; }
196+.mini { display: flex; flex-direction: column; align-items: center; min-width: 100px; }
197+.mini-label { color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em; }
198+.mini strong { font-size: 20px; font-weight: 600; }
199+.mini small { color: var(--muted); font-size: 11px; max-width: 230px; text-align: center; }
200+
201+
202+
203+.sub { font-size: 12px; text-align: center; margin-bottom: 4px; flex: none; }
204+
205+/* short windows (small laptops): tighter spacing so the dashboard still fits without scrolling */
206+@media (max-height: 780px) and (min-width: 1101px) {
207+ .shell { padding: 0 10px 10px; }
208+ .shell > main { gap: 8px; }
209+ .topbar { padding: 6px 0; }
210+ .tile { padding: 6px 8px 8px; gap: 4px; }
211+ .tile-body { gap: 4px; }
212+ .kpi-value { font-size: clamp(28px, 6vh, 56px); }
213+ table.data th, table.data td { padding: 3px 6px; }
214+ .heat-legend, .legend { font-size: 10px; }
215+ .chart-area { min-height: 90px; }
216+ .gauge { min-height: 52px; }
217+ .heat-grid { gap: 1px; }
218+ .heat-cell { font-size: 9px; }
219+ .spark { height: 18px; }
220+}
221+
222+/* tendency (which way a measure is heading) */
223+.trend { font-size: 12px; text-align: center; color: var(--muted); flex: none; }
224+.trend-good { color: var(--good); } .trend-bad { color: var(--bad); } .trend-neutral { color: var(--muted); }
225+.trend-none { font-style: italic; }
226+.running-link { color: var(--blue); font-weight: 600; text-decoration: none; }
227+.running-link:hover { text-decoration: underline; }
228+
229+/* recommended session */
230+.rec h3 { font-size: 14px; margin-bottom: 4px; }
231+.rec ul { margin: 8px 0 0; padding-left: 18px; display: flex; flex-direction: column; gap: 4px; }
232+.rec li { font-size: 12px; color: var(--ink); }
233+.live-view { flex: 1; min-height: 0; display: flex; flex-direction: column; gap: 6px; }
234+.live-view .chart { flex: 1; min-height: 0; }
235+.live-view .scroll { flex: 1; }

pyre/src/web/src/theme.tsadded

+18−0
@@ -0,0 +1,18 @@
1+import { useEffect, useState } from 'react';
2+
3+export type Theme = 'light' | 'dark';
4+
5+function initial(): Theme {
6+ const set = document.documentElement.dataset.theme;
7+ if (set === 'light' || set === 'dark') return set;
8+ return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
9+}
10+
11+export function useTheme(): [Theme, () => void] {
12+ const [theme, setTheme] = useState<Theme>(initial);
13+ useEffect(() => {
14+ document.documentElement.dataset.theme = theme;
15+ try { localStorage.setItem('pyre-theme', theme); } catch { /* private mode: not persisted */ }
16+ }, [theme]);
17+ return [theme, () => setTheme(t => (t === 'light' ? 'dark' : 'light'))];
18+}

pyre/src/web/src/tiles/Control.tsxadded

+140−0
@@ -0,0 +1,140 @@
1+import { useEffect, useMemo, useState } from 'react';
2+import { TARGET_LABEL } from '../colors';
3+import { Button, Check, Chip, Segmented, Tile } from '../components/ui';
4+import { recommend } from '../recommend';
5+import { DEFAULT_FORM, PRESETS, loadForm, saveForm, toArgs, toForeverArgs, validate, type Level, type Mode, type TrainForm } from '../trainForm';
6+import { OPS, TARGETS, type HistoryEntry, type Info, type OpKey, type Target } from '../types';
7+
8+interface Props {
9+ info: Info | null;
10+ history: HistoryEntry[];
11+ running: boolean;
12+ error: string;
13+ onStart: (cmd: 'train' | 'check' | 'forget', args: string[]) => void;
14+ onStop: () => void;
15+}
16+
17+const LOADS = [25, 50, 75, 100].map(v => ({ value: v, label: `${v}%` }));
18+const PAUSES = [{ value: 10, label: '10 s' }, { value: 30, label: '30 s' }, { value: 60, label: '1 min' }, { value: 300, label: '5 min' }];
19+type NumKey = 'step' | 'repeats' | 'maxProbe' | 'maxWork' | 'maxMem' | 'pause';
20+
21+/** Everything `pyre train` can do, as plain controls (no sliders). Lives on the Training page. */
22+export function ControlTile({ info, history, running, error, onStart, onStop }: Props) {
23+ const [form, setForm] = useState<TrainForm>(loadForm);
24+ const [confirm, setConfirm] = useState(false);
25+ useEffect(() => saveForm(form), [form]);
26+
27+ const set = <K extends keyof TrainForm>(k: K, v: TrainForm[K]) => setForm(f => ({ ...f, [k]: v }));
28+ const rec = useMemo(() => recommend(info, history), [info, history]);
29+ const problem = form.mode === 'recommended' ? '' : validate(form);
30+ const preset = PRESETS[form.level];
31+ const num = (k: NumKey, label: string, placeholder: string) => (
32+ <label className="num-field">
33+ <span>{label}</span>
34+ <input type="number" inputMode="decimal" value={form[k]} placeholder={placeholder} onChange={e => set(k, e.target.value)} />
35+ </label>
36+ );
37+
38+ const scope = (
39+ <div className="ctl">
40+ <span className="ctl-label">Kernels</span>
41+ <div className="chips">
42+ {OPS.map(o => <Chip key={o.key} label={o.short} on={form.ops[o.key]} onClick={() => set('ops', { ...form.ops, [o.key as OpKey]: !form.ops[o.key] })} />)}
43+ </div>
44+ <span className="ctl-label">Targets</span>
45+ <div className="chips">
46+ {TARGETS.map(t => {
47+ const seen = t === 'cpu' || info?.targets.find(x => x.name === t)?.present;
48+ return <Chip key={t} label={TARGET_LABEL[t]} on={form.targets[t]} hint={seen ? undefined : 'not seen on this machine yet'}
49+ onClick={() => set('targets', { ...form.targets, [t as Target]: !form.targets[t] })} />;
50+ })}
51+ </div>
52+ <span className="ctl-label">GPU load</span>
53+ <Segmented label="GPU load" value={form.gpuLoad} onChange={v => set('gpuLoad', v)} options={LOADS} />
54+ <span className="ctl-label">CPU load</span>
55+ <Segmented label="CPU load" value={form.cpuLoad} onChange={v => set('cpuLoad', v)} options={LOADS} />
56+ </div>
57+ );
58+
59+ const startBar = (label: string, args: string[]) => (
60+ <div className="btn-row">
61+ {running ? <Button variant="danger" onClick={onStop}>Stop session</Button>
62+ : <Button variant="primary" disabled={!!problem} onClick={() => onStart('train', args)}>{label}</Button>}
63+ </div>
64+ );
65+
66+ return (
67+ <Tile title="Start a session" className="control">
68+ <Segmented<Mode> label="Mode" value={form.mode} onChange={m => set('mode', m)}
69+ options={[{ value: 'recommended', label: 'Recommended' }, { value: 'custom', label: 'Custom' }, { value: 'forever', label: 'Forever' }]} />
70+
71+ {form.mode === 'recommended' && (
72+ <>
73+ <div className="rec">
74+ <h3>{rec.headline}</h3>
75+ <p className="note">Learns the way a model does: it grades itself on sizes it has not seen, practises exactly where its decisions are weakest, grades again, and keeps a round only if it helped.</p>
76+ <ul>{rec.reasons.map((r, i) => <li key={i}>{r}</li>)}</ul>
77+ </div>
78+ {error && <p className="msg msg-bad">{error}</p>}
79+ {startBar('Start recommended session', rec.args)}
80+ </>
81+ )}
82+
83+ {form.mode === 'forever' && (
84+ <>
85+ <p className="note">Keeps learning until you stop it: every round it re-checks itself on fresh sizes and practises where it is weakest, so it also notices drift (a hotter GPU, a driver update). Saves after every round that helped. Safe to leave running; lower the loads to keep the machine free.</p>
86+ {scope}
87+ <div className="ctl">
88+ <span className="ctl-label">Rest between rounds</span>
89+ <Segmented label="Rest between rounds" value={form.roundPause} onChange={v => set('roundPause', v)} options={PAUSES} />
90+ </div>
91+ {problem && <p className="msg msg-warn">{problem}</p>}
92+ {error && <p className="msg msg-bad">{error}</p>}
93+ {startBar('Start forever retraining', toForeverArgs(form))}
94+ </>
95+ )}
96+
97+ {form.mode === 'custom' && (
98+ <>
99+ <div className="ctl">
100+ <span className="ctl-label">Level</span>
101+ <Segmented label="Level" value={form.level} onChange={(v: Level) => set('level', v)}
102+ options={[{ value: 'quick', label: 'Quick' }, { value: 'normal', label: 'Normal' }, { value: 'deep', label: 'Deep' }]} />
103+ </div>
104+ {scope}
105+ <p className="note">{preset.blurb} Empty fields below use these preset values.</p>
106+ <div className="num-grid">
107+ {num('step', 'Size step', String(preset.step))}
108+ {num('repeats', 'Repeats', String(preset.repeats))}
109+ {num('maxProbe', 'Max probe time (s)', String(preset.maxProbe))}
110+ {num('maxWork', 'Max work (2^k)', '34')}
111+ {num('maxMem', 'Max memory (MB)', 'no cap')}
112+ {num('pause', 'Pause (ms)', '0')}
113+ </div>
114+ <div className="checks">
115+ <Check label="Forget everything first" checked={form.reset} onChange={v => set('reset', v)} hint="Train from an empty profile." />
116+ <Check label="Self-check before and after" checked={form.verify} onChange={v => set('verify', v)} hint="Grades training on sizes it did not train on and undoes it if it made things worse." />
117+ </div>
118+ {problem && <p className="msg msg-warn">{problem}</p>}
119+ {error && <p className="msg msg-bad">{error}</p>}
120+ <div className="btn-row">
121+ {running ? <Button variant="danger" onClick={onStop}>Stop session</Button> : (
122+ <>
123+ <Button variant="primary" disabled={!!problem} onClick={() => onStart('train', toArgs(form))}>Start training</Button>
124+ <Button onClick={() => onStart('check', [])}>Run self-check</Button>
125+ <Button onClick={() => setForm(f => ({ ...DEFAULT_FORM, mode: f.mode }))}>Reset options</Button>
126+ </>
127+ )}
128+ </div>
129+ </>
130+ )}
131+
132+ <div className="danger-row">
133+ <span>Delete everything learned on this machine (relearned as programs run).</span>
134+ {confirm
135+ ? <Button variant="danger" disabled={running} onClick={() => { setConfirm(false); onStart('forget', []); }}>Yes, forget</Button>
136+ : <Button variant="danger" disabled={running} onClick={() => setConfirm(true)}>Forget</Button>}
137+ </div>
138+ </Tile>
139+ );
140+}

pyre/src/web/src/tiles/Heat.tsxadded

+61−0
@@ -0,0 +1,61 @@
1+import { TARGET_COLOR } from '../colors';
2+import { HeatMap, type HeatCell } from '../components/HeatMap';
3+import { Tile } from '../components/ui';
4+import { SIZE_CLASSES, heatRows } from '../analytics';
5+import type { Info, Target } from '../types';
6+
7+const SHORT: Record<string, string> = { matrix_multiply: 'MatMul', chaos_iterate: 'Chaos', monte_carlo_risk: 'Monte Carlo' };
8+const CELL_TEXT: Record<Target, string> = { cpu: 'CPU', cuda: 'CUDA', vulkan: 'VK' };
9+const SOFT: Record<Target, string> = { cpu: '#b8c4d6', cuda: '#8bbf6e', vulkan: '#f9a561' };
10+const EMPTY = 'var(--cell-empty)';
11+
12+/** Which target is fastest, per kernel and size class. */
13+export function WinnerTile({ info }: { info: Info | null }) {
14+ const rows = heatRows(info?.rates ?? []);
15+ const cells: HeatCell[][] = rows.map(r => r.cells.map(c => ({
16+ color: c.winner ? SOFT[c.winner] : EMPTY,
17+ text: c.winner ? CELL_TEXT[c.winner] : '',
18+ title: c.winner ? `${c.winner} is fastest` : 'not measured',
19+ })));
20+ return (
21+ <Tile title="Fastest target by size" className="heatmap">
22+ <HeatMap
23+ rows={rows.map(r => SHORT[r.op.name])}
24+ cols={SIZE_CLASSES.map(c => c.label)}
25+ cells={cells}
26+ xLabel="Workload size"
27+ legend={(['cpu', 'cuda', 'vulkan'] as Target[]).map(t => (
28+ <span key={t}><i style={{ background: SOFT[t], borderColor: TARGET_COLOR[t] }} />{t === 'cpu' ? 'CPU' : t === 'cuda' ? 'CUDA' : 'Vulkan'}</span>
29+ ))}
30+ />
31+ </Tile>
32+ );
33+}
34+
35+/** How much faster the best GPU is than the CPU (green) or slower (red). */
36+export function SpeedupTile({ info }: { info: Info | null }) {
37+ const rows = heatRows(info?.rates ?? []);
38+ const cells: HeatCell[][] = rows.map(r => r.cells.map(c => {
39+ if (c.speedup === undefined) return { color: EMPTY, text: '' };
40+ const s = c.speedup;
41+ const color = s >= 2 ? '#8bbf6e' : s >= 1.1 ? '#c5e1a5' : s >= 0.9 ? '#f6e27f' : s >= 0.5 ? '#f9a561' : '#f26b6b';
42+ return { color, text: s >= 10 ? `${s.toFixed(0)}×` : `${s.toFixed(1)}×`, title: `best GPU is ${s.toFixed(2)}× the CPU` };
43+ }));
44+ return (
45+ <Tile title="GPU speed vs CPU" className="heatmap">
46+ <HeatMap
47+ rows={rows.map(r => SHORT[r.op.name])}
48+ cols={SIZE_CLASSES.map(c => c.label)}
49+ cells={cells}
50+ xLabel="Workload size"
51+ legend={
52+ <>
53+ <span><i style={{ background: '#f26b6b' }} />slower</span>
54+ <span><i style={{ background: '#f6e27f' }} />even</span>
55+ <span><i style={{ background: '#8bbf6e' }} />faster</span>
56+ </>
57+ }
58+ />
59+ </Tile>
60+ );
61+}

pyre/src/web/src/tiles/Kpi.tsxadded

+30−0
@@ -0,0 +1,30 @@
1+import { TrendNote } from '../components/TrendNote';
2+import { Tile } from '../components/ui';
3+import type { Trend } from '../trend';
4+
5+/** Big-number tile with an optional sparkline of recent values. */
6+export function KpiTile({ title, value, unit, spark, tone = 'ink', trend, format }: {
7+ title: string; value: string; unit?: string; spark?: number[]; tone?: 'ink' | 'good' | 'warn' | 'bad';
8+ trend?: Trend | null; format?: (v: number) => string;
9+}) {
10+ return (
11+ <Tile title={title} className="kpi">
12+ <div className={`kpi-value tone-${tone}`}>
13+ {value}
14+ {unit && <span className="kpi-unit">{unit}</span>}
15+ </div>
16+ {spark && spark.length > 1 && <Sparkline values={spark} />}
17+ {format && <TrendNote t={trend ?? null} format={format} />}
18+ </Tile>
19+ );
20+}
21+
22+function Sparkline({ values }: { values: number[] }) {
23+ const w = 160, h = 26, lo = Math.min(...values), hi = Math.max(...values);
24+ const pts = values.map((v, i) => `${(i / (values.length - 1)) * w},${h - 3 - (hi === lo ? 0.5 : (v - lo) / (hi - lo)) * (h - 6)}`).join(' ');
25+ return (
26+ <svg className="spark" viewBox={`0 0 ${w} ${h}`} preserveAspectRatio="none" aria-hidden>
27+ <polyline points={pts} fill="none" stroke="var(--blue)" strokeWidth="1.8" strokeLinejoin="round" />
28+ </svg>
29+ );
30+}

pyre/src/web/src/tiles/Live.tsxadded

+133−0
@@ -0,0 +1,133 @@
1+import { useEffect, useState } from 'react';
2+import { TARGET_COLOR, TARGET_LABEL } from '../colors';
3+import { LineChart, type Series } from '../components/LineChart';
4+import { TrendNote } from '../components/TrendNote';
5+import { Badge, DataTable, Segmented, Tile } from '../components/ui';
6+import { fmtDuration, fmtMs, fmtPct } from '../format';
7+import type { SessionView } from '../session';
8+import { smooth, trend } from '../trend';
9+import { OPS, TARGETS, type ScoreEvent } from '../types';
10+
11+const PHASE: Record<string, string> = {
12+ seeding: 'Seeding grid', 'check-before': 'Grading (before)', training: 'Training probes', practising: 'Practising weak spots',
13+ 'check-after': 'Grading (after)', check: 'Self-check',
14+};
15+const VERDICT: Record<string, { cls: string; text: string }> = {
16+ better: { cls: 'v-good', text: 'Better: decisions improved, training kept' },
17+ same: { cls: 'v-neutral', text: 'No measurable change: decisions were already as good as these probes can make them' },
18+ worse: { cls: 'v-bad', text: 'Worse: training was discarded, previous knowledge kept' },
19+ unknown: { cls: 'v-warn', text: 'Not enough held-out sizes to compare; training kept' },
20+ unverified: { cls: 'v-neutral', text: 'Self-check skipped; training kept' },
21+};
22+
23+function Score({ label, s }: { label: string; s?: ScoreEvent }) {
24+ if (!s || !s.shapes) return null;
25+ return (
26+ <div className="mini">
27+ <span className="mini-label">{label}</span>
28+ <strong className={s.lost_pct < 5 ? 'tone-good' : s.lost_pct < 25 ? 'tone-warn' : 'tone-bad'}>{fmtPct(s.lost_pct)}</strong>
29+ <small>time lost · right {s.hits}/{s.shapes} · error {fmtPct(s.error_pct)}</small>
30+ </div>
31+ );
32+}
33+
34+type Tab = 'rounds' | 'probes' | 'results';
35+
36+/** The running (or last) session: numbers on top, then one of rounds / probes / results. */
37+export function LiveTile({ view }: { view: SessionView | null }) {
38+ const [now, setNow] = useState(() => Date.now() / 1000);
39+ const [picked, setTab] = useState<Tab | null>(null);
40+ const running = view?.running ?? false;
41+ useEffect(() => {
42+ if (!running) return;
43+ const id = window.setInterval(() => setNow(Date.now() / 1000), 500);
44+ return () => window.clearInterval(id);
45+ }, [running]);
46+
47+ if (!view) return <Tile title="Live session" className="live"><div className="empty">No session yet. Start one on the left.</div></Tile>;
48+
49+ const last = view.probes[view.probes.length - 1];
50+ const elapsed = view.done ? view.done.seconds : Math.max(0, now - view.started);
51+ const tabs: { value: Tab; label: string }[] = [
52+ ...(view.rounds.length ? [{ value: 'rounds' as Tab, label: 'Rounds' }] : []),
53+ { value: 'probes', label: 'Probe timings' },
54+ ...(view.rows.length ? [{ value: 'results' as Tab, label: 'Self-check results' }] : []),
55+ ];
56+ const tab: Tab = picked && tabs.some(t => t.value === picked) ? picked : tabs[0].value;
57+
58+ const chartOp = last?.op ?? view.probes[0]?.op;
59+ const probeSeries: Series[] = TARGETS.map(t => ({
60+ name: TARGET_LABEL[t], color: TARGET_COLOR[t],
61+ points: view.probes.filter(p => p.op === chartOp && p.target === t).map(p => [p.exp, p.ms] as [number, number]),
62+ })).filter(s => s.points.length);
63+
64+ // Round curve: time lost per round (noisy: each round grades on different sizes), smoothed, plus a forecast.
65+ const lost = view.rounds.map(r => r.lost_pct);
66+ const sm = smooth(lost);
67+ const rt = trend(sm, 'lower', [0, Infinity]);
68+ const lastRound = view.rounds.length ? view.rounds[view.rounds.length - 1].round : 0;
69+ const roundSeries: Series[] = [
70+ { name: 'Time lost per round (%)', color: '#c0392b', points: view.rounds.map(r => [r.round, r.lost_pct] as [number, number]) },
71+ { name: 'Smoothed', color: '#2457d6', points: view.rounds.map((r, i) => [r.round, sm[i]] as [number, number]) },
72+ ...(rt ? [{ name: 'Tendency', color: '#2457d6', dashed: true, points: [[lastRound, sm[sm.length - 1]] as [number, number], ...rt.forecast(3).map((v, k) => [lastRound + k + 1, v] as [number, number])] }] : []),
73+ ];
74+ const kept = view.rounds.filter(r => r.round > 0 && r.accepted).length;
75+ const tried = view.rounds.filter(r => r.round > 0).length;
76+ const currentFocus = view.rounds.length ? view.rounds[view.rounds.length - 1].focus : [];
77+ const verdict = view.verdict ? VERDICT[view.verdict.verdict] : undefined;
78+ const wrong = view.rows.filter(r => !r.right).length;
79+
80+ const phaseText = !view.running ? 'Done' : view.idle ? 'Resting between rounds' : PHASE[view.phase] ?? 'Starting…';
81+ const badge = view.running ? <Badge tone="info">{view.mode === 'forever' ? 'Forever' : 'Running'}</Badge>
82+ : view.exit === 0 ? <Badge tone="good">Finished</Badge>
83+ : view.done && view.verdict?.discarded ? <Badge tone="warn">Finished, training discarded</Badge>
84+ : <Badge tone="bad">{view.exit === null ? 'Stopped' : `Exited (${view.exit})`}</Badge>;
85+
86+ return (
87+ <Tile title={`Live session: ${view.mode === 'forever' ? 'forever retraining' : view.mode === 'auto' ? 'adaptive' : view.cmd}`} className="live" actions={badge}>
88+ <div className={`bar ${view.running && !view.idle ? 'bar-run' : ''}`}><span /></div>
89+ <div className="strip">
90+ <div className="mini"><span className="mini-label">Phase</span><strong>{phaseText}</strong></div>
91+ {view.mode && <div className="mini"><span className="mini-label">Rounds</span><strong>{tried}</strong><small>{tried ? `${kept} kept, ${tried - kept} reverted` : 'baseline first'}</small></div>}
92+ <div className="mini"><span className="mini-label">Probes run</span><strong>{view.probes.length}</strong><small>{last ? `${last.op} · ${TARGET_LABEL[last.target]} · ${last.shape}` : ''}</small></div>
93+ <div className="mini"><span className="mini-label">Elapsed</span><strong>{fmtDuration(elapsed)}</strong></div>
94+ <Score label="Before" s={view.scores.before} />
95+ <Score label="After" s={view.scores.after} />
96+ <Score label="Current" s={view.scores.now} />
97+ </div>
98+ {verdict && <p className={`msg ${verdict.cls}`}>{view.converged ? 'Converged: ' : ''}{verdict.text}</p>}
99+ {view.running && currentFocus.length > 0 && <p className="note">Last practised: {currentFocus.join(' · ')}</p>}
100+ {view.logs.map((l, i) => <p key={i} className="msg msg-warn">{l}</p>)}
101+
102+ <Segmented label="View" value={tab} onChange={setTab} options={tabs} />
103+ <div className="live-view">
104+ {tab === 'rounds' && (
105+ <>
106+ <LineChart series={roundSeries} xLabel="round" yLabel="time lost (%)" yMin={0} yTick={v => `${v.toFixed(0)}%`} />
107+ <TrendNote t={rt} format={v => `${v.toFixed(1)}%`} />
108+ </>
109+ )}
110+ {tab === 'probes' && (probeSeries.length
111+ ? <LineChart series={probeSeries} xLabel={`size (2^k units) · ${chartOp ?? ''}`} yLabel="time per call" logY xTick={x => `2^${x}`} yTick={fmtMs} />
112+ : <div className="empty">No probes yet.</div>)}
113+ {tab === 'results' && (
114+ <>
115+ <h3 className="sub">{wrong} wrong of {view.rows.length}</h3>
116+ <div className="scroll">
117+ <DataTable head={['Phase', 'Kernel', 'Size', 'CPU', 'CUDA', 'Vulkan', 'Picked', '']} widths={['12%', '13%', '19%', '11%', '11%', '11%', '12%', '11%']}>
118+ {view.rows.map((r, i) => (
119+ <tr key={i}>
120+ <td>{r.phase}</td><td>{OPS.find(o => o.name === r.op)?.short ?? r.op}</td><td className="mono">{r.shape}</td>
121+ {TARGETS.map(t => <td key={t} className="num">{r.times_ms[t] === undefined ? '–' : fmtMs(r.times_ms[t] as number)}</td>)}
122+ <td>{TARGET_LABEL[r.picked]}</td>
123+ <td className={r.right ? 'v-good' : 'v-bad'}>{r.right ? 'right' : 'wrong'}</td>
124+ </tr>
125+ ))}
126+ </DataTable>
127+ </div>
128+ </>
129+ )}
130+ </div>
131+ </Tile>
132+ );
133+}

pyre/src/web/src/tiles/Speed.tsxadded

+54−0
@@ -0,0 +1,54 @@
1+import { useState } from 'react';
2+import { TARGET_COLOR, TARGET_LABEL } from '../colors';
3+import { LineChart, type Series } from '../components/LineChart';
4+import { Empty, Segmented, Tile } from '../components/ui';
5+import { fmtRate } from '../format';
6+import { OPS, TARGETS, type Info, type OpKey } from '../types';
7+
8+/** Chart body: effective speed by workload size, background shaded by which target wins. */
9+function Chart({ info, op }: { info: Info | null; op: OpKey }) {
10+ const spec = OPS.find(o => o.key === op)!;
11+ const mine = (info?.rates ?? []).filter(r => r.op === spec.name);
12+ const series: Series[] = TARGETS.map(t => ({
13+ name: TARGET_LABEL[t],
14+ color: TARGET_COLOR[t],
15+ points: mine.filter(r => r.target === t).map(r => [r.bucket, r.per_second] as [number, number]),
16+ })).filter(s => s.points.length);
17+
18+ // Winner per measured size, shaded from the midpoint before it to the midpoint after.
19+ const buckets = [...new Set(mine.map(r => r.bucket))].sort((a, b) => a - b);
20+ const bands = buckets.map((b, i) => {
21+ const best = mine.filter(r => r.bucket === b).reduce((a, c) => (c.per_second > a.per_second ? c : a));
22+ return {
23+ from: i === 0 ? b : (buckets[i - 1] + b) / 2,
24+ to: i === buckets.length - 1 ? b : (b + buckets[i + 1]) / 2,
25+ color: TARGET_COLOR[best.target],
26+ };
27+ });
28+
29+ return series.length ? (
30+ <LineChart series={series} bands={bands} xLabel="workload size (2^k units)" yLabel={spec.unit} logY xTick={x => `2^${x}`} yTick={fmtRate} />
31+ ) : (
32+ <Empty>Nothing learned for this kernel yet. Run a training session or run programs that use it.</Empty>
33+ );
34+}
35+
36+/** One kernel's chart as its own tile (Performance page). */
37+export function KernelTile({ info, op }: { info: Info | null; op: OpKey }) {
38+ return <Tile title={`${OPS.find(o => o.key === op)!.label}: speed by workload size`} className="chart-tile"><Chart info={info} op={op} /></Tile>;
39+}
40+
41+/** The landing page's main chart, with a kernel switch under the title. */
42+export function SpeedTile({ info }: { info: Info | null }) {
43+ const [picked, setOp] = useState<OpKey | null>(null);
44+ const op = picked ?? OPS.find(o => (info?.rates ?? []).some(r => r.op === o.name))?.key ?? 'matmul';
45+ return (
46+ <Tile
47+ title={`${OPS.find(o => o.key === op)!.label}: speed by workload size`}
48+ className="speed"
49+ actions={<Segmented label="Kernel" value={op} onChange={setOp} options={OPS.map(o => ({ value: o.key, label: o.short }))} />}
50+ >
51+ <Chart info={info} op={op} />
52+ </Tile>
53+ );
54+}

pyre/src/web/src/tiles/Tables.tsxadded

+72−0
@@ -0,0 +1,72 @@
1+import { TARGET_COLOR, TARGET_LABEL } from '../colors';
2+import { DataTable, Empty, Tile } from '../components/ui';
3+import { fmtDate, fmtDuration, fmtPct } from '../format';
4+import type { HistoryEntry, Info } from '../types';
5+
6+export function DevicesTile({ info, detailed = false }: { info: Info | null; detailed?: boolean }) {
7+ return (
8+ <Tile title="Devices" className="table-tile">
9+ {!info ? <Empty>Waiting for the Pyre server…</Empty> : (
10+ <DataTable head={detailed ? ['Target', 'Device', 'Status', 'Last start-up'] : ['Target', 'Device', 'Status']} widths={detailed ? ['16%', '44%', '20%', '20%'] : ['23%', '53%', '24%']}>
11+ {info.targets.map(t => {
12+ const seen = t.name === 'cpu' || !!t.present;
13+ return (
14+ <tr key={t.name}>
15+ <td><i className="swatch" style={{ background: TARGET_COLOR[t.name] }} />{TARGET_LABEL[t.name]}</td>
16+ <td>{t.name === 'cpu' ? `${info.cpu_threads} threads` : seen ? t.device : '–'}</td>
17+ <td><span className={`dot ${seen ? 'dot-good' : 'dot-idle'}`} />{seen ? 'Seen' : t.name === 'cuda' && !info.cuda_built ? 'Not built' : 'Not seen'}</td>
18+ {detailed && <td className="num">{t.init_ms > 0 ? `${t.init_ms.toFixed(0)} ms` : '–'}</td>}
19+ </tr>
20+ );
21+ })}
22+ </DataTable>
23+ )}
24+ </Tile>
25+ );
26+}
27+
28+const VERDICT_CLASS: Record<string, string> = { better: 'v-good', worse: 'v-bad', same: 'v-neutral', unknown: 'v-warn', unverified: 'v-neutral', check: 'v-neutral' };
29+
30+export function SessionsTile({ history }: { history: HistoryEntry[] }) {
31+ const recent = [...history].reverse().slice(0, 5);
32+ return (
33+ <Tile title="Sessions" className="table-tile">
34+ {recent.length === 0 ? <Empty>No sessions yet. Every finished training or self-check is logged here.</Empty> : (
35+ <DataTable head={['Session', 'Time lost', 'Result']} widths={['40%', '36%', '24%']}>
36+ {recent.map((h, i) => (
37+ <tr key={i}>
38+ <td title={fmtDate(h.time)}>{h.kind === 'train' ? `Train · ${h.level}` : 'Self-check'}</td>
39+ <td className="num">{h.before && h.kind === 'train' ? `${fmtPct(h.before.lost_pct)} → ${h.after ? fmtPct(h.after.lost_pct) : '–'}` : h.after ? fmtPct(h.after.lost_pct) : '–'}</td>
40+ <td className={VERDICT_CLASS[h.verdict] ?? 'v-neutral'}>{h.verdict}</td>
41+ </tr>
42+ ))}
43+ </DataTable>
44+ )}
45+ </Tile>
46+ );
47+}
48+
49+/** Gantt-style rows: one bar per recent session, length = how long it took, colour = verdict. */
50+export function TimelineTile({ history }: { history: HistoryEntry[] }) {
51+ const recent = history.slice(-6);
52+ const longest = Math.max(1, ...recent.map(h => h.seconds));
53+ const COLOR: Record<string, string> = { better: '#2e9e5b', worse: '#c0392b', same: '#e0a100', unknown: '#8e44ad', unverified: '#7f8c8d', check: '#3b82f6' };
54+ return (
55+ <Tile title="Session durations" className="timeline">
56+ {recent.length === 0 ? <Empty>Sessions appear here as a timeline once you have run one.</Empty> : (
57+ <ul className="gantt">
58+ {recent.map((h, i) => (
59+ <li key={i}>
60+ <span className="gantt-label" style={{ color: COLOR[h.verdict] ?? '#7f8c8d' }}>{h.kind === 'train' ? `Train · ${h.level}` : 'Self-check'}</span>
61+ <span className="gantt-track">
62+ <span className="gantt-bar" style={{ width: `${Math.max(3, (h.seconds / longest) * 100)}%`, background: COLOR[h.verdict] ?? '#7f8c8d' }} />
63+ </span>
64+ <span className="gantt-value">{fmtDuration(h.seconds)}</span>
65+ </li>
66+ ))}
67+ </ul>
68+ )}
69+ <div className="gantt-axis"><span>0</span><span>duration</span><span>{fmtDuration(longest)}</span></div>
70+ </Tile>
71+ );
72+}

pyre/src/web/src/trainForm.tsadded

+103−0
@@ -0,0 +1,103 @@
1+import type { OpKey, Target } from './types';
2+
3+export type Level = 'quick' | 'normal' | 'deep';
4+
5+export type Mode = 'recommended' | 'custom' | 'forever';
6+
7+export interface TrainForm {
8+ mode: Mode;
9+ roundPause: number; // seconds between forever rounds
10+ level: Level;
11+ ops: Record<OpKey, boolean>;
12+ targets: Record<Target, boolean>;
13+ gpuLoad: number;
14+ cpuLoad: number;
15+ pause: string;
16+ maxWork: string;
17+ maxProbe: string;
18+ maxMem: string;
19+ repeats: string;
20+ step: string;
21+ reset: boolean;
22+ verify: boolean;
23+}
24+
25+export const PRESETS: Record<Level, { step: number; repeats: number; maxProbe: number; blurb: string }> = {
26+ quick: { step: 3, repeats: 1, maxProbe: 0.25, blurb: 'Every 3rd size, 1 run each, stops at 0.25 s probes.' },
27+ normal: { step: 2, repeats: 2, maxProbe: 1, blurb: 'Every 2nd size, 2 runs each, stops at 1 s probes.' },
28+ deep: { step: 1, repeats: 3, maxProbe: 3, blurb: 'Every size, 3 runs each, stops at 3 s probes.' },
29+};
30+
31+export const DEFAULT_FORM: TrainForm = {
32+ mode: 'recommended',
33+ roundPause: 30,
34+ level: 'normal',
35+ ops: { matmul: true, chaos: true, mc: true },
36+ targets: { cpu: true, cuda: true, vulkan: true },
37+ gpuLoad: 100,
38+ cpuLoad: 100,
39+ pause: '',
40+ maxWork: '',
41+ maxProbe: '',
42+ maxMem: '',
43+ repeats: '',
44+ step: '',
45+ reset: false,
46+ verify: true,
47+};
48+
49+const STORE_KEY = 'pyre-train-form';
50+
51+export function loadForm(): TrainForm {
52+ try {
53+ const raw = localStorage.getItem(STORE_KEY);
54+ if (raw) return { ...DEFAULT_FORM, ...(JSON.parse(raw) as Partial<TrainForm>) };
55+ } catch { /* fall through to defaults */ }
56+ return DEFAULT_FORM;
57+}
58+
59+export function saveForm(f: TrainForm): void {
60+ try { localStorage.setItem(STORE_KEY, JSON.stringify(f)); } catch { /* not persisted */ }
61+}
62+
63+/** Why the form can't be submitted, or '' when it can. */
64+export function validate(f: TrainForm): string {
65+ if (!Object.values(f.ops).some(Boolean)) return 'Pick at least one kernel.';
66+ if (!Object.values(f.targets).some(Boolean)) return 'Pick at least one target.';
67+ const nums: [string, string, number, number][] = [
68+ ['Pause', f.pause, 0, 60000], ['Max work', f.maxWork, 12, 40], ['Max probe time', f.maxProbe, 0.001, 600],
69+ ['Max memory', f.maxMem, 0, 1e6], ['Repeats', f.repeats, 1, 50], ['Step', f.step, 1, 12],
70+ ];
71+ for (const [name, raw, lo, hi] of nums) {
72+ if (raw === '') continue;
73+ const v = Number(raw);
74+ if (!Number.isFinite(v) || v < lo || v > hi) return `${name} must be between ${lo} and ${hi}.`;
75+ }
76+ return '';
77+}
78+
79+/** The `pyre train` argument list for this form (empty fields keep the preset's value). */
80+export function toArgs(f: TrainForm): string[] {
81+ const args = ['--level', f.level];
82+ const on = <K extends string>(m: Record<K, boolean>) => (Object.keys(m) as K[]).filter(k => m[k]).join(',');
83+ args.push('--ops', on(f.ops), '--targets', on(f.targets));
84+ if (f.gpuLoad !== 100) args.push('--gpu-load', String(f.gpuLoad));
85+ if (f.cpuLoad !== 100) args.push('--cpu-load', String(f.cpuLoad));
86+ const opt: [string, string][] = [
87+ ['--pause', f.pause], ['--max-work', f.maxWork], ['--max-probe', f.maxProbe],
88+ ['--max-mem', f.maxMem], ['--repeats', f.repeats], ['--step', f.step],
89+ ];
90+ for (const [flag, v] of opt) if (v !== '') args.push(flag, v);
91+ if (f.reset) args.push('--reset');
92+ if (!f.verify) args.push('--no-verify');
93+ return args;
94+}
95+
96+/** `pyre train --forever`: adaptive learning with no end, until stopped. Gentle by default (loads come from the form). */
97+export function toForeverArgs(f: TrainForm): string[] {
98+ const on = <K extends string>(m: Record<K, boolean>) => (Object.keys(m) as K[]).filter(k => m[k]).join(',');
99+ const args = ['--forever', '--level', 'quick', '--ops', on(f.ops), '--targets', on(f.targets), '--round-pause', String(f.roundPause)];
100+ if (f.gpuLoad !== 100) args.push('--gpu-load', String(f.gpuLoad));
101+ if (f.cpuLoad !== 100) args.push('--cpu-load', String(f.cpuLoad));
102+ return args;
103+}

pyre/src/web/src/trend.tsadded

+46−0
@@ -0,0 +1,46 @@
1+// "Tendency": which way a measure is heading, and a guess at where it lands next.
2+// Plain least-squares line through the most recent points. It is a guess, so it is only
3+// offered with at least three points and is always labelled as an estimate in the UI.
4+
5+export type Direction = 'improving' | 'worsening' | 'steady';
6+
7+export interface Trend {
8+ direction: Direction;
9+ /** Change per step (per session / per round) along the fitted line. */
10+ slope: number;
11+ /** Where the line says the next value will be (clamped to the metric's range). */
12+ next: number;
13+ /** The fitted line continued `steps` points past the last value. */
14+ forecast: (steps: number) => number[];
15+ points: number;
16+}
17+
18+const WINDOW = 8;
19+
20+export function trend(values: number[], betterIs: 'lower' | 'higher', range: [number, number] = [0, Infinity]): Trend | null {
21+ const y = values.filter(Number.isFinite).slice(-WINDOW);
22+ const n = y.length;
23+ if (n < 3) return null;
24+
25+ const mx = (n - 1) / 2;
26+ const my = y.reduce((a, b) => a + b, 0) / n;
27+ let sxy = 0, sxx = 0;
28+ y.forEach((v, i) => { sxy += (i - mx) * (v - my); sxx += (i - mx) ** 2; });
29+ const slope = sxy / sxx;
30+ const at = (i: number) => Math.min(range[1], Math.max(range[0], my + slope * (i - mx)));
31+
32+ // "Steady" when the line moves less than 3% of the typical size per step (and under 0.2 absolute).
33+ const scale = Math.max(Math.abs(my), 1);
34+ const steady = Math.abs(slope) < Math.max(0.03 * scale, 0.2);
35+ const direction: Direction = steady ? 'steady' : (slope < 0) === (betterIs === 'lower') ? 'improving' : 'worsening';
36+
37+ return { direction, slope, next: at(n), points: n, forecast: steps => Array.from({ length: steps }, (_, k) => at(n + k)) };
38+}
39+
40+/** Moving average (window 3) to calm noisy per-round numbers. */
41+export function smooth(values: number[]): number[] {
42+ return values.map((_, i) => {
43+ const w = values.slice(Math.max(0, i - 1), i + 2);
44+ return w.reduce((a, b) => a + b, 0) / w.length;
45+ });
46+}

pyre/src/web/src/types.tsadded

+88−0
@@ -0,0 +1,88 @@
1+export type Target = 'cpu' | 'cuda' | 'vulkan';
2+export const TARGETS: Target[] = ['cpu', 'cuda', 'vulkan'];
3+export type OpKey = 'matmul' | 'chaos' | 'mc';
4+
5+// Kernel names as `pyre` reports them, with display labels and work units.
6+export const OPS: { name: string; key: OpKey; label: string; short: string; unit: string }[] = [
7+ { name: 'matrix_multiply', key: 'matmul', label: 'Matrix multiply', short: 'MatMul', unit: 'mul-add/s' },
8+ { name: 'chaos_iterate', key: 'chaos', label: 'Chaos iterate', short: 'Chaos', unit: 'element-iter/s' },
9+ { name: 'monte_carlo_risk', key: 'mc', label: 'Monte Carlo', short: 'Monte Carlo', unit: 'iter/s' },
10+];
11+
12+export interface InfoTarget { name: Target; present: number; device: string; init_ms: number }
13+export interface RatePoint { op: string; target: Target; bucket: number; per_second: number }
14+export interface Info {
15+ event: 'info';
16+ profile: string;
17+ cpu_threads: number;
18+ cuda_built: number;
19+ targets: InfoTarget[];
20+ rates: RatePoint[];
21+}
22+
23+export interface ProbeEvent { event: 'probe'; op: string; target: Target; exp: number; shape: string; ms: number }
24+export interface EvalRow {
25+ event: 'eval_row';
26+ phase: string;
27+ op: string;
28+ shape: string;
29+ times_ms: Partial<Record<Target, number>>;
30+ picked: Target;
31+ predicted_ms?: number;
32+ right: boolean;
33+}
34+export interface ScoreEvent {
35+ event: 'score';
36+ phase: string;
37+ shapes: number;
38+ hits: number;
39+ lost_pct: number;
40+ error_pct: number | null;
41+}
42+export interface RoundEvent {
43+ event: 'round';
44+ round: number;
45+ lost_pct: number;
46+ error_pct: number | null;
47+ hits: number;
48+ shapes: number;
49+ accepted: boolean;
50+ probes: number;
51+ cost_before: number;
52+ cost_after: number;
53+ focus: string[];
54+}
55+export interface ModeEvent { event: 'mode'; mode: 'auto' | 'forever' }
56+export interface IdleEvent { event: 'idle'; seconds: number }
57+export interface ConvergedEvent { event: 'converged'; round: number }
58+export interface PhaseEvent { event: 'phase'; name: string }
59+export interface VerdictEvent { event: 'verdict'; verdict: 'better' | 'worse' | 'same' | 'unknown' | 'unverified'; discarded: boolean }
60+export interface DoneEvent { event: 'done'; probes: number; seconds: number; saved: boolean }
61+export interface LogEvent { event: 'log'; message: string }
62+export interface SkippedEvent { event: 'skipped'; target: Target }
63+export type PyreEvent = ProbeEvent | EvalRow | ScoreEvent | PhaseEvent | VerdictEvent | DoneEvent | LogEvent | SkippedEvent | RoundEvent | ModeEvent | IdleEvent | ConvergedEvent;
64+
65+export interface JobStatus {
66+ running: boolean;
67+ cmd: string;
68+ started: number;
69+ exit: number | null;
70+ next: number;
71+ lines: PyreEvent[];
72+}
73+
74+export interface ScoreSummary { shapes: number; hits: number; lost_pct: number; error_pct: number | null }
75+export interface HistoryEntry {
76+ time: number;
77+ kind: 'train' | 'check';
78+ level: string;
79+ verdict: string;
80+ probes: number;
81+ seconds: number;
82+ gpu_load: number;
83+ cpu_load: number;
84+ step: number;
85+ repeats: number;
86+ before?: ScoreSummary;
87+ after?: ScoreSummary;
88+}

pyre/src/web/src/vite-env.d.tsadded

+1−0
@@ -0,0 +1 @@
1+/// <reference types="vite/client" />

pyre/src/web/tsconfig.jsonadded

+17−0
@@ -0,0 +1,17 @@
1+{
2+ "compilerOptions": {
3+ "target": "ES2022",
4+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
5+ "module": "ESNext",
6+ "moduleResolution": "bundler",
7+ "jsx": "react-jsx",
8+ "strict": true,
9+ "noUnusedLocals": true,
10+ "noUnusedParameters": true,
11+ "noFallthroughCasesInSwitch": true,
12+ "skipLibCheck": true,
13+ "isolatedModules": true,
14+ "types": []
15+ },
16+ "include": ["src"]
17+}

pyre/src/web/vite.config.tsadded

+20−0
@@ -0,0 +1,20 @@
1+import { defineConfig } from 'vite';
2+import react from '@vitejs/plugin-react';
3+
4+// The built dashboard lands next to the `pyre` executable (pyre/src/web ->
5+// <repo>/bin/extensions/pyre/web), which is where `pyre dashboard` serves it
6+// from. Relative base: the page must work from any port, fully offline.
7+export default defineConfig({
8+ plugins: [react()],
9+ base: './',
10+ build: {
11+ outDir: '../../../bin/extensions/pyre/web',
12+ emptyOutDir: true,
13+ sourcemap: false,
14+ },
15+ server: {
16+ fs: { allow: ['../../..'] }, // logo.svg lives in the repo root
17+ // `pnpm dev` against a running `pyre dashboard --no-open` (port 1198).
18+ proxy: { '/api': 'http://127.0.0.1:1198' },
19+ },
20+});

pyre/tests/cuda_test.cadded

+85−0
@@ -0,0 +1,85 @@
1+/* CUDA backend vs CPU backend on shapes that stress the kernels' edge
2+ * handling: sizes that aren't multiples of the 64x64x16 matmul tile, odd
3+ * element counts around the chaos kernel's 1024-element blocks and its
4+ * multi-stream chunking, and Monte Carlo thresholds at/outside [0,1).
5+ * Skips (exit 0) when there is no CUDA device or CUDA wasn't built in. */
6+#include "H/runtime/ash_gpu.h"
7+#include <math.h>
8+#include <stdio.h>
9+#include <stdlib.h>
10+
11+#ifndef ASH_GPU_HAVE_CUDA
12+int main(void) { printf("cuda_test: SKIPPED (built without CUDA)\n"); return 0; }
13+#else
14+#include "H/backends/cpu/matrix_multiply.h"
15+#include "H/backends/cpu/chaos.h"
16+#include "H/backends/cpu/monte_carlo.h"
17+#include "H/backends/cuda/context.h"
18+
19+static int failures = 0;
20+#define CHECK(cond, ...) do { if (!(cond)) { fprintf(stderr, __VA_ARGS__); fputc('\n', stderr); failures++; } } while (0)
21+
22+static void check_matmul(int n) {
23+ size_t cnt = (size_t)n * n;
24+ double *a = malloc(cnt * 8), *b = malloc(cnt * 8), *want = malloc(cnt * 8), *got = malloc(cnt * 8);
25+ for (size_t i = 0; i < cnt; i++) {
26+ a[i] = (double)((i * 7919) % 101) / 17.0 - 3.0;
27+ b[i] = (double)((i * 104729) % 89) / 13.0 - 2.5;
28+ }
29+ ash_gpu_cpu_matrix_multiply(a, b, want, n);
30+ CHECK(ash_gpu_cuda_matrix_multiply(a, b, got, n) == 1, "matmul n=%d: CUDA reported failure", n);
31+ for (size_t i = 0; i < cnt; i++) {
32+ if (fabs(got[i] - want[i]) > 1e-9 * (fabs(want[i]) + 1.0)) {
33+ CHECK(0, "matmul n=%d: element %zu got %.17g want %.17g", n, i, got[i], want[i]);
34+ break;
35+ }
36+ }
37+ free(a); free(b); free(want); free(got);
38+}
39+
40+static void check_chaos(int n, int iterations) {
41+ double *want = malloc((size_t)n * 8), *got = malloc((size_t)n * 8);
42+ for (int i = 0; i < n; i++) want[i] = got[i] = (double)(i % 1000) * 0.001 - 0.2;
43+ ash_gpu_cpu_chaos_iterate(want, n, iterations);
44+ CHECK(ash_gpu_cuda_chaos_iterate(got, n, iterations) == 1, "chaos n=%d: CUDA reported failure", n);
45+ for (int i = 0; i < n; i++) {
46+ if (got[i] != want[i]) { CHECK(0, "chaos n=%d: element %d got %.17g want %.17g", n, i, got[i], want[i]); break; }
47+ }
48+ free(want); free(got);
49+}
50+
51+static void check_monte_carlo(double threshold, long long iterations) {
52+ long long risky; double p;
53+ CHECK(ash_gpu_cuda_monte_carlo_risk(iterations, threshold, 42u, &risky, &p) == 1,
54+ "monte_carlo thr=%g: CUDA reported failure", threshold);
55+ if (threshold >= 1.0) CHECK(risky == 0, "monte_carlo thr=%g: expected 0, got %lld", threshold, risky);
56+ else if (threshold < 0.0) CHECK(risky == iterations, "monte_carlo thr=%g: expected all, got %lld", threshold, risky);
57+ else {
58+ /* P(U1*U2 > t) = 1 - t + t*ln(t) for independent uniforms. */
59+ double expect = threshold == 0.0 ? 1.0 : 1.0 - threshold + threshold * log(threshold);
60+ CHECK(fabs(p - expect) < 0.002, "monte_carlo thr=%g: p=%g, analytic %g", threshold, p, expect);
61+ }
62+}
63+
64+int main(void) {
65+ if (!ash_gpu_cuda_init()) { printf("cuda_test: SKIPPED (no CUDA device)\n"); return 0; }
66+
67+ static const int sizes[] = {1, 2, 7, 15, 16, 17, 63, 64, 65, 100, 129, 511, 513};
68+ for (size_t i = 0; i < sizeof(sizes) / sizeof(sizes[0]); i++) check_matmul(sizes[i]);
69+
70+ static const int counts[] = {1, 3, 1023, 1024, 1025, 262143, 262145, 1000003};
71+ for (size_t i = 0; i < sizeof(counts) / sizeof(counts[0]); i++) check_chaos(counts[i], 50);
72+
73+ static const double thresholds[] = {-0.5, 0.0, 0.1, 0.75, 0.999, 1.0, 2.0};
74+ for (size_t i = 0; i < sizeof(thresholds) / sizeof(thresholds[0]); i++) check_monte_carlo(thresholds[i], 20000000LL);
75+ { /* fewer iterations than GPU threads: just must run and stay in range */
76+ long long risky; double p;
77+ CHECK(ash_gpu_cuda_monte_carlo_risk(1, 0.5, 42u, &risky, &p) == 1 && (risky == 0 || risky == 1),
78+ "monte_carlo with 1 iteration: bad result %lld", risky);
79+ }
80+
81+ if (failures) { fprintf(stderr, "cuda_test: FAILED (%d)\n", failures); return 1; }
82+ printf("cuda_test: OK (%s)\n", ash_gpu_cuda_device_name());
83+ return 0;
84+}
85+#endif

pyre/todo/todo.mdmodified

+6−3
@@ -78,7 +78,7 @@ Both cleared the bar without touching a GPU at all: `matrix_multiply` from the i
7878 ### Ash bindings and kiln integration (ideas/gpu_acceleration.md Phases 3 and 4) -- both done
7979 `matrix_mul(a, b)` exists as a plain builtin on `ashvm` (`ashvm/src/builtins/C/matrix.c`), calling `ash_gpu_matrix_multiply` directly for square arrays-of-arrays of numbers at or above `ASH_GPU_MIN_N = 64`, a local C loop below it. `ashvm/tests/cases/collections/matrix_mul.ash` covers both paths plus the error cases.
8080
81-`kiln` now has the same builtin (`codegen/C/collections/matrix_mul.c`), available only under `--link=shared`: it really calls through the GOT into `libashgpu.so` (a second `DT_NEEDED` added to `elf/C/elf_dynamic.c`'s writer, folded into the same combined hash/dynsym/GOT/rela table as kiln's own runtime imports), not a native reimplementation -- see ideas/assigned.md for the full mechanism, including the SysV ABI call wrapper (`codegen/C/platform/linux_call.c`) kiln never needed before this (its own hand-rolled routines are alignment-agnostic; a real gcc/g++-compiled function with OpenMP/SSE isn't). Under `--link=static`, `matrix_mul` is a clear compile error, not a silent no-op -- that mode's whole point is staying dependency-free. `kiln/tests/scripts/shared_link_check.sh` (new) covers both the general `--link=shared` path (previously untested at all) and `matrix_mul` specifically.
81+`kiln` now has the same builtin (`codegen/C/collections/matrix_mul.c`), available only under `--link=shared`: it really calls through the GOT into `libpyre.so` (a second `DT_NEEDED` added to `elf/C/elf_dynamic.c`'s writer, folded into the same combined hash/dynsym/GOT/rela table as kiln's own runtime imports), not a native reimplementation -- see ideas/assigned.md for the full mechanism, including the SysV ABI call wrapper (`codegen/C/platform/linux_call.c`) kiln never needed before this (its own hand-rolled routines are alignment-agnostic; a real gcc/g++-compiled function with OpenMP/SSE isn't). Under `--link=static`, `matrix_mul` is a clear compile error, not a silent no-op -- that mode's whole point is staying dependency-free. `kiln/tests/scripts/shared_link_check.sh` (new) covers both the general `--link=shared` path (previously untested at all) and `matrix_mul` specifically.
8282
8383 Actually running this found a real, previously invisible kiln bug: its process-exit path used syscall 60 (`exit`, kills only the calling thread) instead of 231 (`exit_group`, kills the whole process) -- harmless for kiln's entire history until `ash_gpu_matrix_multiply`'s OpenMP worker threads gave a kiln-generated program a second thread for the first time ever. The program printed the right answer and then hung forever. Fixed in all three exit sites; confirmed behavior-identical for every existing (single-threaded) program via `run_regression.sh`.
8484
@@ -94,8 +94,11 @@ Same-moment, forced-CPU-vs-real-GPU comparisons on the RX 9070 XT (via WSL2/Doze
9494
9595 The two results together are the concrete evidence for the next task, not a contradiction: `matrix_multiply` is memory/transfer-bound and specifically hurt by fp64-on-consumer-GPU plus an untiled shader; `chaos_iterate` is compute-bound enough that none of that matters. **Neither dispatcher (`src/C/runtime/device.c`) currently tells the difference** -- both unconditionally prefer GPU-when-available with no cost model at all.
9696
97-### Smart dispatch: not started, this is the real next task
98-Both of today's dispatchers (`ash_gpu_matrix_multiply`, `ash_gpu_chaos_iterate`) pick GPU whenever `ash_gpu_available()`, full stop -- which the table above proves is actively wrong for one of the two kernels that exist. The real fix is an algorithm that decides CPU vs. GPU per call based on the workload's actual shape (arithmetic intensity, data size, element count x iterations, whatever the right features turn out to be) rather than "a GPU exists." ideas/gpu_acceleration.md already names this (`ash-gpu benchmark`, "Benchmark everything," design principle 7) but nothing implements it yet. Explicitly deferred, not forgotten -- the user asked for this to wait until the rest is wrapped up; `matrix_multiply` and `chaos_iterate` are exactly the two real, measured data points whoever builds it should calibrate against first.
97+### Smart dispatch: done 2026-10-09 (the manager)
98+`src/C/runtime/manager.c` places every call from a table of what this machine was actually seen to do at that workload size, learned online from real calls -- no calibration pass, nothing machine-specific shipped, no call ever waits for a GPU to start (background warm-up while the CPU runs). See CHANGELOG 2026-10-09 and `pyre help`. Open: (1) a process that makes a single heavy call exits before the GPU is up, so it never learns the GPU -- `pyre train` covers that; splitting one big call between CPU and GPU (chunked, concurrent) would use the GPU inside that first call too. (2) A size where the GPU lost is not retried later (no re-exploration of lost buckets); `pyre train --reset` or deleting the profile clears it. (3) ashvm's `ASH_GPU_MIN_N = 64` guard is still a fixed first guess; the manager could replace it now.
99+
100+### Dashboard (2026-10-09)
101+`pyre dashboard` (http://127.0.0.1:1198, offline): source `pyre/src/web/`, built to `bin/extensions/pyre/web/`. Not done: authentication beyond the localhost/Host/Origin checks (it is a local tool), a live per-call feed of the manager's decisions in a running program (`PYRE_TRACE=1` still covers that in the terminal), several concurrent sessions (one at a time by design).
99102
100103 ### Tooling
101104 `pyre/tools/install_deps.sh` installs the Vulkan SDK pieces the Vulkan backend needs to build (headers, loader, `glslc`/`glslangValidator`, spirv-tools) -- Arch/pacman only so far, matches both this sandbox and the user's own machine. It installs the SDK, not a GPU driver; `vulkaninfo` still correctly reports no device on a machine with none.
\ No newline at end of file

pyre/tools/dashboard.cadded

+520−0
Show diff (520 changed lines)
@@ -0,0 +1,520 @@
1+/* `pyre dashboard`: a tiny local web server for the Pyre dashboard.
2+ *
3+ * Fully offline and local: binds 127.0.0.1 only, serves the pre-built static
4+ * UI from <directory of this executable>/web, and exposes a small API:
5+ *
6+ * GET /api/state what is learned (runs `pyre info --json`)
7+ * GET /api/history finished train/check sessions (history.jsonl)
8+ * GET /api/stamp when the profile / history files last changed (cheap: lets the page
9+ * refresh itself only when something actually changed)
10+ * POST /api/job start a session: {"cmd":"train|check|forget","args":[...]}
11+ * GET /api/job?since=N live status + the session's JSON events from N on
12+ * POST /api/job/stop stop the running session
13+ *
14+ * The port is always PYRE_DASHBOARD_PORT (1198: a=1, s=19, h=8, "ash" by alphabet
15+ * position) unless --port says otherwise; it never hops to another port on its own.
16+ *
17+ * Every session is a child `pyre <cmd> ... --json`, so the dashboard runs
18+ * exactly the same code as the command line and cannot drift from it. One
19+ * session at a time. Single-threaded: a poll() loop serves requests and
20+ * drains the running session's output.
21+ */
22+#define _POSIX_C_SOURCE 200809L
23+#include "dashboard.h"
24+#include "H/runtime/manager.h"
25+#include <arpa/inet.h>
26+#include <errno.h>
27+#include <fcntl.h>
28+#include <poll.h>
29+#include <signal.h>
30+#include <stdio.h>
31+#include <stdlib.h>
32+#include <string.h>
33+#include <sys/socket.h>
34+#include <sys/stat.h>
35+#include <sys/wait.h>
36+#include <time.h>
37+#include <unistd.h>
38+
39+#define MAX_LINES 60000
40+#define REQ_MAX 20000
41+
42+static char self_path[1024];
43+static char web_dir[1100];
44+static int port;
45+static volatile sig_atomic_t stop_requested;
46+
47+/* ---------- the running session ---------- */
48+
49+static struct {
50+ pid_t pid;
51+ int fd; /* read end of the child's stdout+stderr, -1 when not running */
52+ int running;
53+ int exit_code; /* valid once !running and pid != 0 */
54+ char cmd[16];
55+ long long started;
56+ char **lines; /* one JSON object per line (the newest `count`) */
57+ int count;
58+ int dropped; /* lines discarded from the front; indices stay absolute */
59+ char partial[8192];
60+ int partial_len;
61+} job = {.fd = -1};
62+
63+static void job_clear_lines(void) {
64+ for (int i = 0; i < job.count; i++) free(job.lines[i]);
65+ free(job.lines);
66+ job.lines = NULL;
67+ job.count = 0;
68+ job.dropped = 0;
69+ job.partial_len = 0;
70+}
71+
72+static void job_add_line(const char *line) {
73+ if (job.count >= MAX_LINES) { /* a session that never ends must not grow without bound: drop the oldest half */
74+ int half = MAX_LINES / 2;
75+ for (int i = 0; i < half; i++) free(job.lines[i]);
76+ memmove(job.lines, job.lines + half, sizeof(char *) * (size_t)(job.count - half));
77+ job.count -= half;
78+ job.dropped += half;
79+ }
80+ char *copy;
81+ if (line[0] == '{') {
82+ copy = strdup(line);
83+ } else { /* plain text from the child (an error, say): wrap as a log event */
84+ size_t n = strlen(line);
85+ copy = malloc(n * 6 + 40);
86+ if (!copy) return;
87+ char *w = copy + sprintf(copy, "{\"event\":\"log\",\"message\":\"");
88+ for (const char *r = line; *r; r++) {
89+ unsigned char c = (unsigned char)*r;
90+ if (c == '"' || c == '\\') { *w++ = '\\'; *w++ = (char)c; }
91+ else if (c < 0x20) w += sprintf(w, "\\u%04x", c);
92+ else *w++ = (char)c;
93+ }
94+ strcpy(w, "\"}");
95+ }
96+ if (!copy) return;
97+ char **grown = realloc(job.lines, sizeof(char *) * (size_t)(job.count + 1));
98+ if (!grown) { free(copy); return; }
99+ job.lines = grown;
100+ job.lines[job.count++] = copy;
101+}
102+
103+static void job_drain(void) {
104+ char buf[4096];
105+ ssize_t n;
106+ while ((n = read(job.fd, buf, sizeof(buf))) > 0) {
107+ for (ssize_t i = 0; i < n; i++) {
108+ if (buf[i] == '\n') {
109+ job.partial[job.partial_len] = '\0';
110+ if (job.partial_len) job_add_line(job.partial);
111+ job.partial_len = 0;
112+ } else if (job.partial_len < (int)sizeof(job.partial) - 1) {
113+ job.partial[job.partial_len++] = buf[i];
114+ }
115+ }
116+ }
117+ if (n == 0 || (n < 0 && errno != EAGAIN && errno != EWOULDBLOCK)) { /* child closed its output */
118+ int status = 0;
119+ waitpid(job.pid, &status, 0);
120+ job.exit_code = WIFEXITED(status) ? WEXITSTATUS(status) : 128 + WTERMSIG(status);
121+ close(job.fd);
122+ job.fd = -1;
123+ job.running = 0;
124+ }
125+}
126+
127+/* argv for the child must be plain words: no shell is ever involved, and the
128+ * child (pyre itself) validates every flag again. */
129+static int word_ok(const char *w) {
130+ if (!*w || strlen(w) > 64) return 0;
131+ for (; *w; w++) {
132+ if (!((*w >= 'a' && *w <= 'z') || (*w >= 'A' && *w <= 'Z') || (*w >= '0' && *w <= '9') ||
133+ *w == '-' || *w == '_' || *w == '.' || *w == ',')) return 0;
134+ }
135+ return 1;
136+}
137+
138+static int job_start(const char *cmd, char **args, int nargs) {
139+ if (job.running) return 0;
140+ job_clear_lines();
141+ int fds[2];
142+ if (pipe(fds) != 0) return 0;
143+ char *argv[40];
144+ int n = 0;
145+ argv[n++] = self_path;
146+ argv[n++] = (char *)cmd;
147+ for (int i = 0; i < nargs && n < 36; i++) argv[n++] = args[i];
148+ argv[n++] = "--json";
149+ argv[n] = NULL;
150+ pid_t pid = fork();
151+ if (pid < 0) { close(fds[0]); close(fds[1]); return 0; }
152+ if (pid == 0) {
153+ dup2(fds[1], 1);
154+ dup2(fds[1], 2);
155+ close(fds[0]);
156+ close(fds[1]);
157+ execv(self_path, argv);
158+ _exit(127);
159+ }
160+ close(fds[1]);
161+ fcntl(fds[0], F_SETFL, fcntl(fds[0], F_GETFL) | O_NONBLOCK);
162+ job.pid = pid;
163+ job.fd = fds[0];
164+ job.running = 1;
165+ job.exit_code = 0;
166+ job.started = (long long)time(NULL);
167+ snprintf(job.cmd, sizeof(job.cmd), "%s", cmd);
168+ return 1;
169+}
170+
171+/* ---------- HTTP ---------- */
172+
173+static void send_all(int fd, const char *data, size_t len) {
174+ while (len) {
175+ ssize_t n = send(fd, data, len, MSG_NOSIGNAL);
176+ if (n <= 0) return;
177+ data += n;
178+ len -= (size_t)n;
179+ }
180+}
181+
182+static void respond(int fd, int status, const char *type, const char *body, size_t len) {
183+ const char *reason = status == 200 ? "OK" : status == 400 ? "Bad Request" : status == 403 ? "Forbidden" :
184+ status == 404 ? "Not Found" : status == 409 ? "Conflict" : "Service Unavailable";
185+ char head[256];
186+ int h = snprintf(head, sizeof(head),
187+ "HTTP/1.1 %d %s\r\nContent-Type: %s\r\nContent-Length: %zu\r\nCache-Control: no-store\r\n"
188+ "X-Content-Type-Options: nosniff\r\nConnection: close\r\n\r\n", status, reason, type, len);
189+ send_all(fd, head, (size_t)h);
190+ send_all(fd, body, len);
191+}
192+
193+static void respond_text(int fd, int status, const char *type, const char *body) { respond(fd, status, type, body, strlen(body)); }
194+
195+typedef struct { char *data; size_t len, cap; } Buf;
196+static void buf_add(Buf *b, const char *s, size_t n) {
197+ if (n > (size_t)1 << 30) return;
198+ if (b->len + n + 1 > b->cap) {
199+ b->cap = (b->len + n + 1) * 2;
200+ b->data = realloc(b->data, b->cap);
201+ }
202+ memcpy(b->data + b->len, s, n);
203+ b->len += n;
204+ b->data[b->len] = '\0';
205+}
206+
207+/* Runs `pyre <args> --json` to completion and returns its first stdout line. */
208+static char *run_capture(const char *cmd) {
209+ int fds[2];
210+ if (pipe(fds) != 0) return NULL;
211+ pid_t pid = fork();
212+ if (pid == 0) {
213+ dup2(fds[1], 1);
214+ close(fds[0]);
215+ close(fds[1]);
216+ execl(self_path, self_path, cmd, "--json", (char *)NULL);
217+ _exit(127);
218+ }
219+ close(fds[1]);
220+ Buf b = {0};
221+ char chunk[4096];
222+ ssize_t n;
223+ while ((n = read(fds[0], chunk, sizeof(chunk))) > 0) buf_add(&b, chunk, (size_t)n);
224+ close(fds[0]);
225+ waitpid(pid, NULL, 0);
226+ return b.data;
227+}
228+
229+static void history_path(char *out, size_t cap) {
230+ const char *prof = pyre_manager_profile_path();
231+ snprintf(out, cap, "%s", prof);
232+ char *slash = strrchr(out, '/');
233+ if (!*prof) { out[0] = '\0'; return; }
234+ snprintf(slash ? slash + 1 : out, cap - (slash ? (size_t)(slash + 1 - out) : 0), "history.jsonl");
235+}
236+
237+static void api_history(int fd) {
238+ char path[1200];
239+ history_path(path, sizeof(path));
240+ Buf b = {0};
241+ buf_add(&b, "[", 1);
242+ FILE *f = path[0] ? fopen(path, "r") : NULL;
243+ if (f) {
244+ char line[4096];
245+ int first = 1;
246+ while (fgets(line, sizeof(line), f)) {
247+ size_t n = strcspn(line, "\r\n");
248+ if (!n || line[0] != '{') continue;
249+ if (!first) buf_add(&b, ",", 1);
250+ buf_add(&b, line, n);
251+ first = 0;
252+ }
253+ fclose(f);
254+ }
255+ buf_add(&b, "]", 1);
256+ respond(fd, 200, "application/json", b.data, b.len);
257+ free(b.data);
258+}
259+
260+/* Modification time of a file in nanoseconds (0 if it does not exist). */
261+static long long mtime_ns(const char *path) {
262+ struct stat st;
263+ if (!path[0] || stat(path, &st) != 0) return 0;
264+ return (long long)st.st_mtim.tv_sec * 1000000000LL + st.st_mtim.tv_nsec;
265+}
266+
267+static void api_stamp(int fd) {
268+ char hist[1200], out[160];
269+ history_path(hist, sizeof(hist));
270+ int n = snprintf(out, sizeof(out), "{\"profile\":%lld,\"history\":%lld}", mtime_ns(pyre_manager_profile_path()), mtime_ns(hist));
271+ respond(fd, 200, "application/json", out, (size_t)n);
272+}
273+
274+static void api_job_status(int fd, int since) {
275+ if (since < 0) since = 0;
276+ Buf b = {0};
277+ char head[256];
278+ int h = snprintf(head, sizeof(head), "{\"running\":%s,\"cmd\":\"%s\",\"started\":%lld,\"exit\":",
279+ job.running ? "true" : "false", job.cmd, job.started);
280+ buf_add(&b, head, (size_t)h);
281+ if (!job.running && job.pid) { h = snprintf(head, sizeof(head), "%d", job.exit_code); buf_add(&b, head, (size_t)h); }
282+ else buf_add(&b, "null", 4);
283+ h = snprintf(head, sizeof(head), ",\"next\":%d,\"lines\":[", job.dropped + job.count);
284+ buf_add(&b, head, (size_t)h);
285+ int from = since > job.dropped ? since - job.dropped : 0; /* `since` is an absolute index */
286+ for (int i = from; i < job.count; i++) {
287+ if (i > from) buf_add(&b, ",", 1);
288+ buf_add(&b, job.lines[i], strlen(job.lines[i]));
289+ }
290+ buf_add(&b, "]}", 2);
291+ respond(fd, 200, "application/json", b.data, b.len);
292+ free(b.data);
293+}
294+
295+/* Minimal reader for {"cmd":"x","args":["a","b"]}. */
296+static int parse_job_body(const char *body, char *cmd, size_t cmd_cap, char **args, int *nargs, char *store, size_t store_cap) {
297+ const char *c = strstr(body, "\"cmd\"");
298+ if (!c || !(c = strchr(c + 5, ':'))) return 0;
299+ c = strchr(c, '"');
300+ if (!c) return 0;
301+ c++;
302+ size_t n = strcspn(c, "\"");
303+ if (n == 0 || n >= cmd_cap) return 0;
304+ memcpy(cmd, c, n);
305+ cmd[n] = '\0';
306+ *nargs = 0;
307+ const char *a = strstr(body, "\"args\"");
308+ if (!a) return 1;
309+ a = strchr(a, '[');
310+ if (!a) return 0;
311+ a++;
312+ size_t used = 0;
313+ while (*a && *a != ']') {
314+ if (*a == '"') {
315+ a++;
316+ size_t len = strcspn(a, "\"");
317+ if (!a[len] || *nargs >= 32 || used + len + 1 > store_cap) return 0;
318+ memcpy(store + used, a, len);
319+ store[used + len] = '\0';
320+ args[(*nargs)++] = store + used;
321+ used += len + 1;
322+ a += len + 1;
323+ } else a++;
324+ }
325+ return 1;
326+}
327+
328+static void api_job_start(int fd, const char *body) {
329+ char cmd[16], store[1024];
330+ char *args[32];
331+ int nargs;
332+ if (!parse_job_body(body, cmd, sizeof(cmd), args, &nargs, store, sizeof(store))) { respond_text(fd, 400, "text/plain", "bad request body"); return; }
333+ if (strcmp(cmd, "train") && strcmp(cmd, "check") && strcmp(cmd, "forget")) { respond_text(fd, 400, "text/plain", "unknown command"); return; }
334+ for (int i = 0; i < nargs; i++) if (!word_ok(args[i])) { respond_text(fd, 400, "text/plain", "bad argument"); return; }
335+ if (!job_start(cmd, args, nargs)) { respond_text(fd, 409, "text/plain", "a session is already running"); return; }
336+ respond_text(fd, 200, "application/json", "{\"started\":true}");
337+}
338+
339+static const char *mime_of(const char *path) {
340+ const char *dot = strrchr(path, '.');
341+ if (!dot) return "application/octet-stream";
342+ static const struct { const char *ext, *type; } types[] = {
343+ {".html", "text/html; charset=utf-8"}, {".js", "text/javascript; charset=utf-8"}, {".css", "text/css; charset=utf-8"},
344+ {".svg", "image/svg+xml"}, {".json", "application/json"}, {".png", "image/png"}, {".ico", "image/x-icon"},
345+ {".woff2", "font/woff2"}, {".map", "application/json"}, {".txt", "text/plain; charset=utf-8"}};
346+ for (unsigned i = 0; i < sizeof(types) / sizeof(types[0]); i++) if (!strcmp(dot, types[i].ext)) return types[i].type;
347+ return "application/octet-stream";
348+}
349+
350+static void serve_static(int fd, const char *url_path) {
351+ char rel[640];
352+ snprintf(rel, sizeof(rel), "%s", url_path);
353+ rel[strcspn(rel, "?#")] = '\0';
354+ if (strstr(rel, "..") || strchr(rel, '\\')) { respond_text(fd, 403, "text/plain", "forbidden"); return; }
355+ if (!strcmp(rel, "/")) snprintf(rel, sizeof(rel), "/index.html");
356+ char full[2000];
357+ snprintf(full, sizeof(full), "%s%s", web_dir, rel);
358+ struct stat st;
359+ if (stat(full, &st) != 0 || !S_ISREG(st.st_mode)) {
360+ if (strchr(rel + 1, '.')) { respond_text(fd, 404, "text/plain", "not found"); return; }
361+ snprintf(full, sizeof(full), "%s/index.html", web_dir); /* single-page app fallback */
362+ if (stat(full, &st) != 0) {
363+ respond_text(fd, 503, "text/html; charset=utf-8",
364+ "<h1>Pyre dashboard is not built</h1><p>Run <code>make web</code> in <code>pyre/</code> (needs Node and pnpm once, at build time).</p>");
365+ return;
366+ }
367+ }
368+ FILE *f = fopen(full, "rb");
369+ if (!f) { respond_text(fd, 404, "text/plain", "not found"); return; }
370+ char *data = malloc((size_t)st.st_size + 1);
371+ size_t got = data ? fread(data, 1, (size_t)st.st_size, f) : 0;
372+ fclose(f);
373+ if (data) respond(fd, 200, mime_of(full), data, got);
374+ free(data);
375+}
376+
377+static const char *header_value(const char *req, const char *name, char *out, size_t cap) {
378+ size_t nl = strlen(name);
379+ for (const char *p = req; (p = strstr(p, "\r\n")); ) {
380+ p += 2;
381+ if (!strncasecmp(p, name, nl) && p[nl] == ':') {
382+ p += nl + 1;
383+ while (*p == ' ') p++;
384+ size_t n = strcspn(p, "\r\n");
385+ if (n >= cap) n = cap - 1;
386+ memcpy(out, p, n);
387+ out[n] = '\0';
388+ return out;
389+ }
390+ }
391+ return NULL;
392+}
393+
394+static void handle_client(int fd) {
395+ struct timeval tv = {2, 0};
396+ setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
397+ char req[REQ_MAX + 1];
398+ size_t len = 0;
399+ char *body = NULL;
400+ while (len < REQ_MAX) {
401+ ssize_t n = recv(fd, req + len, REQ_MAX - len, 0);
402+ if (n <= 0) break;
403+ len += (size_t)n;
404+ req[len] = '\0';
405+ body = strstr(req, "\r\n\r\n");
406+ if (body) {
407+ char cl[32];
408+ size_t want = header_value(req, "Content-Length", cl, sizeof(cl)) ? (size_t)atoi(cl) : 0;
409+ if (len >= (size_t)(body + 4 - req) + want) break;
410+ }
411+ }
412+ req[len] = '\0';
413+ if (!body) { respond_text(fd, 400, "text/plain", "bad request"); return; }
414+ body += 4;
415+
416+ char method[8], target[600];
417+ if (sscanf(req, "%7s %599s", method, target) != 2) { respond_text(fd, 400, "text/plain", "bad request"); return; }
418+
419+ /* Local-only: refuse requests whose Host is not us (DNS rebinding) and
420+ * cross-site POSTs (a web page you visit cannot drive training). */
421+ char host[128], expect_a[64], expect_b[64], origin[160];
422+ snprintf(expect_a, sizeof(expect_a), "127.0.0.1:%d", port);
423+ snprintf(expect_b, sizeof(expect_b), "localhost:%d", port);
424+ if (!header_value(req, "Host", host, sizeof(host)) || (strcmp(host, expect_a) && strcmp(host, expect_b))) {
425+ respond_text(fd, 403, "text/plain", "forbidden host");
426+ return;
427+ }
428+ if (!strcmp(method, "POST") && header_value(req, "Origin", origin, sizeof(origin))) {
429+ char ok_a[96], ok_b[96];
430+ snprintf(ok_a, sizeof(ok_a), "http://%s", expect_a);
431+ snprintf(ok_b, sizeof(ok_b), "http://%s", expect_b);
432+ if (strcmp(origin, ok_a) && strcmp(origin, ok_b)) { respond_text(fd, 403, "text/plain", "forbidden origin"); return; }
433+ }
434+
435+ if (!strcmp(method, "GET") && !strcmp(target, "/api/state")) {
436+ char *out = run_capture("info");
437+ respond_text(fd, out ? 200 : 503, "application/json", out ? out : "{}");
438+ free(out);
439+ } else if (!strcmp(method, "GET") && !strcmp(target, "/api/history")) {
440+ api_history(fd);
441+ } else if (!strcmp(method, "GET") && !strcmp(target, "/api/stamp")) {
442+ api_stamp(fd);
443+ } else if (!strcmp(method, "GET") && !strncmp(target, "/api/job", 8)) {
444+ const char *q = strstr(target, "since=");
445+ api_job_status(fd, q ? atoi(q + 6) : 0);
446+ } else if (!strcmp(method, "POST") && !strcmp(target, "/api/job")) {
447+ api_job_start(fd, body);
448+ } else if (!strcmp(method, "POST") && !strcmp(target, "/api/job/stop")) {
449+ if (job.running) kill(job.pid, SIGTERM);
450+ respond_text(fd, 200, "application/json", "{\"stopping\":true}");
451+ } else if (!strcmp(method, "GET")) {
452+ serve_static(fd, target);
453+ } else {
454+ respond_text(fd, 404, "text/plain", "not found");
455+ }
456+}
457+
458+static void on_signal(int sig) { (void)sig; stop_requested = 1; }
459+
460+int pyre_dashboard_main(int argc, char **argv) {
461+ int want_port = PYRE_DASHBOARD_PORT, open_browser = 1;
462+ for (int i = 2; i < argc; i++) {
463+ if (!strcmp(argv[i], "--port") && i + 1 < argc) want_port = atoi(argv[++i]);
464+ else if (!strcmp(argv[i], "--no-open")) open_browser = 0;
465+ else { fprintf(stderr, "pyre dashboard: unknown option '%s' (see `pyre help dashboard`)\n", argv[i]); return 2; }
466+ }
467+ ssize_t n = readlink("/proc/self/exe", self_path, sizeof(self_path) - 1);
468+ if (n <= 0) { fprintf(stderr, "pyre dashboard: cannot locate this executable\n"); return 1; }
469+ self_path[n] = '\0';
470+ snprintf(web_dir, sizeof(web_dir), "%s", self_path);
471+ *strrchr(web_dir, '/') = '\0';
472+ strncat(web_dir, "/web", sizeof(web_dir) - strlen(web_dir) - 1);
473+
474+ int lfd = socket(AF_INET, SOCK_STREAM, 0);
475+ int one = 1;
476+ setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));
477+ struct sockaddr_in addr = {.sin_family = AF_INET, .sin_addr.s_addr = htonl(INADDR_LOOPBACK)};
478+ port = want_port;
479+ addr.sin_port = htons((uint16_t)port);
480+ if (bind(lfd, (struct sockaddr *)&addr, sizeof(addr)) != 0 || listen(lfd, 8) != 0) {
481+ fprintf(stderr, "pyre dashboard: cannot listen on 127.0.0.1:%d (%s).%s\n", port, strerror(errno),
482+ errno == EADDRINUSE ? " Is a dashboard already running? Open http://127.0.0.1:1198 or pass --port N." : "");
483+ return 1;
484+ }
485+
486+ struct sigaction sa = {.sa_handler = on_signal};
487+ sigaction(SIGINT, &sa, NULL);
488+ sigaction(SIGTERM, &sa, NULL);
489+ signal(SIGPIPE, SIG_IGN);
490+
491+ struct stat st;
492+ char index_path[1200];
493+ snprintf(index_path, sizeof(index_path), "%s/index.html", web_dir);
494+ if (stat(index_path, &st) != 0) fprintf(stderr, "pyre dashboard: warning: %s is missing -- run `make web` in pyre/\n", web_dir);
495+
496+ printf("Pyre dashboard: http://127.0.0.1:%d (offline, local only; Ctrl+C to stop)\n", port);
497+ fflush(stdout);
498+ if (open_browser && fork() == 0) {
499+ char url[64];
500+ snprintf(url, sizeof(url), "http://127.0.0.1:%d", port);
501+ int devnull = open("/dev/null", O_RDWR);
502+ dup2(devnull, 0); dup2(devnull, 1); dup2(devnull, 2);
503+ execlp("xdg-open", "xdg-open", url, (char *)NULL);
504+ _exit(0);
505+ }
506+
507+ while (!stop_requested) {
508+ struct pollfd fds[2] = {{lfd, POLLIN, 0}, {job.fd, POLLIN, 0}};
509+ int nfds = job.running ? 2 : 1;
510+ if (poll(fds, (nfds_t)nfds, 500) < 0 && errno != EINTR) break;
511+ if (job.running && (fds[1].revents & (POLLIN | POLLHUP | POLLERR))) job_drain();
512+ if (fds[0].revents & POLLIN) {
513+ int cfd = accept(lfd, NULL, NULL);
514+ if (cfd >= 0) { handle_client(cfd); close(cfd); }
515+ }
516+ }
517+ if (job.running) { kill(job.pid, SIGTERM); waitpid(job.pid, NULL, 0); }
518+ close(lfd);
519+ return 0;
520+}

pyre/tools/dashboard.hadded

+10−0
@@ -0,0 +1,10 @@
1+#ifndef PYRE_DASHBOARD_H
2+#define PYRE_DASHBOARD_H
3+// "ash" by alphabet position: a=1, s=19, h=8.
4+#define PYRE_DASHBOARD_PORT 1198
5+
6+// `pyre dashboard [--port N] [--no-open]`: serves the built web UI
7+// (bin/extensions/pyre/web) and a small JSON API on 127.0.0.1 only, fully
8+// offline. Returns the process exit code.
9+int pyre_dashboard_main(int argc, char **argv);
10+#endif

pyre/tools/json_out.hadded

+24−0
@@ -0,0 +1,24 @@
1+#ifndef PYRE_JSON_OUT_H
2+#define PYRE_JSON_OUT_H
3+// --json output for the `pyre` tool: one JSON object per line (the dashboard's feed).
4+#include <stdio.h>
5+
6+extern int json_mode; // defined in pyre.c
7+
8+static inline void jstr(const char *v) {
9+ putchar('"');
10+ for (; *v; v++) {
11+ unsigned char c = (unsigned char)*v;
12+ if (c == '"' || c == '\\') { putchar('\\'); putchar(c); }
13+ else if (c < 0x20) printf("\\u%04x", c);
14+ else putchar(c);
15+ }
16+ putchar('"');
17+}
18+
19+// Starts an event line: {"event":"<name>" -- append jkey("k") + a value, finish with jend().
20+static inline void jbegin(const char *event) { printf("{\"event\":"); jstr(event); }
21+static inline void jend(void) { printf("}\n"); fflush(stdout); }
22+static inline void jkey(const char *k) { printf(",\"%s\":", k); }
23+
24+#endif

pyre/tools/pyre.cadded

+267−0
@@ -0,0 +1,267 @@
1+/* `pyre`: developer tool for the manager (src/C/runtime/manager.c).
2+ *
3+ * The runtime that ships inside programs never calibrates and never delays
4+ * a call; it learns this machine from the work the program really does.
5+ * This tool is for the developer who wants to look at, or pre-teach, that
6+ * learning on THIS machine. Nothing it produces is shipped: it only edits
7+ * the local profile (~/.cache/pyre/profile.txt, or $PYRE_PROFILE).
8+ *
9+ * pyre info devices, profile file, everything learned
10+ * pyre plan matmul N what the manager would do for this job
11+ * pyre plan chaos N ITERATIONS
12+ * pyre plan mc ITERATIONS
13+ * pyre train [flags] run real probes on this machine and teach it
14+ * pyre forget delete what was learned (relearned on demand)
15+ *
16+ * `pyre help` and `pyre help train` list every flag.
17+ *
18+ * Set PYRE_TRACE=1 on any program to watch the manager's live decisions.
19+ */
20+#define _POSIX_C_SOURCE 200809L
21+#include "H/runtime/ash_gpu.h"
22+#include "H/runtime/manager.h"
23+#include "dashboard.h"
24+#include "json_out.h"
25+#include "train.h"
26+#include <math.h>
27+#include <stdio.h>
28+#include <stdlib.h>
29+#include <string.h>
30+#include <time.h>
31+
32+int json_mode; /* --json: machine-readable output (json_out.h) */
33+
34+/* ---------- info / plan ---------- */
35+
36+static void print_rates(const PyreProfile *p) {
37+ static const char *unit[PYRE_OP_COUNT] = {"mul-add/s", "elem-iter/s", "iter/s"};
38+ int any = 0;
39+ for (int o = 0; o < PYRE_OP_COUNT; o++) {
40+ for (int t = 0; t < PYRE_TARGET_COUNT; t++) {
41+ const PyreRates *r = &p->rates[o][t];
42+ int n = 0;
43+ for (int b = 0; b < PYRE_BUCKETS; b++) n += r->seen[b];
44+ if (!n) continue;
45+ if (!any) { printf("\nlearned (effective speed by workload size, 2^k work units):\n"); any = 1; }
46+ printf(" %-17s %-7s", pyre_op_name(o), pyre_target_name(t));
47+ for (int b = 0; b < PYRE_BUCKETS; b++) {
48+ if (r->seen[b]) printf(" 2^%d:%.2g", b, 1.0 / r->rate[b]);
49+ }
50+ printf(" %s\n", unit[o]);
51+ }
52+ }
53+ if (!any) printf("\nnothing learned yet (programs learn as they run; or `pyre train`)\n");
54+}
55+
56+static void cmd_info_json(void) {
57+ const PyreProfile *p = pyre_manager_profile();
58+ jbegin("info");
59+ jkey("profile"); jstr(pyre_manager_profile_path());
60+ jkey("cpu_threads"); printf("%d", p->cpu_threads);
61+ jkey("cuda_built"); printf("%d", p->cuda_built);
62+ jkey("targets"); printf("[");
63+ for (int t = 0; t < PYRE_TARGET_COUNT; t++) {
64+ printf("%s{\"name\":", t ? "," : ""); jstr(pyre_target_name(t));
65+ printf(",\"present\":%d,\"device\":", t == PYRE_CPU ? 1 : p->present[t]);
66+ jstr(t == PYRE_CPU ? "CPU" : p->device[t]);
67+ printf(",\"init_ms\":%.3f}", p->init_seconds[t] * 1e3);
68+ }
69+ printf("]");
70+ jkey("rates"); printf("[");
71+ int first = 1;
72+ for (int o = 0; o < PYRE_OP_COUNT; o++) {
73+ for (int t = 0; t < PYRE_TARGET_COUNT; t++) {
74+ for (int b = 0; b < PYRE_BUCKETS; b++) {
75+ if (!p->rates[o][t].seen[b]) continue;
76+ printf("%s{\"op\":", first ? "" : ","); jstr(pyre_op_name(o));
77+ printf(",\"target\":"); jstr(pyre_target_name(t));
78+ printf(",\"bucket\":%d,\"per_second\":%.6g}", b, 1.0 / p->rates[o][t].rate[b]);
79+ first = 0;
80+ }
81+ }
82+ }
83+ printf("]");
84+ jend();
85+}
86+
87+static void cmd_info(void) {
88+ if (json_mode) { cmd_info_json(); return; }
89+ const PyreProfile *p = pyre_manager_profile();
90+ printf("profile: %s\n\ntargets:\n", pyre_manager_profile_path()[0] ? pyre_manager_profile_path() : "(memory only)");
91+ printf(" %-7s CPU (%d threads)\n", "cpu", p->cpu_threads);
92+ for (int t = PYRE_CUDA; t < PYRE_TARGET_COUNT; t++) {
93+ if (p->present[t]) {
94+ printf(" %-7s %s", pyre_target_name(t), p->device[t]);
95+ if (p->init_seconds[t] > 0) printf(" (last start-up %.0f ms)", p->init_seconds[t] * 1e3);
96+ printf("\n");
97+ } else {
98+ printf(" %-7s not seen yet%s\n", pyre_target_name(t),
99+ t == PYRE_CUDA && !p->cuda_built ? " (built without CUDA)" : "");
100+ }
101+ }
102+ print_rates(p);
103+}
104+
105+static void cmd_plan(PyreOp op, PyreWorkload w, const char *what) {
106+ double est[PYRE_TARGET_COUNT];
107+ PyreTarget best = pyre_manager_estimate(op, w, est);
108+ printf("%s\n", what);
109+ for (int t = 0; t < PYRE_TARGET_COUNT; t++) {
110+ if (est[t] < 0) printf(" %-7s not measured at this size\n", pyre_target_name(t));
111+ else printf(" %-7s %10.4f ms%s\n", pyre_target_name(t), est[t] * 1e3, t == (int)best ? " <-- would run here" : "");
112+ }
113+}
114+
115+/* ---------- help ---------- */
116+
117+static void help_main(void) {
118+ puts("pyre - look at, and teach, Pyre's placement manager on THIS machine\n"
119+ "\n"
120+ "The runtime inside your programs never calibrates and never delays a call;\n"
121+ "it learns the machine from the work the program really does. This tool is\n"
122+ "for developers: inspect what was learned, ask what the manager would do,\n"
123+ "or pre-teach it with real probes. Nothing here is shipped with programs.\n"
124+ "\n"
125+ "usage: pyre <command> [options]\n"
126+ "\n"
127+ "commands:\n"
128+ " info devices, profile file, everything learned so far\n"
129+ " plan matmul N what the manager would do for an N x N multiply\n"
130+ " plan chaos N ITERATIONS ... for chaos_iterate\n"
131+ " plan mc ITERATIONS ... for monte_carlo_risk\n"
132+ " train [options] run real probes and teach this machine's profile;\n"
133+ " checks itself before and after, and undoes training\n"
134+ " that made decisions worse\n"
135+ " check score the manager's decisions right now: runs every\n"
136+ " target on held-out sizes and compares its picks\n"
137+ " dashboard [--port N] [--no-open]\n"
138+ " (always http://127.0.0.1:1198 unless --port; local only)\n"
139+ " local, offline web dashboard: analyse what was learned,\n"
140+ " start / watch / stop training sessions, see history\n"
141+ " (needs `make web` to have built the UI)\n"
142+ " forget delete what was learned (relearned on demand)\n"
143+ " help [command] this text, or details for one command\n"
144+ "\n"
145+ "environment:\n"
146+ " PYRE_TRACE=1 any program prints one line per kernel call: where it ran,\n"
147+ " what each target was predicted to take, how long it took\n"
148+ " PYRE_PROFILE=F use F as the profile file instead of ~/.cache/pyre/profile.txt");
149+}
150+
151+static void help_train(void) {
152+ puts("pyre train - run real probes on this machine and save what they show\n"
153+ "\n"
154+ "usage: pyre train [options]\n"
155+ "\n"
156+ "Each probe runs a real kernel at a size of 2^k work units, from 2^12 up,\n"
157+ "on every chosen target, and records how fast it was. It stops growing a\n"
158+ "target's size once a probe gets slow, so a weak device stays quick.\n"
159+ "\n"
160+ "adaptive learning (the way a model learns, not a fixed grid):\n"
161+ " --auto grade the manager on fresh held-out sizes, find where its\n"
162+ " decisions are weakest, practise exactly there, grade again, keep\n"
163+ " the round only if decisions did not get worse; practises less each\n"
164+ " round and stops by itself once it has converged\n"
165+ " --rounds N most rounds --auto may run (1..1000, default 6)\n"
166+ " --forever the same loop with no end: keeps re-checking and re-practising\n"
167+ " (so it also notices drift) until you stop it (Ctrl+C, or Stop in\n"
168+ " the dashboard); saves after every kept round\n"
169+ " --round-pause S seconds to idle between --forever rounds (default 20)\n"
170+ "\n"
171+ "how thorough:\n"
172+ " --level quick|normal|deep preset for step, repeats and --max-probe\n"
173+ " quick: every 3rd size, 1 run, 0.25 s probes\n"
174+ " normal: every 2nd size, 2 runs, 1 s probes (default)\n"
175+ " deep: every size, 3 runs, 3 s probes\n"
176+ " --step N sizes to skip between probes: 1 = every power of two (1..12)\n"
177+ " --repeats N runs per size; more smooths noise (1..50)\n"
178+ " --max-work EXP largest size, as 2^EXP work units (12..40, default 34)\n"
179+ " --max-probe SECS stop growing once one probe takes longer than this\n"
180+ " --max-mem MB do not run probes that would move more than this many MB\n"
181+ "\n"
182+ "how much of the machine it may use (the load scale):\n"
183+ " --gpu-load PCT 1..100 (default 100). Duty cycle on GPU targets: after each\n"
184+ " probe it idles so the GPU is busy about PCT% of the time\n"
185+ " --cpu-load PCT 1..100 (default 100). Duty cycle on the CPU target AND the\n"
186+ " share of CPU threads probes may use\n"
187+ " --pause MS extra idle after every probe, on any target\n"
188+ " (the load scale limits the AVERAGE load; to shrink single\n"
189+ " probes use --max-probe, --max-work or --max-mem)\n"
190+ "\n"
191+ "what to train:\n"
192+ " --ops LIST matmul,chaos,mc (default: all)\n"
193+ " --targets LIST cpu,cuda,vulkan (default: all that start)\n"
194+ " --reset forget everything learned before training\n"
195+ " --quiet print only the summary\n"
196+ " --no-verify skip the before/after self-check (faster, no safety net)\n"
197+ "\n"
198+ "self-check: unless --no-verify, training is graded on sizes it did not\n"
199+ "train on. Every target really runs them; the manager's pick is scored by\n"
200+ "how much slower it was than the truly fastest target, and by how far its\n"
201+ "predicted time was from the real one. If decisions got worse, the training\n"
202+ "is thrown away and the previous knowledge kept.\n"
203+ "\n"
204+ "examples:\n"
205+ " pyre train --auto adaptive: practise where it is weakest\n"
206+ " pyre train --forever --gpu-load 25 keep learning in the background, gently\n"
207+ " pyre train --level quick fast first pass\n"
208+ " pyre train --level deep --targets cuda thorough, CUDA only\n"
209+ " pyre train --gpu-load 25 --pause 50 gentle: GPU about a quarter busy\n"
210+ " pyre train --ops chaos --max-work 28 --max-mem 256 --reset");
211+}
212+
213+int main(int argc, char **argv) {
214+ /* --json anywhere: machine-readable one-object-per-line output (the dashboard's feed). */
215+ for (int i = 1; i < argc; i++) {
216+ if (!strcmp(argv[i], "--json")) {
217+ json_mode = 1;
218+ for (int j = i; j < argc; j++) argv[j] = argv[j + 1];
219+ argc--;
220+ i--;
221+ }
222+ }
223+ if (json_mode) setvbuf(stdout, NULL, _IOLBF, 0);
224+ if (argc < 2 || !strcmp(argv[1], "help") || !strcmp(argv[1], "--help") || !strcmp(argv[1], "-h")) {
225+ if (argc >= 3 && !strcmp(argv[2], "train")) help_train(); else help_main();
226+ return 0;
227+ }
228+ for (int i = 2; i < argc; i++) {
229+ if (!strcmp(argv[i], "--help") || !strcmp(argv[i], "-h")) {
230+ if (!strcmp(argv[1], "train")) help_train(); else help_main();
231+ return 0;
232+ }
233+ }
234+ if (!strcmp(argv[1], "dashboard")) return pyre_dashboard_main(argc, argv);
235+ if (!strcmp(argv[1], "info")) { cmd_info(); return 0; }
236+ if (!strcmp(argv[1], "check")) return cmd_check();
237+ if (!strcmp(argv[1], "train")) return cmd_train(argc, argv);
238+ if (!strcmp(argv[1], "forget")) {
239+ pyre_manager_forget();
240+ if (json_mode) { jbegin("done"); jkey("probes"); printf("0"); jkey("seconds"); printf("0"); jkey("saved"); printf("true"); jend(); }
241+ else printf("forgot; relearned on demand\n");
242+ return 0;
243+ }
244+ if (argc == 4 && !strcmp(argv[1], "plan") && !strcmp(argv[2], "matmul")) {
245+ int n = atoi(argv[3]);
246+ char what[64];
247+ snprintf(what, sizeof(what), "matrix_multiply %dx%d:", n, n);
248+ cmd_plan(PYRE_OP_MATMUL, pyre_workload_matmul(n), what);
249+ return 0;
250+ }
251+ if (argc == 5 && !strcmp(argv[1], "plan") && !strcmp(argv[2], "chaos")) {
252+ int n = atoi(argv[3]), it = atoi(argv[4]);
253+ char what[80];
254+ snprintf(what, sizeof(what), "chaos_iterate n=%d iterations=%d:", n, it);
255+ cmd_plan(PYRE_OP_CHAOS, pyre_workload_chaos(n, it), what);
256+ return 0;
257+ }
258+ if (argc == 4 && !strcmp(argv[1], "plan") && !strcmp(argv[2], "mc")) {
259+ long long it = atoll(argv[3]);
260+ char what[64];
261+ snprintf(what, sizeof(what), "monte_carlo_risk iterations=%lld:", it);
262+ cmd_plan(PYRE_OP_MONTE_CARLO, pyre_workload_monte_carlo(it), what);
263+ return 0;
264+ }
265+ fprintf(stderr, "pyre: unknown command or arguments '%s' -- run `pyre help`\n", argv[1]);
266+ return 2;
267+}

pyre/tools/train.cadded

+635−0
Show diff (635 changed lines)
@@ -0,0 +1,635 @@
1+/* `pyre train` / `pyre check`: teach the placement manager on THIS machine and grade it.
2+ *
3+ * Three ways to train, all grading themselves on sizes they did not train on:
4+ * plain a fixed grid of probes (--level/--step/--repeats), graded before and after;
5+ * --auto adaptive: grade, find where the manager's decisions are weakest, practise
6+ * exactly there, grade again on FRESH held-out sizes, keep the round only if it
7+ * did not make decisions worse, practise less as it converges, stop when it has;
8+ * --forever the same adaptive loop with no end: it keeps re-checking and re-practising
9+ * (also catching drift: a GPU that got hotter, a driver update) until stopped.
10+ *
11+ * Grading never trains on its own data: each round measures a different set of held-out
12+ * sizes, every target really runs them, and both the previous and the candidate profile are
13+ * scored against the SAME measured times (so noise cannot make a round look better or worse
14+ * than it is).
15+ */
16+#define _POSIX_C_SOURCE 200809L
17+#include "train.h"
18+#include "json_out.h"
19+#include "H/runtime/manager.h"
20+#include <math.h>
21+#ifdef _OPENMP
22+#include <omp.h>
23+#endif
24+#include <signal.h>
25+#include <stdio.h>
26+#include <stdlib.h>
27+#include <string.h>
28+#include <time.h>
29+
30+typedef struct {
31+ int step, repeats;
32+ double max_probe; /* stop growing a target's size once one probe takes longer (s) */
33+ int max_exp; /* largest size: 2^max_exp work units */
34+ double max_mem_mb; /* skip probes that would move/hold more than this (0 = no cap) */
35+ double gpu_load; /* 1..100: duty cycle on GPU targets */
36+ double cpu_load; /* 1..100: duty cycle AND thread share on the CPU */
37+ double pause_ms; /* fixed extra pause after every probe */
38+ int ops[PYRE_OP_COUNT], targets[PYRE_TARGET_COUNT];
39+ int reset, quiet, verify;
40+ const char *level; /* preset name for the history log */
41+ int adaptive; /* --auto */
42+ int forever; /* --forever (implies adaptive) */
43+ int rounds; /* --rounds N: adaptive rounds before it stops by itself */
44+ double round_pause_s; /* --round-pause S: idle between forever rounds */
45+} TrainOpts;
46+
47+static volatile sig_atomic_t g_stop;
48+static void on_stop(int sig) { (void)sig; g_stop = 1; }
49+
50+/* ---------- problem shapes and one timed run ---------- */
51+
52+typedef struct { int a, b; PyreWorkload w; char text[48]; } Shape;
53+
54+/* The concrete problem size for ~2^exp work units of this kernel. */
55+static Shape shape_of(PyreOp op, int exp) {
56+ Shape s = {0};
57+ double work = ldexp(1.0, exp);
58+ if (op == PYRE_OP_MATMUL) {
59+ s.a = (int)llround(cbrt(work));
60+ if (s.a < 4) s.a = 4;
61+ s.w = pyre_workload_matmul(s.a);
62+ snprintf(s.text, sizeof(s.text), "n=%d", s.a);
63+ } else if (op == PYRE_OP_CHAOS) {
64+ double nd = work / 200.0;
65+ s.a = nd < 1000 ? 1000 : nd > 2000000 ? 2000000 : (int)nd;
66+ s.b = (int)fmax(1.0, work / s.a);
67+ s.w = pyre_workload_chaos(s.a, s.b);
68+ snprintf(s.text, sizeof(s.text), "n=%d it=%d", s.a, s.b);
69+ } else {
70+ s.w = pyre_workload_monte_carlo((long long)work);
71+ snprintf(s.text, sizeof(s.text), "iters=%lld", (long long)work);
72+ }
73+ return s;
74+}
75+
76+/* Runs the shape on a target; seconds, or < 0 if it could not run. */
77+static double run_shape(PyreOp op, PyreTarget t, const Shape *s) {
78+ double sec = -1.0;
79+ if (op == PYRE_OP_MATMUL) {
80+ size_t cnt = (size_t)s->a * s->a;
81+ double *a = malloc(cnt * 8), *b = malloc(cnt * 8), *c = malloc(cnt * 8);
82+ if (a && b && c) {
83+ for (size_t i = 0; i < cnt; i++) { a[i] = (double)(i % 13) * 0.5; b[i] = (double)(i % 7) * 1.5; }
84+ double t0 = pyre_now_seconds();
85+ if (pyre_run_matmul(t, a, b, c, s->a)) sec = pyre_now_seconds() - t0;
86+ }
87+ free(a); free(b); free(c);
88+ } else if (op == PYRE_OP_CHAOS) {
89+ double *d = malloc((size_t)s->a * 8);
90+ if (d) {
91+ for (int i = 0; i < s->a; i++) d[i] = (double)(i % 1000) * 0.001;
92+ double t0 = pyre_now_seconds();
93+ if (pyre_run_chaos(t, d, s->a, s->b)) sec = pyre_now_seconds() - t0;
94+ }
95+ free(d);
96+ } else {
97+ long long risky;
98+ double p;
99+ double t0 = pyre_now_seconds();
100+ if (pyre_run_monte_carlo(t, (long long)s->w.work, 0.75, 1u, &risky, &p)) sec = pyre_now_seconds() - t0;
101+ }
102+ return sec;
103+}
104+
105+/* Sleeps in short slices so a stop request is honoured within a fraction of a second. */
106+static void sleep_interruptible(double seconds) {
107+ while (seconds > 0 && !g_stop) {
108+ double slice = seconds < 0.1 ? seconds : 0.1;
109+ struct timespec ts = {(time_t)slice, (long)((slice - (double)(time_t)slice) * 1e9)};
110+ nanosleep(&ts, NULL);
111+ seconds -= slice;
112+ }
113+}
114+
115+/* Duty-cycle throttle: after a probe that kept the device busy for `busy` seconds, idle long
116+ * enough that busy / (busy + idle) == load%. Caps the AVERAGE share of the device training
117+ * occupies; it cannot shrink a single probe (use --max-probe / --max-work / --max-mem). */
118+static void throttle(double busy, double load, double pause_ms) {
119+ double idle = load < 100.0 ? busy * (100.0 / load - 1.0) : 0.0;
120+ idle += pause_ms / 1e3;
121+ sleep_interruptible(idle);
122+}
123+
124+/* ---------- grading: measure real times once, score any profile against them ---------- */
125+
126+typedef struct {
127+ int shapes, best_hits;
128+ double lost_sum; /* seconds lost to picking a slower target than the best */
129+ double best_sum; /* seconds the best targets would have taken */
130+ double err_sum; /* sum of |predicted - actual| / actual for the pick */
131+ int err_n;
132+} Score;
133+
134+static double mean_regret(const Score *s) { return s->best_sum > 0 ? s->lost_sum / s->best_sum : 0.0; }
135+static double mean_error(const Score *s) { return s->err_n ? s->err_sum / s->err_n : 0.0; }
136+/* One number for "how good are the decisions": time lost, plus a light penalty for wrong predictions. */
137+static double cost(const Score *s) { return mean_regret(s) + 0.1 * mean_error(s); }
138+
139+#define MAX_ROWS 48
140+typedef struct {
141+ int op, exp;
142+ Shape sh;
143+ double actual[PYRE_TARGET_COUNT];
144+ int assumed[PYRE_TARGET_COUNT]; /* not run: already past --max-probe, charged at twice that */
145+} MRow;
146+typedef struct { int n; MRow row[MAX_ROWS]; } Measured;
147+
148+typedef struct { int op, exp; double priority; } Weak; /* where the manager was wrong, worst first */
149+
150+static void print_score(const char *label, const Score *s);
151+
152+/* Really runs every chosen target on held-out sizes (between the sizes the grid trains).
153+ * `offset` shifts which sizes, so successive rounds never grade on the same ones. */
154+static void measure(const TrainOpts *o, double offset, Measured *m) {
155+ m->n = 0;
156+ for (int op = 0; op < PYRE_OP_COUNT; op++) {
157+ if (!o->ops[op]) continue;
158+ int slow[PYRE_TARGET_COUNT] = {0};
159+ for (double e = 13.5 + offset; e <= o->max_exp; e += 4.0) {
160+ if (g_stop || m->n >= MAX_ROWS) return;
161+ MRow *r = &m->row[m->n];
162+ memset(r, 0, sizeof(*r));
163+ r->op = op;
164+ r->exp = (int)e;
165+ r->sh = shape_of(op, r->exp);
166+ int any = 0;
167+ for (int t = 0; t < PYRE_TARGET_COUNT; t++) {
168+ r->actual[t] = -1.0;
169+ if (!o->targets[t] || !pyre_target_is_up(t) || !pyre_target_supports(t, op)) continue;
170+ if (slow[t]) { r->actual[t] = o->max_probe * 2.0; r->assumed[t] = 1; continue; }
171+ /* Twice, keep the faster: the first run pays cold-start costs. */
172+ r->actual[t] = run_shape(op, t, &r->sh);
173+ if (r->actual[t] > 0 && r->actual[t] <= o->max_probe) {
174+ double again = run_shape(op, t, &r->sh);
175+ if (again > 0 && again < r->actual[t]) r->actual[t] = again;
176+ }
177+ if (r->actual[t] > o->max_probe) slow[t] = 1;
178+ throttle(r->actual[t] > 0 ? r->actual[t] : 0, t == PYRE_CPU ? o->cpu_load : o->gpu_load, o->pause_ms);
179+ any = 1;
180+ }
181+ if (any) m->n++;
182+ }
183+ }
184+}
185+
186+/* Scores whatever profile the manager currently holds against measured times. */
187+static Score score_measured(const Measured *m, const char *phase, int verbose, Weak *weak, int *nweak) {
188+ Score sc = {0};
189+ if (nweak) *nweak = 0;
190+ for (int i = 0; i < m->n; i++) {
191+ const MRow *r = &m->row[i];
192+ double best = 1e30;
193+ for (int t = 0; t < PYRE_TARGET_COUNT; t++) if (r->actual[t] > 0 && r->actual[t] < best) best = r->actual[t];
194+ double est[PYRE_TARGET_COUNT];
195+ PyreTarget pick = pyre_manager_estimate(r->op, r->sh.w, est);
196+ if (r->actual[pick] <= 0) continue; /* picked something this run did not measure */
197+ /* Right = within 5% of the best, or within 0.2 ms of it (below that, timing noise
198+ * outweighs any difference worth caring about). */
199+ int right = r->actual[pick] <= best * 1.05 || r->actual[pick] - best < 2e-4;
200+ double lost = r->actual[pick] - best;
201+ sc.shapes++;
202+ sc.lost_sum += lost;
203+ sc.best_sum += best;
204+ if (right) sc.best_hits++;
205+ double err = 0.5; /* never measured at this size: treat as a poor prediction */
206+ if (est[pick] >= 0 && !r->assumed[pick]) {
207+ err = fabs(est[pick] - r->actual[pick]) / r->actual[pick];
208+ sc.err_sum += err;
209+ sc.err_n++;
210+ }
211+ if (weak && nweak && *nweak < MAX_ROWS) weak[(*nweak)++] = (Weak){r->op, r->exp, lost / best + 0.5 * err + (right ? 0.0 : 0.5)};
212+
213+ if (json_mode) {
214+ jbegin("eval_row");
215+ jkey("phase"); jstr(phase);
216+ jkey("op"); jstr(pyre_op_name(r->op));
217+ jkey("shape"); jstr(r->sh.text);
218+ jkey("times_ms"); printf("{");
219+ int first = 1;
220+ for (int t = 0; t < PYRE_TARGET_COUNT; t++) {
221+ if (r->actual[t] > 0 && !r->assumed[t]) { printf("%s\"%s\":%.6g", first ? "" : ",", pyre_target_name(t), r->actual[t] * 1e3); first = 0; }
222+ }
223+ printf("}");
224+ jkey("picked"); jstr(pyre_target_name(pick));
225+ if (est[pick] >= 0) { jkey("predicted_ms"); printf("%.6g", est[pick] * 1e3); }
226+ jkey("right"); printf("%s", right ? "true" : "false");
227+ jend();
228+ } else if (verbose) {
229+ printf(" %-17s %-22s", pyre_op_name(r->op), r->sh.text);
230+ for (int t = 0; t < PYRE_TARGET_COUNT; t++) if (r->actual[t] > 0 && !r->assumed[t]) printf(" %s %.4gms", pyre_target_name(t), r->actual[t] * 1e3);
231+ printf(" | picked %s", pyre_target_name(pick));
232+ if (est[pick] >= 0) printf(" (predicted %.4gms)", est[pick] * 1e3);
233+ printf("%s\n", right ? "" : " <-- WRONG");
234+ }
235+ }
236+ (void)phase;
237+ return sc;
238+}
239+
240+static void print_score(const char *label, const Score *s) {
241+ if (json_mode) {
242+ jbegin("score");
243+ jkey("phase"); jstr(label);
244+ jkey("shapes"); printf("%d", s->shapes);
245+ jkey("hits"); printf("%d", s->best_hits);
246+ jkey("lost_pct"); printf("%.4f", mean_regret(s) * 100.0);
247+ jkey("error_pct"); if (s->err_n) printf("%.4f", mean_error(s) * 100.0); else printf("null");
248+ jend();
249+ return;
250+ }
251+ if (!s->shapes) { printf(" %-7s no held-out sizes could be judged\n", label); return; }
252+ printf(" %-7s time lost to wrong placement: %5.1f%% picked the best: %d/%d", label, mean_regret(s) * 100.0, s->best_hits, s->shapes);
253+ if (s->err_n) printf(" prediction error: %.0f%%", mean_error(s) * 100.0);
254+ else printf(" prediction error: n/a (nothing measured yet)");
255+ printf("\n");
256+}
257+
258+/* ---------- history: one JSON line per finished train/check, next to the profile ---------- */
259+
260+static void append_history(const char *kind, const TrainOpts *o, const Score *before, const Score *after,
261+ const char *verdict, int probes, double seconds) {
262+ const char *prof = pyre_manager_profile_path();
263+ if (!*prof) return;
264+ char path[1100];
265+ snprintf(path, sizeof(path), "%s", prof);
266+ char *slash = strrchr(path, '/');
267+ snprintf(slash ? slash + 1 : path, sizeof(path) - (slash ? (size_t)(slash + 1 - path) : 0), "history.jsonl");
268+ FILE *f = fopen(path, "a");
269+ if (!f) return;
270+ const Score *sc[2] = {before, after};
271+ const char *names[2] = {"before", "after"};
272+ fprintf(f, "{\"time\":%lld,\"kind\":\"%s\",\"level\":\"%s\",\"verdict\":\"%s\",\"probes\":%d,\"seconds\":%.2f,"
273+ "\"gpu_load\":%g,\"cpu_load\":%g,\"step\":%d,\"repeats\":%d",
274+ (long long)time(NULL), kind, o->level, verdict, probes, seconds, o->gpu_load, o->cpu_load, o->step, o->repeats);
275+ for (int i = 0; i < 2; i++) {
276+ if (!sc[i] || !sc[i]->shapes) continue;
277+ fprintf(f, ",\"%s\":{\"shapes\":%d,\"hits\":%d,\"lost_pct\":%.4f,\"error_pct\":", names[i], sc[i]->shapes, sc[i]->best_hits, mean_regret(sc[i]) * 100.0);
278+ if (sc[i]->err_n) fprintf(f, "%.4f}", mean_error(sc[i]) * 100.0); else fprintf(f, "null}");
279+ }
280+ fprintf(f, "}\n");
281+ fclose(f);
282+}
283+
284+/* ---------- practising: timed probes that update the profile ---------- */
285+
286+/* `repeats` real runs at ~2^exp units of `op` on target `t`, folded into the profile.
287+ * Returns the slowest run in seconds, -1 if it could not run, -2 if the size is over --max-mem. */
288+static double probe_exp(const TrainOpts *o, PyreProfile *p, int op, int t, int exp, int repeats, int *probes) {
289+ Shape sh = shape_of(op, exp);
290+ if (o->max_mem_mb > 0 && sh.w.bytes / 1048576.0 > o->max_mem_mb) return -2.0;
291+ double load = t == PYRE_CPU ? o->cpu_load : o->gpu_load;
292+ double slowest = 0.0;
293+ for (int rep = 0; rep < repeats && !g_stop; rep++) {
294+ double sec = run_shape(op, t, &sh);
295+ if (sec < 0) return -1.0;
296+ pyre_rates_observe(&p->rates[op][t], sh.w.work, sec);
297+ (*probes)++;
298+ if (sec > slowest) slowest = sec;
299+ if (json_mode) {
300+ jbegin("probe");
301+ jkey("op"); jstr(pyre_op_name(op));
302+ jkey("target"); jstr(pyre_target_name(t));
303+ jkey("exp"); printf("%d", exp);
304+ jkey("shape"); jstr(sh.text);
305+ jkey("ms"); printf("%.6g", sec * 1e3);
306+ jend();
307+ } else if (!o->quiet) {
308+ printf(" %-17s %-7s 2^%-2d %-22s %10.4f ms\n", pyre_op_name(op), pyre_target_name(t), exp, sh.text, sec * 1e3);
309+ }
310+ throttle(sec, load, o->pause_ms);
311+ }
312+ return slowest;
313+}
314+
315+static void record_target(PyreProfile *p, int t) {
316+ if (t == PYRE_CPU) return;
317+ p->present[t] = 1;
318+ snprintf(p->device[t], sizeof(p->device[t]), "%s", pyre_target_device(t));
319+ p->init_seconds[t] = pyre_target_init_seconds(t);
320+}
321+
322+/* The plain grid: every chosen kernel on every chosen target at every `step`-th size. */
323+static void train_grid(const TrainOpts *o, PyreProfile *p, int *probes) {
324+ for (int t = 0; t < PYRE_TARGET_COUNT && !g_stop; t++) {
325+ if (!o->targets[t]) continue;
326+ if (!pyre_target_init(t)) {
327+ if (json_mode) { jbegin("skipped"); jkey("target"); jstr(pyre_target_name(t)); jend(); }
328+ else printf("%s: not available, skipped\n", pyre_target_name(t));
329+ continue;
330+ }
331+ record_target(p, t);
332+ for (int op = 0; op < PYRE_OP_COUNT && !g_stop; op++) {
333+ if (!o->ops[op] || !pyre_target_supports(t, op)) continue;
334+ Shape warm = shape_of(op, 12);
335+ run_shape(op, t, &warm); /* warm-up, discarded */
336+ for (int exp = 12; exp <= o->max_exp && !g_stop; exp += o->step) {
337+ double slowest = probe_exp(o, p, op, t, exp, o->repeats, probes);
338+ if (slowest < 0 || slowest > o->max_probe) break; /* bigger only gets slower: enough for this device */
339+ }
340+ }
341+ }
342+}
343+
344+/* Practise on every usable target at the sizes around `center` (the weak spot's neighbourhood). */
345+static void practise(const TrainOpts *o, PyreProfile *p, int op, int center, int repeats, int *probes) {
346+ for (int t = 0; t < PYRE_TARGET_COUNT && !g_stop; t++) {
347+ if (!o->targets[t] || !pyre_target_is_up(t) || !pyre_target_supports(t, op)) continue;
348+ for (int exp = center - 1; exp <= center + 1 && !g_stop; exp++) {
349+ if (exp < 12 || exp > o->max_exp) continue;
350+ double slowest = probe_exp(o, p, op, t, exp, repeats, probes);
351+ if (slowest < 0 || slowest > o->max_probe) break;
352+ }
353+ }
354+}
355+
356+/* ---------- the adaptive learner (--auto / --forever) ---------- */
357+
358+static int has_learned(const TrainOpts *o, const PyreProfile *p) {
359+ for (int op = 0; op < PYRE_OP_COUNT; op++) {
360+ if (!o->ops[op]) continue;
361+ for (int t = 0; t < PYRE_TARGET_COUNT; t++) {
362+ if (!o->targets[t]) continue;
363+ for (int b = 0; b < PYRE_BUCKETS; b++) if (p->rates[op][t].seen[b]) return 1;
364+ }
365+ }
366+ return 0;
367+}
368+
369+static int by_priority(const void *a, const void *b) {
370+ double x = ((const Weak *)a)->priority, y = ((const Weak *)b)->priority;
371+ return x < y ? 1 : x > y ? -1 : 0;
372+}
373+
374+static void emit_round(int round, const Score *s, int accepted, int probes, const Weak *focus, int nfocus, double cost_before, double cost_after) {
375+ if (!json_mode) {
376+ printf("round %d: time lost %.1f%%, right %d/%d, error %.0f%% -> %s (%d probes)\n", round, mean_regret(s) * 100.0,
377+ s->best_hits, s->shapes, mean_error(s) * 100.0, round == 0 ? "baseline" : accepted ? "kept" : "reverted", probes);
378+ return;
379+ }
380+ jbegin("round");
381+ jkey("round"); printf("%d", round);
382+ jkey("lost_pct"); printf("%.4f", mean_regret(s) * 100.0);
383+ jkey("error_pct"); if (s->err_n) printf("%.4f", mean_error(s) * 100.0); else printf("null");
384+ jkey("hits"); printf("%d", s->best_hits);
385+ jkey("shapes"); printf("%d", s->shapes);
386+ jkey("accepted"); printf("%s", accepted ? "true" : "false");
387+ jkey("probes"); printf("%d", probes);
388+ jkey("cost_before"); printf("%.5f", cost_before);
389+ jkey("cost_after"); printf("%.5f", cost_after);
390+ jkey("focus"); printf("[");
391+ for (int i = 0; i < nfocus; i++) {
392+ printf("%s", i ? "," : "");
393+ char buf[64];
394+ snprintf(buf, sizeof(buf), "%s 2^%d", pyre_op_name(focus[i].op), focus[i].exp);
395+ jstr(buf);
396+ }
397+ printf("]");
398+ jend();
399+}
400+
401+/* Grade, practise where it is weakest, grade again on fresh sizes, keep it only if no worse.
402+ * Returns the exit code. */
403+static int run_adaptive(TrainOpts *o, PyreProfile *p) {
404+ double started = pyre_now_seconds();
405+ int probes = 0;
406+ for (int t = 1; t < PYRE_TARGET_COUNT; t++) if (o->targets[t]) pyre_target_init(t);
407+ if (json_mode) { jbegin("mode"); jkey("mode"); jstr(o->forever ? "forever" : "auto"); jend(); }
408+
409+ if (!has_learned(o, p)) { /* nothing to be weak at yet: lay down a grid first */
410+ if (json_mode) { jbegin("phase"); jkey("name"); jstr("seeding"); jend(); }
411+ else printf("nothing learned yet: seeding with a grid first\n");
412+ train_grid(o, p, &probes);
413+ }
414+
415+ PyreProfile accepted = *p;
416+ Score first = {0}, last = {0};
417+ int converged = 0, round = 0, kept = 0;
418+
419+ for (round = 0; !g_stop; round++) {
420+ if (!o->forever && round > o->rounds) break;
421+ if (json_mode) { jbegin("phase"); jkey("name"); jstr(round == 0 ? "check-before" : "practising"); jend(); }
422+
423+ /* Fresh held-out sizes every round, measured once; the accepted profile is the baseline. */
424+ Measured m;
425+ measure(o, (double)(round % 4), &m);
426+ if (g_stop) break;
427+ Weak weak[MAX_ROWS];
428+ int nweak = 0;
429+ Score before = score_measured(&m, round == 0 ? "before" : "round-before", 0, weak, &nweak);
430+ if (round == 0) {
431+ first = before;
432+ last = before;
433+ print_score("before", &before);
434+ emit_round(0, &before, 1, probes, NULL, 0, cost(&before), cost(&before));
435+ continue;
436+ }
437+
438+ qsort(weak, (size_t)nweak, sizeof(Weak), by_priority);
439+ Weak focus[3];
440+ int nfocus = 0;
441+ for (int i = 0; i < nweak && nfocus < 3; i++) {
442+ int dup = 0;
443+ for (int j = 0; j < nfocus; j++) if (focus[j].op == weak[i].op && focus[j].exp == weak[i].exp) dup = 1;
444+ if (!dup) focus[nfocus++] = weak[i];
445+ }
446+ /* Practice shrinks as it converges (like a decaying learning rate), but never to nothing. */
447+ int floor_reps = o->forever ? 2 : 1;
448+ int reps = (int)ceil(o->repeats * pow(0.75, round - 1));
449+ if (reps < floor_reps) reps = floor_reps;
450+ int round_probes = 0;
451+ for (int i = 0; i < nfocus && !g_stop; i++) practise(o, p, focus[i].op, focus[i].exp, reps, &round_probes);
452+ probes += round_probes;
453+ if (g_stop) break;
454+
455+ Score after = score_measured(&m, "round-after", 0, NULL, NULL);
456+ double cb = cost(&before), ca = cost(&after);
457+ int accept = ca <= cb + 0.002;
458+ if (accept) { accepted = *p; kept++; last = after; }
459+ else { *p = accepted; last = before; }
460+ emit_round(round, accept ? &after : &before, accept, round_probes, focus, nfocus, cb, ca);
461+
462+ if (accept && !o->forever) {
463+ /* Converged: two rounds in a row that improve by under 0.3 points. */
464+ converged = (cb - ca < 0.003) ? converged + 1 : 0;
465+ if (converged >= 2) { if (json_mode) { jbegin("converged"); jkey("round"); printf("%d", round); jend(); } else printf("converged\n"); break; }
466+ }
467+ if (o->forever) {
468+ if (accept) pyre_manager_save(); /* the dashboard sees learning as it happens */
469+ if (json_mode) { jbegin("idle"); jkey("seconds"); printf("%.0f", o->round_pause_s); jend(); }
470+ sleep_interruptible(o->round_pause_s);
471+ }
472+ }
473+
474+ *p = accepted; /* a stop mid-round must not leave half-practised data */
475+ double seconds = pyre_now_seconds() - started;
476+ const char *verdict = cost(&last) < cost(&first) - 0.02 ? "better" : "same";
477+ if (!first.shapes || !last.shapes) verdict = "unknown";
478+ int saved = pyre_manager_save();
479+ if (json_mode) {
480+ jbegin("verdict"); jkey("verdict"); jstr(verdict); jkey("discarded"); printf("false"); jend();
481+ jbegin("done"); jkey("probes"); printf("%d", probes); jkey("seconds"); printf("%.2f", seconds); jkey("saved"); printf("%s", saved ? "true" : "false"); jend();
482+ } else {
483+ printf("done after %d rounds (%d kept), %d probes in %.1fs: %s\n", round, kept, probes, seconds, verdict);
484+ print_score("before", &first);
485+ print_score("after", &last);
486+ }
487+ o->level = o->forever ? "forever" : "auto";
488+ append_history("train", o, &first, &last, verdict, probes, seconds);
489+ return 0;
490+}
491+
492+/* ---------- commands ---------- */
493+
494+static int parse_list(const char *csv, const char *const names[], int count, int out[]) {
495+ memset(out, 0, sizeof(int) * count);
496+ char buf[128];
497+ snprintf(buf, sizeof(buf), "%s", csv);
498+ for (char *tok = strtok(buf, ","); tok; tok = strtok(NULL, ",")) {
499+ int found = 0;
500+ for (int i = 0; i < count; i++) if (!strcmp(tok, names[i])) { out[i] = 1; found = 1; }
501+ if (!found) { fprintf(stderr, "pyre: unknown name '%s' (try `pyre help train`)\n", tok); return 0; }
502+ }
503+ return 1;
504+}
505+
506+static int bad_flag(const char *flag, const char *why) {
507+ fprintf(stderr, "pyre: %s %s (try `pyre help train`)\n", flag, why);
508+ return 2;
509+}
510+
511+int cmd_train(int argc, char **argv) {
512+ static const char *const op_names[PYRE_OP_COUNT] = {"matmul", "chaos", "mc"};
513+ static const char *const target_names[PYRE_TARGET_COUNT] = {"cpu", "cuda", "vulkan"};
514+ TrainOpts o = {.step = 2, .repeats = 2, .max_probe = 1.0, .max_exp = 34, .gpu_load = 100, .cpu_load = 100, .verify = 1,
515+ .level = "normal", .rounds = 6, .round_pause_s = 20};
516+ for (int i = 0; i < PYRE_OP_COUNT; i++) o.ops[i] = 1;
517+ for (int i = 0; i < PYRE_TARGET_COUNT; i++) o.targets[i] = 1;
518+ int probe_set = 0;
519+
520+ for (int i = 2; i < argc; i++) {
521+ const char *a = argv[i];
522+ const char *v = i + 1 < argc ? argv[i + 1] : NULL;
523+ int flag_only = !strcmp(a, "--reset") || !strcmp(a, "--quiet") || !strcmp(a, "--no-verify") || !strcmp(a, "--auto") || !strcmp(a, "--forever");
524+ if (!flag_only && !v) return bad_flag(a, "needs a value");
525+ if (!strcmp(a, "--level")) {
526+ if (!strcmp(v, "quick")) { o.level = "quick"; o.step = 3; o.repeats = 1; if (!probe_set) o.max_probe = 0.25; }
527+ else if (!strcmp(v, "normal")) { o.level = "normal"; o.step = 2; o.repeats = 2; if (!probe_set) o.max_probe = 1.0; }
528+ else if (!strcmp(v, "deep")) { o.level = "deep"; o.step = 1; o.repeats = 3; if (!probe_set) o.max_probe = 3.0; }
529+ else return bad_flag(a, "is quick|normal|deep");
530+ } else if (!strcmp(a, "--ops")) { if (!parse_list(v, op_names, PYRE_OP_COUNT, o.ops)) return 2; }
531+ else if (!strcmp(a, "--targets")) { if (!parse_list(v, target_names, PYRE_TARGET_COUNT, o.targets)) return 2; }
532+ else if (!strcmp(a, "--gpu-load")) { o.gpu_load = atof(v); if (o.gpu_load < 1 || o.gpu_load > 100) return bad_flag(a, "is 1..100"); }
533+ else if (!strcmp(a, "--cpu-load")) { o.cpu_load = atof(v); if (o.cpu_load < 1 || o.cpu_load > 100) return bad_flag(a, "is 1..100"); }
534+ else if (!strcmp(a, "--max-probe")) { o.max_probe = atof(v); probe_set = 1; if (o.max_probe <= 0) return bad_flag(a, "must be > 0"); }
535+ else if (!strcmp(a, "--max-work")) { o.max_exp = atoi(v); if (o.max_exp < 12 || o.max_exp > 40) return bad_flag(a, "is 12..40"); }
536+ else if (!strcmp(a, "--max-mem")) { o.max_mem_mb = atof(v); if (o.max_mem_mb < 0) return bad_flag(a, "must be >= 0"); }
537+ else if (!strcmp(a, "--repeats")) { o.repeats = atoi(v); if (o.repeats < 1 || o.repeats > 50) return bad_flag(a, "is 1..50"); }
538+ else if (!strcmp(a, "--step")) { o.step = atoi(v); if (o.step < 1 || o.step > 12) return bad_flag(a, "is 1..12"); }
539+ else if (!strcmp(a, "--pause")) { o.pause_ms = atof(v); if (o.pause_ms < 0) return bad_flag(a, "must be >= 0"); }
540+ else if (!strcmp(a, "--rounds")) { o.rounds = atoi(v); if (o.rounds < 1 || o.rounds > 1000) return bad_flag(a, "is 1..1000"); }
541+ else if (!strcmp(a, "--round-pause")) { o.round_pause_s = atof(v); if (o.round_pause_s < 0 || o.round_pause_s > 86400) return bad_flag(a, "is 0..86400 seconds"); }
542+ else if (!strcmp(a, "--reset")) { o.reset = 1; continue; }
543+ else if (!strcmp(a, "--quiet")) { o.quiet = 1; continue; }
544+ else if (!strcmp(a, "--no-verify")) { o.verify = 0; continue; }
545+ else if (!strcmp(a, "--auto")) { o.adaptive = 1; continue; }
546+ else if (!strcmp(a, "--forever")) { o.adaptive = o.forever = 1; continue; }
547+ else return bad_flag(a, "is not a train flag");
548+ i++;
549+ }
550+ if (o.reset) pyre_manager_forget();
551+
552+#ifdef _OPENMP
553+ if (o.cpu_load < 100) {
554+ int n = (int)(omp_get_max_threads() * o.cpu_load / 100.0);
555+ omp_set_num_threads(n < 1 ? 1 : n);
556+ }
557+#endif
558+ struct sigaction sa = {.sa_handler = on_stop}; /* Stop in the dashboard sends SIGTERM: finish tidily */
559+ sigaction(SIGTERM, &sa, NULL);
560+ sigaction(SIGINT, &sa, NULL);
561+
562+ PyreProfile *p = pyre_manager_profile_edit();
563+ if (o.adaptive) return run_adaptive(&o, p);
564+
565+ PyreProfile before_profile = *p;
566+ Measured m;
567+ Score before = {0};
568+ if (o.verify) {
569+ for (int t = 1; t < PYRE_TARGET_COUNT; t++) if (o.targets[t]) pyre_target_init(t);
570+ if (!o.quiet && !json_mode) printf("self-check before training (held-out sizes)...\n");
571+ if (json_mode) { jbegin("phase"); jkey("name"); jstr("check-before"); jend(); }
572+ measure(&o, 0.0, &m);
573+ before = score_measured(&m, "before", 0, NULL, NULL);
574+ print_score("before", &before);
575+ }
576+ double started = pyre_now_seconds();
577+ int probes = 0;
578+ if (json_mode) { jbegin("phase"); jkey("name"); jstr("training"); jend(); }
579+ train_grid(&o, p, &probes);
580+ double seconds = pyre_now_seconds() - started;
581+ if (!json_mode) printf("trained %d probes in %.1fs\n", probes, seconds);
582+
583+ const char *verdict = "unverified";
584+ int discarded = 0;
585+ Score after = {0};
586+ if (o.verify) {
587+ if (!json_mode) printf("self-check after training (held-out sizes):\n");
588+ else { jbegin("phase"); jkey("name"); jstr("check-after"); jend(); }
589+ after = score_measured(&m, "after", 0, NULL, NULL); /* same measured times: only the profile changed */
590+ if (!json_mode) { print_score("before", &before); print_score("after", &after); }
591+ else print_score("after", &after);
592+ double rb = mean_regret(&before), ra = mean_regret(&after);
593+ if (!before.shapes || !after.shapes) {
594+ verdict = "unknown";
595+ if (!json_mode) printf("verdict: not enough to compare; keeping the training\n");
596+ } else if (ra > rb + 0.02) {
597+ verdict = "worse";
598+ discarded = 1;
599+ *p = before_profile; /* training made decisions worse: undo it */
600+ if (!json_mode) printf("verdict: WORSE (%.1f%% -> %.1f%% time lost). Training discarded; previous knowledge kept.\n", rb * 100, ra * 100);
601+ } else if (ra < rb - 0.02) {
602+ verdict = "better";
603+ if (!json_mode) printf("verdict: BETTER (%.1f%% -> %.1f%% of time lost to wrong placement)\n", rb * 100, ra * 100);
604+ } else {
605+ verdict = "same";
606+ if (!json_mode) printf("verdict: no measurable change (%.1f%% -> %.1f%%); decisions were already as good as these probes can make them\n", rb * 100, ra * 100);
607+ }
608+ }
609+ int saved = discarded ? 0 : pyre_manager_save();
610+ if (json_mode) {
611+ jbegin("verdict"); jkey("verdict"); jstr(verdict); jkey("discarded"); printf("%s", discarded ? "true" : "false"); jend();
612+ jbegin("done"); jkey("probes"); printf("%d", probes); jkey("seconds"); printf("%.2f", seconds);
613+ jkey("saved"); printf("%s", saved ? "true" : "false"); jend();
614+ } else if (!discarded) {
615+ printf("%s %s\n", saved ? "saved to" : "NOT saved (no writable profile path):", pyre_manager_profile_path());
616+ }
617+ append_history("train", &o, o.verify ? &before : NULL, o.verify ? &after : NULL, verdict, probes, seconds);
618+ return discarded ? 1 : 0;
619+}
620+
621+int cmd_check(void) {
622+ TrainOpts o = {.max_probe = 1.0, .max_exp = 30, .gpu_load = 100, .cpu_load = 100, .level = "check"};
623+ for (int i = 0; i < PYRE_OP_COUNT; i++) o.ops[i] = 1;
624+ for (int i = 0; i < PYRE_TARGET_COUNT; i++) { o.targets[i] = 1; pyre_target_init(i); }
625+ if (!json_mode) printf("self-check on held-out sizes (runs every target for real):\n");
626+ else { jbegin("phase"); jkey("name"); jstr("check"); jend(); }
627+ double t0 = pyre_now_seconds();
628+ Measured m;
629+ measure(&o, 0.0, &m);
630+ Score s = score_measured(&m, "check", 1, NULL, NULL);
631+ print_score("now", &s);
632+ if (json_mode) { jbegin("done"); jkey("probes"); printf("0"); jkey("seconds"); printf("%.2f", pyre_now_seconds() - t0); jkey("saved"); printf("false"); jend(); }
633+ append_history("check", &o, NULL, &s, "check", 0, pyre_now_seconds() - t0);
634+ return 0;
635+}

pyre/tools/train.hadded

+7−0
@@ -0,0 +1,7 @@
1+#ifndef PYRE_TRAIN_H
2+#define PYRE_TRAIN_H
3+// `pyre train` and `pyre check`: teaching and grading the manager on this machine.
4+// argv[0] is the program, argv[1] the command; flags follow (see `pyre help train`).
5+int cmd_train(int argc, char **argv);
6+int cmd_check(void);
7+#endif