Skip to content

perf: Hot reload no longer re-reads the compiled assembly for every method it patches - #3199

Merged
hatayama merged 3 commits into
feature/hot-reload-large-project-feedbackfrom
perf/hot-reload-matcher-reads-assembly-once
Oct 7, 2026
Merged

hatayama merged 3 commits into
feature/hot-reload-large-project-feedbackfrom
perf/hot-reload-matcher-reads-assembly-once

Conversation

@hatayama

@hatayama hatayama commented Oct 6, 2026 •

Copy link
Copy Markdown
Owner

Summary

  • Hot reload of a file that already holds many live patches is faster: the patch stage now reads each compiled assembly once per run instead of once for every method it patches.
  • In a file with 200 live patches, a hot reload that changes one method took a median of 2,096 ms before and 1,215 ms after; the patch stage alone went from 954 ms to 228 ms.

User Impact

  • Before: for every method a run patched, the patch stage read the whole compiled assembly from disk again and looked the method's type up in that fresh read. A run re-patches every method of the file that holds a live patch, so the patch stage grew with the number of live patches in the file and with the size of the assembly. With 200 live patches it took about one second even when only one method had changed.
  • After: a run reads each compiled assembly once to resolve its methods (and once more when it peels unchanged methods), and answers every method from that read. The check that the loaded assembly is the one that was read still runs for every method.

Measured on a fast Editor, right after a forced compile, with a temporary file of 200 instance methods (not committed). Every round finished within 13.1 s of the end of the compile, because a background Editor on macOS slows down from about 25 s after a compile and multiplies every phase (a separate issue). PatchedTotal was 200 in every run.

Run Phase Before: median (range) After: median (range)
One method changed, the other 199 hold live patches (9 runs each) Patch 954 ms (904–1,245) 228 ms (187–274)
Total 2,096 ms (1,877–2,374) 1,215 ms (1,158–1,639)
All 200 methods changed, no live patch before the run (3 runs each) Patch 1,169 ms (936–1,261) 230 ms (153–248)
Total 4,990 ms (4,402–5,261) 3,583 ms (3,572–3,913)

Changes

  • HotReloadMethodMatcher is now an instance made for one run (IDisposable). It keeps each compiled image it read successfully, keyed by path, and resolves the rest of the run's methods from it. A missing image or a failed read is not kept, so the next method looks again, as before.
  • Only the read is shared. The Mvid comparison with the loaded assembly still runs for every method, so a metadata token is never applied to an assembly other than the one it was read from.
  • A matcher is created in two places only, each with using and outside the file loop: group preparation (resolution) and the unchanged-method peel. Entry resolution receives the matcher's Resolve as a delegate, as the peel already did, so it does not own the matcher's lifetime. No matcher is kept in a field or a static, so no read outlives its run.
  • A disposed matcher throws ObjectDisposedException. A loader that returns no image throws InvalidOperationException, and nothing is kept.
  • The static Resolve is removed; tests create a matcher for each test.

Input space

The axes come from the branches of Resolve: whether this matcher has already read the path, the file's state at the call, the matcher's state, whether the type and the method are found, the Mvid against the loaded assembly, and the number of paths in one run.

Tests, in HotReloadMethodMatcherTests unless noted:

  • T1 Resolve_SeveralEntriesOfOneImage_ReadsTheImageOnce (one of the three is a nested type)
  • T2 Resolve_TwoImages_ReadsEachOnceAndAnswersFromItsOwn
  • T3 Resolve_MissingImage_IsLookedForAgainOnTheNextEntry
  • T4 Resolve_ImageThatFailedToRead_IsReadAgainOnTheNextEntry
  • T5 Resolve_ImageDeletedAfterTheFirstRead_StillAnswersFromThatRead
  • T6 Resolve_ImageReplacedAfterTheFirstRead_SameMatcherKeepsItsRead_AndANewMatcherReadsTheNewImage
  • T7 Resolve_AfterDispose_Throws
  • T8 Resolve_UnknownType_ReturnsTypeNotFound
  • T9 Resolve_AssemblyNotLoaded_IsReportedForEveryEntry_WithOneRead
  • T10 HotReloadEntryResolutionTests.ResolveEntries_ResolvesEachExistingMethodEntryThroughTheGivenResolver
  • T11 Resolve_SameImageUnderTwoHomes_ChecksTheLoadedAssemblyForEachHome
  • T12 Resolve_LoaderReturningNull_Throws_AndTheImageIsReadAgainOnTheNextEntry
