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
52 changes: 52 additions & 0 deletions .claude/hooks/context-loader.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
#!/usr/bin/env python3
"""UserPromptSubmit hook: adds a context file to the turn when the prompt names its topic."""

from __future__ import annotations

import json
import re
import sys
import tempfile
from pathlib import Path

REPO_ROOT = Path(__file__).resolve().parents[2]

TOPICS = {
"profiling": (
re.compile(r"\b(profil|alloc|cost cent|flame ?graph)|\+RTS", re.IGNORECASE),
"PROFILING_DOCS.md",
),
}


def already_loaded(session_id: str, topic: str) -> bool:
marker = Path(tempfile.gettempdir()) / f"jbeam-edit-context-{session_id}-{topic}"
if marker.exists():
return True
marker.touch()
return False


def main() -> int:
event = json.loads(sys.stdin.buffer.read().decode("utf-8"))
prompt = event.get("prompt")
session_id = event.get("session_id")
if not isinstance(prompt, str) or not isinstance(session_id, str):
return 0
sections = [
(REPO_ROOT / doc).read_text(encoding="utf-8")
for topic, (pattern, doc) in TOPICS.items()
if pattern.search(prompt) and not already_loaded(session_id, topic)
]
if sections:
print(json.dumps({
"hookSpecificOutput": {
"hookEventName": "UserPromptSubmit",
"additionalContext": "\n".join(sections),
}
}))
return 0


if __name__ == "__main__":
sys.exit(main())
12 changes: 12 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,17 @@
"Read(/tmp/jbeam-edit-*/**)",
"Read(/tmp/jbeam-corpus/**)"
]
},
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "python3 \"$CLAUDE_PROJECT_DIR/.claude/hooks/context-loader.py\""
}
]
}
]
}
}
95 changes: 2 additions & 93 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,99 +220,8 @@ Regression fixtures go in `examples/regression_jbeam/`. Read that directory's RE

## Profiling

Profiling needs `profiling: True` and `profiling-detail: all-functions` in
`cabal.project`. Neither belongs in a commit, so take them out again when you
are done.

Each of the five below has cost a wrong conclusion at least once.

**Read allocation, not time.** Two runs of the same binary on the same input
allocate the same number of bytes, reproducible to about a hundred bytes out of
several hundred megabytes. Tick counts move by several percent between the same
two runs, and a typical run is only a few hundred ticks, so a cost centre at 2%
is two or three ticks. Rank by `%alloc` and treat `%time` as an indication.

**Never profile against `examples/`.** Every run rewrites the file it is given,
with or without `-i`: `-i` only skips the `.bak.jbeam` copy that is otherwise
made first (`createBackupFile` in `Main.hs`). There is no read-only mode, so a
run that looks like a measurement is an edit. On top of that `--transform`
rewrites every `.jbeam` beside the file you name, so one run overwrites all
three fixtures. It also silently changes what the next run reads: an
already-transformed file is shorter, which makes two consecutive runs
incomparable. Copy the file to a directory of its own first, exactly as the
section above says for transformation bugs.

**Profile the large tail, not only the examples.** `examples/jbeam/frame.jbeam`
is close to a typical stock file, so it is a fair sample of the common case, but
it says nothing about the expensive one. Extract the corpus with
`tools/extract-and-format-jbeam/corpus-extract.sh` and measure the sizes before
choosing: the median file is around 11 KB, the 90th percentile around 44 KB, and
the largest is half a megabyte, roughly 36 times the frame example. Anything in
the rule lookup that scales worse than linearly is invisible at the median and
obvious at the tail, so profile one file from each end. Copy them out of the
corpus directory first, for the same reason as above.

**Give the binary its data directory, and keep stderr.** The shipped ruleset is
found through `getDataFileName` (`Formatting/Config.hs`), so a binary run from
outside the build tree finds no `examples/jbfl/minimal.jbfl` and formats with no
rules at all. Rule lookup is about two thirds of all allocation, so the profile
then measures something the tool never does: `frame.jbeam` allocated 15 MB
without rules and 62 MB with them. Export `jbeam_edit_datadir=<repo>` when
running the binary directly, and keep stderr, because a failed lookup announces
itself there. Do not look for `Loading jbfl:`, which is printed only when
`--rules-path` is given, so its absence means nothing on an ordinary run.

