← Commit history
Pyre: CUDA backend, placement manager, adaptive training, offline dashboard
lennoxrose committed on Oct 8, 2026 at 23:57 UTC
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_jit1515
pyre/benchmarks/*/run_benchmark1616
pyre/tests/matrix_multiply_test1717
pyre/tests/monte_carlo_test18
-pyre/lib/18
+pyre/tests/cuda_test19
+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 as2122
# any other object code.@@ -37,4 +38,6 @@ skills-lock.json3738
goals.md3839
rundown.md3940
missing.md40
-test.ash\ No newline at end of file41
+test.ash42
+# nvcc dependency files list this machine's absolute CUDA install paths; generated by the build, never committed43
+pyre/src/D/backends/cuda/*.dCHANGELOG.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 = gcc22
.DEFAULT_GOAL := all3
-# -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 at6
+# bin/extensions/pyre/ also holds libpyre.so for kiln's --link=shared to find at77
# runtime, and a bare -lashgpu would silently prefer that .so over the8
-# .a, turning ashvm from self-contained into "needs libashgpu.so8
+# .a, turning ashvm from self-contained into "needs libpyre.so99
# findable at runtime with no rpath to say where" -- found exactly that1010
# way when pyre/Makefile grew the .so target). ashvm already links1111
# against libc/libm normally, unlike kiln's hand-rolled, zero-dependency1212
# ELF/PE output, so this one extra build-time dependency doesn't break1313
# any existing promise (see ideas/assigned.md). -lstdc++ because14
-# libashgpu.a's Vulkan backend is C++ (RAII) internally, even though14
+# libpyre.a's Vulkan backend is C++ (RAII) internally, even though1515
# ash_gpu.h itself is a plain C ABI -- plain gcc as the final linker1616
# needs that spelled out, g++ would do it automatically.17
+include ../pyre/cuda.mk18
+1719
CFLAGS = -Wall -Wextra -std=c11 -O2 -Isrc -I../pyre/src -fopenmp18
-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/ashvm7577
7678
all: $(BIN)7779
78
-../pyre/lib/libashgpu.a:80
+../bin/extensions/pyre/libpyre.a:7981
$(MAKE) -C ../pyre lib8082
81
-$(BIN): $(ASHVM_OBJ) ../pyre/lib/libashgpu.a83
+$(BIN): $(ASHVM_OBJ) ../bin/extensions/pyre/libpyre.a8284
mkdir -p ../bin8385
$(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 than17
-// linking against libashgpu.17
+// linking against libpyre.1818
//1919
// Below ASH_GPU_MIN_N, a plain local loop skips Pyre entirely: OpenMP's2020
// 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 suitable3636
workloads," *only after the runtime is solid*. **Done**: kiln's37
- generated code really does call into `libashgpu.so` through a GOT37
+ generated code really does call into `libpyre.so` through a GOT3838
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 needs5353
OpenMP/libgomp, `runtime/device.c` needs libdl for its detection probe)5454
doesn't break any promise ashvm makes. ashvm's own Makefile links55
-`../pyre/lib/libashgpu.a` unconditionally -- there's no "Pyre not55
+`../pyre/lib/libpyre.a` unconditionally -- there's no "Pyre not5656
installed" fallback path today; if that matters later (packaging ashvm5757
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 exactly8282
this shape -- it emits a dynamically-linked ELF with a `PT_INTERP`, so8383
the real system `ld.so` resolves calls into a second binary8484
(`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 SECOND86
-needed library: one more `DT_NEEDED`, and `libashgpu.so`'s one export85
+Extended that same machinery to also declare `libpyre.so` as a SECOND86
+needed library: one more `DT_NEEDED`, and `libpyre.so`'s one export8787
(`ash_gpu_matrix_multiply`) folded into the SAME combined hash/dynsym/GOT/rela8888
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` -- no152152
separate opt-in, because ashvm never promised zero dependencies.153
-- kiln: always under `--link=shared` (once `libashgpu.so` is installed153
+- kiln: always under `--link=shared` (once `libpyre.so` is installed154154
at `KILN_RPATH`, same as `libkilnrt.so`); never under the default155155
`--link=static`, which keeps working with nothing installed beyond156156
kiln itself, unconditionally, forever.kiln/Makefilemodified
+2−1@@ -1,6 +1,7 @@11
CC = gcc22
.DEFAULT_GOAL := all3
-CFLAGS = -Wall -Wextra -std=c11 -O2 -Isrc3
+KILN_RPATH_LIST = $(abspath ../bin/extensions/pyre):/usr/local/lib/kiln4
+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_multiply12
+// ideas/assigned.md): calls INTO libpyre.so's ash_gpu_matrix_multiply1313
// through the GOT slot elf/C/elf_dynamic.c's --link=shared writer lays1414
// out, rather than reimplementing the algorithm in kiln's own hand-rolled1515
// 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 thread434434
// exits -- invisible as long as a kiln program is single-threaded,435435
// which was every one of them until matrix_mul's --link=shared path436
- // started calling into libashgpu.so's OpenMP-based CPU backend436
+ // started calling into libpyre.so's OpenMP-based CPU backend437437
// (found by actually running that: the process hung forever after438438
// 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.md55
// -- a --link=static binary stays the hand-rolled, zero-dependency ELF/PE66
// kiln otherwise always produces; this builtin's whole implementation is7
-// a call through the GOT into libashgpu.so, which only exists under7
+// a call through the GOT into libpyre.so, which only exists under88
// --link=shared at all). codegen/C/builtins/builtins.c's dispatch calls99
// this after parsing matrix_mul(...)'s arguments, same convention as1010
// every other builtin in that file -- a and b already pushedkiln/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=shared1
+// x-macro list of libpyre.so's exported symbols kiln's --link=shared22
// 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's44
// header comment and ideas/assigned.md for why this is a SECOND neededkiln/src/codegen/H/emit/ash_gpu_import_enum.hmodified
+1−1@@ -1,7 +1,7 @@11
#ifndef KILN_ASH_GPU_IMPORT_ENUM_H22
#define KILN_ASH_GPU_IMPORT_ENUM_H33
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 and66
// elf_dynamic.c's actual table bytes, so the two can never silently77
// 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 SECOND7
-// 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 was99
// for libkilnrt.dll's imports, but for a different reason: kiln's OWN1010
// runtime routines (heap_alloc, print_top, ...), the only thingskiln/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.so165
- 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_HASH168168
DT(5, dynstr_addr); // DT_STRTAB (after the code -- depends on its length)169169
DT(6, KILN_DYN_SYMTAB_ADDR); // DT_SYMTABkiln/src/elf/H/elf_dynamic.hmodified
+9−4@@ -19,22 +19,27 @@1919
// "executable" with PT_INTERP/PT_DYNAMIC/GOT/RELA, loaded and resolved2020
// correctly by /lib64/ld-linux-x86-64.so.2) -- this header encodes that2121
// same validated structure, not a first attempt.22
+// Colon-separated search list. kiln/Makefile overrides it to also name the23
+// repo's bin/extensions/pyre (where Pyre builds libpyre.so) ahead of the24
+// system install dir.25
+#ifndef KILN_RPATH2226
#define KILN_RPATH "/usr/local/lib/kiln"27
+#endif2328
#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 into26
-// it (libkilnrt.so is kiln's own hand-rolled routines; libashgpu.so is a31
+// it (libkilnrt.so is kiln's own hand-rolled routines; libpyre.so is a2732
// normal gcc/g++-built shared object this project doesn't build). ld.so2833
// resolves each imported symbol name against ALL needed libraries, so2934
// the two don't need separate hash/dynsym/GOT regions -- one combined3035
// table (KILN_DYN_TOTAL_IMPORTS below) covering both libraries' symbols3136
// 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 needed3641
// library -- kiln's own (RUNTIME_IMPORT_COUNT_, from libkilnrt.so) then37
-// 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 11u7277
#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 terminatorkiln/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, see17
+// Same mechanism, for the second needed library (libpyre.so, see1818
// elf_dynamic.h's header comment) -- `which`'s own SysV C calling1919
// 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 ownkiln/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.sh55
# only ever compiles with kiln's default --link=static) -- this fills that66
# 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 builtin7
+# elf/H/elf_dynamic.h's second DT_NEEDED for libpyre.so), the one builtin88
# that only exists under --link=shared at all.99
#10
-# 1. Rebuilds kiln, libkilnrt.so, and libashgpu.so, installing both at10
+# 1. Rebuilds kiln, libkilnrt.so, and libpyre.so, installing both at1111
# kiln's hardcoded rpath (KILN_RPATH, elf/H/elf_dynamic.h) -- there is1212
# 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_check3333
rm -rf "$OUTDIR"pyre/Makefilemodified
+83−20@@ -17,30 +17,52 @@1717
# pyre/tools/install_deps.sh, never run end-to-end -- this sandbox has1818
# no GPU at all).1919
20
+include cuda.mk21
+2022
CC = gcc2123
CXX = g++2224
.DEFAULT_GOAL := all23
-CFLAGS = -Wall -Wextra -std=c11 -O3 -march=native -fopenmp -Isrc -fPIC24
-CXXFLAGS = -Wall -Wextra -std=c++17 -O3 -Isrc -fPIC25
-LDFLAGS = -fopenmp -lm -ldl -lvulkan25
+CFLAGS = -Wall -Wextra -std=c11 -O3 -march=native -fopenmp -Isrc -fPIC -pthread $(CUDA_DEFINE)26
+CXXFLAGS = -Wall -Wextra -std=c++17 -O3 -Isrc -fPIC -pthread27
+LDFLAGS = -fopenmp -lm -ldl -lvulkan $(CUDA_LIBS)28
+NVCCFLAGS = -std=c++17 -O3 $(CUDA_ARCH) -Isrc -Xcompiler -fPIC -Xcompiler -Wall29
+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 links32
+# the .a from here; kiln's --link=shared binaries find the .so here via an33
+# rpath entry (see kiln/Makefile).34
+EXT_DIR = ../bin/extensions/pyre2635
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.c3244
PYRE_CPP_SRC = \3345
src/C/backends/vulkan/context.cpp \3446
src/C/backends/vulkan/matrix_multiply.cpp \3547
src/C/backends/vulkan/chaos.cpp48
+# NVIDIA-only CUDA backend (src/C/backends/cuda/*.cu), built only when49
+# 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.cu55
+ifeq ($(NVCC),)56
+PYRE_CU_SRC =57
+endif3658
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.d3961
# src/C/<feature>/x.cpp -> src/O/<feature>/x.o src/D/<feature>/x.d4062
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 to4668
# SPIR-V and embedded as a C array (glslc -mfmt=c) rather than loaded@@ -82,37 +104,73 @@ $(call obj_of,$(1)): $(1)82104
endef83105
$(foreach s,$(PYRE_CPP_SRC),$(eval $(call cpp_compile_rule,$(s))))84106
107
+define cu_compile_rule108
+$(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
+endef112
+$(foreach s,$(PYRE_CU_SRC),$(eval $(call cu_compile_rule,$(s))))113
+85114
-include $(PYRE_DEP)86115
87
-.PHONY: all clean test benchmark lib install116
+.PHONY: all clean test benchmark lib install tool web88117
89
-all: test benchmark lib118
+all: test benchmark lib tool web90119
91
-# Consumed by ashvm's Makefile (-Lpyre/lib -lashgpu) -- see120
+# Consumed by ashvm's Makefile (../bin/extensions/pyre/libpyre.a) -- see92121
# ashvm/src/builtins/C/matrix.c and ideas/assigned.md for why ashvm links93122
# 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 lib124
+$(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's100129
# 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 system102131
# toolchain, not another hand-rolled kiln-side writer like libkilnrt.so.103
-lib/libashgpu.so: $(PYRE_OBJ)104
- mkdir -p lib132
+$(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.so136
+lib: $(EXT_DIR)/libpyre.a $(EXT_DIR)/libpyre.so108137
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.h140
+ $(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)/pyre147
+148
+# The dashboard UI (`pyre dashboard`): React + TypeScript in src/web, built149
+# with pnpm to <bin>/extensions/pyre/web, which the pyre executable serves150
+# fully offline. Node and pnpm are needed at BUILD time only (and network the151
+# first time, to fetch packages); without them Pyre still builds and only the152
+# dashboard UI is skipped, with a warning.153
+WEB_SRC = src/web154
+WEB_OUT = $(EXT_DIR)/web155
+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
+ fi164
+165
+web: $(WEB_OUT)/index.html166
+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 can111169
# actually find it at load time -- same spot `kiln --emit-runtime` installs112170
# libkilnrt.so to by hand today (there's no kiln "make install" yet either).113
-install: lib/libashgpu.so171
+install: $(EXT_DIR)/libpyre.so114172
mkdir -p /usr/local/lib/kiln115
- cp lib/libashgpu.so /usr/local/lib/kiln/libashgpu.so173
+ cp $(EXT_DIR)/libpyre.so /usr/local/lib/kiln/libpyre.so116174
117175
# Each test/benchmark main is plain C -- compiled with $(CC) into its own118176
# .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_test193
+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_test136197
tests/matrix_multiply_test137198
tests/monte_carlo_test199
+ tests/cuda_test138200
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' -delete155217
find src -name '*.d' -delete156218
rm -f src/H/backends/vulkan/matrix_multiply_spv.h src/H/backends/vulkan/chaos_spv.h157
- rm -f tests/*.o benchmarks/*/*.o158
- rm -f tests/matrix_multiply_test tests/monte_carlo_test219
+ rm -f tests/*.o benchmarks/*/*.o tools/*.o $(EXT_DIR)/pyre220
+ rm -rf $(WEB_OUT)221
+ rm -f tests/matrix_multiply_test tests/monte_carlo_test tests/cuda_test159222
rm -f benchmarks/matrix/run_benchmark benchmarks/monte_carlo_no_pi/run_benchmark benchmarks/chaos/run_benchmark160
- rm -rf lib223
+ rm -f $(EXT_DIR)/libpyre.a $(EXT_DIR)/libpyre.sopyre/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_CUDA18
+#include "H/backends/cuda/context.h"19
+#endif1720
#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_CUDA53
+ /* CUDA first (NVIDIA only): a warm-up call so context creation and the54
+ * 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
+#endif80
+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 match62
- * (within fp rounding-order slack, since the GPU evaluates per63
- * thread independently same as the CPU's OpenMP loop does, both64
- * 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_CUDA12
+#include "H/backends/cuda/context.h"13
+#endif14
+#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_CUDA26
+/* Backend-by-backend timing at the benchmark's own N, on non-constant data27
+ * (the challenge's constant fill can't catch a wrong index), CUDA checked28
+ * 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
+#endif1260
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_CUDA77
+ printf("Backends at N=%d:\n", n);78
+ compare_backends(n);79
+#endif2880
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 the2
+# "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 machine5
+# 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 are9
+# compiled for exactly the hardware they will run on). Pass something like10
+# 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=native16
+# Static cudart: libpyre.so / ashvm then need only the NVIDIA driver's17
+# libcuda at run time, not a CUDA toolkit install.18
+CUDA_LIBS = -L$(CUDA_HOME)/lib64 -lcudart_static -lrt -lpthread19
+CUDA_DEFINE = -DASH_GPU_HAVE_CUDA20
+else21
+CUDA_LIBS =22
+CUDA_DEFINE =23
+endifpyre/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 _OPENMP34
#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 by6
+// the fp64 sqrt (long latency, low throughput on consumer parts). Each7
+// thread therefore advances ILP independent elements in lock step so the8
+// chains overlap instead of the warp stalling on one.9
+// * Elements of a block are contiguous and a thread's ILP elements are one10
+// block-width apart, so every load/store is a fully coalesced 256 B warp11
+// access.12
+// * Host<->device traffic is split into chunks over several streams, so13
+// 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 fuse16
+// them into one FMA, which rounds once instead of twice and would make the17
+// result differ from the CPU backend (compiled -std=c11, no contraction) in18
+// the last bit. Paying one extra DADD per step keeps CPU and GPU results19
+// 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 unroll37
+ 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 unroll45
+ 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 unroll51
+ 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
+} // namespace58
+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 and2
+// 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; below24
+// 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 the35
+ // 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 the54
+ // first real dispatch.55
+ if (cudaFree(0) != cudaSuccess) return 0;56
+ return 1;57
+}58
+59
+} // namespace60
+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 rate5
+// (a laptop RTX 3050 peaks near 100 GFLOP/s of fp64), and fp64 has no tensor6
+// core path on consumer Ampere/Ada. This kernel's job is to keep that small7
+// fp64 pipe saturated, not to beat a many-core CPU on a card that8
+// 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 held12
+// in registers: each loaded value feeds 4 FMAs, so shared-memory13
+// traffic per FMA is a quarter of the naive kernel's.14
+// * BK=16 deep k-slices staged through shared memory. A is stored15
+// transposed with a 65-double row stride so the transposing store is16
+// bank-conflict free; B is read as double2 (16 B) vectors.17
+// * Software-pipelined: the next k-slice is fetched into registers while18
+// the current one is multiplied, then written to the other shared19
+// buffer -- one __syncthreads per slice, global-load latency hidden.20
+// * Tile loads are bounds-checked and zero-filled, so any N works, not21
+// just multiples of 64.22
+// * Host side splits C by rows over streams: B uploads once, then each23
+// stream uploads its rows of A, multiplies, and downloads its rows of C24
+// 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; // 1634
+constexpr int THREADS_Y = BM / TM; // 1635
+constexpr int THREADS = THREADS_X * THREADS_Y;36
+constexpr int A_STRIDE = BM + 1; // odd stride: conflict-free transposed store37
+constexpr int A_LOADS = (BM * BK) / THREADS; // 438
+constexpr int B_LOADS = (BK * BN) / THREADS; // 439
+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 unroll54
+ for (int i = 0; i < TM; i++)55
+#pragma unroll56
+ 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 unroll63
+ 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 unroll69
+ 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 unroll77
+ 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 unroll82
+ 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 unroll99
+ for (int kk = 0; kk < BK; kk++) {100
+ double a[TM], b[TN];101
+#pragma unroll102
+ 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 unroll107
+ for (int i = 0; i < TM; i++)108
+#pragma unroll109
+ 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 unroll117
+ for (int i = 0; i < TM; i++) {118
+ const int r = row0 + ty * TM + i;119
+ if (r >= m) continue;120
+#pragma unroll121
+ 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
+} // namespace129
+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 two144
+ // 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 the151
+ // 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 per2
+// iteration, count how often their product exceeds `threshold`.3
+//4
+// Same recurrence and per-thread seed derivation as the CPU backend5
+// (src/C/backends/cpu/monte_carlo.c): every GPU thread owns an independent6
+// splitmix32-seeded LCG stream, which is why ash_gpu.h already documents7
+// 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 is11
+// one 32-bit seed in a register; the only global write is one atomic12
+// per block.13
+// * No fp64 at all in the loop. The CPU backend tests14
+// (s1/2^32) * (s2/2^32) > threshold in doubles; the same test in exact15
+// integers is (u64)s1 * s2 > floor(threshold * 2^64), because the left16
+// side is an integer and the right side's fractional part cannot change17
+// which integers exceed it. That is one IMAD.WIDE plus a 64-bit18
+// compare, at full integer rate, instead of two fp64 conversions, a19
+// multiply and a compare at the card's 1/64 fp64 rate -- and it is the20
+// 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-IMAD23
+// latency of each chain; iterations are divided evenly with the24
+// 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..Ada34
+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 split53
+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 unroll63
+ 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
+} // namespace76
+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 never87
+ // exceeded, a threshold < 0 always is (even by a product of 0). In88
+ // between, scaling by 2^64 is an exact exponent shift and the cast89
+ // 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 and29
-// the shaderFloat64 feature (ash_gpu's ABI is `double` throughout, see28
+// Best physical device with both a compute-capable queue family and the29
+// shaderFloat64 feature (ash_gpu's ABI is `double` throughout, see3030
// 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 lists32
+// 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 ? 250
+ : 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, uin4959
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 200809L2
+#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.39
+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 200809L12
#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 just9
- * 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 no11
- * Vulkan driver installed at first -- installing vulkan-dzn (Mesa's12
- * Vulkan-over-D3D12 driver) made this report ASH_GPU_BACKEND_VULKAN for13
- * real, naming the user's actual AMD Radeon RX 9070 XT. See14
- * src/C/backends/vulkan/context.cpp's header comment.7
+ * Real detection: tries to actually bring up a device, best first -- CUDA8
+ * (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-presence10
+ * check: each init really creates a device. This reports what EXISTS; which11
+ * of them a given call actually uses is the manager's decision12
+ * (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 real37
-// measurement says that's the WRONG call for that kernel on this38
-// hardware (ash_gpu.h's comment on it); chaos_iterate exists precisely39
-// because it's shaped to actually win on GPU instead. Neither dispatcher40
-// has been taught to tell the difference yet -- see ideas/assigned.md.36
+// ---- placement queries and the three kernels: describe the work, let the37
+// 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 200809L2
+#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 any9
+// machine: a job under HEAVY_WORK units (1e8 multiply-adds, element10
+// iterations, ...) finishes in a fraction of a second on any current CPU, so11
+// it is not worth waking a GPU for; a job whose CPU time is under12
+// EXPLORE_MIN_S is too cheap to be worth an experiment on an unmeasured size.13
+#define HEAVY_WORK 1e814
+#define WARM_AFTER_S 0.2515
+#define EXPLORE_MIN_S 0.0516
+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 process23
+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, else32
+// ~/.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 empty86
+ }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 this102
+// device: a different GPU (or driver reinstall onto other hardware) than103
+// 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 costs130
+ // 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 it136
+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 flight144
+ // Starting it in the foreground only when we already KNOW (from a145
+ // 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 one154
+ // 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 rates190
+ }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 200809L2
+#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_CUDA8
+#include "H/backends/cuda/context.h"9
+#endif10
+#ifdef _OPENMP11
+#include <omp.h>12
+#endif13
+#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 be19
+// 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 _OPENMP39
+ return omp_get_max_threads();40
+#else41
+ return 1;42
+#endif43
+}44
+45
+int pyre_cuda_built(void) {46
+#ifdef ASH_GPU_HAVE_CUDA47
+ return 1;48
+#else49
+ return 0;50
+#endif51
+}52
+53
+int pyre_target_supports(PyreTarget t, PyreOp op) {54
+ switch (t) {55
+ case PYRE_CPU: return 1;56
+#ifdef ASH_GPU_HAVE_CUDA57
+ case PYRE_CUDA: return 1;58
+#endif59
+ 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_CUDA73
+ else if (t == PYRE_CUDA) ok = ash_gpu_cuda_init();74
+#endif75
+ 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_CUDA94
+ case PYRE_CUDA: return up[t] ? ash_gpu_cuda_device_name() : "";95
+#endif96
+ 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_CUDA103
+ if (t == PYRE_CUDA && up[t]) return ash_gpu_cuda_vram_bytes();104
+#endif105
+ (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_CUDA113
+ case PYRE_CUDA: return ash_gpu_cuda_matrix_multiply(a, b, c, n);114
+#endif115
+ 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_CUDA124
+ case PYRE_CUDA: return ash_gpu_cuda_chaos_iterate(data, n, iterations);125
+#endif126
+ 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_CUDA138
+ case PYRE_CUDA:139
+ return ash_gpu_cuda_monte_carlo_risk(iterations, threshold, seed, out_risky, out_probability);140
+#endif141
+ 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 device163
+ // 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.h2
+ src/H/runtime/ash_gpu.h src/H/backends/cpu/monte_carlo.h33
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.h3
+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.h2
+ src/H/runtime/manager.h src/H/runtime/cost_model.h \3
+ src/H/runtime/targets.h44
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.h3
+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.h5
+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_H2
+#define ASH_GPU_CPU_MONTE_CARLO_H3
+// Internal to the dispatcher (src/C/runtime/device.c) -- not part of4
+// 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
+#endifpyre/src/H/backends/cuda/context.hadded
+38−0@@ -0,0 +1,38 @@1
+#ifndef ASH_GPU_CUDA_CONTEXT_H2
+#define ASH_GPU_CUDA_CONTEXT_H3
+// Internal to the dispatcher (src/C/runtime/device.c) -- not part of4
+// ash_gpu.h's public ABI. NVIDIA-only backend, CUDA runtime API, kernels5
+// in src/C/backends/cuda/*.cu. Only compiled when pyre/cuda.mk finds6
+// nvcc (ASH_GPU_HAVE_CUDA); device.c never references this header7
+// otherwise.8
+//9
+// Every kernel entry point returns 1 when the GPU did the work and 0 when10
+// it could not (out of device memory, launch failure, ...): the11
+// dispatcher then runs the CPU backend, so "no GPU" and "GPU failed" are12
+// both a normal answer, never an error (design principle 1,13
+// ideas/gpu_acceleration.md). Not thread-safe: one caller at a time, like14
+// the Vulkan backend's singleton.15
+#ifdef __cplusplus16
+extern "C" {17
+#endif18
+19
+// Brings up the best CUDA device (most multiprocessors) once; later calls20
+// return the cached result. 1 = a CUDA device is ready, 0 = none (no21
+// 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 __cplusplus36
+}37
+#endif38
+#endifpyre/src/H/backends/cuda/context_internal.cuhadded
+34−0@@ -0,0 +1,34 @@1
+#ifndef ASH_GPU_CUDA_CONTEXT_INTERNAL_CUH2
+#define ASH_GPU_CUDA_CONTEXT_INTERNAL_CUH3
+// 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 sticky8
+// 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 417
+cudaStream_t ash_cuda_stream(int index);18
+19
+// Grow-only device scratch buffers, reused across calls so repeated20
+// 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 323
+void *ash_cuda_scratch(int slot, size_t bytes);24
+25
+// Page-locks a caller's malloc'd buffer so cudaMemcpyAsync can DMA straight26
+// from/to it (no staging copy) and overlap with kernels. Skipped for small27
+// 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
+#endifpyre/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?", without52
+ * doing the work. Every kernel call below asks the same question itself and53
+ * 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 Pyre55
+ * would only hand straight back to the CPU.56
+ *57
+ * The prediction comes from what this machine has actually been seen to do58
+ * at this workload size, learned from real calls (no calibration pass, no59
+ * fixed threshold, nothing shipped; see H/runtime/manager.h). `backend` is60
+ * where Pyre would run it right now (ASH_GPU_BACKEND_NONE = the CPU61
+ * backend); est_seconds is the predicted time there and est_cpu_seconds the62
+ * CPU backend's, both negative until that size has been measured. A GPU63
+ * that is not up yet is never chosen unless the saving is known to beat64
+ * 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 array5278
* of row pointers -- see todo.md 2.A's "Contiguous Buffer Type": this ispyre/src/H/runtime/cost_model.hadded
+59−0@@ -0,0 +1,59 @@1
+#ifndef PYRE_COST_MODEL_H2
+#define PYRE_COST_MODEL_H3
+// 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 on6
+// one machine and run on another starts knowing nothing and learns THAT7
+// machine from the work it really does. For every (kernel, target) the model8
+// is a table indexed by workload size (powers of two of the kernel's work9
+// units: n^3 for matmul, n*iterations for chaos, iterations for Monte10
+// Carlo) holding the measured seconds-per-work-unit at that size. Rates are11
+// kept per size bucket, not as one formula, because both transfer overhead12
+// and cache effects change the effective rate with size and a table simply13
+// records what happened.14
+#include "H/runtime/targets.h"15
+16
+#define PYRE_BUCKETS 4817
+18
+typedef struct {19
+ double rate[PYRE_BUCKETS]; // measured seconds per work unit, EMA20
+ unsigned char seen[PYRE_BUCKETS]; // 1 once a real call landed in the bucket21
+} 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 ones28
+// 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 this32
+// 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 one36
+// (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 the44
+// next run on the SAME machine -- it is never required, never shipped, and45
+// is discarded when the hardware it describes is not the hardware found.46
+#define PYRE_PROFILE_VERSION 247
+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 empty57
+int pyre_profile_save(const PyreProfile *p, const char *path);58
+59
+#endifpyre/src/H/runtime/manager.hadded
+47−0@@ -0,0 +1,47 @@1
+#ifndef PYRE_MANAGER_H2
+#define PYRE_MANAGER_H3
+// The manager: Pyre's resident decision-maker. For every kernel call it4
+// looks up what this machine has actually been seen to do at this workload5
+// size (cost_model.h), runs the work on the fastest target, times it, and6
+// records the result -- so it keeps learning from the program's own work7
+// 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 the10
+// work; when the work is heavy, a background thread brings the GPU up11
+// meanwhile (targets.h: pyre_target_warm_async).12
+// * A GPU is only chosen where it was measured -- or can be inferred from a13
+// smaller measured size -- to beat the CPU. A size it has never run at14
+// gets one real try, and only when the job is big enough15
+// (EXPLORE_MIN_S) that one slow try is cheap.16
+// * What it learns is saved for the next run on the same machine, as an17
+// 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 not21
+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 off30
+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: predicted38
+// seconds per target (< 0 = not known / can't run it), returning the target39
+// 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 to43
+// run is taken out of rotation and the work falls through, ending at the44
+// CPU, which always runs.45
+void pyre_manager_execute(PyreOp op, PyreWorkload w, PyreRunFn run, void *args);46
+47
+#endifpyre/src/H/runtime/targets.hadded
+55−0@@ -0,0 +1,55 @@1
+#ifndef PYRE_TARGETS_H2
+#define PYRE_TARGETS_H3
+// 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, so5
+// the manager can reason about "CPU / CUDA / Vulkan" without knowing any6
+// 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 Carlo20
+// 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 the25
+// one-off price of using that target in a fresh process, which the26
+// 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 work30
+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 falls35
+// back). CPU always runs.36
+// Starts a background thread that brings up the best available GPU37
+// (CUDA, else Vulkan) and runs one tiny kernel of each kind on it so lazy38
+// module / pipeline loading is paid there, not by a real call. Returns at39
+// once; at most one warm-up ever runs. A call never waits for it -- the40
+// 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
+#endifpyre/src/O/backends/cuda/.gitkeepadded
+0−0No 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−0Show diff (730 changed lines)
@@ -0,0 +1,730 @@1
+lockfileVersion: '9.0'2
+3
+settings:4
+ autoInstallPeers: true5
+ excludeLinksFromLockfile: false6
+7
+importers:8
+9
+ .:10
+ dependencies:11
+ react:12
+ specifier: ^19.3.013
+ version: 19.3.014
+ react-dom:15
+ specifier: ^19.3.016
+ version: 19.3.0(react@19.3.0)17
+ devDependencies:18
+ '@types/react':19
+ specifier: ^19.3.020
+ version: 19.3.021
+ '@types/react-dom':22
+ specifier: ^19.3.023
+ version: 19.3.0(@types/react@19.3.0)24
+ '@vitejs/plugin-react':25
+ specifier: ^6.1.226
+ version: 6.1.2(vite@8.3.3)27
+ typescript:28
+ specifier: ^7.0.229
+ version: 7.0.230
+ vite:31
+ specifier: ^8.3.332
+ version: 8.3.333
+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.0142
+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.0271
+ babel-plugin-react-compiler: ^1.0.0272
+ oxc-transform-react: ^0.152.0273
+ vite: ^8.0.0274
+ peerDependenciesMeta:275
+ '@rolldown/plugin-babel':276
+ optional: true277
+ babel-plugin-react-compiler:278
+ optional: true279
+ oxc-transform-react:280
+ optional: true281
+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 || ^4294
+ peerDependenciesMeta:295
+ picomatch:296
+ optional: true297
+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: true381
+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.0397
+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: true406
+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: true422
+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: true427
+ peerDependencies:428
+ '@types/node': ^20.19.0 || >=22.12.0429
+ '@vitejs/devtools': ^0.7.1430
+ esbuild: ^0.27.0 || ^0.28.0431
+ jiti: '>=1.21.0'432
+ less: ^4.0.0433
+ sass: ^1.70.0434
+ sass-embedded: ^1.70.0435
+ stylus: '>=0.54.8'436
+ sugarss: ^5.0.0437
+ terser: ^5.16.0438
+ tsx: ^4.8.1439
+ yaml: ^2.4.2440
+ peerDependenciesMeta:441
+ '@types/node':442
+ optional: true443
+ '@vitejs/devtools':444
+ optional: true445
+ esbuild:446
+ optional: true447
+ jiti:448
+ optional: true449
+ less:450
+ optional: true451
+ sass:452
+ optional: true453
+ sass-embedded:454
+ optional: true455
+ stylus:456
+ optional: true457
+ sugarss:458
+ optional: true459
+ terser:460
+ optional: true461
+ tsx:462
+ optional: true463
+ yaml:464
+ optional: true465
+466
+snapshots:467
+468
+ '@oxc-project/types@0.153.0': {}469
+470
+ '@rolldown/binding-android-arm-eabi@1.2.13':471
+ optional: true472
+473
+ '@rolldown/binding-android-arm64@1.2.13':474
+ optional: true475
+476
+ '@rolldown/binding-darwin-arm64@1.2.13':477
+ optional: true478
+479
+ '@rolldown/binding-darwin-x64@1.2.13':480
+ optional: true481
+482
+ '@rolldown/binding-freebsd-x64@1.2.13':483
+ optional: true484
+485
+ '@rolldown/binding-linux-arm-gnueabihf@1.2.13':486
+ optional: true487
+488
+ '@rolldown/binding-linux-arm64-gnu@1.2.13':489
+ optional: true490
+491
+ '@rolldown/binding-linux-arm64-musl@1.2.13':492
+ optional: true493
+494
+ '@rolldown/binding-linux-ppc64-gnu@1.2.13':495
+ optional: true496
+497
+ '@rolldown/binding-linux-s390x-gnu@1.2.13':498
+ optional: true499
+500
+ '@rolldown/binding-linux-x64-gnu@1.2.13':501
+ optional: true502
+503
+ '@rolldown/binding-linux-x64-musl@1.2.13':504
+ optional: true505
+506
+ '@rolldown/binding-openharmony-arm64@1.2.13':507
+ optional: true508
+509
+ '@rolldown/binding-win32-arm64-msvc@1.2.13':510
+ optional: true511
+512
+ '@rolldown/binding-win32-x64-msvc@1.2.13':513
+ optional: true514
+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.0520
+521
+ '@types/react@19.3.0':522
+ dependencies:523
+ csstype: 3.2.3524
+525
+ '@typescript/typescript-aix-ppc64@7.0.2':526
+ optional: true527
+528
+ '@typescript/typescript-darwin-arm64@7.0.2':529
+ optional: true530
+531
+ '@typescript/typescript-darwin-x64@7.0.2':532
+ optional: true533
+534
+ '@typescript/typescript-freebsd-arm64@7.0.2':535
+ optional: true536
+537
+ '@typescript/typescript-freebsd-x64@7.0.2':538
+ optional: true539
+540
+ '@typescript/typescript-linux-arm64@7.0.2':541
+ optional: true542
+543
+ '@typescript/typescript-linux-arm@7.0.2':544
+ optional: true545
+546
+ '@typescript/typescript-linux-loong64@7.0.2':547
+ optional: true548
+549
+ '@typescript/typescript-linux-mips64el@7.0.2':550
+ optional: true551
+552
+ '@typescript/typescript-linux-ppc64@7.0.2':553
+ optional: true554
+555
+ '@typescript/typescript-linux-riscv64@7.0.2':556
+ optional: true557
+558
+ '@typescript/typescript-linux-s390x@7.0.2':559
+ optional: true560
+561
+ '@typescript/typescript-linux-x64@7.0.2':562
+ optional: true563
+564
+ '@typescript/typescript-netbsd-arm64@7.0.2':565
+ optional: true566
+567
+ '@typescript/typescript-netbsd-x64@7.0.2':568
+ optional: true569
+570
+ '@typescript/typescript-openbsd-arm64@7.0.2':571
+ optional: true572
+573
+ '@typescript/typescript-openbsd-x64@7.0.2':574
+ optional: true575
+576
+ '@typescript/typescript-sunos-x64@7.0.2':577
+ optional: true578
+579
+ '@typescript/typescript-win32-arm64@7.0.2':580
+ optional: true581
+582
+ '@typescript/typescript-win32-x64@7.0.2':583
+ optional: true584
+585
+ '@vitejs/plugin-react@6.1.2(vite@8.3.3)':586
+ dependencies:587
+ '@rolldown/pluginutils': 1.0.1588
+ vite: 8.3.3589
+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.7597
+598
+ fsevents@2.3.3:599
+ optional: true600
+601
+ lightningcss-android-arm64@1.33.0:602
+ optional: true603
+604
+ lightningcss-darwin-arm64@1.33.0:605
+ optional: true606
+607
+ lightningcss-darwin-x64@1.33.0:608
+ optional: true609
+610
+ lightningcss-freebsd-x64@1.33.0:611
+ optional: true612
+613
+ lightningcss-linux-arm-gnueabihf@1.33.0:614
+ optional: true615
+616
+ lightningcss-linux-arm64-gnu@1.33.0:617
+ optional: true618
+619
+ lightningcss-linux-arm64-musl@1.33.0:620
+ optional: true621
+622
+ lightningcss-linux-x64-gnu@1.33.0:623
+ optional: true624
+625
+ lightningcss-linux-x64-musl@1.33.0:626
+ optional: true627
+628
+ lightningcss-win32-arm64-msvc@1.33.0:629
+ optional: true630
+631
+ lightningcss-win32-x64-msvc@1.33.0:632
+ optional: true633
+634
+ lightningcss@1.33.0:635
+ dependencies:636
+ detect-libc: 2.1.2637
+ optionalDependencies:638
+ lightningcss-android-arm64: 1.33.0639
+ lightningcss-darwin-arm64: 1.33.0640
+ lightningcss-darwin-x64: 1.33.0641
+ lightningcss-freebsd-x64: 1.33.0642
+ lightningcss-linux-arm-gnueabihf: 1.33.0643
+ lightningcss-linux-arm64-gnu: 1.33.0644
+ lightningcss-linux-arm64-musl: 1.33.0645
+ lightningcss-linux-x64-gnu: 1.33.0646
+ lightningcss-linux-x64-musl: 1.33.0647
+ lightningcss-win32-arm64-msvc: 1.33.0648
+ lightningcss-win32-x64-msvc: 1.33.0649
+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.20659
+ picocolors: 1.1.1660
+ source-map-js: 1.2.2661
+662
+ react-dom@19.3.0(react@19.3.0):663
+ dependencies:664
+ react: 19.3.0665
+ scheduler: 0.28.0666
+667
+ react@19.3.0: {}668
+669
+ rolldown@1.2.13:670
+ dependencies:671
+ '@oxc-project/types': 0.153.0672
+ '@rolldown/pluginutils': 1.0.1673
+ optionalDependencies:674
+ '@rolldown/binding-android-arm-eabi': 1.2.13675
+ '@rolldown/binding-android-arm64': 1.2.13676
+ '@rolldown/binding-darwin-arm64': 1.2.13677
+ '@rolldown/binding-darwin-x64': 1.2.13678
+ '@rolldown/binding-freebsd-x64': 1.2.13679
+ '@rolldown/binding-linux-arm-gnueabihf': 1.2.13680
+ '@rolldown/binding-linux-arm64-gnu': 1.2.13681
+ '@rolldown/binding-linux-arm64-musl': 1.2.13682
+ '@rolldown/binding-linux-ppc64-gnu': 1.2.13683
+ '@rolldown/binding-linux-s390x-gnu': 1.2.13684
+ '@rolldown/binding-linux-x64-gnu': 1.2.13685
+ '@rolldown/binding-linux-x64-musl': 1.2.13686
+ '@rolldown/binding-openharmony-arm64': 1.2.13687
+ '@rolldown/binding-win32-arm64-msvc': 1.2.13688
+ '@rolldown/binding-win32-x64-msvc': 1.2.13689
+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.7698
+699
+ typescript@7.0.2:700
+ optionalDependencies:701
+ '@typescript/typescript-aix-ppc64': 7.0.2702
+ '@typescript/typescript-darwin-arm64': 7.0.2703
+ '@typescript/typescript-darwin-x64': 7.0.2704
+ '@typescript/typescript-freebsd-arm64': 7.0.2705
+ '@typescript/typescript-freebsd-x64': 7.0.2706
+ '@typescript/typescript-linux-arm': 7.0.2707
+ '@typescript/typescript-linux-arm64': 7.0.2708
+ '@typescript/typescript-linux-loong64': 7.0.2709
+ '@typescript/typescript-linux-mips64el': 7.0.2710
+ '@typescript/typescript-linux-ppc64': 7.0.2711
+ '@typescript/typescript-linux-riscv64': 7.0.2712
+ '@typescript/typescript-linux-s390x': 7.0.2713
+ '@typescript/typescript-linux-x64': 7.0.2714
+ '@typescript/typescript-netbsd-arm64': 7.0.2715
+ '@typescript/typescript-netbsd-x64': 7.0.2716
+ '@typescript/typescript-openbsd-arm64': 7.0.2717
+ '@typescript/typescript-openbsd-x64': 7.0.2718
+ '@typescript/typescript-sunos-x64': 7.0.2719
+ '@typescript/typescript-win32-arm64': 7.0.2720
+ '@typescript/typescript-win32-x64': 7.0.2721
+722
+ vite@8.3.3:723
+ dependencies:724
+ lightningcss: 1.33.0725
+ picomatch: 4.0.7726
+ postcss: 8.5.29727
+ rolldown: 1.2.13728
+ tinyglobby: 0.2.17729
+ optionalDependencies:730
+ fsevents: 2.3.3pyre/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 the51
+ // history changed -- training, a forever session, or any program that learned something -- and52
+ // 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 size61
+ });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% = right7
+ 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 tile11
+ 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 lines15
+ yMax?: number; // fixed top of a linear axis (e.g. 100 for percentages): no padding above it16
+}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
+ <LineChart133
+ 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 targets16
+ * exist on this machine, and how the last self-check went. It always runs the adaptive learner17
+ * (`--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 rounds17
+ 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 over67
+ 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 tile3
+ 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
+ {confirm135
+ ? <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
+ <HeatMap23
+ 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
+ <HeatMap47
+ 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.length111
+ ? <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
+ <Tile47
+ 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 rounds10
+ 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 only3
+// 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 it6
+// 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 root17
+ // `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' edge2
+ * handling: sizes that aren't multiples of the 64x64x16 matmul tile, odd3
+ * element counts around the chaos kernel's 1024-element blocks and its4
+ * 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_CUDA12
+int main(void) { printf("cuda_test: SKIPPED (built without CUDA)\n"); return 0; }13
+#else14
+#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
+#endifpyre/todo/todo.mdmodified
+6−3@@ -78,7 +78,7 @@ Both cleared the bar without touching a GPU at all: `matrix_multiply` from the i7878
### Ash bindings and kiln integration (ideas/gpu_acceleration.md Phases 3 and 4) -- both done7979
`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/Doze9494
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 task98
-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
### Tooling101104
`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 filepyre/tools/dashboard.cadded
+520−0Show 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 static4
+ * 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 page9
+ * 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 on12
+ * POST /api/job/stop stop the running session13
+ *14
+ * The port is always PYRE_DASHBOARD_PORT (1198: a=1, s=19, h=8, "ash" by alphabet15
+ * 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 runs18
+ * exactly the same code as the command line and cannot drift from it. One19
+ * session at a time. Single-threaded: a poll() loop serves requests and20
+ * drains the running session's output.21
+ */22
+#define _POSIX_C_SOURCE 200809L23
+#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 6000040
+#define REQ_MAX 2000041
+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 the128
+ * 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) and420
+ * 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_H2
+#define PYRE_DASHBOARD_H3
+// "ash" by alphabet position: a=1, s=19, h=8.4
+#define PYRE_DASHBOARD_PORT 11985
+6
+// `pyre dashboard [--port N] [--no-open]`: serves the built web UI7
+// (bin/extensions/pyre/web) and a small JSON API on 127.0.0.1 only, fully8
+// offline. Returns the process exit code.9
+int pyre_dashboard_main(int argc, char **argv);10
+#endifpyre/tools/json_out.hadded
+24−0@@ -0,0 +1,24 @@1
+#ifndef PYRE_JSON_OUT_H2
+#define PYRE_JSON_OUT_H3
+// --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.c7
+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
+#endifpyre/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 delays4
+ * 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, that6
+ * learning on THIS machine. Nothing it produces is shipped: it only edits7
+ * the local profile (~/.cache/pyre/profile.txt, or $PYRE_PROFILE).8
+ *9
+ * pyre info devices, profile file, everything learned10
+ * pyre plan matmul N what the manager would do for this job11
+ * pyre plan chaos N ITERATIONS12
+ * pyre plan mc ITERATIONS13
+ * pyre train [flags] run real probes on this machine and teach it14
+ * 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 200809L21
+#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−0Show 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, practise6
+ * exactly there, grade again on FRESH held-out sizes, keep the round only if it7
+ * 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-practising9
+ * (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-out12
+ * sizes, every target really runs them, and both the previous and the candidate profile are13
+ * scored against the SAME measured times (so noise cannot make a round look better or worse14
+ * than it is).15
+ */16
+#define _POSIX_C_SOURCE 200809L17
+#include "train.h"18
+#include "json_out.h"19
+#include "H/runtime/manager.h"20
+#include <math.h>21
+#ifdef _OPENMP22
+#include <omp.h>23
+#endif24
+#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 long116
+ * enough that busy / (busy + idle) == load%. Caps the AVERAGE share of the device training117
+ * 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 48140
+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 noise198
+ * 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 _OPENMP553
+ 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
+#endif558
+ 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_H2
+#define PYRE_TRAIN_H3
+// `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