# Call within one matcher File at the call Base Head Tests
C1 First for that image Present, readable Read, resolve Same (one read) Existing Resolve_* (5), T1
C2 Later Unchanged Read again, resolve Resolved from the first read; same result T1
C3 First Missing CompiledAssemblyNotFound Same; nothing kept T3
C4 After C3, the file appears Present Read, resolve Same (the first read happens here) T3
C5 First The read throws The exception propagates Same; nothing kept T4
C6 After C5, readable again Present Read, resolve Same (second read attempt) T4
C7 Later Deleted after the first read CompiledAssemblyNotFound Resolved from the first read (differs) T5
C8 Later Replaced by another image after the first read Reads the new image (TypeNotFound, or StaleAssembly if the Mvid differs) Resolved from the first read (differs); the Mvid check compares that read with the loaded assembly T6
C9 A new matcher (the next run) Replaced after the previous run Reads the new image Same; a new matcher always reads T6
C10 Two images in one run Both readable Read for every method Read once per image; each method answered from its own image T2
C11 Any After Dispose n/a (static) ObjectDisposedException T7
C12 Any Readable; no such type / method TypeNotFound / MethodNotFound Same T8; existing Resolve_ParameterTypeMismatch_ReturnsMethodNotFound
C13 Any Readable; the loaded assembly is stale / not loaded StaleAssembly / AssemblyNotLoaded, per method Same, per method Existing ResolveLoadedMethod_MvidMismatch_ReturnsStaleAssembly, T9
C14 The same image under two homes with different assembly names Readable The first succeeds, the second is AssemblyNotLoaded Same: the read is shared, the loaded-assembly check is not T11
C15 First The loader returns null n/a InvalidOperationException; nothing kept T12

Entry resolution resolves each existing method through the resolver it is given (T10).

Why C7 and C8 may change: the base failure there protects one thing: a token read from an image that differs from the loaded assembly is never applied. Head takes the token and the Mvid from the same read and compares that Mvid with the loaded assembly for every method, so this still holds. The DLL on disk changes during a run (about a second from the first method to the last) only when a compile runs, and the domain reload after it removes every patch.

Resolution and the unchanged-method peel use separate matchers, so a run that does both reads the image twice, once each. If the image is replaced between the two, the peel skips the row with StaleAssembly, as before.

Where the matcher is created (outside the file loop) is not pinned by a test, so that group preparation needs no loader hook; please check it in the diff.

Mutations

Each mutation was applied to the committed tree, HotReloadMethodMatcherTests and HotReloadEntryResolutionTests were run (26 tests), and the file was restored with git checkout. No mutation survived.

# Mutation Failed
m1 Keep nothing: read the image for every method (the old behaviour) T1, T2, T5, T6, T9, T11
m2 One slot shared by every path T2
m3 Remember a missing image as missing T3
m4 Record the path before the read succeeds T4, T12 (NullReferenceException)
m5 No disposed check in Resolve T7
m6 Entry resolution makes its own matcher instead of using the resolver it is given T10
m7 Dispose does not mark the matcher disposed T7
m8 Remember the loaded-assembly check per path T11
m9 No null check of the loader's result T12 (NullReferenceException)

Verification (local)

