From 1269641786b2cda32651462c573186e089652856 Mon Sep 17 00:00:00 2001 From: webdevred <148627186+webdevred@users.noreply.github.com> Date: Fri, 30 Oct 2026 18:22:27 +0100 Subject: [PATCH] Load the profiling notes from PROFILING_DOCS.md by keyword The profiling section moves out of CLAUDE.md into PROFILING_DOCS.md, which contributors can read too. A UserPromptSubmit hook in .claude/hooks/context-loader.py adds the document to a session once, when the prompt mentions profiling. --- .claude/hooks/context-loader.py | 52 ++++++++++++++++++ .claude/settings.json | 12 +++++ CLAUDE.md | 95 +-------------------------------- PROFILING_DOCS.md | 48 +++++++++++++++++ 4 files changed, 114 insertions(+), 93 deletions(-) create mode 100644 .claude/hooks/context-loader.py create mode 100644 PROFILING_DOCS.md diff --git a/.claude/hooks/context-loader.py b/.claude/hooks/context-loader.py new file mode 100644 index 00000000..58d99db6 --- /dev/null +++ b/.claude/hooks/context-loader.py @@ -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()) diff --git a/.claude/settings.json b/.claude/settings.json index 44debf3b..7b9bc530 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -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\"" + } + ] + } + ] } } diff --git a/CLAUDE.md b/CLAUDE.md index 4298e891..5f974532 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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=` 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 diff --git a/PROFILING_DOCS.md b/PROFILING_DOCS.md new file mode 100644 index 00000000..9173c7a0 --- /dev/null +++ b/PROFILING_DOCS.md @@ -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=` 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.