diff --git a/PROGRESS.md b/PROGRESS.md index 63ddd93..aadd6c6 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -21,7 +21,7 @@ This file is the resume point: what's done, how to run it, what changed, and nex | **Cloud** | FastAPI gateway + Redis/Arq job queue; both producers async submit/poll; stdlib runner retired | ✅ done | | **P3** | MPI execution (`{kind:"mpi"}` → `vhpce-mpi`) + "MPI Halo Exchange" experiment (strong vs weak) | ✅ done | | **P4** | GPU/CUDA (`{kind:"cuda"}` → `vhpce-cuda`, RTX 5060) + GPU Occupancy, Coalescing, Divergence, Atomics | ✅ done | -| **Engagement** | Intro, Start (predict-before-you-run), Compare, Play/Race, Reference Essentials + ~190 entries, Playground predict + diagnostics + thread-count explorer, predict streaks + badge system | ✅ done (quiz + Amdahl/Gustafson sandbox in next PR) | +| **Engagement** | Intro, Start (predict-before-you-run), Compare, Play (Race + Quiz + Sandbox + Badges), Reference Essentials + ~190 entries, Playground predict + diagnostics + thread-count explorer, badge + streak system | ✅ done | | **Rollout** | LICENSE (AGPL-3.0), rewritten README, SETUP.md, CI (GitHub Actions), devcontainer (Codespaces) | ✅ done | | P5 | Engineering domain modules, classrooms/LMS, K8s autoscaling, true PMU counters | ⬜ not started | @@ -105,8 +105,10 @@ Playground code) go through one async backend; **Model mode is the only offline bottleneck; **thread-count explorer** (per-point breakdown); opt-in **cachegrind** cache-miss profiling (D1/LLd miss rates). - **Play** (`/play`) — the fun-first corner: **⚡ Race** pits two kernel variants head-to-head - (predict the winner, then run both). More games land here over time. Badge/streak system rewards - correct predictions across Start, Playground, and Flagship experiments. + (predict the winner, then run both); **🧩 Quiz** tests your bottleneck instincts; + **🔬 Sandbox** lets you explore Amdahl's and Gustafson's laws interactively; **🏅 Badges** + rewards correct predictions across Start, Playground, and Flagship experiments and tracks + prediction streaks. - **Cloud gateway** (`services/api`) — **FastAPI + Redis + Arq**, run via `docker-compose`. The Flagship's Measured mode and the Playground both **submit jobs and poll**: `POST /api/jobs` (`{kind:"bench"|"code", ...}`) → `GET /api/jobs/{id}`. The Arq worker (`max_jobs=1`, serialized for @@ -221,8 +223,8 @@ vhpce/ guide), devcontainer (Codespaces), CI. - ✅ **Engagement layer** — Intro, Start (predict), Compare, Play/Race, badges/streaks system, Playground predict + diagnostics + thread-count explorer, Reference Essentials filter. -- **Next PR (`feat/badges-pr30`)** — guess-the-bottleneck quiz (#31) + Amdahl & Gustafson - interactive sandbox (#32) + `explain` bugfix (undefined peak in falseSharing/mpiHalo). +- ✅ **Engagement Play layer** — Race, Quiz, Amdahl/Gustafson Sandbox, Badges/streaks; `explain` + bugfix (undefined peak guard in falseSharing/mpiHalo). --- @@ -230,10 +232,7 @@ vhpce/ The core product and engagement layer are complete. Candidate next steps: -1. **Merge `feat/badges-pr30`** — guess-the-bottleneck quiz, Amdahl & Gustafson interactive - sandbox, and a bugfix for `explain.falseSharing`/`mpiHalo` when `r.peak` is undefined. - -2. **Engineering-domain modules (P5).** FEM/FDTD/CFD/stencil/FFT mini-labs with interactive +1. **Engineering-domain modules (P5).** FEM/FDTD/CFD/stencil/FFT mini-labs with interactive domain decomposition and convergence plots, built on the same `ProfileResult` seam and the gateway job kinds (OpenMP/MPI/CUDA) already in place. diff --git a/README.md b/README.md index ae90649..2259150 100644 --- a/README.md +++ b/README.md @@ -49,9 +49,11 @@ shared data contract, and two interchangeable data sources (an in-browser physic run a thread-count sweep in a locked-down Docker sandbox. Results come with plain-language diagnostics that route you to the Flagship experiment explaining the bottleneck, a thread-count explorer, and optional cachegrind cache-miss profiling. -- **[Play](apps/web/src/components/Play.tsx)** (`/play`) — the fun-first corner: race two kernel - variants head-to-head, predicting the winner before the real numbers come in (more games land - here over time). +- **[Play](apps/web/src/components/Play.tsx)** (`/play`) — the fun-first corner: **⚡ Race** + pits two kernel variants head-to-head (predict the winner, then run both); **🧩 Quiz** tests + your bottleneck instincts; **🔬 Sandbox** lets you explore Amdahl's and Gustafson's laws + interactively; **🏅 Badges** rewards correct predictions across Start, Playground, and + Flagship experiments and tracks your prediction streaks. Everything above shares one data contract — `ProfileResult`/`ExperimentResult` (`@vhpce/profile-schema`) — so the **model** (runs entirely in your browser) and **measured** diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index fbac01e..5ff4886 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -261,6 +261,39 @@ footer.note code { font-family: var(--mono); color: var(--muted); background: #0 .ref-cat-tag { font-size: 11px; color: var(--dim); } .ref-sig { margin: 0 0 12px; } +/* ---- Amdahl / Gustafson sandbox ---- */ +.sbox-controls { display: flex; flex-direction: column; gap: 14px; } +.sbox-slider-block { display: flex; flex-direction: column; gap: 6px; } +.sbox-slider-label { display: flex; justify-content: space-between; align-items: baseline; font-size: 12.5px; color: var(--muted); } +.sbox-slider-ends { display: flex; justify-content: space-between; font-size: 10.5px; color: var(--dim); margin-top: 2px; } +.sbox-row2 { display: flex; gap: 20px; flex-wrap: wrap; align-items: flex-start; } +.sbox-group { display: flex; flex-direction: column; gap: 6px; } +.sbox-group-label { font-size: 11px; color: var(--dim); text-transform: uppercase; letter-spacing: .8px; font-weight: 600; } +.sbox-chart { border: 1px solid var(--line); border-radius: 10px; padding: 10px 6px 4px; background: var(--panel2); } +.sbox-legend { display: flex; gap: 14px; flex-wrap: wrap; font-size: 12px; color: var(--muted); margin-top: 8px; align-items: center; } +.sbox-dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: 5px; vertical-align: middle; } + +/* ---- Guess-the-Bottleneck quiz ---- */ +.quiz-progress { display: flex; align-items: center; gap: 6px; margin-bottom: 14px; } +.quiz-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--line); flex: none; transition: .2s; } +.quiz-dot.on { background: var(--accent); box-shadow: 0 0 8px rgba(76,201,240,.5); } +.quiz-dot.done { background: var(--good); } +.quiz-count { font-size: 11px; color: var(--dim); margin-left: 6px; } +.quiz-score { font-size: 11px; color: var(--muted); margin-left: auto; } +.quiz-layout { display: grid; grid-template-columns: 1fr 230px; gap: 14px; align-items: start; margin-bottom: 4px; } +@media (max-width: 780px) { .quiz-layout { grid-template-columns: 1fr; } } +.quiz-chart { border: 1px solid var(--line); border-radius: 10px; padding: 10px 6px 4px; background: var(--panel2); } +.quiz-choices { display: flex; flex-direction: column; gap: 7px; } +.quiz-choice { border: 1px solid var(--line); background: var(--panel2); color: var(--text); padding: 10px 12px; border-radius: 9px; cursor: pointer; font-size: 12.5px; font-family: var(--sans); text-align: left; transition: .12s; line-height: 1.4; } +.quiz-choice:hover:not(:disabled) { border-color: var(--accent); background: rgba(76,201,240,.07); } +.quiz-choice.correct { border-color: var(--good) !important; background: rgba(61,220,151,.1) !important; color: var(--good) !important; font-weight: 600; } +.quiz-choice.wrong { border-color: var(--bad) !important; background: rgba(255,93,115,.08) !important; color: var(--bad) !important; } +.quiz-review { display: flex; flex-direction: column; gap: 5px; margin-top: 12px; } +.quiz-review-row { display: flex; align-items: center; gap: 10px; font-size: 13px; padding: 7px 10px; border-radius: 8px; } +.quiz-review-row.hit { background: rgba(61,220,151,.08); color: var(--good); } +.quiz-review-row.miss { background: rgba(255,93,115,.08); color: var(--bad); } +.quiz-review-label { color: var(--text); } + /* ---- Badge toast (global, fixed bottom-right) ---- */ .badge-toast { position: fixed; bottom: 24px; right: 24px; z-index: 9999; diff --git a/apps/web/src/components/Play.tsx b/apps/web/src/components/Play.tsx index 0582bd7..ae49784 100644 --- a/apps/web/src/components/Play.tsx +++ b/apps/web/src/components/Play.tsx @@ -4,11 +4,15 @@ import { useEffect, useState } from "react"; import { health } from "../lib/runner"; import Race from "./play/Race"; import Badges from "./play/Badges"; +import Quiz from "./play/Quiz"; +import Sandbox from "./play/Sandbox"; // The "Play" hub — the fun-first corner. Tabs are added here as each game lands. const TABS = [ - { id: "race", label: "⚡ Race" }, - { id: "badges", label: "🏅 Badges" }, + { id: "race", label: "⚡ Race" }, + { id: "quiz", label: "🧩 Quiz" }, + { id: "sandbox", label: "🔬 Sandbox" }, + { id: "badges", label: "🏅 Badges" }, ]; export default function Play() { @@ -40,8 +44,10 @@ export default function Play() { ))} - {tab === "race" && } - {tab === "badges" && } + {tab === "race" && } + {tab === "quiz" && } + {tab === "sandbox" && } + {tab === "badges" && } ); } diff --git a/apps/web/src/components/play/Quiz.tsx b/apps/web/src/components/play/Quiz.tsx new file mode 100644 index 0000000..1aac8ef --- /dev/null +++ b/apps/web/src/components/play/Quiz.tsx @@ -0,0 +1,228 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { recordPredict } from "../../lib/badges"; +import { QUESTIONS } from "./quiz-questions"; + +// ── Inline SVG scaling chart ────────────────────────────────────────────────── +// Draws the actual curve (accent color) + dashed ideal line, no canvas needed. +function CurveChart({ pts }: { pts: { x: number; y: number }[] }) { + const W = 380, H = 196; + const ml = 38, mr = 8, mt = 10, mb = 26; + const pw = W - ml - mr; + const ph = H - mt - mb; + + const peakY = Math.max(...pts.map((p) => p.y)); + const yMax = Math.max(3, peakY * 1.28); + const idealEnd = Math.min(24, yMax); // clip ideal line to chart top + + const tx = (x: number) => ml + ((x - 1) / 23) * pw; + const ty = (y: number) => mt + (1 - y / yMax) * ph; + + const pathD = pts.map((p, i) => `${i === 0 ? "M" : "L"}${tx(p.x).toFixed(1)},${ty(p.y).toFixed(1)}`).join(" "); + const idealD = `M${tx(1).toFixed(1)},${ty(1).toFixed(1)} L${tx(idealEnd).toFixed(1)},${ty(idealEnd).toFixed(1)}`; + + const xTicks = [1, 4, 8, 12, 16, 20, 24] as const; + const yFracs = [0.25, 0.5, 0.75, 1.0]; + const cy = mt + ph / 2; + + return ( + + {/* Grid */} + {xTicks.map((x) => ( + + ))} + {yFracs.map((f) => ( + + ))} + + {/* Ideal line (dashed) */} + + + {/* Actual curve */} + + + {/* Dots */} + {pts.map((p) => ( + + ))} + + {/* X-axis ticks */} + {xTicks.map((x) => ( + {x} + ))} + + {/* Y-axis ticks */} + {yFracs.map((f) => { + const v = f * yMax; + const label = v < 2 ? v.toFixed(1) : Math.round(v).toString(); + return ( + {label}× + ); + })} + + {/* Axis labels */} + + Threads + + + Speedup + + + {/* Legend */} + + ideal + + ); +} + +// ── Quiz component ──────────────────────────────────────────────────────────── +type Phase = "question" | "revealed" | "done"; + +export default function Quiz() { + const [qi, setQi] = useState(0); + const [selected, setSelected] = useState(null); + const [phase, setPhase] = useState("question"); + const [answers, setAnswers] = useState([]); + + const q = QUESTIONS[qi]; + const total = QUESTIONS.length; + const hit = selected === q.answer; + const score = answers.filter(Boolean).length; + + function pick(id: string) { + if (phase !== "question") return; + const isHit = id === q.answer; + setSelected(id); + setPhase("revealed"); + setAnswers((prev) => [...prev, isHit]); + recordPredict(isHit); // feeds Oracle / On Fire badge streak + } + + function advance() { + if (qi < total - 1) { + setQi((i) => i + 1); + setSelected(null); + setPhase("question"); + } else { + setPhase("done"); + } + } + + function restart() { + setQi(0); setSelected(null); setPhase("question"); setAnswers([]); + } + + // ── Done screen ────────────────────────────────────────────────────────── + if (phase === "done") { + const pct = Math.round((score / total) * 100); + const verdict = + pct === 100 ? "Perfect — you know every bottleneck cold. 🏆" : + pct >= 83 ? "Excellent bottleneck detective. 🔬" : + pct >= 66 ? "Solid — the curve shapes are sinking in. ✓" : + pct >= 50 ? "Half right — a few more Flagship runs will sharpen the eye." : + "Good start — run the Flagship experiments, then retry."; + const big = pct >= 66 ? "var(--good)" : pct >= 50 ? "var(--warn)" : "var(--bad)"; + + return ( +
+
+

Quiz complete

+
+
{score}/{total}
+
{verdict}
+
+
+ {QUESTIONS.map((qq, i) => ( +
+ {(answers[i] ?? false) ? "✓" : "✗"} + + {qq.choices.find((c) => c.id === qq.answer)?.label} + +
+ ))} +
+
+ + → See each bottleneck live: Flagship experiments +
+
+
+ ); + } + + // ── Question / Revealed screen ─────────────────────────────────────────── + const correctChoice = q.choices.find((c) => c.id === q.answer); + + return ( +
+
+ {/* Progress bar */} +
+ {QUESTIONS.map((_, i) => ( + + ))} + Q {qi + 1} / {total} + {qi > 0 && ( + Score so far: {score}/{qi} + )} +
+ +