`--rules-path` does not get you out of this. The named file is loaded and then
discarded when the shipped default cannot be found, so the run formats with no
rules while reporting that it loaded yours. The only sign is a second stderr
line, `Failed to parse default ruleset`. This applies to any comparison between
rulesets, not only to profiling: without the data directory every variant
produces byte-identical output, which reads as "the rules make no difference".

**Rebuild fully when build flags change.** A partial rebuild leaves modules
compiled under the old flags, and the result looks like a real difference. If
allocation moves by more than a rounding error between two runs that should be
identical, suspect the binary before believing the number.

### Baseline

Measured with `--transform`, with the shipped ruleset loaded, reading
`bytes allocated in the heap` from `+RTS -s`. Reproduce it before trusting a
comparison against it.

The conditions decide the numbers, so they are part of the measurement: **GHC
9.14.1, `-O1`, containers 0.8, master at `6e9c96f`**. Optimization is worth a
factor of three and the compiler version four to five percent, so a figure
quoted without them says nothing. `-O1` because that is what ships: `configure_project.sh` passes it, and
a `-O0` baseline describes a build no user ever gets.

| Input | Size | Allocation |
|---------------------------------------------|-----------|------------|
| `examples/jbeam/frame.jbeam`, alone | 14 236 B | 23 MB |
| a 150 KB stock body file, alone | 149 896 B | 173 MB |
| the same body file in its vehicle, 74 files | 1.4 MB | 1 465 MB |

The third row is the one that describes the tool as users run it. `--transform`
rewrites every neighbouring file that referenced a renamed node, 37 of the 74
here, and rewriting means reformatting the file in full. So the formatter runs
38 times for one command, and `updateOtherFiles` inherits two thirds of all
allocation while the renaming inside it costs well under one percent. Anything
saved in the rule lookup is saved 38 times over. Making that lookup a trie is
what took these three rows down by about sixty percent.

Two things follow, and both have been mistaken for something else before.
Allocation runs one to two thousand bytes per byte of input, so the absolute
figures look alarming and say nothing on their own. And a tenfold larger input
allocates eight times as much, so the cost is slightly sublinear: there is no
asymptotic problem hiding in the rule lookup, only a large constant.

Where that constant sat was profiled once, at `-O0`, before the rule lookup
became a trie: a linear sweep over every stored pattern, the `Seq` pattern match
that compared each one against the cursor, and `propertyName` rebuilding a
`Text` from a string literal on every `Eq SomeKey` comparison, together about
two thirds of all allocation. The first two no longer exist. Nothing has been
profiled since, so treat the shares as history and measure before acting on
them.
Before profiling anything, read `PROFILING_DOCS.md`. It has the build settings, the traps
that have given wrong results, and the baseline allocation figures.

## Key Architectural Notes

Expand Down
48 changes: 48 additions & 0 deletions PROFILING_DOCS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Profiling

Profiling needs `profiling: True` and `profiling-detail: all-functions` in `cabal.project`. Neither belongs in a commit. Take them out again when you are done.

Each of the five points below has led to a wrong conclusion at least once.

**Read allocation first.** Two runs of the same binary on the same input allocate the same number of bytes. They differ by about a hundred bytes out of several hundred megabytes. Tick counts move by several percent between the same two runs. A typical run is only a few hundred ticks, which makes a cost centre at 2% two or three ticks. Rank by `%alloc` and treat `%time` as an indication.

