Add profiling build target + native profiler workflow; closes #345

The default Release build (-O3 -DNDEBUG) omits frame pointers and ships no
DWARF, so it can't be line-annotated or unwound; build-debug (-O0) profiles
unoptimized code. Add a dedicated profiling variant for C++ hot-path work
(validating #331/#342/#343/#344).

- CMakeLists.txt: MCRF_PROFILE option adds -fno-omit-frame-pointer on top of
  RelWithDebInfo (-O2 -g). Binary not stripped. Post-build lib/assets/scripts
  copy already keys on the mcrogueface target, so build-profile/ self-populates.
- Makefile: `make profile` (-> build-profile/), `make callgrind SCRIPT=...`
  (one-shot Callgrind on a headless benchmark), `make clean-profile` (wired
  into clean-all).
- docs/profiling.md: full Callgrind + perf workflow, incl. the one-time
  `sudo sysctl kernel.perf_event_paranoid=1` perf needs on this box.
- Pointers from the Makefile header and CLAUDE.md build section.

Verified: DWARF sections present, frame-pointer prologues emitted, headless
smoke test passes, Callgrind yields source line + call-count attribution, and
perf --call-graph fp resolves engine symbols.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
John McCardle 2026-07-10 18:59:39 -04:00
commit 03c9b9ff58
4 changed files with 139 additions and 1 deletions

View file

@ -541,6 +541,22 @@ make windows # Windows cross-compile
make clean && make # Full rebuild
```
### Profiling the Engine (native C++)
For diagnosing/validating C++ hot-path work, use the dedicated profiling build — the
default Release build omits frame pointers and DWARF, so it can't be annotated. See
[docs/profiling.md](docs/profiling.md) for the full workflow. Quick version:
```bash
make profile # build-profile/ : RelWithDebInfo + frame pointers
make callgrind SCRIPT=tests/benchmarks/foo.py # deterministic instruction counts, no perms needed
callgrind_annotate build-profile/callgrind.out | head -60
```
Callgrind is deterministic (exact `Ir` counts + source line attribution) — ideal for
A/B-validating optimizations like #331/#342. `perf record --call-graph fp` gives
wall-clock sampling but needs `sudo sysctl kernel.perf_event_paranoid=1` first.
### Running and Capturing Output
```bash
# Run with timeout and capture output

View file

@ -31,6 +31,7 @@ option(MCRF_DEBUG_PYTHON "Link against debug CPython from __lib_debug/" OFF)
option(MCRF_FREE_THREADED_PYTHON "Link against free-threaded CPython (python3.14t)" OFF)
option(MCRF_WASM_DEBUG "Build WASM with DWARF debug info and source maps" OFF)
option(MCRF_FUZZER "Build with libFuzzer coverage instrumentation for atheris" OFF)
option(MCRF_PROFILE "Keep frame pointers for perf/Callgrind profiling (use with RelWithDebInfo)" OFF)
# Validate mutually exclusive sanitizers
if(MCRF_SANITIZE_ADDRESS AND MCRF_SANITIZE_THREAD)
@ -334,6 +335,14 @@ if(MCRF_SANITIZE_THREAD)
-fsanitize=thread)
endif()
# Profiling instrumentation (#345) — retain frame pointers so perf --call-graph fp
# and Callgrind can unwind through optimized code. Optimization/-g come from the
# RelWithDebInfo build type; this only adds the frame pointer. Binary is NOT stripped.
if(MCRF_PROFILE)
message(STATUS "Profiling build: frame pointers retained (perf/Callgrind)")
target_compile_options(mcrogueface PRIVATE -fno-omit-frame-pointer)
endif()
if(MCRF_FUZZER)
if(NOT CMAKE_CXX_COMPILER_ID STREQUAL "Clang")
message(FATAL_ERROR "MCRF_FUZZER=ON requires Clang. Invoke with CC=clang-18 CXX=clang++-18.")

View file

