Analyzer diagnostics: blame only real culprits, split otherMs by Unity phase, read patch headers, mark GC frames in heap mode (PL4, PL5, PL6, PL3) - #3
Merged
Conversation
summary.md's Blame column and section 5 of `perflog.py report` named the biggest singletons of every slow frame however small they were. In the real 0.1.3 session 2026-09-21_23-13-11 each of the nine 1.1-1.9 s save frames read "<- AnimatorRegistry 1-2 ms", and the sample session blamed PanelStack (1 ms) for a 132 ms garbage-collection frame. A singleton is now named only when it took at least 10% of the frame or at least 5 ms (Summary.BlameMinShare / BlameMinMs, and BLAME_MIN_SHARE / BLAME_MIN_MS in perflog.py). Otherwise the cell says that no singleton stood out, with the largest one's time and share of the frame, and whether the frame had a save or a garbage collection, which no singleton's time shows. spikes.csv stays raw data, and the findings already required a 40% share. Tests (both failed before, naming AnimatorRegistry in the 1142 ms save frame): SummaryTests "a slow frame's Blame names only a singleton that took a real part of it" and test_perflog test_slow_frames_blame_only_a_singleton_that_took_a_real_part. The fixtures' summary.md "slowest frames" sections are regenerated (only that section, so the test process's own heap figures do not churn). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
otherMs (frame time outside every timed part) was reported as one
number, and when it dominated a slow session the report always said
"Most of the frame is not the game's or any mod's code ... likely the
graphics card", even when the time was script code in Unity's Update
phase. The phase columns already recorded say where it went: the game's
tick loop and singleton updates run inside plUpdate (Ticker.Update,
SingletonLifecycleUnityAdapter.Update), the late singletons inside
plLate, and drawing and the vsync wait are plPost.
summary.md ("Where an average frame goes") and section 3 of
`perflog.py report` now split otherMs into: plUpdate less the timed
parts that run in it (other scripts' Update: the game's and mods'
MonoBehaviours, coroutines); plLate less lateMs, labelled "other work in
Unity's LateUpdate phase" (animation, UI Toolkit, scripts' LateUpdate),
not mods; plPost; the phases before Update; and what falls between the
phases. saveMs runs in LateUpdate for the game's own save
(GameSaverUnityAdapter.LateUpdate) and in Update when BeaverBuddies
defers it to a tick (the real 0.1.3 session has both), so it is taken
out of the phase with more room left. On the real session
2026-09-21_23-13-11 the split reads 1.33 / 1.14 / 3.91 / 0.16 / 0.13 ms
of 6.67 ms. Summary.OtherByPhase and perflog.split_other use the same
rule; the report applies it per summary row, the summary to the
session row. It is printed only when phases were measured, so older
recordings read as before.
The "not the game's code" finding now looks at the split: when the
Update or LateUpdate remainder is bigger than plPost it points at
scripts running every frame (or animation and UI), not the graphics
card. report --json gains otherMsByPhase. SESSION-README (and so the
fixtures' README.md) explains the split.
Tests (failed before: no split in the summary or report, no
split_other, and the GPU finding for a frame spent in Update): SummaryTests
"otherMs is split by Unity phase", test_perflog
test_other_ms_is_split_by_unity_phase,
test_the_report_splits_other_ms_by_unity_phase and
test_other_ms_in_the_update_phase_points_at_scripts_not_the_graphics_card.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every recording's frames.csv header lists Harmony's patches
('# patch|tag|method|kind|owner|...': every hot method, and every method
another mod patches; 501 lines in the real 0.1.3 session), but
perflog.py stored them and never read them. The report listed
InputService (16 ms/s, 10% of per-frame updates) under mod "game" with
no word that MixedStorage and BeaverBuddies patch its UpdateSingleton,
and compare did not notice two sessions patching different things.
report, section 6: a singleton row now ends "(includes patches by X,
Y)" when another mod patches the method that row's kind times (Tick,
UpdateSingleton, LateUpdateSingleton or StartParallelTick): the wrapper
calls the patched method, so those patches are inside the row's time.
Patches on the class's other methods are not listed on it, and this
mod's own (kyler.performancelog*) never are. The section ends with the
hot methods other mods patch, owner and patch kinds each (methods
several mods patch first, --top of them unless --all), and a note when
the header was truncated. A session without a profile still gets that
list under its own heading.
compare, section 1: patches only one session has are listed per
session, with a count per owner and a few examples. A patch is (method,
kind, owner); the tag is left out because it changes when a second mod
starts patching the same method.
Older recordings have the same line format (checked on 0.1.0, 0.1.1
and 0.1.3 recordings) and read as before otherwise.
Tests (failed before: the InputService row did not mention its patcher,
and compare listed no patch difference):
test_singletons_other_mods_patch_are_marked_and_hot_patches_listed and
test_patches_that_differ_are_listed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every real recording reads allocation from the heap size (GC.GetTotalMemory(false): the game's Mono does not count bytes per thread), and the heap size falls at a garbage collection. A frame with one lost what it allocated: otherKB and the slot KB columns were clamped to 0 (or read low) and nothing told that zero from a measurement. The comment on the otherKB computation claimed the counter "never goes down at a collection", which is only true of the exact per-thread counter. The probe now counts such frames (SessionStats.AllocUnmeasuredFrames and AllocUnmeasuredMs): a frame is not measured when the counter fell inside a timed part or over the frame, or, with the heap size, when a collection ran. No column is added and the fixtures do not change; the session ends with a new line, '# capability-final|allocSource|heap size|allocation not measured in N frames (X s) with a garbage collection: ... the rows with gcDelta above 0 or a negative allocKB' (or '...|measured in every frame'), from Probe.AllocFinalLine. summary.md says so when it happens and leaves those frames' time out of "allocated about X KB per second". perflog.py leaves the slow frames with a collection (heap mode: gcDelta > 0 or a negative allocKB) out of every allocation-per-second figure (section 7, the garbage-collection finding and compare) and says how many frames were not measured: the mod's count when the recording has the new line, else "at least" its slow rows with one, so older recordings are read the same way. The freed bytes are not added back. Alloc.UseTestSource can now stand in for the heap size (asHeapSize), so the heap-mode rule is tested. Tests (the first failed before: "the frame with the collection reads otherKB 0 and nothing says its allocation was not measured"; the Python one read 100 KB/s, not 109): tests/HeapModeTests.cs (3 checks) and test_perflog test_heap_mode_leaves_frames_with_a_collection_out_of_allocation_per_second. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The PL5 finding picked the biggest of only the Update remainder, the LateUpdate remainder and plPost. Unity can wait for the last frame to be presented in its first phase (plTime, TimeUpdate), so in a GPU-bound session whose wait lands there, a small Update remainder that still beat plPost turned the high-severity "not the game's or any mod's code, likely the graphics card" finding into an info finding that said "the graphics card is not what holds the frame". The choice now also weighs the phases before Update and the time between phases, and those keep the graphics-card finding (its evidence says how much is in the phases before Update). When the Update or LateUpdate remainder is biggest but the game thread is busy for less than 70% of the frame, the finding says a script there is waiting, not that the graphics card is ruled out. The split_other docstring and Summary.OtherByPhase comment said a save taken out of the wrong phase makes an error "less than the save"; it is up to the save, and the two splits (session mean in C#, per window in Python) can differ in a session with both kinds of save. Found by the adversarial review of this branch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
compare's patch diff counted Performance Log's own measuring patches (kyler.performancelog*), which report section 6 already leaves out. Two recordings that differ only in this mod's Profile setting or version listed those patches as a difference and they inflated the per-owner count; the Profile difference is already reported as a header key. patch_set now drops them, by the same rule as other_patchers. Report section 6 printed the "hot methods other mods patch" heading over an empty list, which reads as "nothing hot is patched" even when the recording could not list the patches. The heading now appears only over a list, and a recording with '# patches-unavailable' says the patches were not recorded, in the report and in compare (which no longer lists every patch of the other session as missing). Found by the adversarial review of this branch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ates Follow-ups from the adversarial review of this branch: - Report section 7 put the unmeasured-frame count (the mod's whole-session count, or for an older recording every slow row with a collection, paused and background ones included) right after the steady-state collection count, so the two could not be reconciled. The note now says the count is the whole session's and how many of those slow frames fall in the windows the figures cover. - The allocation rate left an unmeasured frame's time out but kept whatever positive heap growth it still showed, since allocKB sums its positive part. Probe now also counts that growth (SessionStats.AllocUnmeasuredKB), summary.md subtracts it, and perflog.py subtracts it for the slow rows it knows. - The capability-final line and summary.md said the unmeasured frames "are the rows with gcDelta above 0 or a negative allocKB"; only slow frames have rows, so they now say the slow ones are those F rows. - HeapModeTests' first check ran under the exact test counter although its name says heap mode. It now uses the heap-size stand-in inside a no-GC region (so the test process's own collections do not count), and asserts the '|heap size|' final line and the heap-mode "measured in every frame" line. - Alloc.ModeName wrote "(coarse: moves only when the heap grows)" into every heap-mode recording's allocSource line; the heap size also falls at a collection, which is this item's bug. The text now says so. The analyzers match only the "GC.GetTotalMemory" prefix, and no fixture holds this text. - docs/TESTING.md: the five-minute check gains a step for the new capability-final line and the otherMs split, which no game has shown yet. No column or fixture change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…corded patches without a profile Summary.Blame wrote 'no singleton stood out' for a slow frame with no timed singleton, reading nothing as a measurement; it now leaves the cell empty, as perflog.py's blame_text does. The report's 'patches were not recorded' note was only printed inside section 6's profile branch; a recording with no profile now gets it too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Four analyzer and summary fixes from the review of the first real 0.1.3 recording (
2026-09-21_23-13-11), plus follow-ups from an adversarial review of this branch. Performance Log only observes, so nothing here touches simulation, co-op or other mods' patches. The column layout andframes.csvformat are unchanged, and the analyzer still reads 0.1.0, 0.1.1 and 0.1.3 recordings.summary.md's Blame column and report section 5 no longer name a 1 ms singleton as the cause of a 1.1 s save frame.otherMsis split by Unity phase, so "the rest" points at scripts' Update, LateUpdate work (animation, UI Toolkit), drawing, or the phases before Update.# patch|header lines. It names other mods whose patches run inside a singleton's time, lists hot methods other mods patch, andcomparelists patches that only one session has.# capability-final|allocSource|line and left out of the allocation-per-second figures, where they used to be read as 0 KB.Items
PL4: Blame names only a singleton that took a real part of the frame
Summary.Blame, Python section 5) names a singleton only if it took at least 10% of the frame or at least 5 ms (Summary.BlameMinShare/BlameMinMs,BLAME_MIN_SHARE/BLAME_MIN_MS). Otherwise it saysno singleton stood out (largest X ms, Y% of the frame), plusthe frame had a saveand/ora garbage collection.spikes.csvis unchanged. In the fixtures'summary.md, only the slowest-frames section changed.FindingTests.test_slow_frames_blame_only_a_singleton_that_took_a_real_part.| 101 | 0 | 1142 | ... | Timberborn.TimbermeshAnimations.AnimatorRegistry 1 |, and in Python[GC,save] saveMs 1133 ... <- AnimatorRegistry 1 ms. Passes after. On the real session, all 9 save frames now readno singleton stood out (largest 0.3-2.1 ms, 0.0-0.2%); the frame had a save and a garbage collection.PL5: split
otherMsby Unity phaseWhat changed:
summary.md("HowotherMssplits by Unity phase") and report section 3 splitotherMsinto five parts:lateMs, labelled "other work in Unity's LateUpdate phase (animation, UI Toolkit, scripts' LateUpdate)", not "mods"plPostsaveMsis taken out of whichever phase has more room. The decompiledGameSaverUnityAdapter.LateUpdatecallsSaveQueued, so the game's own save runs in LateUpdate, while BeaverBuddies defers it into Update. The real session has both kinds.report --jsongainsotherMsByPhase.SESSION-READMEexplains the split, so the fixtures'README.mdwas regenerated. On the real session the split is 1.33 / 1.14 / 3.91 / 0.16 / 0.13 ms ofotherMs6.67 ms.Finding (report section 4): when the Update or LateUpdate remainder holds most of the frame, the finding points at scripts, not at the graphics card. Review follow-up:
plTime, so those keep the high-severity "likely the graphics card or vertical sync" finding.Tests:
HelperTests.test_other_ms_is_split_by_unity_phaseFindingTests.test_the_report_splits_other_ms_by_unity_phaseFindingTests.test_other_ms_in_the_update_phase_points_at_scripts_not_the_graphics_cardFindingTests.test_a_wait_before_the_update_phase_still_points_at_the_graphics_card(new)FindingTests.test_update_phase_time_with_an_idle_game_thread_is_not_called_work(new)Failed before:
module 'perflog' has no attribute 'split_other', and a 40 ms frame with 31 ms in Update was reported as "Likely the graphics card".plTimeand the thread 40% busy got[ ] Most of the frame is other work in Unity's Update phase ... The graphics card is not what holds the frame.Passes after.
PL6: read the
# patch|header linesWhat changed in the report:
(includes patches by X, Y)when another mod patches the method that row's kind times (Tick,UpdateSingleton,LateUpdateSingletonorStartParallelTick).What changed in
compare: section 1 lists the patches only one session has, counted per owner, with examples. The comparison key is (method, kind, owner), without the tag.What is left out: this mod's own patches (
kyler.performancelog*) never appear.Review follow-up:
comparealso leaves out this mod's own patches. They follow its Profile setting, which compare already reports as a header key.# patches-unavailablesays the patches were not recorded, in both the report andcompare.Tests:
FindingTests.test_singletons_other_mods_patch_are_marked_and_hot_patches_listedFindingTests.test_no_hot_patch_list_without_hot_patches_by_other_mods(new)CompareTests.test_patches_that_differ_are_listed(extended with akyler.performancelogpatch in one session only)CompareTests.test_patches_are_not_compared_when_one_session_did_not_record_them(new)Failed before:
'some.mod' not found in ' Timberborn.InputSystem.InputService game 30.00 ms/s ...', and compare gave1 != 0.only ... has 4 patches (kyler.performancelog 2, new.mod 2); the heading printed over an empty list;'not recorded in ...' not found.Passes after.
On the real sessions: InputService shows
(includes patches by kyler.mixedstorage, timbermods.BeaverBuddiesMultiColony). Comparing 0.1.117-13-43with 0.1.323-05-35now lists 60 patches, not 62; the two dropped are this mod's own.Limitation: patch lines name the declaring type, so a patch on an inherited base-class method is not matched to a derived singleton's row.
PL3: heap-mode frames with a collection are counted as not measured
What changed in the probe:
Probecounts a frame as not measured when its allocation counter fell. That covers a fall inside a timed part, a fall over the whole frame, and, in heap mode, any collection. The counts areSessionStats.AllocUnmeasuredFrames,AllocUnmeasuredMsand (follow-up)AllocUnmeasuredKB. There is no new column.New header line:
Session.Stopwrites# capability-final|allocSource|heap size|allocation not measured in N frames (X s) with a garbage collection: ..., or|measured in every frame(Probe.AllocFinalLine).summary.md: says how many frames were not measured and leaves them out of "allocated about X KB per second". It leaves out their time and, after the follow-up, the positive heap growth they still showed.perflog.py: does the same for heap-mode slow rows withgcDelta > 0orallocKB < 0, in section 7, the GC finding andcompare. It uses the mod's count when the recording has one, and "at least N" for older recordings.Code and test cleanup: the
Probe.cscomment is corrected, andAlloc.UseTestSource(source, asHeapSize)is added for tests.Review follow-up:
Frows".Alloc.ModeNamenow says(coarse: grows with allocation, falls at a garbage collection), replacing "moves only when the heap grows". The analyzers match only theGC.GetTotalMemoryprefix, and no fixture holds this text.Tests:
tests/HeapModeTests.cs(3 checks, registered intests/Program.cs; no PL1 content).FindingTests.test_heap_mode_leaves_frames_with_a_collection_out_of_allocation_per_secondFindingTests.test_heap_mode_counts_are_scoped_and_a_lost_frames_growth_is_left_out(new)Failed before:
AllocUnmeasuredKB. With only the field declared, "the lost frame's heap growth: expected about 363.6, got 0" and "only the slow ones have rows" failed. Python read 109 KB/s instead of 100.Passes after. A mutant that ignores a falling counter is caught by the heap-mode check.
Unchanged: columns and fixtures. Regenerating the samples gives identical
summary.md,README.mdandcolumns.md, andWriterTests.FixturesAreCurrentpasses.Tests
dotnet run --project tests -c Releasepython -W error::ResourceWarning -m unittest discover -s tools -p test_perflog.pydotnet build source/PerformanceLog.csproj -c Release(offline)docs/TESTING.mdcheck counts are updated in each commit.perflog.py reportandreport --jsonran on all 26 real recordings (0.1.0, 0.1.1 and 0.1.3) without error, andcompareran across versions. Against the pre-follow-up head, the only report change on those recordings is the reworded section-7 note. Both Python files parse as Python 3.8.While running the C# suite, two
WriterTestsfile-sharing checks each failed once, in separate runs, with "being used by another process" on a fresh temp file. Neither recurred on the re-runs. This PR does not touch the writer. The likely cause is another process (a virus scanner, or the many parallel builds on this machine) opening the temp file.Co-op impact
None. Performance Log only observes: no simulation change, no wire-format change, and no Harmony patch added, removed or reordered. The frame path still allocates nothing (its check passes). The new per-frame work is one bool and a few adds.
Proposed CHANGELOG lines
perflog.py reportblame a slow frame on a singleton only if it took at least 10% of the frame or 5 ms. Otherwise they say no singleton stood out, and whether the frame had a save or a garbage collection.otherMsby Unity phase: the Update phase minus the timed parts inside it, the LateUpdate phase minuslateMs,plPost, the earlier phases, and time between phases. When scripts' Update or LateUpdate work holds the time, the "not the game's code" pointer now points there. When the wait is inplPostor the phases before Update, it keeps pointing at the graphics card or vertical sync.perflog.py reportnames the other mods whose Harmony patches run inside a singleton's time and lists the hot methods other mods patch.comparelists the other mods' patches that only one session has.# capability-final|allocSource|line and in summary.md. Its time and heap growth are left out of the allocation-per-second figures; perflog.py does the same for older recordings. TheallocSourcecapability line now says the heap size falls at a collection.Needs in-game testing
Solo is enough; there is no co-op change.
frames.csvshould end with# capability-final|allocSource|heap size|allocation not measured in N frames (X s) with a garbage collection ..., with N at least the number ofFrows withgcDelta> 0, andsummary.md's garbage-collection section should give the same N. A session with no collection should show|heap size|measured in every frame. The header's# capability|allocSource|should read(coarse: grows with allocation, falls at a garbage collection).summary.md, the parts of "HowotherMssplits by Unity phase" should add up tootherMswithin about 0.1 ms. A save deferred by BeaverBuddies should come out of the Update phase, and a manual save while paused out of LateUpdate.no singleton stood out (...); the frame had a save.python tools/perflog.py report <folder>on a new recording should mark InputService and NavigationSynchronizer with the mods that patch them.Review
Three adversarial reviewers approved the branch: determinism and co-op, save compatibility and cross-mod interaction, and whether the tests are real. They reported nothing major or worse. Each item's tests failed when only that item's fix was reverted, and several mutants were caught. What they found, and what happened:
Fixed:
plTimelost the high-severity graphics-card finding. Fixed incc384d0: the finding now weighs the phases before Update and between phases, and gates on how busy the game thread was.cc384d0: they now say "up to the save" and note that the per-window and session-mean splits can differ.comparecounted this mod's own patches. Fixed incc6743a.cc6743a, which also covers# patches-unavailable.1d1affb:Alloc.ModeNamesaid the heap size "moves only when the heap grows".1d1affbadds a step to the five-minute check instead of an item to the "still not verified" list, because the docs PR (claude/docs-refresh) rewrites that list's items 7 and 8, and an item appended there would conflict.Declined:
CLAUDE.md's "about 70" checks count belongs to the docs PR, which already replaces it with a pointer todocs/TESTING.md.Merge note:
claude/sampling-calibrationandclaude/beaver-rollupalso edittools/perflog.py,tools/test_perflog.py,tests/Program.csand the check counts indocs/TESTING.md, so expect textual conflicts when these PRs land after one another.🤖 Generated with Claude Code