**Never profile against `examples/`.** Every run rewrites the file it is given, with or without `-i`. The option only skips the `.bak.jbeam` copy that is otherwise made first (`createBackupFile` in `Main.hs`). There is no read-only mode, and a run that looks like a measurement is an edit. `--transform` also rewrites every `.jbeam` beside the file you name, which means one run overwrites all three fixtures. The next run then reads something different, because an already-transformed file is shorter. Two consecutive runs are not comparable. Copy the file to a directory of its own first, as described under "Investigating a transformation bug" in `CLAUDE.md`.

**Profile the large tail too.** `examples/jbeam/frame.jbeam` is close to a typical stock file. It is a fair sample of the common case and says nothing about the expensive one. Extract the corpus with `tools/extract-and-format-jbeam/corpus-extract.sh` and measure the sizes before choosing. The median file is around 11 KB and the 90th percentile around 44 KB. The largest is half a megabyte, roughly 36 times the frame example. Anything in the rule lookup that scales worse than linearly is invisible at the median and obvious at the tail. Profile one file from each end. Copy them out of the corpus directory first, for the same reason as above.

**Give the binary its data directory and keep stderr.** The shipped ruleset is found through `getDataFileName` (`Formatting/Config.hs`). A binary run from outside the build tree therefore finds no `examples/jbfl/minimal.jbfl` and formats with no rules at all. Rule lookup is about two thirds of all allocation. The profile then measures something the tool never does: `frame.jbeam` allocated 15 MB without rules and 62 MB with them. Export `jbeam_edit_datadir=<repo>` when running the binary directly. Keep stderr, because a failed lookup announces itself there. Do not use `Loading jbfl:` as a sign. It is printed only when `--rules-path` is given, and its absence means nothing on an ordinary run.

`--rules-path` does not get you out of this. The named file is loaded and then discarded when the shipped default cannot be found. The run formats with no rules while reporting that it loaded yours. The only sign is a second stderr line, `Failed to parse default ruleset`. This applies to any comparison between rulesets, not only to profiling. Without the data directory every variant produces byte-identical output, which reads as "the rules make no difference".

**Rebuild fully when build flags change.** A partial rebuild leaves modules compiled under the old flags, and the result looks like a real difference. If allocation moves by more than a rounding error between two runs that should be identical, suspect the binary before believing the number.

## Baseline

Measured with `--transform` and the shipped ruleset loaded. The figure is `bytes allocated in the heap` from `+RTS -s`. Reproduce it before trusting a comparison against it.

The conditions decide the numbers and belong to the measurement:

- GHC 9.14.1.
- `-O1`, because that is what ships (`configure_project.sh` passes it). A `-O0` baseline describes a build no user ever gets.
- containers 0.8.
- master at `6e9c96f`.

Optimization is worth a factor of three and the compiler version four to five percent. A figure quoted without these conditions says nothing.

| Input | Size | Allocation |
|---------------------------------------------|-----------|------------|
| `examples/jbeam/frame.jbeam`, alone | 14 236 B | 23 MB |
| a 150 KB stock body file, alone | 149 896 B | 173 MB |
| the same body file in its vehicle, 74 files | 1.4 MB | 1 465 MB |

The third row describes the tool as users run it. `--transform` rewrites every neighbouring file that referenced a renamed node (37 of the 74 here), and rewriting means reformatting the file in full. The formatter therefore runs 38 times for one command. `updateOtherFiles` inherits two thirds of all allocation, while the renaming inside it costs well under one percent. Anything saved in the rule lookup is saved 38 times over. Making that lookup a trie took these three rows down by about sixty percent.

Two points follow, and both have been mistaken for something else before. Allocation runs one to two thousand bytes per byte of input. The absolute figures look alarming and say nothing on their own. A tenfold larger input allocates eight times as much. The cost is slightly sublinear. There is no asymptotic problem hiding in the rule lookup, only a large constant.

The constant was profiled once at `-O0`, before the rule lookup became a trie. Three things made up about two thirds of all allocation:

- a linear sweep over every stored pattern
- the `Seq` pattern match that compared each one against the cursor
- `propertyName` rebuilding a `Text` from a string literal on every `Eq SomeKey` comparison

The first two no longer exist. Treat these shares as history and measure before acting on them.