{q.prompt}

+

{q.context}

+ +
+ {/* Scaling chart */} +
+ +
+ + {/* Answer choices */} +
+ {q.choices.map((c) => { + let cls = "quiz-choice"; + if (phase === "revealed") { + if (c.id === q.answer) cls += " correct"; + else if (c.id === selected) cls += " wrong"; + } + return ( + + ); + })} +
+
+ + {/* Explanation after reveal */} + {phase === "revealed" && ( +
+
+ {hit ? "✓ Correct — " : "✗ Not quite — the answer is "} + {correctChoice?.label} +
+
{q.explain}
+
+ )} + + {phase === "revealed" && ( +
+ +
+ )} +
+
+ ); +} diff --git a/apps/web/src/components/play/Sandbox.tsx b/apps/web/src/components/play/Sandbox.tsx new file mode 100644 index 0000000..d997a5b --- /dev/null +++ b/apps/web/src/components/play/Sandbox.tsx @@ -0,0 +1,297 @@ +"use client"; + +// Amdahl & Gustafson sandbox — pure-client, no gateway needed. +// Drag the serial-fraction slider; the chart and metrics update instantly. + +import { useState } from "react"; + +// ── Math helpers ────────────────────────────────────────────────────────────── +const amdahl = (s: number, p: number): number => s > 0 ? 1 / (s + (1 - s) / p) : p; +const gustafson = (s: number, p: number): number => p * (1 - s) + s; + +function niceStep(max: number): number { + const raw = max / 5; + const exp = Math.floor(Math.log10(Math.max(raw, 1e-9))); + const frac = raw / Math.pow(10, exp); + const nice = frac < 1.5 ? 1 : frac < 3.5 ? 2 : frac < 7.5 ? 5 : 10; + return nice * Math.pow(10, exp); +} + +function fmt1(v: number): string { + if (!isFinite(v)) return "∞"; + return v >= 10000 ? `${(v / 1000).toFixed(0)}k` : v >= 100 ? v.toFixed(0) : v >= 10 ? v.toFixed(1) : v.toFixed(2); +} + +type Mode = "amdahl" | "gustafson" | "both"; +const MAX_P_OPTS = [8, 24, 64, 256] as const; + +// ── SVG chart ───────────────────────────────────────────────────────────────── +function SandboxChart({ serial, maxP, mode }: { serial: number; maxP: number; mode: Mode }) { + const W = 500, H = 230; + const ml = 44, mr = 14, mt = 14, mb = 28; + const pw = W - ml - mr, ph = H - mt - mb; + const logMax = Math.log(maxP); + + // X: log-linear Y: linear + const tx = (p: number) => ml + (Math.log(Math.max(p, 1)) / logMax) * pw; + const ty = (y: number) => mt + (1 - Math.min(y, yMax * 1.01) / yMax) * ph; + + // Y axis ceiling + const aMax = serial > 0 ? 1 / serial : maxP; + const gMax = maxP * (1 - serial) + serial; + + let yMax: number; + if (mode === "gustafson") yMax = gMax * 1.08; + else if (mode === "both") yMax = Math.min(Math.max(aMax * 1.5, 4), maxP); + else yMax = Math.min(aMax * 1.15, maxP * 1.06); + + // 100-point smooth curves in log space + const curve = (fn: (p: number) => number) => + Array.from({ length: 100 }, (_, i) => { + const p = Math.exp((logMax * i) / 99); + return { x: p, y: fn(p) }; + }); + + const aPts = curve((p) => amdahl(serial, p)); + const gPts = curve((p) => gustafson(serial, p)); + + const toSVGPath = (pts: { x: number; y: number }[]) => + pts.map((p, i) => `${i === 0 ? "M" : "L"}${tx(p.x).toFixed(1)},${ty(p.y).toFixed(1)}`).join(" "); + + // Ideal line (y=p), clipped to chart top + const idealTop = Math.min(maxP, yMax); + const idealPath = `M${tx(1).toFixed(1)},${ty(1).toFixed(1)} L${tx(idealTop).toFixed(1)},${ty(idealTop).toFixed(1)}`; + + // X ticks: powers of 2 + const xTicks: number[] = []; + for (let p = 1; p <= maxP; p *= 2) xTicks.push(p); + if (xTicks[xTicks.length - 1] < maxP) xTicks.push(maxP); + + // Y ticks + const step = niceStep(yMax); + const yTicks: number[] = []; + for (let y = step; y < yMax * 0.99; y += step) yTicks.push(y); + + // Amdahl asymptote (only if it fits inside the chart) + const showAsymp = serial > 0 && aMax < yMax * 0.97 && (mode === "amdahl" || mode === "both"); + + const cy = mt + ph / 2; + + return ( + + {/* X grid */} + {xTicks.map((x) => ( + + ))} + {/* Y grid */} + {yTicks.map((y) => ( + + ))} + + {/* Ideal (dashed) */} + + + {/* Amdahl asymptote */} + {showAsymp && ( + <> + + + ceiling = {fmt1(aMax)}× + + + )} + + {/* Curves */} + {(mode === "amdahl" || mode === "both") && ( + + )} + {(mode === "gustafson" || mode === "both") && ( + + )} + + {/* X labels */} + {xTicks.map((x) => ( + {x} + ))} + + {/* Y labels */} + {yTicks.map((y) => ( + {y}× + ))} + + {/* Axis titles */} + + Threads (log scale) + + + Speedup + + + ); +} + +// ── Main component ──────────────────────────────────────────────────────────── +export default function Sandbox() { + const [serial, setSerial] = useState(0.20); + const [maxP, setMaxP] = useState(24); + const [mode, setMode] = useState("amdahl"); + + const aMax = serial > 0 ? 1 / serial : Infinity; + const spAmdahl = amdahl(serial, maxP); + const spGust = gustafson(serial, maxP); + const effAmdahl = spAmdahl / maxP; + + // Threads needed to reach 90% of Amdahl ceiling: n = 9*(1-s)/s + const nFor90 = serial > 0 ? Math.ceil(9 * (1 - serial) / serial) : Infinity; + + function summaryText(): string { + if (serial === 0) + return "Zero serial code — every thread does only useful work. Speedup equals core count: doubling cores halves the runtime forever. Real programs always have some serial fraction, but this is the target."; + if (serial >= 0.5) + return "More than half the program is serial. The ceiling is ≤ 2× no matter how many cores you throw at it. More hardware cannot fix a serial bottleneck — you must parallelize the code first."; + if (aMax < 4) + return `With ${(serial*100).toFixed(0)}% serial code the ceiling is only ${fmt1(aMax)}×. Adding a hundred more cores won't help. This is a code problem, not a hardware problem.`; + if (nFor90 <= maxP) + return `You need ${nFor90} threads to reach 90% of the ${fmt1(aMax)}× ceiling — achievable on your ${maxP}-core system. You're spending hardware budget well.`; + return `The ${fmt1(aMax)}× ceiling is attractive, but you'd need ${fmt1(nFor90)} threads to reach 90% of it. On ${maxP} cores you realise ${fmt1(spAmdahl)}× (${(effAmdahl*100).toFixed(0)}% of ceiling). Reducing the serial fraction from ${(serial*100).toFixed(0)}% to even ${(serial*50).toFixed(1)}% would double the ceiling.`; + } + + const MODES: { id: Mode; label: string }[] = [ + { id: "amdahl", label: "Amdahl" }, + { id: "gustafson", label: "Gustafson" }, + { id: "both", label: "Both" }, + ]; + + const sColor = serial > 0.2 ? "var(--bad)" : serial > 0.05 ? "var(--warn)" : "var(--good)"; + + return ( +
+
+

