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
9 changes: 9 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,12 @@ TAGS
*.ps
*.svg
tests/purs/make/

# Legacy perf-harness scratch space (replaced by experiments/ framework)
.profile-baseline/
profile-output/
profile-results.log

.claude/settings.local.json
.claude/*.lock
thoughts
181 changes: 181 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
# Claude guidance for this repo

This is the PureScript compiler (restaumatic fork). Build with
`stack build`, test with `stack test --fast`. See `INSTALL.md` for
toolchain setup.

---

## Performance work

All compiler-performance optimisation work is organised as
**experiments** tracked under `experiments/`. Before starting anything
perf-related, read:

1. `experiments/README.md` — index of all experiments with status,
verdict, and headline delta.
2. `experiments/LESSONS.md` — **required reading.** Distilled
learnings from closed experiments, including dead-end techniques
(e.g., don't cache cheap per-decl typecheck work; don't trust
headline speedups >20% without checking for a semantics bug).
3. `experiments/SCHEMA.md` — layout of each experiment folder and
the lifecycle states.

### Workload

Performance is measured against **pr-admin**
(`/workspace/restaumatic/apps/pr-admin`, 1758 modules), built via
`spago build` with a custom `purs` binary on PATH. Baseline numbers on
the `restaumatic` branch, optimised build:

| Scenario | Time |
| ------------------------------------- | ------- |
| Full build | ~72–73s |
| No-change rebuild | ~1.1s |
| Touch leaf | ~1.2s |
| Comment change to Prelude (1342 deps) | ~2.3s |

### Four scenarios to always run

An optimisation is only a win if it doesn't regress any scenario. Many
cache-oriented changes win on `full` and lose on `nochange` or
`prelude` — that's the whole reason we run all four:

| Scenario | How | What it catches |
| ---------- | --------------------------------------------------- | ------------------------------- |
| `full` | `rm -rf output && spago build` | raw throughput |
| `nochange` | full, then a second `spago build` | overhead on the no-op path |
| `prelude` | full, touch Prelude, `spago build` | cascade cost (1342 deps) |
| `leaf` | full, touch an arbitrary leaf module, `spago build` | single-module rebuild overhead |

### Noise discipline

- Discard run 1 as warm-up, report median + (min, max) of the
remaining runs.
- Run ≥4 runs after warm-up (so median-of-4 is meaningful).
- Don't compare a `--profile` build against a non-profile build, or a
`--fast` build against an optimised one — the overhead is real.
- If baseline-vs-baseline varies by more than ~1–2% between
back-to-back invocations, the measurement harness has a bug; fix it
before trusting any numbers.

### Starting a new experiment

```sh
# Scaffolds experiments/<id>/, creates branch <id> off baseline-sha,
# creates worktree at /workspace/p/<id>
experiments/scripts/exp new <id> [--from <baseline-sha>]
```

The scaffold produces:
- `experiments/<id>/EXPERIMENT.md` — frontmatter + hypothesis. Fill
this in immediately; it's the entry point for anyone finding the
experiment later.
- `experiments/<id>/TASK.md` — detailed plan (what to change, where,
why).
- `experiments/<id>/HANDOFF.md` — live work log. Update as you go so
the next agent (or future-you) can pick up where you left off.
- `experiments/<id>/results.md` — structured results table,
append-only.

Then work on the branch in `/workspace/p/<id>`. The main repo at
`/workspace/purescript` stays on the current branch; the worktree has
its own checkout.

### Measuring

```sh
# Single scenario, quick iteration
experiments/scripts/exp run <id> --scenarios full --runs 5

# All four scenarios, with cost-centre profiling
experiments/scripts/exp run <id> --scenarios all --runs 5 --profile
```

Results append to `experiments/<id>/results.md` with baseline SHA,
head SHA, median, and notes. If `--profile` is set, `.prof` files land
in `experiments/<id>/profiles/` (gitignored) with a tracked
`.meta.md` sidecar recording the top cost centres and the commit.

### Baselines

Baselines are keyed by the commit SHA they were built from, stored at
`experiments/baselines/<short-sha>/purs` (gitignored). Rebuild on
demand:

```sh
experiments/scripts/exp build-baseline <sha-or-branch>
```

The manifest at `experiments/baselines/manifest.md` tracks what
baselines have been built, on what machine, with what GHC — so any
measurement can be reproduced.

Every `EXPERIMENT.md` records which `baseline_sha` its results are
against, so you can always figure out what a number is comparing to.

### Closing an experiment

```sh
experiments/scripts/exp close <id> --verdict win|partial|no-win|abandoned
```

This sets the frontmatter to closed and prompts for a one-paragraph
entry in `experiments/LESSONS.md`. **Add that entry** — especially for
dead ends. The lesson is worth more than the code.

### Worktrees

Active experiment worktrees live at `/workspace/p/<id>`. The current
set:

```sh
git worktree list
# /workspace/purescript [restaumatic]
# /workspace/p/tc-queries [tc-queries]
# /workspace/p/synonym-opt [synonym-opt]
# /workspace/p/rust-interning [rust-interning]
```

Each experiment's `EXPERIMENT.md` also records its worktree path in
the frontmatter.

### Per-declaration profiling

The compiler emits eventlog markers for every typechecked declaration.
To see which specific declarations are slow (e.g., complex type-level
row-list computations, heavy instance resolution):

```sh
# Within an experiment: before/after profiles
experiments/scripts/exp profile <id> --phase before # profile baseline
# ... make changes ...
experiments/scripts/exp profile <id> --phase after # profile head

# Manual workflow (outside experiments)
stack build
purs +RTS -l-agu -N1 -RTS compile $(spago sources)
eventlog2html --json purs.eventlog
node debug/eventlog.js purs.eventlog.json # text report
node debug/eventlog-chrome-trace.js purs.eventlog.json > profile.json
# Open profile.json in chrome://tracing
```

See `debug/README.md` for details on RTS flags and tools.

### Hotspot reference

From the most recent profile on the `restaumatic` branch (see
`experiments/README.md` for the live table):

| Cost Centre | Module | % time | Status |
| ---------------------------- | --------------------- | ------ | ------------------------ |
| `compare` (Qualified a) | Names.hs:234 | 20.8% | unattacked |
| `replaceAllTypeSynonyms'.go` | TypeChecker.Synonyms | 16.9% | see `synonym-opt` |
| `compare` (PSString) | PSString.hs:52 | 8.6% | unattacked |
| `compareType` | Types.hs | 4.2% | unattacked |

When selecting a new experiment, pick an unattacked hotspot, or a
previously-attacked one whose experiment reached `no-win` with a
clear path to a different approach. **Don't re-attempt a dead-end
technique without new evidence** — check `LESSONS.md` first.
105 changes: 105 additions & 0 deletions debug/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
# Per-declaration typecheck profiling

The compiler emits eventlog markers at two levels:

- **Module-level**: `"ModuleName start"` / `"ModuleName end"` (from `Make.hs`)
- **Declaration-level**: `"tc ModuleName kind:name start"` / `"tc ModuleName kind:name end"` (from `TypeChecker.hs`)

Declaration kinds: `val`, `bind` (recursive group), `data`, `datagroup`,
`syn`, `kind`, `class`, `instance`, `extern`, `externdata`, `role`.

## Quick start

```bash
# 1. Build the compiler (eventlog is enabled by default in GHC >= 9.2)
stack build

# 2. Run with eventlog enabled, single-threaded for clean nesting
cd /path/to/your/purescript/project
purs +RTS -l-agu -N1 -RTS compile $(spago sources)

# 3. Convert the binary eventlog to JSON
eventlog2html --json purs.eventlog

# 4a. Text report (module + declaration breakdown)
node /path/to/purescript/debug/eventlog.js purs.eventlog.json

# 4b. Flamegraph (Chrome trace format — works in speedscope and chrome://tracing)
node /path/to/purescript/debug/eventlog-chrome-trace.js purs.eventlog.json > profile.json
# Open profile.json at https://www.speedscope.app/ or chrome://tracing
```

## RTS flags explained

| Flag | Purpose |
| ------- | -------------------------------------------------- |
| `-l` | Enable eventlog output to `purs.eventlog` |
| `-agu` | Suppress GC/scheduler/user-tick noise |
| `-N1` | Single-threaded: gives clean per-declaration nesting |
| `-N` | Multi-threaded (default): faster but events interleave |

Without `-N1`, modules typecheck in parallel and declaration events
from different modules interleave. The speedscope converter handles
this, but the flamegraph is cleaner with `-N1`.

## Tools

### `eventlog.js` — text report

```
node debug/eventlog.js purs.eventlog.json
```

Prints per-module timing (sorted ascending), concurrency stats, and
a per-declaration breakdown showing the top 50 slowest declarations
with module name, declaration kind, wall-clock time, and percentage.

### `eventlog-chrome-trace.js` — flamegraph

```
node debug/eventlog-chrome-trace.js purs.eventlog.json > profile.json
```

Outputs Chrome trace format JSON. Open in:
- https://www.speedscope.app/ (drag and drop)
- `chrome://tracing` in Chrome (load file)

The flamegraph shows module-level and declaration-level spans nested
properly. The text report (top N declarations) is also printed to
stderr.

Options:
- `--top N` — number of declarations in stderr report (default: 50)
- `--cap CAP` — filter to a specific GHC capability (thread)

## What the flamegraph shows

The flamegraph has two levels:

1. **Module** — total time for the module (includes desugaring, codegen, etc.)
2. **Declaration** — time for typechecking each declaration within the module

This tells you which declarations are expensive. Common patterns:

- **Large `instance:` spans** — complex instance resolution, often
involving row-list traversals or `EncodeJson`/`DecodeJson` generics
- **Large `bind:` or `val:` spans** — complex type inference with
many constraints
- **Large `datagroup:` spans** — mutually recursive type definitions
with many constructors

## Overhead

The `traceMarker` calls are effectively free when not profiling:
- GHC >= 9.2: eventlog is always linked; `traceMarker` checks a flag and returns
- Per call: ~tens of nanoseconds (buffer write)
- Total for ~10k declarations: ~1ms against a ~72s build

## Prerequisites

Install `eventlog2html`:
```bash
cabal install eventlog2html
# or
pip install eventlog2html
```
Loading
Loading