perf: reduce session load/reload time - #3580
Conversation
BuildExtensionAssembly() now runs in parallel across UI extensions so a cache miss (fresh install, upgrade, or edited extension) no longer serializes full Roslyn compilations. Results are written into per-index array slots to preserve the uiExtensions ordering consumed by later passes (ribbon tab/panel layout). Locks were added to AssemblyBuilderService for the shared assembly name cache and the AppDomain referenced-assembly dictionary, which BuildExtensionAssembly() writes from concurrent worker threads. CleanupStaleAssemblyFiles and CleanupAppDataFolder no longer block the ribbon's first paint - they run on a background Task with the Revit version captured up front. Auto-update is deferred from _perform_onsessionloadstart_ops to perform_postload so a slow or offline check cannot delay UI first paint. The LoadSession MethodInfo is now resolved once and cached for subsequent reloads.
Each extension startup script runs in its own clean IronPython engine, so importing pyrevit.userconfig used to re-verify, re-upgrade, and re-save the user config file N times per session. The verify step is still safe to repeat (it only touches the file when missing), but upgrade.upgrade_user_config() and save_changes() are now gated behind a new CONFIGUPGRADED env var so the first engine in the AppDomain runs them once and every subsequent clean engine just parses the already upgraded file on disk.
check_for_updates() walks every remote on every repo and is invoked from the Update smart button's __selfinit__ on every session load and reload, re-hitting the network each time. Cache the boolean result and its timestamp in AppDomain env vars with a 15-minute TTL so rapid reloads (e.g. while iterating on an extension) reuse the last answer until the cache expires.
IconManager now shares a single, process-lifetime BitmapCache instead of allocating a fresh one per LoadSession, so decoded ribbon icons survive reloads. Each entry records the source file's last-write time; TryGet still misses (and evicts) when the file has changed since caching, so icons edited on disk between reloads still update. IniFile parses the whole file into a (section,key)->value cache on first read and serves subsequent property reads from it. Writes through this instance invalidate the cache; the containing PyRevitConfig is dropped on reload by PyRevitConfig.ClearCache(), which drops this IniFile with it. Also drop unused coreutils/HOME_DIR imports from versionmgr.updater.
Bring the branch's new comments in line with AGENTS.md's no-inline-comments rule: convert new // and # explanatory comments into /// XML doc comments or Python docstrings, extracting two inline code blocks in SessionManagerService (BuildAndLoadAllAssemblies, QueueBackgroundCleanup) and one in userconfig.py (_load_user_config) into named methods so the rationale has a proper home. Pre-existing comments (including ones only reworded this branch) are left as they were, per the same rule.
There was a problem hiding this comment.
PR Summary:
This PR reduces pyRevit session load/reload time through several independent optimizations:
- Gates
userconfigupgrade/save to once-per-process via an AppDomain env var (was once per extension engine) - Caches
check_for_updates()network fetch results for 15 minutes via AppDomain env vars - Moves auto-update from preload to postload so update checks don't delay ribbon first paint
- Parallelizes extension assembly build+load (PASS 1) and queues cleanup on background tasks
- Adds locks around shared mutable state in the now-parallel build path
- Caches
IniFileparsed content in-memory (was re-reading the file on every property read) - Makes the icon
BitmapCachea process-lifetime static with last-write-time invalidation (was discarded on every reload)
Review Summary:
Reviewed all 10 changed files across Python and C#. The performance approach is sound — each optimization targets a real bottleneck identified via runtime.log instrumentation, and the thread-safety additions for the parallelized build pass (locks on _loadedPyRevitAssemblyNames and the env dict) are correctly scoped. The IniFile in-memory cache and the shared BitmapCache are well-designed with appropriate invalidation strategies.
Five comments recorded, two medium and three low severity:
- Medium:
check_for_updates()cachesFalse(including no-internet) for 15 minutes — a transient network blip will suppress update detection for the full TTL window. Consider only caching positive results. - Medium:
has_pending_updates()returnsNoneon fetch failure, which gets cached as falsy for 15 minutes — same suppression risk as #1 but from a git-fetch failure rather than no-internet. - Low:
IniFile._valueCache/_sectionCachelack thread synchronization — latent risk now that the build pass is parallel, though currently no config reads happen on the parallel path. - Low: Moving auto-update to postload creates a re-entrant
LoadSessioncall when updates are applied — the outer session's cleanup/logging runs against a replaced session. - Low: Shared
BitmapCachemakes per-extensionResetAndGetStats()cache hit/miss[PERF]log lines misleading (diagnostic-only, no functional impact).
IPY2712 compatibility verified: AppDomain env var storage, time.time() floats, and True/None boolean semantics all work correctly across clean IronPython engines. Black formatting and PEP8 naming conventions are respected in the Python changes.
Suggestions
| now = time.time() | ||
| last_check = envvars.get_pyrevit_env_var( | ||
| envvars.CHECKUPDATES_TIMESTAMP_ENVVAR) | ||
| if last_check is not None and (now - last_check) < CHECKUPDATES_TTL_SECONDS: |
There was a problem hiding this comment.
The cache now stores False (no updates / no internet) for 15 minutes. If a user experiences a brief network blip during session load, _check_for_updates_uncached() returns False, which gets cached. Even if connectivity returns moments later, the Update smart button won't detect available updates for up to 15 minutes.
Consider only caching the True (updates available) result, or skip caching when the result was due to no-internet, so a transient outage doesn't suppress update detection for the full TTL window.
result = _check_for_updates_uncached()
# Only cache positive results; a False could be a transient network failure
if result:
envvars.set_pyrevit_env_var(envvars.CHECKUPDATES_TIMESTAMP_ENVVAR, now)
envvars.set_pyrevit_env_var(envvars.CHECKUPDATES_RESULT_ENVVAR, result)
return resultactions
Feedback: Rate this comment to help me improve future code reviews:
- 👍 Good - Helpful and accurate
- 👎 Poor - Wrong, unclear, or unhelpful
- Skip if you don't have any strong opinions either way.
| return True | ||
| else: | ||
| logger.info('%s is up-to-date...', repo.name) | ||
| return False |
There was a problem hiding this comment.
has_pending_updates() returns True or None (it has no explicit return False). When has_pending_updates returns None, the if has_pending_updates(repo): check falls through to the else branch which logs "up-to-date" — which is misleading since None means "fetch failed" or "no data", not necessarily "up to date".
This is a pre-existing issue, but now that the result is cached for 15 minutes, a None (falsified to False by the cache layer) from a failed fetch will be cached and suppress update detection for 15 minutes. Consider adding an explicit return False at the end of has_pending_updates and distinguishing "fetch failed" from "up to date" in the logging.
actions
Feedback: Rate this comment to help me improve future code reviews:
- 👍 Good - Helpful and accurate
- 👎 Poor - Wrong, unclear, or unhelpful
- Skip if you don't have any strong opinions either way.
| private bool EnsureCacheLoaded() | ||
| { | ||
| if (_valueCache != null) |
There was a problem hiding this comment.
The IniFile._valueCache/_sectionCache fields are read and written without any synchronization. EnsureCacheLoaded() does a non-atomic check-then-fill: if _valueCache is null, it builds the dictionaries and assigns them.
PyRevitConfig.Load() uses double-checked locking to protect the _defaultInstance singleton, but once the instance is created, subsequent concurrent reads through the same PyRevitConfig→IniFile instance hit EnsureCacheLoaded() without a lock. If the C# build path ever accesses PyRevitConfig from the now-parallel BuildAndLoadAllAssemblies loop (e.g., via extension parsing or ParseExtensionByName), two threads could race on the null-check and both try to populate the cache, or one could read a half-constructed dictionary.
Currently BuildExtensionAssembly works with already-parsed ParsedExtension objects and doesn't touch PyRevitConfig directly, so this is latent rather than active. But since the build pass is now parallel, consider adding a lock around EnsureCacheLoaded() (or making the cache fields volatile + using Lazy<>), to future-proof against any config read that may be added to the build path.
actions
Feedback: Rate this comment to help me improve future code reviews:
- 👍 Good - Helpful and accurate
- 👎 Poor - Wrong, unclear, or unhelpful
- Skip if you don't have any strong opinions either way.
| except Exception as imp_err: | ||
| mlogger.error("Error setting up self_destruct on output window | %s", imp_err) | ||
|
|
||
| _perform_postload_autoupdate() |
There was a problem hiding this comment.
Moving auto-update from perform_preload to perform_postload is a good UX improvement (ribbon appears before the update check runs), but note a subtle re-entrancy consequence: updater.update_pyrevit() can call sessionmgr.reload_pyrevit() → load_session() → _invoke_csharp_loadsession() → LoadSession(), which re-enters the C# LoadSession() method while the current LoadSession() is still on the call stack (between ExecuteEntryScript(POSTLOAD_SCRIPT) and the final totalStopwatch.Stop()).
In the original preload placement, the re-entrant LoadSession happened before any C# UI state was built, so there was nothing to corrupt. Now, the outer LoadSession has already built the ribbon and is in its postload phase when the inner reload tears it all down and rebuilds. If the inner LoadSession succeeds, the outer one's remaining lines (_cleanup_output(), totalStopwatch.Stop(), final log) run against a session that was just replaced, producing a misleading "Session loaded" log for the old session.
This only triggers when updates are actually found and applied (not every load), so it's low-frequency. But consider guarding against the re-entrancy, e.g., by having update_pyrevit set a flag that perform_postload checks to skip the remaining cleanup/logging, or by documenting that reload_pyrevit from within postload is expected to short-circuit the rest of postload.
actions
Feedback: Rate this comment to help me improve future code reviews:
- 👍 Good - Helpful and accurate
- 👎 Poor - Wrong, unclear, or unhelpful
- Skip if you don't have any strong opinions either way.
| /// disk, which <see cref="BitmapCache.TryGet"/> already detects via last-write-time and | ||
| /// treats as a miss. | ||
| /// </summary> | ||
| private static readonly BitmapCache _sharedCache = new BitmapCache(); |
There was a problem hiding this comment.
ResetAndGetStats() on BitmapCache resets the global _hits/_misses counters via Interlocked.Exchange. With this shared cache, EmitBuildUIPerfLines (called after each extension's BuildUI) resets counters that are now shared across all extensions and all reloads.
This means the per-extension [PERF] cache hit/miss attribution is now incorrect: the first extension's ResetAndGetStats will pick up hits accumulated during previous sessions (since the cache is process-lifetime), and subsequent extensions will only see hits since the previous extension's reset, not since their own BuildUI started.
The _decodeMs field on IconManager is still per-instance, so decode time attribution is still correct, but the cache hit/miss numbers in the [PERF] log lines will be misleading. Since this is diagnostic-only (no functional impact), it's low priority, but worth noting so the [PERF] lines don't mislead future profiling work.
actions
Feedback: Rate this comment to help me improve future code reviews:
- 👍 Good - Helpful and accurate
- 👎 Poor - Wrong, unclear, or unhelpful
- Skip if you don't have any strong opinions either way.
…ed-path check Gate SharedSessionEngine for extension startup scripts on the extension's own rocket_mode_compatible flag (extension.json) plus the global Rocket Mode setting, reusing the same author-declared, user-gated trust signal that already lets an extension's own commands share a cached engine (CommandTypeGenerator.BuildEngineConfigs), instead of a hardcoded check for whether the extension lives under pyRevit's own extensions\ folder. Preload and Postload keep sharing unconditionally, as pyRevit's own trusted code. Also fixes new inline comments left over from restoring this change, per AGENTS.md's no-inline-comments rule.
Summary
A set of independent fixes to cut down pyRevit session load/reload time, found by
instrumenting a real dev session's
runtime.logand following the evidence.pyrevitlib/pyrevit/loader/sessionmgr.py/pyrevit/coreutils/envvars.py:userconfig'sverify/upgrade/save cycle was re-running from scratch on every "clean" IronPython engine
spin-up (once per extension's startup script, since each gets a fresh engine). Gated behind a
new
CONFIGUPGRADEDAppDomain env var so it only runs once per Revit process. Also caches thereflection lookup for the C#
LoadSessionmethod instead of re-scanning the AppDomain on everyreload.
pyrevitlib/pyrevit/versionmgr/updater.py:check_for_updates()(called by the "Update"smart button's
__selfinit__on every session load/reload) did a full git-remote-fetchnetwork round trip each time. Now cached for 15 minutes via AppDomain env vars.
pyrevitlib/pyrevit/loader/sessionmgr.py: auto-update now runs after the ribbon UI is built(
perform_postload) instead of before (perform_preload), so a slow or offline update checkcan't delay the ribbon's first paint.
dev/pyRevitLoader/pyRevitAssemblyBuilder/UIManager/SessionManagerService.cs: extensionassembly build+load (PASS 1) now runs in parallel instead of a sequential loop; stale-assembly
and appdata cleanup are queued on a background task instead of blocking the load.
dev/pyRevitLoader/pyRevitAssemblyBuilder/AssemblyMaker/AssemblyBuilderService.cs: addedlocks around the two pieces of shared mutable state the now-parallel build pass touches.
dev/pyRevitLoader/pyRevitExtensionParser/IniFile.cs:PyRevitConfig's underlying INI filewas fully re-read and re-scanned line-by-line on every single property read (hit repeatedly
per extension during UI build). Now parsed once into an in-memory dictionary, invalidated on
write or on the existing session-reload cache-clear hook.
dev/pyRevitLoader/pyRevitAssemblyBuilder/UIManager/IconsHandling/{BitmapCache,IconManager}.cs:the decoded-icon bitmap cache was thrown away and rebuilt on every
LoadSession()call (firstload and every reload), so every ribbon icon got redecoded from disk on every reload. Now a
single process-lifetime cache; entries self-invalidate via file last-write-time so an icon a
developer edits and reloads still picks up the change.
Test plan
dotnet buildonpyRevitAssemblyBuilder.csprojandpyRevitExtensionParser.csproj-clean, no new warnings
pyRevitExtensionParserTestsuite - same pre-existing baseline failure count beforeand after (verified via
git stashon the changed files), no new failurespy_compileon all changed Python filesruntime.log: userconfig upgrade/save now runsonce per process instead of once per extension;
check_for_updatesnetwork fetch countdropped as expected; no new errors/warnings in the log