@ -7,6 +7,10 @@
# make clean-windows - Clean Windows build
# make run - Run the Linux build
#
# Profiling (see docs/profiling.md):
# make profile - RelWithDebInfo + frame pointers build in build-profile/
# make callgrind SCRIPT=tests/benchmarks/foo.py - Callgrind a headless benchmark
#
# WebAssembly / Emscripten:
# make wasm - Build full game for web (requires emsdk activated)
# make wasm-game - Build game for web with fullscreen canvas (no REPL)
@ -31,6 +35,7 @@
.PHONY: package-windows-light package-windows-full package-linux-light package-linux-full package-all
.PHONY: version-bump
.PHONY: debug debug-test asan asan-test tsan tsan-test valgrind-test massif-test analyze clean-debug
.PHONY: profile callgrind clean-profile
# Number of parallel jobs for compilation
JOBS := $(shell nproc 2>/dev/null || echo 4)
@ -73,7 +78,7 @@ clean-dist:
@echo "Cleaning distribution packages..."
@rm -rf dist
clean-all: clean clean-windows clean-wasm clean-debug clean-dist
clean-all: clean clean-windows clean-wasm clean-debug clean-profile clean-dist
@echo "All builds and packages cleaned."
run: linux
@ -94,6 +99,32 @@ debug-test: debug
MCRF_LIB_DIR=../__lib_debug \
python3 run_tests.py -v
# Profiling build (#345): RelWithDebInfo (-O2 -g) + frame pointers, self-contained
# in build-profile/ (lib/assets/scripts copied like the normal build). Suited to
# both Callgrind (deterministic, no perms) and perf (--call-graph fp).
profile:
@echo "Building McRogueFace for profiling (RelWithDebInfo + frame pointers)..."
@mkdir -p build-profile
@cd build-profile && cmake .. \
-DCMAKE_BUILD_TYPE=RelWithDebInfo \
-DMCRF_PROFILE=ON && make -j$(JOBS)
@echo "Profile build complete! Binary: build-profile/mcrogueface"
# Callgrind a headless benchmark script. Deterministic, exact instruction counts,
# no special permissions. Usage: make callgrind SCRIPT=tests/benchmarks/foo.py
# Output: callgrind.out (feed to callgrind_annotate).
SCRIPT ?= tests/benchmarks/issue_331_property_read_bench.py
callgrind: profile
@echo "Running Callgrind on $(SCRIPT)..."
@cd build-profile && valgrind --tool=callgrind \
--callgrind-out-file=callgrind.out \
./mcrogueface --headless --exec ../$(SCRIPT)
@echo "Done. Annotate with: callgrind_annotate build-profile/callgrind.out | head -60"
clean-profile:
@echo "Cleaning profile build..."
@rm -rf build-profile
asan:
@echo "Building McRogueFace with ASan + UBSan..."
@mkdir -p build-asan

82
docs/profiling.md Normal file
View file

@ -0,0 +1,82 @@
# Native Profiling Workflow (Callgrind + perf)
External C++ profiling for engine hot-path work (issue #345). This is separate from
the in-engine `ProfilerOverlay` / `ProfilingMetrics` live HUD (`src/Profiler.*`,
`GameEngine.h`) — those show frame time / draw calls at runtime; this is for
diagnosing and A/B-validating C++ optimizations (e.g. #331, #342, #343, #344).
## The profiling build
The default `make` (Release, `-O3 -DNDEBUG`) omits frame pointers and ships no DWARF,
so it can't be line-annotated or unwound. `make build-debug` (`-O0`) profiles the wrong
(unoptimized) code. Use the dedicated profiling build instead:
```bash
make profile
```
Produces `build-profile/mcrogueface`, configured `RelWithDebInfo` (`-O2 -g`) plus
`-fno-omit-frame-pointer` (CMake `-DMCRF_PROFILE=ON`). Not stripped. The build is
self-contained — `lib/`, `assets/`, and `scripts/` are copied next to the binary just
like the normal build, so `--headless --exec` works out of the box.
## Callgrind — primary, deterministic, no special permissions
Best fit for validating optimizations. `--headless --exec <script>` is deterministic,
so Callgrind yields exact, reproducible instruction counts (`Ir`) with source file:line
attribution and call counts. ~30-50x slowdown, so point it at a bounded benchmark.
One-shot via the Makefile (defaults to the #331 benchmark; override with `SCRIPT=`):
```bash
make callgrind SCRIPT=tests/benchmarks/issue_331_property_read_bench.py
callgrind_annotate build-profile/callgrind.out | head -60
```
Or manually:
```bash
valgrind --tool=callgrind --callgrind-out-file=build-profile/callgrind.out \
build-profile/mcrogueface --headless --exec tests/benchmarks/<bench>.py
# Function-level, engine code only:
callgrind_annotate --threshold=95 build-profile/callgrind.out \
| grep -E "src/" | grep -Ev "python3\.14|/usr/|libpython"
```
**A/B a change:** run Callgrind before and after, compare the total `Ir` for the target
function (Callgrind reports per-function self + inclusive counts and per-call-site
counts). Because it's deterministic, any delta is the change, not noise. `kcachegrind`
(GUI) isn't installed on the dev box; `callgrind_annotate` (CLI) is sufficient.
Caveat: Callgrind models an idealized cache and counts instructions, not wall-clock —
great for "did this do less work," not for real-time behavior.
## perf — real wall-clock sampling
For the interactive game where you need actual time spent (draw calls, SFML/Python cost
Callgrind's model hides):
```bash
perf record --call-graph fp -- build-profile/mcrogueface [args]
perf report
```
`--call-graph fp` relies on the frame pointers the profiling build retains.
**Prerequisite (one-time, needs root):** the dev box ships
`kernel.perf_event_paranoid = 3`, which blocks `perf_event_open` for non-root entirely.
Lower it for the session:
```bash
sudo sysctl kernel.perf_event_paranoid=1
```
(Or run perf under `sudo`. To persist, add `kernel.perf_event_paranoid = 1` to
`/etc/sysctl.conf`.)
## Notes
- gprof is intentionally not used: it requires a `-pg` instrumentation recompile and
handles the Python/SFML shared libraries poorly.
- Clean up with `make clean-profile` (also covered by `make clean-all`).