Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,11 @@ every pipeline register, forwarding path and stall comes from a `dbg_*` port on
- Phase 7 (views): DONE. L1 datapath SVG (`DatapathView`, values from a single-cycle run of the same program, `datapath.ts`),
L3 hazard lab (`hazard-lab.ts`: 8 cases, each run on pipeline fwd/no-fwd and single-cycle), L4 programs comparison and data-memory
heatmap (`programs-compare.ts`, `Heatmap`), light/dark theme toggle that restores its saved choice. 67 Vitest + 9 Playwright.
- Next: docs pages and launch (PLAN section 8, the non-app pages); Phase 8 per docs/PLAN.md.
- Phase 8 (landing and docs): BUILT on feat/phase-8-launch, not yet deployed. `/` has a live fib run in the hero; `/docs/*` has the
five pages (how it works, architecture, the pipeline, hazards, verification), each with a live `MiniRun` embed where it helps.
72 Vitest and 17 Playwright tests pass. Still to do: Vercel deploy (needs the user's go-ahead), the hero GIF/MP4, and the
CodeMirror editor, pass-by-pass replay and default-branch items that the plan lists under Phase 8.
- Next: Phase 9 (FPGA synthesis), which can run alongside the Phase 8 launch work.

## Interfaces Phase 2+ must honour (defined by the ISS, `sim/golden/iss.py`)
- Retire record fields, lane/mask conventions, forced alignment, `cycle_dependent` masking: see the module docstring and PLAN 2.4.
Expand Down Expand Up @@ -120,3 +124,11 @@ every pipeline register, forwarding path and stall comes from a `dbg_*` port on
- The datapath view needs operand values, which a pipeline run does not expose per instruction. It reads them from a single-cycle
run of the same program; both cores compute the same architecture, so the values match.
- `tabpanel` ids and `aria-controls` must match the tab ids exactly, or the Playwright role queries and screen readers miss them.

## Gotchas found in Phase 8
- The plan refers to tiny-tpu's `LandingEmbed` and `gen-og-image.mjs`, but that repo has no web app here, so the landing and
docs pages use their own `MiniRun` island instead.
- A branch switch whose tool call was interrupted did not happen; check `git branch --show-current` before building on it.
- A `.gitignore` rule with a trailing comment is not an exception: git reads the comment as part of the pattern. Keep comments on
their own lines (this hid the WASM binary from git until Phase 7's CI caught it).
- An embedded run starts at cycle 1, so "Halted" appears only after stepping to the end. Tests must click "Go to end" first.
33 changes: 24 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,9 @@ Third in the series after [tiny-gpu](https://github.com/deaneeth/tiny-gpu) and [

</div>

> **Status: Phase 7 done (views).** The app at `/app` has four views: the pipeline, the single-cycle datapath, a hazard lab that measures each hazard on the pipeline with and without forwarding, and a programs comparison with a data-memory heatmap. Next: the docs pages and launch.
> **Status: Phase 8 built, not yet deployed.** The app at `/app`, the landing page and five docs pages are built and
> tested on `feat/phase-8-launch`. The Vercel deploy and FPGA synthesis numbers (Phase 9) are still to come.
>
> *Previous: Phase 6 done (web app, first slice); Phase 5 done (WASM harness).* Both cores run in one WebAssembly module (`wasm/build.sh`, 77 KB gzipped) and pass the golden fixtures in Node (`node wasm/test.mjs`). Next: the web app.
>
> *Previous: Phase 4 done (hazards).* Both RV32I cores retire instructions identically to the Python golden ISS on all 40 official `rv32ui-p-*` tests, 5 programs and hundreds of random programs. The 5-stage core has forwarding and load-use stalls (switchable at runtime) and matches an independent timing model cycle for cycle. Next: the WASM harness.
> See [`docs/PLAN.md`](docs/PLAN.md) for the full roadmap.

Every pipeline register, forwarding path and stall the UI will draw is a live `dbg_*` signal from the
Expand All @@ -27,12 +25,29 @@ compiled RTL. Nothing on screen is faked.
rtl/*.sv -> Verilator C++ -> Emscripten -> web/public/tiny_cpu.wasm -> React island -> SVG
```

## Planned
## What it does

- A single-cycle and a 5-stage pipelined RV32I core sharing one decoder, ALU and register file, with forwarding,
load-use stalls and taken-branch flushes.
- The same hardware in the browser: an assembler (byte-identical to GNU's), an editor, and a viewer that steps through
every clock edge with the pipeline, the single-cycle datapath, a hazard lab and a programs comparison.
- FPGA synthesis numbers for iCE40 and ECP5 (Phase 9, in progress).

## Verification

Each check runs in continuous integration on every change.

| What | How much | Against |
|---|---|---|
| Official RISC-V instruction tests (`rv32ui`) | 40 of 42, both cores | Python golden ISS. `fence_i` and `ma_data` are excluded. |
| Hand-written and random programs | 5 and 260 (both forwarding modes) | Golden ISS for results; timing model for pipeline cycles |
| Fault cases | 13 | Golden ISS |
| Mutation checks | 7 (pipeline) and 11 (hazards) | Each one must make a test fail |
| WASM module | 534 checks | Golden fixtures, both cores, forwarding on and off |
| Web app | 72 unit tests, 17 browser tests | Assembler vs GNU bytes; presets on the WASM core |
| Size | About 77 KB gzipped | Build fails above 400 KB |

- Single-cycle and 5-stage pipelined RV32I cores sharing one ALU/decoder/regfile
- Verified against a Python golden ISS, the official `riscv-tests` and random hazard-heavy programs
- In-browser assembler, step/rewind, datapath / pipeline / hazard-lab / programs views
- FPGA synthesis numbers (iCE40 / ECP5)
Details, and what is not verified yet, are on the Verification docs page and in `docs/PLAN.md`.

## Layout

Expand Down
45 changes: 45 additions & 0 deletions web/e2e/docs.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// Smoke tests for the landing page and the five docs pages, against the production build. The embedded runs
// start when they scroll into view (client:visible), so each test scrolls the embed first.
import { expect, test } from "@playwright/test";

test("the landing page runs fib live and reports PASS", async ({ page }) => {
await page.goto("/");
await expect(page.getByRole("heading", { level: 1 })).toContainText("A real RISC-V CPU");
const embed = page.locator("figure").first();
await embed.scrollIntoViewIfNeeded();
await expect(embed.getByText(/^Cycle 1 of \d+/)).toBeVisible({ timeout: 60_000 });
await embed.getByRole("button", { name: "Go to end" }).click();
await expect(embed.getByText(/Halted at cycle \d+/)).toBeVisible();
await expect(embed.getByText(/PASS/)).toBeVisible();
});

for (const [path, heading] of [
["/docs/how-it-works", "How it works"],
["/docs/architecture", "Architecture"],
["/docs/the-pipeline", "The pipeline"],
["/docs/hazards", "Hazards"],
["/docs/verification", "Verification"],
] as const) {
test(`${path} renders with navigation`, async ({ page }) => {
await page.goto(path);
await expect(page.getByRole("heading", { level: 1, name: heading })).toBeVisible();
await expect(page.getByRole("link", { name: "Open the CPU" }).first()).toBeVisible();
await expect(page.locator('a[aria-current="page"]')).toHaveText(heading);
});
}

test("the how-it-works embed runs on the single-cycle core", async ({ page }) => {
await page.goto("/docs/how-it-works");
const embed = page.locator("figure").first();
await embed.scrollIntoViewIfNeeded();
await expect(embed.getByText(/^Cycle 1 of \d+/)).toBeVisible({ timeout: 60_000 });
await embed.getByRole("button", { name: "Go to end" }).click();
await expect(embed.getByText(/Halted at cycle \d+ — PASS/)).toBeVisible();
});

test("docs pages have no horizontal scroll at phone width", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/docs/verification");
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
expect(overflow).toBeLessThanOrEqual(1);
});
118 changes: 118 additions & 0 deletions web/src/components/MiniRun.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
// A small, self-contained run for the landing page and docs: assembles one program, runs it once on the
// chosen core, and lets the reader step through the cycles with the same pipeline view as /app.
import { useEffect, useMemo, useState } from "react";
import { assemble, type AsmResult } from "../lib/assembler";
import { runProgram, type RunResult } from "../lib/runner";
import { buildRunModel, type RunModel } from "../lib/run-model";
import { loadTinyCpu, type TinyCpuModule } from "../lib/wasm-loader";
import type { CoreName } from "../lib/state-schema";
import { PipelineView } from "./PipelineView";

interface Props {
source: string;
core: CoreName;
forwarding?: boolean;
/** what the reader is looking at, shown above the run */
caption: string;
maxCycles?: number;
}

interface Ready {
asm: AsmResult;
run: RunResult;
model: RunModel;
}

const button =
"rounded-md border border-[var(--line)] px-3 py-1 font-mono text-sm hover:bg-[var(--active)] disabled:opacity-40";

export function MiniRun({ source, core, forwarding = true, caption, maxCycles = 20_000 }: Props) {
const [mod, setMod] = useState<TinyCpuModule | null>(null);
const [failure, setFailure] = useState<string | null>(null);
const [index, setIndex] = useState(0);
const [playing, setPlaying] = useState(false);

useEffect(() => {
let alive = true;
loadTinyCpu(`${import.meta.env.BASE_URL}tiny_cpu.mjs`)
.then((m) => alive && setMod(m))
.catch((e: unknown) => alive && setFailure(e instanceof Error ? e.message : String(e)));
return () => {
alive = false;
};
}, []);

const result = useMemo<Ready | { error: string } | null>(() => {
if (!mod) return null;
const asm = assemble(source);
if (!asm.ok) return { error: asm.errors[0]?.message ?? "does not assemble" };
const run = runProgram(mod, asm, { core, forwarding, maxCycles });
return { asm, run, model: buildRunModel(run.states, asm.dmem) };
}, [mod, source, core, forwarding, maxCycles]);

const ok: Ready | null = result && "run" in result ? result : null;
const last = ok ? ok.run.states.length - 1 : 0;
const i = Math.min(index, last);

useEffect(() => {
if (!playing) return;
const id = window.setInterval(() => {
setIndex((k) => {
if (k >= last) {
setPlaying(false);
return k;
}
return k + 1;
});
}, 250);
return () => window.clearInterval(id);
}, [playing, last]);

const state = ok ? (ok.run.states[i] ?? null) : null;
const stats = ok ? ok.model.stats(i) : null;
const status = (() => {
if (failure) return `Could not load the core: ${failure}`;
if (!result) return "Loading the core…";
if ("error" in result) return `Assembly error: ${result.error}`;
if (result.run.fault) return "Fault: the program stopped on an illegal access.";
if (state?.halted) return `Halted at cycle ${state.cycle}${state.tohost === 1 ? " — PASS" : ""}.`;
if (result.run.truncated) return `Stopped at ${maxCycles.toLocaleString()} cycles without halting.`;
return `Cycle ${state?.cycle ?? 0} of ${result.run.states.length}`;
})();

return (
<figure className="flex flex-col gap-3 rounded-lg border border-[var(--line)] bg-[var(--panel)] p-4">
<figcaption className="flex flex-wrap items-baseline justify-between gap-2 text-sm">
<span className="font-semibold text-[var(--fg)]">{caption}</span>
<span className="font-mono text-xs text-[var(--muted)]">{core === "PIPE" ? (forwarding ? "5-stage, forwarding" : "5-stage, no forwarding") : "single-cycle"}</span>
</figcaption>

{ok && state ? (
<PipelineView state={state} asm={ok.asm} model={ok.model} index={i} core={core} />
) : (
<p className="text-sm text-[var(--muted)]">{status}</p>
)}

{ok && (
<>
<div className="flex flex-wrap items-center gap-2">
<button className={button} disabled={i === 0} onClick={() => setIndex(0)} aria-label="Go to start">⏮</button>
<button className={button} disabled={i === 0} onClick={() => setIndex(Math.max(0, i - 1))} aria-label="Step back">◀</button>
<button className={button} disabled={i >= last} onClick={() => setPlaying(!playing)} aria-label={playing ? "Pause" : "Play"}>
{playing ? "❚❚" : "▶︎"}
</button>
<button className={button} disabled={i >= last} onClick={() => setIndex(Math.min(last, i + 1))} aria-label="Step forward">▶</button>
<button className={button} disabled={i >= last} onClick={() => setIndex(last)} aria-label="Go to end">⏭</button>
<span className="ml-auto font-mono text-xs text-[var(--muted)]">{status}</span>
</div>
{stats && (
<p className="font-mono text-xs text-[var(--muted)]">
{stats.instret} instructions · CPI {stats.instret ? stats.cpi.toFixed(2) : "—"}
{core === "PIPE" ? ` · ${stats.stalls} stalls · ${stats.flushes} flushes` : ""}
</p>
)}
</>
)}
</figure>
);
}
88 changes: 88 additions & 0 deletions web/src/layouts/DocsLayout.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
---
import Layout from "@/layouts/Layout.astro";

interface Props {
title: string;
current: string;
}

const { title, current } = Astro.props;
const pages = [
{ href: "/docs/how-it-works", label: "How it works" },
{ href: "/docs/architecture", label: "Architecture" },
{ href: "/docs/the-pipeline", label: "The pipeline" },
{ href: "/docs/hazards", label: "Hazards" },
{ href: "/docs/verification", label: "Verification" },
];
---

<Layout title={`${title} · TinyCPU`}>
<header class="border-b border-[var(--line)]">
<nav class="mx-auto flex max-w-5xl flex-wrap items-center gap-x-5 gap-y-2 px-4 py-3 text-sm" aria-label="Site">
<a href="/" class="font-bold text-[var(--fg)]">TinyCPU</a>
{
pages.map((p) => (
<a href={p.href} class="text-[var(--muted)] hover:text-[var(--fg)]" aria-current={p.href === current ? "page" : undefined}>
{p.label}
</a>
))
}
<a href="/app" class="ml-auto rounded-md bg-[var(--accent)] px-3 py-1 font-semibold text-[var(--bg)]">Open the CPU</a>
</nav>
</header>
<main class="docs mx-auto flex max-w-3xl flex-col gap-5 px-4 py-10">
<h1 class="text-3xl font-bold text-[var(--fg)]">{title}</h1>
<slot />
</main>
<footer class="mx-auto max-w-3xl px-4 pb-10 text-sm text-[var(--muted)]">
<a class="underline" href="https://github.com/deaneeth/tiny-cpu">Source on GitHub</a>
</footer>
</Layout>

<style is:global>
.docs h2 {
margin-top: 1.25rem;
font-size: 1.25rem;
font-weight: 700;
color: var(--fg);
}
.docs p,
.docs li {
line-height: 1.65;
color: var(--fg);
}
.docs ul {
list-style: disc;
padding-left: 1.25rem;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.docs code {
font-family: ui-monospace, monospace;
font-size: 0.9em;
background: var(--panel);
padding: 0.05rem 0.3rem;
border-radius: 0.25rem;
}
.docs table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
.docs th,
.docs td {
text-align: left;
padding: 0.4rem 0.5rem;
border-top: 1px solid var(--line);
vertical-align: top;
}
.docs th {
color: var(--muted);
font-weight: 500;
}
.docs a {
color: var(--accent);
text-decoration: underline;
}
</style>
52 changes: 52 additions & 0 deletions web/src/lib/__tests__/phase8.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// Phase 8: every state a real run produces passes the schema validator, and the programs used on the
// landing and docs pages assemble and halt with PASS on the WASM core.
import { describe, expect, it } from "vitest";
import { assemble } from "../assembler";
import { HAZARD_CASES } from "../hazard-lab";
import { runProgram } from "../runner";
import { isCycleState } from "../state-schema";
import { loadTinyCpu } from "../wasm-loader";
import fibSource from "../presets/fib.s?raw";
import helloSource from "../presets/hello.s?raw";

const moduleUrl = new URL("../../../public/tiny_cpu.mjs", import.meta.url).href;
const mod = await loadTinyCpu(moduleUrl);

describe("states from a real run", () => {
it("every CycleState from the pipeline run passes the validator", () => {
const asm = assemble(fibSource);
const run = runProgram(mod, asm, { core: "PIPE", forwarding: true, maxCycles: 20_000 });
expect(run.states.every((s) => isCycleState(s))).toBe(true);
});

it("the single-cycle run validates too, and the validator rejects a corrupted state", () => {
const asm = assemble(helloSource);
const run = runProgram(mod, asm, { core: "SINGLE", forwarding: true, maxCycles: 20_000 });
expect(run.states.every((s) => isCycleState(s))).toBe(true);
const broken = { ...run.states[0], regs: run.states[0]?.regs.slice(0, 31) };
expect(isCycleState(broken)).toBe(false);
});
});

describe("programs on the landing and docs pages", () => {
it("fib halts with PASS on the pipeline with forwarding", () => {
const run = runProgram(mod, assemble(fibSource), { core: "PIPE", forwarding: true, maxCycles: 20_000 });
expect(run.halted).toBe(true);
expect(run.tohost).toBe(1);
});

it("hello halts with PASS on the single-cycle core", () => {
const run = runProgram(mod, assemble(helloSource), { core: "SINGLE", forwarding: true, maxCycles: 20_000 });
expect(run.halted).toBe(true);
expect(run.tohost).toBe(1);
});

it("the load-use hazard program used on the hazards page assembles and halts", () => {
const c = HAZARD_CASES.find((x) => x.id === "load-use");
expect(c).toBeDefined();
const asm = assemble(c?.source ?? "");
expect(asm.errors).toEqual([]);
const run = runProgram(mod, asm, { core: "PIPE", forwarding: true, maxCycles: 2_000 });
expect(run.halted).toBe(true);
});
});
Loading
Loading