Sample watched methods at their own rate (PL1); charge patch calls what they cost (NF2) - #2
Merged
Merged
Conversation
Watched methods (config Watch) chose their sampling interval from the call rate of all watched methods together, and the interval could only grow. One busy window, or one busy method, left every watched method timed on about 1 call in 30 for the rest of the session, so a rare method's 20 calls of 5 ms often produced no timed call at all: its row was dropped (ms rounded to 0) while the session totals still added the calls at 0 ms. Profile.FlushWindow now calls AdaptMethods before the counters are zeroed: each method's interval for the next window comes from its own calls, is set again every window (never below the interval it was registered with, never above 4096), and the methods' share of the budget is split between the methods that ran. Every method's countdown restarts at 1 at each window start, so a method that runs has its first call of the window timed. A watched method gets a row for every window it ran in; a window where it was counted but never timed (its timed calls threw, so Harmony skipped the postfix) is written with sampled 0 and adds nothing to the session totals. tools/perflog.py leaves such rows out of calls and ms the same way and says how many calls were never timed. Tests: WatchSamplingTests (from the PL1 part of the verification harness, with the rare-method check tightened to every window) and a test_perflog check. Column descriptions changed, so the fixtures' columns.md and README.md are regenerated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
overheadUs charges every call of the per-call patches (entity ticks, components, watched methods; the patchCalls counter) PatchCallTicks. That was measured by patching a method of our own with an empty prefix and postfix, which came out under half a nanosecond in the game, so every recording up to 0.1.3 says patchCallNs|0 and overheadUs, and the 2% "measuring cost too much" warnings built on it, left out about 100,000 patch calls a second at speed 7. Instrumentation.MeasurePatchCost now times the real EntityPrefix and EntityPostfix on the unsampled path (Probe.MeasureUnsampled: the probe switched on, entity and component sampling held off with Profile.HoldSampling, fastest of five warmed-up rounds, probe and profile left as a log start expects) and adds what Harmony adds to call a prefix and a postfix (the old empty-patch measurement, now with the same Sample state, clamped at 0). The calibration header line gains patchBodyNs, and patchCallNs is written with one decimal; if the body could not be measured both say "unmeasured" and the 40 ns assumption overheadUs then uses is named instead of hidden. tools/perflog.py gets a KNOWN_ISSUES note (fixed in 0.1.4) that overheadUs understates the mod's cost, printed only for recordings whose calibration line has no patchBodyNs, so a build of the fix that still says 0.1.3 is not flagged. docs/TESTING.md no longer claims 0.1.1 measures the patch cost, and the five-minute check looks at the new calibration fields. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review follow-up. AdaptMethods set every watched method's interval from its calls in the window just ended, so one window without calls put it back to the interval it was registered with (8). A method that is busy only in bursts was then timed at about 1 call in 8 in every burst: 3.75 times the watched methods' share of the budget each time. A window with no calls says nothing about the method's rate, so its interval is now kept; the countdown still starts again at 1, so its first call in the next window it runs is timed, and any window in which it runs less still brings the interval back down. Tests: BurstsStayInsideTheBudget (busy in windows 1, 3 and 5, not called in 2 and 4; before: burst window 3 took 0.375% of a second against the 0.1% share; after: 0.100%) and FewCallsEveryWindow (3 calls a window at an interval of 8 are timed in every window, which only holds because each window's countdown restarts at 1; no test covered that before). The 'method' kind description said N widens "only while that method itself is busy", but the budget is split between the watched methods that ran, so N also widens when many of them are busy. It now says so; the fixtures' columns.md and README.md are regenerated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…dings Review follow-up. The KNOWN_ISSUES note said every recording up to 0.1.3 charged its patch calls nothing. It did not: patchCallNs is written to whole nanoseconds, and an empty-patch reading of exactly 0 (patched minus unpatched, clamped) made the mod charge an assumed 40 ns a call, while a reading just above 0 charged about nothing. Both show patchCallNs|0. With the 40 ns charge no row's overheadUs can fall below 40 ns per patch call, so a row below it proves the charge was about nothing. perflog.py now prints the "understates" note only for such recordings, and a neutral note (the charge was a guess or about nothing, and no row shows which) for the other old recordings that ran patches. Neither is printed when the calibration line has patchBodyNs or patchCallNs is not 0. On the 26 real recordings: every 0.1.3 one with patch calls and four 0.1.1 ones are flagged as understated; the 0.1.0 ones and the other 0.1.1 ones get the neutral note, where before they were wrongly told they understated. docs/TESTING.md item 2 and the MeasurePatchCost comment say the same. PatchCostCoversTheBodies timed the bodies directly in one loop of a million calls, so a moment of CPU contention could push that figure past twice the calibrated one and fail the check (a reviewer saw it fail in 1 of 3 runs next to a busy loop). It now takes the fastest of ten rounds, as the calibration does. The calibration header line moves into Probe.CalibrationParts, and the new CoreTests.CalibrationLine checks the names perflog.py relies on (patchBodyNs, patchCallNs), the one-decimal values and the "unmeasured" wording. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- perflog.py: an old recording whose empty-patch reading was above 0 (any value, not just under half a nanosecond) left the bodies out too, and now gets the 'understates' note; the neutral note is only for a reading of 0. - Say that a watched method's call is charged the entity patch's figure and so is still undercharged a little (MeasurePatchCost, TESTING.md). - WatchSamplingTests: a check that several busy watched methods share the methods' budget (removing the split failed no check before), and put Profile.BudgetFraction back after each check. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
kramsey458
marked this pull request as ready for review
September 22, 2026 19:50
kramsey458
added a commit
that referenced
this pull request
Sep 22, 2026
Auto Watch: time other mods' patches on hot methods within the 40-method cap (stacked on #2)
Merged
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
Two fixes to how Performance Log measures its own work. The mod only observes the game, so nothing here touches simulation.
Watch) are sampled at their own rate, and the rate can go down as well as up. Before, the sampling interval came from all watched methods' calls added together, and it could only grow. One busy window, or one busy method, left every watched method timed on about 1 call in 30 for the rest of the session. A rare method's windows then often had no timed call at all: the row was dropped, but the totals still added those calls at 0 ms.overheadUsnow charges the per-call patches what they actually cost.patchCallNsused to time an empty patch, and every recording up to 0.1.3 showspatchCallNs|0. It now times the realEntityPrefix/EntityPostfixbodies on the unsampled path, adds what Harmony costs to call them, and writespatchBodyNsnext to it. For older recordings,perflog.pyexplains what theiroverheadUsreally charged.Items
PL1: per-method watched sampling that can go both ways
What changed (
source/Core/Profile.cs):Profile.FlushWindowcalls a newAdaptMethodsbefore the counters are zeroed. For each watched method, it sets the next window's interval from that method's own calls in the window.methodCounter[id]restarts at 1 at every window start, so a method's first call in each window is timed.sampled0 and adds nothing to the session totals.perflog.pyalso leavessampled0 rows out of calls and ms, and prints(+N calls never timed).SESSION-READMEitem 7 andTESTING.mdare updated. The fixtures'columns.mdandREADME.mdare regenerated. Theirframes.csvdiffered only by real-memory noise, so I kept the committed ones.Tests:
tests/PL1WatchSamplingTests.cs(WatchSamplingTests). It holds only the PL1 part of the verification harness, withProbe.SamplePairTicks = 50e-9 * Stopwatch.Frequencyas a double;Rig.Mswould truncate that to 0 and switch adaptation off. It has five checks:sampled0 row, and the totals get no fake 0 ms.Plus
test_perflog.test_a_watched_method_window_nobody_timed_is_not_counted_at_0_ms.Fail before / pass after:
window 2: only 1 of 20 calls were timed; the interval is still the one the hot method neededwindow 4: only 3 of 100 calls were timed; the interval never came back down after the busy windowswindow 2: the method ran 3 times and left no row463.0 us/callinstead of500.0 us/call(a fake 0 ms averaged in).burst window 3: measuring took 0.375% of a second, over the watched methods' share of the budget. After: 0.100%.FewCallsEveryWindowfails withwindow 2: 3 calls, none timed.NF2: honest patch-cost calibration
What changed:
Instrumentation.MeasurePatchCosttimes the realEntityPrefix+EntityPostfixon the unsampled path through the newProbe.MeasureUnsampled:Profile.HoldSampling;Profileis reset;Samplestate, clamped at 0.PatchCallTicks= body + trampoline, andPatchBodyTicks= body.# calibration|line (now built byProbe.CalibrationParts) gainspatchBodyNs, and both values are written to one decimal. If the body can't be measured, both sayunmeasured, and the 40 ns fallback is named (unmeasured (40 assumed)) instead of hidden.Old recordings (
tools/perflog.py, reworked after review):patchCallNs|0meant one of two things, and the header rounds away the difference:With the 40 ns charge, no row's
overheadUscan fall below 40 ns per patch call. So a row below that proves the charge was about nothing.perflog.pynow prints:patchBodyNs.On the 26 real recordings (read-only), every 0.1.3 recording with patch calls and four 0.1.1 ones are flagged as understated. The 0.1.0 recordings and the other 0.1.1 ones get the neutral note.
TESTING.mditem 2 and theMeasurePatchCostcomment say the same.Tests:
GameBindingTests.PatchCostCoversTheBodiescallsMeasurePatchCost. Harmony can't patch in the .NET 8 test process, so only the body part runs there. It then times the real bodies directly, as the fastest of 10 rounds so that CPU contention cannot fail it. It asserts that the charge is at least half the body time, thatPatchBodyTicks > 0, and that the probe is left off with no keys.CoreTests.UnsampledBodyTiming: the probe is on during the measurement, no call is sampled even at interval 1, the state is restored, a throwing body gives 0, and nothing is measured while a log runs.CoreTests.CalibrationLine: the namesperflog.pyrelies on, the one-decimal values and theunmeasuredwording.test_perflog.test_a_recording_whose_patch_cost_read_0_is_told_what_overhead_charged_the_patches: proven by S rows, proven by one slow frame, a 40 ns-consistent (guess) recording, no patch calls, a new calibration line, no calibration line, and version 0.1.4.Fail before / pass after:
each patch call is charged 0.00 ns, less than its own bodies take (8.70 ns): overheadUs leaves the patches out. Head:charged per patch call 8.50 ns; the entity patch bodies timed here 9.47 ns.False != Truefor the 0.1.0 recording at 45 ns a call, which was wrongly told it understated). It passes at the head.Tests
dotnet run --project tests -c Releasepython -W error::ResourceWarning -m unittest discover -s tools -p test_perflog.pydotnet build source/PerformanceLog.csproj -c Releaseperflog.py report,report --jsonandcompareexit 0 on all 26 real recordings (read-only).Co-op impact
None. Both changes stay inside the profiler's own state (the
Profilearrays,Probecounters and the calibration header):UnpatchAll(the calibration patch on the mod's ownCostTargetis removed by its own id);MeasureUnsampledruns once atStartMod, on the mod's own methods.No simulation or wire-format change. Co-op players need not update together.
Proposed CHANGELOG lines
Not written to
CHANGELOG.md. The two newKNOWN_ISSUESentries intools/perflog.py(PATCH_COST_NOTEandPATCH_COST_GUESS_NOTE) hard-code the next release as 0.1.4, as #1's entry does. If the release gets another number, change the"0.1.4"in those entries to match at release time.Watch) are sampled at their own call rate, chosen again every window they run in. One busy method no longer leaves every watched method, or itself after it quietens, timed on about 1 call in 30 for the rest of the session, and a method that is busy in bursts stays inside the budget. Every window a watched method ran in has a row with at least its first call timed. A window where none of its calls could be timed is written withsampled0 instead of being dropped, and no longer adds its calls at 0 ms to the totals.overheadUsnow includes what the per-call patches really cost:patchCallNsis measured from the real entity patch bodies (newpatchBodyNsin the# calibration|line) plus what Harmony adds. Up to 0.1.3 it timed an empty patch, so every recording showspatchCallNs0, andoverheadUscharged those calls either an assumed 40 ns or almost nothing.perflog.pynow says which when a recording's rows show it.Needs in-game testing
frames.csvheader's# calibration|...|patchBodyNs|X|patchCallNs|Yshows X and Y as a few ns each (not 0, notunmeasured), with Y >= X.Player.loghas no[PerformanceLog] Could not measure what a patch body costswarning and noCould not measure what Harmony adds...warning. F-rowoverheadUs/patchCallsis at leastpatchCallNs/ 1000 us.python tools/perflog.py report <new folder>prints no patch-cost KNOWN ISSUE. On an old 0.1.3 folder it prints "overheadUs understates"; on a 0.1.0 folder, the neutral note.Watchentry inPerformanceLog.cfg, e.g. one hot and one rare method):profile.csvhas amethodrow for every profile window each method ran in, withsampled>= 1. After a busy period, the method'ssampled/callsratio climbs back toward 1/8.overheadUsis an honest estimate. Compare the frame rate with the mod off vs standard vs deep to judge the real cost.Review
Three adversarial reviewers looked at 4030581, each through a different lens: determinism/co-op (approve), save compatibility/cross-mod (changes requested) and "is the test real" (approve). They found:
patchCallNs|0did not always mean the patches were charged nothing. Recordings where the empty patch read exactly 0 were charged the 40 ns default; that covers 0.1.0 and several 0.1.1 recordings, and the slow-frame rows prove it. Fixed in 2e4d468: the note is now gated on evidence, with a neutral note for the rest, andTESTING.mdand the code comment are corrected.methodkind description was wrong. It said N widens "only while that method itself is busy", but the budget is split among the methods that ran. Fixed in 9edf543, with the fixtures regenerated.FewCallsEveryWindow, the reviewer's scratch test, is added.PatchCostCoversTheBodieswas flaky under CPU contention (1 of 3 runs failed next to a pinned busy loop). Fixed: the direct timing is now the fastest of 10 rounds.Probe.CalibrationParts, withCoreTests.CalibrationLine.Declined: rewording 4030581's commit message, which repeats the old "read 0 in every recording ... left the patches out" claim. The branch keeps its history; 2e4d468's message corrects it, and this description is accurate.
Notes for whoever merges:
claude/beaver-rollup, NF1) both add aKNOWN_ISSUE_APPLIESmechanism totools/perflog.pyand both edit the check counts and table rows indocs/TESTING.md. The two conflict textually there: keep both sets of entries and add the counts together.WriterTests.ReadableWhileRunningandWriterTests.RetriesWhenHeldfail sometimes. They read the liveframes.csvwithFile.ReadAllLines(FileShare.Read) while the writer holds it for append. Also,tests/SampleSession.cssetsProbe.PatchCallTicks = Ms(0.00005), which truncates to 0 ticks.Merge notes
Checked with
git merge-treeagainst the current PR heads.claude/beaver-rollup): conflicts intools/perflog.pyanddocs/TESTING.md.tools/perflog.py: both add 0.1.4 notes andKNOWN_ISSUESentries, aKNOWN_ISSUE_APPLIESmap with its helper functions, and a change toprofile_totals(Roll named beaver rows up to their kind (NF1) #1 keys by entity kind; this PR adds the branch for rows that were never timed).docs/TESTING.md: the check counts, the table rows and in-game step 7.claude/analyzer-diagnostics):tools/perflog.py, the singleton row print line (give it both suffixes), anddocs/TESTING.md(counts and table rows, resolved the same way).🤖 Generated with Claude Code