Amdahl & Gustafson — interactive sandbox

+

+ Set the serial fraction and watch the theoretical speedup ceiling update. Switch to Gustafson's law to see how scaling the problem with the hardware changes the picture entirely. +

+ + {/* ── Controls ── */} +
+ {/* Serial fraction slider */} +
+
+ Serial fraction + + {serial === 0 ? "0%" : serial < 0.01 ? `${(serial * 100).toFixed(1)}%` : `${(serial * 100).toFixed(0)}%`} + +
+ setSerial(parseFloat(e.target.value))} /> +
+ 0% — perfect scaling100% — fully serial +
+
+ +
+ {/* Thread count presets */} +
+ Show up to +
+ {MAX_P_OPTS.map((p) => ( + + ))} +
+
+ {/* Law selector */} +
+ Law +
+ {MODES.map((m) => ( + + ))} +
+
+
+
+ + {/* ── Chart + legend ── */} +
+
+ +
+
+ {(mode === "amdahl" || mode === "both") && ( + Amdahl's law (fixed work) + )} + {(mode === "gustafson" || mode === "both") && ( + Gustafson's law (scaled work) + )} + ideal (linear) + {serial > 0 && (mode === "amdahl" || mode === "both") && ( + ceiling (1/s) + )} +
+
+ + {/* ── Metrics ── */} +
+ {(mode === "amdahl" || mode === "both") && ( + <> +
+ Amdahl ceiling — 1 ∕ s + 12 ? "good" : aMax > 4 ? "warn" : "bad")}> + {!isFinite(aMax) ? "∞ (perfect)" : `${fmt1(aMax)}×`} + +
+
+ Speedup at {maxP} threads (Amdahl) + 0.7 ? "good" : effAmdahl > 0.4 ? "warn" : "bad")}> + {fmt1(spAmdahl)}× · {(effAmdahl * 100).toFixed(0)}% efficiency + +
+ {serial > 0 && ( +
+ Threads to reach 90% of ceiling + + {nFor90 > 999999 ? "millions" : nFor90 > 9999 ? `~${(nFor90 / 1000).toFixed(0)} k` : `${nFor90}`} + +
+ )} + + )} + {(mode === "gustafson" || mode === "both") && ( +
+ Speedup at {maxP} threads (Gustafson) + {fmt1(spGust)}× +
+ )} +
+ + {/* ── Plain-English explanation ── */} +
+
+ {mode === "gustafson" ? "Gustafson's view" : mode === "both" ? "The key difference" : "What this means"} +
+
+ {mode === "gustafson" + ? <>Gustafson's law says: if you scale the problem size with the hardware (more threads → bigger dataset, same wall time), speedup grows near-linearly regardless of the serial fraction. It doesn't contradict Amdahl — they answer different questions. Amdahl: “how much faster for the same work?” Gustafson: “how much more work in the same time?” + : mode === "both" + ? <>The gap between the curves is the scalability premium from growing the problem with hardware. When work is fixed (Amdahl), the serial fraction is an iron ceiling. When the problem scales (Gustafson), even significant serial fractions allow near-linear throughput growth — which is why clusters with thousands of nodes are useful in practice. + : summaryText() + } +
+
+
+
+ ); +} diff --git a/apps/web/src/components/play/quiz-questions.ts b/apps/web/src/components/play/quiz-questions.ts new file mode 100644 index 0000000..df0d296 --- /dev/null +++ b/apps/web/src/components/play/quiz-questions.ts @@ -0,0 +1,146 @@ +// Guess-the-Bottleneck: six archetypal scaling curves the student must identify. +// Each entry is self-contained: curve data + choices + answer + explanation. +// No gateway needed — all data is pre-generated from analytical models. + +export type Choice = { id: string; label: string }; +export type Question = { + id: string; + prompt: string; + context: string; + curve: { x: number; y: number }[]; + choices: Choice[]; + answer: string; + explain: string; +}; + +const T = [1, 2, 4, 8, 12, 16, 20, 24] as const; + +// Amdahl's law S(p) = 1 / (s + (1-s)/p) +const ahl = (s: number): { x: number; y: number }[] => + T.map((p) => ({ x: p, y: +(1 / (s + (1 - s) / p)).toFixed(2) })); + +export const QUESTIONS: Question[] = [ + // ── 1. Near-perfect ──────────────────────────────────────────────────────── + { + id: "perfect", + prompt: "What does this scaling curve tell you?", + context: + "24 independent tasks — each thread works on a completely separate slice of a large array and never touches any shared data.", + curve: T.map((p) => ({ x: p, y: +(p * (1 - 0.002 * (p - 1))).toFixed(2) })), + choices: [ + { id: "perfect", label: "Near-perfect (embarrassingly parallel)" }, + { id: "amdahl", label: "Serial fraction — Amdahl's law" }, + { id: "bw", label: "Memory bandwidth saturation" }, + { id: "sync", label: "Synchronization contention" }, + ], + answer: "perfect", + explain: + "Near-linear speedup — every core does proportional useful work, with only minor scheduling overhead. No shared state, no synchronization, and the working set still fits in cache. This is the gold standard every parallel programmer aims for.", + }, + + // ── 2. Synchronization contention ────────────────────────────────────────── + { + id: "sync", + prompt: "What does this scaling curve tell you?", + context: + "All 24 threads increment a single shared global counter on every loop iteration, each acquisition guarded by #pragma omp atomic.", + curve: [ + { x: 1, y: 1.00 }, { x: 2, y: 1.04 }, { x: 4, y: 0.96 }, + { x: 8, y: 0.84 }, { x: 12, y: 0.78 }, { x: 16, y: 0.74 }, + { x: 20, y: 0.71 }, { x: 24, y: 0.69 }, + ], + choices: [ + { id: "falsesh", label: "False sharing" }, + { id: "sync", label: "Synchronization contention" }, + { id: "imbal", label: "Load imbalance" }, + { id: "bw", label: "Memory bandwidth saturation" }, + ], + answer: "sync", + explain: + "Every core must queue for the lock on every iteration — queuing time grows faster than the work done. 24 threads on one atomic counter ends up slower than 1 thread alone. The fix is a reduction: each thread keeps a private partial sum and OpenMP combines them once at the end.", + }, + + // ── 3. Amdahl's law ──────────────────────────────────────────────────────── + { + id: "amdahl", + prompt: "What does this scaling curve tell you?", + context: + "The kernel has a setup phase that always runs on a single thread (file I/O + initialisation), then a fully parallel compute section. Past ~8 threads the speedup barely improves.", + curve: ahl(0.10), + choices: [ + { id: "bw", label: "Memory bandwidth saturation" }, + { id: "amdahl", label: "Serial fraction — Amdahl's law" }, + { id: "imbal", label: "Load imbalance" }, + { id: "perfect", label: "Near-perfect scaling" }, + ], + answer: "amdahl", + explain: + "The curve follows Amdahl's law with ~10% serial code: S(N) = 1 ∕ (0.10 + 0.90∕N). No matter how many cores you add, the serial 10% dominates — the theoretical ceiling is 10× regardless of core count. The only fix is to parallelize or eliminate the serial section.", + }, + + // ── 4. False sharing ─────────────────────────────────────────────────────── + { + id: "falsesh", + prompt: "What does this scaling curve tell you?", + context: + "Each thread writes only to its own counter — no logical sharing — but all counters are packed next to each other inside one 64-byte cache line.", + curve: [ + { x: 1, y: 1.00 }, { x: 2, y: 1.62 }, { x: 4, y: 2.18 }, + { x: 8, y: 2.48 }, { x: 12, y: 2.31 }, { x: 16, y: 2.09 }, + { x: 20, y: 1.91 }, { x: 24, y: 1.74 }, + ], + choices: [ + { id: "amdahl", label: "Serial fraction — Amdahl's law" }, + { id: "falsesh", label: "False sharing" }, + { id: "perfect", label: "Near-perfect scaling" }, + { id: "sync", label: "Synchronization contention" }, + ], + answer: "falsesh", + explain: + "Initial gains look promising, but every write by one core invalidates the same cache line in every other core's L1 cache (MESI protocol). As thread count grows, this coherence traffic escalates and the speedup reverses. The fingerprint is a peak followed by degradation. Fix: pad each counter to its own cache line.", + }, + + // ── 5. Memory bandwidth saturation ──────────────────────────────────────── + { + id: "bw", + prompt: "What does this scaling curve tell you?", + context: + "A memory-intensive stencil kernel. Each thread streams through a large array — the working set is far too big to fit in any level of cache.", + curve: [ + { x: 1, y: 1.00 }, { x: 2, y: 1.88 }, { x: 4, y: 3.45 }, + { x: 8, y: 3.78 }, { x: 12, y: 3.90 }, { x: 16, y: 3.94 }, + { x: 20, y: 3.97 }, { x: 24, y: 3.98 }, + ], + choices: [ + { id: "imbal", label: "Load imbalance" }, + { id: "sync", label: "Synchronization contention" }, + { id: "bw", label: "Memory bandwidth saturation" }, + { id: "amdahl", label: "Serial fraction — Amdahl's law" }, + ], + answer: "bw", + explain: + "DRAM bandwidth is shared across all cores — once ~3–4 cores saturate the memory bus, extra cores idle waiting for data. The flat ceiling at ~4× is the bandwidth wall, not a code quality issue. The Roofline model predicts exactly this. Fix: tiling or prefetching to reuse data from cache before adding more threads.", + }, + + // ── 6. Load imbalance ───────────────────────────────────────────────────── + { + id: "imbal", + prompt: "What does this scaling curve tell you?", + context: + "A triangular loop: iteration i costs O(i²) work. The N iterations are divided into equal-sized static chunks — the last threads get the heaviest iterations.", + curve: [ + { x: 1, y: 1.00 }, { x: 2, y: 1.71 }, { x: 4, y: 2.85 }, + { x: 8, y: 4.22 }, { x: 12, y: 5.10 }, { x: 16, y: 5.52 }, + { x: 20, y: 5.63 }, { x: 24, y: 5.65 }, + ], + choices: [ + { id: "bw", label: "Memory bandwidth saturation" }, + { id: "falsesh", label: "False sharing" }, + { id: "amdahl", label: "Serial fraction — Amdahl's law" }, + { id: "imbal", label: "Load imbalance" }, + ], + answer: "imbal", + explain: + "With schedule(static), the last threads receive the heaviest iterations. Every other thread finishes and idles while the unlucky ones grind through the expensive tail — total runtime equals the slowest thread. Fix: schedule(dynamic) or schedule(guided) lets idle threads steal remaining work and balances the load.", + }, +]; diff --git a/docs/02-roadmap.md b/docs/02-roadmap.md index f4a088b..b520072 100644 --- a/docs/02-roadmap.md +++ b/docs/02-roadmap.md @@ -137,12 +137,11 @@ the Slurm bridge for real clusters. These bare-metal/cloud nodes also unlock **t **DoD:** an instructor runs a class through a "speed up this code" challenge backed by real cluster execution. -**Status (2026-06-17) — gamification layer partially landed.** The `/play` hub, head-to-head -kernel race, predict-before-you-run mechanic (across Start / Playground / Flagship), and a -badge + streak system for correct predictions are built and merged. A guess-the-bottleneck quiz -and an Amdahl & Gustafson interactive sandbox are in the next PR (`feat/badges-pr30`). -Engineering domain modules (FEM/FDTD/CFD), classrooms/LMS, K8s autoscaling, and PMU counters -remain future work. +**Status (2026-06-17) — gamification layer complete.** The `/play` hub ships four games: +**⚡ Race** (head-to-head kernel comparison), **🧩 Quiz** (guess-the-bottleneck), +**🔬 Sandbox** (Amdahl & Gustafson interactive explorer), and **🏅 Badges** (predict-before- +you-run reward system across Start / Playground / Flagship). Engineering domain modules +(FEM/FDTD/CFD), classrooms/LMS, K8s autoscaling, and PMU counters remain future work. --- diff --git a/packages/explain/src/index.ts b/packages/explain/src/index.ts index d0e1c97..82f6ce2 100644 --- a/packages/explain/src/index.ts +++ b/packages/explain/src/index.ts @@ -10,8 +10,8 @@ type ExplainFn = (r: ExperimentResult) => Explanation; export const Explain: Record = { falseSharing(r) { - const c = r.current, peak = r.peak!, padded = r.params.padded; - if (padded) + const c = r.current, peak = r.peak, padded = r.params.padded; + if (padded || !peak) return { sev: "info", what: `Padded: speedup reaches ${fmt(c.speedup, 1)}× at ${c.x} threads — close to the ideal ${c.x}×.`, @@ -22,7 +22,7 @@ export const Explain: Record = { return { sev: "critical", what: `Speedup peaks at only ${fmt(peak.speedup, 1)}× around ${peak.x} threads, then falls to ${fmt(c.speedup, 1)}× at ${c.x}. Adding threads makes it slower.`, - why: `All ${c.x} counters share one 64-byte cache line. Every increment invalidates that line in every other core, so it ping-pongs across cores over the bus — coherence traffic grows ~linearly with thread count and now dominates (${fmt(r.coh!, 0)} ms of the ${fmt(c.time, 0)} ms runtime).`, + why: `All ${c.x} counters share one 64-byte cache line. Every increment invalidates that line in every other core, so it ping-pongs across cores over the bus — coherence traffic grows ~linearly with thread count and now dominates (${fmt(r.coh ?? 0, 0)} ms of the ${fmt(c.time, 0)} ms runtime).`, how: `Pad/align each thread's counter to its own cache line (64-byte alignment, or per-thread padded struct). Flip "Pad to cache line".`, exp: `Near-linear speedup restored (~${c.x}× at ${c.x} threads); coherence stall → ~0%.`, }; @@ -82,8 +82,8 @@ export const Explain: Record = { }; }, mpiHalo(r) { - const c = r.current, weak = r.params.mode === "weak", commPct = r.idlePct ?? 0, peak = r.peak!; - if (weak) + const c = r.current, weak = r.params.mode === "weak", commPct = r.idlePct ?? 0, peak = r.peak; + if (weak || !peak) return { sev: c.efficiency > 0.7 ? "info" : "warn", what: `Weak scaling: as the grid grows with the ranks, scaled speedup reaches ${fmt(c.speedup, 1)}× on ${c.x} ranks at ${fmt(c.efficiency * 100, 0)}% efficiency — close to ideal.`,