Pull requests to the integration branch do not trigger the main PR CI, so these checks were run locally.

  • Red (1bcbe03: the instance shape and the tests, with Resolve still reading on every call): 17 tests in HotReloadMethodMatcherTests, 8 failed as predicted: T1 (3 reads), T2 (4 reads), T5 (CompiledAssemblyNotFound), T6 (TypeNotFound), T7 (no exception), T9 (2 reads), T11 (2 reads), T12 (NullReferenceException). T3, T4, T8 and the 6 existing tests passed.
  • Green (fb95e76): 17/17. With T10 (ee891dd): HotReloadMethodMatcherTests and HotReloadEntryResolutionTests 26/26.
  • Regression:
  • Static checks:
    • scripts/check-code-complexity.sh (fail on exceeded): 0 Go issues, no CA1502 finding above 15.
    • scripts/check-file-length.sh (fail on exceeded): no file over 500 SLOC.
    • scripts/check-dead-code.sh with the CI arguments: exit 0, PublicCandidate 36 (limit 37), and no symbol of the matcher is listed.
    • uloop compile-check --all: 0 errors; the 7 warnings are all in existing test fixtures, none in a changed file.
    • No call to a static HotReloadMethodMatcher.Resolve( remains. CreateReadingFromDisk is called in the two production places and in tests only.
  • Performance:
    • Fixture: a temporary file of 200 instance methods int MNNN() { return N; }.
    • Each round: a forced compile, one hot reload that changes all 200 bodies, three hot reloads that each change only one method (the other 199 keep their live patches), and --revert-all. Every hot reload used --compile-on-skip off.
    • Order: base ×2 before the change, head ×2, base ×1 (the hot reload sources and tests checked out from the base commit), head ×1.
    • From the compile's end to the revert: base 12.1–13.0 s, head 8.2–8.5 s. 1-minute load average: base rounds 13.0–26.1, head rounds 15.2–19.5.
    • One more base round was discarded and measured again: it ran past the 20 s window (33.5 s), and one of its runs spent 10,413 ms in the patch stage.
  • The integration branch head is still the base of this branch (ef68b94).

Not covered

  1. Re-patching methods whose body did not change: a run still re-patches every method of the file that holds a live patch (PatchedTotal 200 above), so the number of methods resolved per run is unchanged. Reducing that is a separate candidate.
  2. Finding the loaded assembly: the per-method Mvid check still walks the AppDomain's assemblies for every method.
  3. Reuse across runs: a read is shared only within one run; sharing it across runs would need a freshness check.
  4. Other Cecil reads: places other than Resolve that read a DLL with Cecil (the patch-target support and the compiled call-site cache) are unchanged.

Review in cubic

Entry resolution and the unchanged-patch peel now each create one matcher
per group and pass its Resolve on as a delegate. The matcher still reads
the compiled image on every call, so the new tests for one read per image,
answers after the file is deleted or replaced, the per-home loaded-assembly
check, the loader contract and disposal fail until the next commit.
Every entry used to read the whole compiled assembly again and rebuild its
type table, so the patch stage grew with the number of live patches in a
file. The matcher now keeps each image it read successfully until it is
disposed at the end of the group, while a missing or unreadable image is
still looked for again by the next entry and the Mvid guard still runs
for every entry. A disposed matcher refuses to resolve, and a loader that
returns no image is reported instead of failing on a null read.
Existing-method entries are now resolved through the delegate the caller
passes, so a test counts the calls to make sure the group's matcher is the
one that answers. The peel's comments now say that only the first row
resolved against an image reads it, which the name prefilter still spares
along with the per-row matching.
@coderabbitai

coderabbitai Bot commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository: hatayama/unity-cli-loop/.coderabbit.yaml
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: ae3d3897-ae7a-4d33-9a49-3310a9a64f0f
📥 Commits

Reviewing files that changed from the base of the PR and between ef68b94 and ee891dd.

📒 Files selected for processing (8)
  • Assets/Tests/Editor/HotReload/HotReloadEntryResolutionTests.cs
  • Assets/Tests/Editor/HotReload/HotReloadMethodMatcherTests.cs
  • Assets/Tests/Editor/HotReload/HotReloadSpikeS4ArtifactPatchTests.cs
  • Assets/Tests/Editor/HotReload/HotReloadUnchangedPatchPeelTests.cs
  • Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEntryApplier.cs
  • Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEntryResolution.cs
  • Packages/src/Editor/FirstPartyTools/HotReload/HotReloadGroupEntryPreparation.cs
  • Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadMethodMatcher.cs

Included review availability: This review used your included allowance. Your plan provides up to 4 included reviews per hour; 3 remain after this review.


📝 Walkthrough

Walkthrough

HotReloadMethodMatcher is now a disposable instance that caches successfully loaded assembly images by DLL path. Entry preparation, resolution, and patch reversion pass its resolver through existing-method matching. Tests cover cache behavior, retries, disposal, and updated call sites.

Changes

Hot reload method resolution

Layer / File(s) Summary
Matcher cache and lifecycle
Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadMethodMatcher.cs, Assets/Tests/Editor/HotReload/HotReloadMethodMatcherTests.cs
HotReloadMethodMatcher is now a disposable instance. It caches successfully loaded assembly images by DLL path and retries missing or failed reads. Tests cover cache reuse, retries, loaded-assembly checks, and disposal.
Entry preparation and resolution
Packages/src/Editor/FirstPartyTools/HotReload/HotReloadGroupEntryPreparation.cs, Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEntryResolution.cs, Assets/Tests/Editor/HotReload/HotReloadEntryResolutionTests.cs
Group preparation passes one matcher resolver through file preparation to existing-method resolution. Added-method resolution is unchanged. Tests verify resolver use and resolution results.
Patch reversion resolver integration
Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEntryApplier.cs, Assets/Tests/Editor/HotReload/HotReloadSpikeS4ArtifactPatchTests.cs, Assets/Tests/Editor/HotReload/HotReloadUnchangedPatchPeelTests.cs
Patch reversion creates one matcher for the group and passes its resolver to per-file reversion. The patch tests use disposable matcher instances.

Priority: ➖ Normal

Estimated code review effort: 3 (Moderate) | ~25 minutes

Change: Refactor

Sequence Diagram(s)

sequenceDiagram
  participant PrepareGroup
  participant PrepareFile
  participant ResolveEntries
  participant HotReloadMethodMatcher
  PrepareGroup->>HotReloadMethodMatcher: CreateReadingFromDisk
  PrepareGroup->>PrepareFile: Pass matcher.Resolve
  PrepareFile->>ResolveEntries: Pass resolveMethod
  ResolveEntries->>HotReloadMethodMatcher: Resolve existing method
  HotReloadMethodMatcher-->>ResolveEntries: Return match result
Loading

Merge Risk: ⚪ Minimal · up to ee891

This change speeds up hot-reload patching by reusing each compiled assembly read within a group. No merge-blocking risk was found in the supplied evidence.

Security Architecture Review

Security architecture risk: 🔵 Low · up to ee891

The cache has an explicit, short lifetime, and every method still undergoes the existing assembly-identity check. No introduced security weakness was identified in the reviewed paths, although broader security coverage remains incomplete.

Retained concerns
No architecture-level concerns identified.

Security review details

Security Blast Radius

  • inferred — The reviewed change affects method selection for live patches within the current Editor AppDomain, including retained introduced-type artifacts. The inspected production wiring does not add a new input channel or increase the authority of worker entry fields.

Trust Boundaries and Controls

  • observed — An empty row-supplied home name uses the file home. A named home must identify the prepared artifact or an artifact retained by the domain; other named homes are rejected. Each matcher call validates that home's loaded assembly against the cached image MVID before resolving the metadata token. These controls predate the cache and remain in force.

Resilience and Maintainability Implications

  • observed — Patch reversion retains its existing containment rules: skipped files are left untouched, the method-name prefilter does not replace exact signature resolution, failed matches do not trigger reversion, and unpatch failures produce failure outcomes. The new cache changes read reuse, not this mutation ordering.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 70.59% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 51 functions across 8 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: reusing compiled assembly reads during hot-reload method resolution.
Description check ✅ Passed The description explains the change, its user impact, implementation, tests, performance measurements, and known limitations. It is directly related to the changeset.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@hatayama
hatayama merged commit 7950674 into feature/hot-reload-large-project-feedback Oct 7, 2026
5 checks passed
@hatayama
hatayama deleted the perf/hot-reload-matcher-reads-assembly-once branch October 7, 2026 01:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant