fix: optimize serve/sourcemap startup from >180s to ~8s

Root cause: notify-debouncer-full with macos_kqueue caused 25K+
directory watch registrations to take >170s on large projects.

Changes:
- Replace notify-debouncer-full with notify-debouncer-mini to avoid
  FSEvents Create+Remove event cancellation and heavy per-watch overhead
- Remove macos_kqueue feature; use FSEvents (matches upstream Rojo)
- Fix VFS read guard: watch_enabled && watch_recursive -> watch_enabled
  so recording mechanism works when watch_recursive is false
- Build RefPathIndex from patch_apply data instead of redundant 4.8s
  WalkDir + 5,854 file re-reads (populate_from_dir)
- Use ServeSession::new_oneshot() for sourcemap without --watch
- Restore simple synchronous watch setup (implicit during reads)

Serve: >180s (never completed) -> ~8s to Listening
Sourcemap: 14.3s -> ~9s
All 489 tests passing.
This commit is contained in:
Joe 2026-03-12 18:02:45 -05:00
parent 4ddc4a3e31
commit 9cf4cea936
10 changed files with 236 additions and 210 deletions

View file

@ -31,6 +31,8 @@ Making a new release? Simply add the new header with the version and date undern
## Unreleased ## Unreleased
* Add .gitignore for logs and refactor ServeSession to include path_roots in init_tree return
## [8.5.6] (March 8th, 2026) ## [8.5.6] (March 8th, 2026)
* Add asynchronous log file compression with lowered thread priority to avoid impacting main workloads * Add asynchronous log file compression with lowered thread priority to avoid impacting main workloads

18
Cargo.lock generated
View file

@ -1030,15 +1030,6 @@ version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
[[package]]
name = "file-id"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1fc6a637b6dc58414714eddd9170ff187ecb0933d4c7024d1abbd23a3cc26e9"
dependencies = [
"windows-sys 0.60.2",
]
[[package]] [[package]]
name = "filetime" name = "filetime"
version = "0.2.27" version = "0.2.27"
@ -1920,7 +1911,7 @@ dependencies = [
"fs-err", "fs-err",
"log", "log",
"notify", "notify",
"notify-debouncer-full", "notify-debouncer-mini",
"serde", "serde",
"tempfile", "tempfile",
] ]
@ -1991,16 +1982,15 @@ dependencies = [
] ]
[[package]] [[package]]
name = "notify-debouncer-full" name = "notify-debouncer-mini"
version = "0.7.0" version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c02b49179cfebc9932238d04d6079912d26de0379328872846118a0fa0dbb302" checksum = "17849edfaabd9a5fef1c606d99cfc615a8e99f7ac4366406d86c7942a3184cf2"
dependencies = [ dependencies = [
"file-id",
"log", "log",
"notify", "notify",
"notify-types", "notify-types",
"walkdir", "tempfile",
] ]
[[package]] [[package]]

View file

@ -18,14 +18,9 @@ homepage = "https://github.com/UserGeneratedLLC/rojo/tree/master/memofs"
crossbeam-channel = "0.5.15" crossbeam-channel = "0.5.15"
fs-err = "3" fs-err = "3"
log = "0.4" log = "0.4"
notify-debouncer-full = "0.7"
serde = { version = "1.0.228", features = ["derive"] }
[target.'cfg(target_os = "macos")'.dependencies]
notify = { version = "8.2", features = ["macos_kqueue"] }
[target.'cfg(not(target_os = "macos"))'.dependencies]
notify = "8.2" notify = "8.2"
notify-debouncer-mini = "0.7"
serde = { version = "1.0.228", features = ["derive"] }
[dev-dependencies] [dev-dependencies]
tempfile = "3.26.0" tempfile = "3.26.0"

View file

@ -190,7 +190,7 @@ impl VfsInner {
fn read_raw(&mut self, path: &Path) -> io::Result<Vec<u8>> { fn read_raw(&mut self, path: &Path) -> io::Result<Vec<u8>> {
if let Some(cache) = &mut self.prefetch_cache { if let Some(cache) = &mut self.prefetch_cache {
if let Some(contents) = cache.files.remove(path) { if let Some(contents) = cache.files.remove(path) {
if self.watch_enabled && self.watch_recursive { if self.watch_enabled {
self.watch_or_record(path)?; self.watch_or_record(path)?;
} }
return Ok(contents); return Ok(contents);
@ -199,7 +199,7 @@ impl VfsInner {
let contents = self.backend.read(path)?; let contents = self.backend.read(path)?;
if self.watch_enabled && self.watch_recursive { if self.watch_enabled {
self.watch_or_record(path)?; self.watch_or_record(path)?;
} }

View file

@ -4,11 +4,7 @@ use std::{collections::HashSet, io};
use crossbeam_channel::{Receiver, Sender}; use crossbeam_channel::{Receiver, Sender};
use notify::RecursiveMode; use notify::RecursiveMode;
use notify_debouncer_full::{ use notify_debouncer_mini::{new_debouncer, DebounceEventResult, DebouncedEventKind, Debouncer};
new_debouncer,
notify::event::{CreateKind, EventKind, ModifyKind, RemoveKind, RenameMode},
DebounceEventResult, Debouncer, RecommendedCache,
};
use crate::{DirEntry, Metadata, ReadDir, VfsBackend, VfsEvent}; use crate::{DirEntry, Metadata, ReadDir, VfsBackend, VfsEvent};
@ -55,7 +51,7 @@ pub type CriticalErrorHandler = Box<dyn Fn(WatcherCriticalError) -> bool + Send
/// `VfsBackend` that uses `std::fs` and the `notify` crate. /// `VfsBackend` that uses `std::fs` and the `notify` crate.
pub struct StdBackend { pub struct StdBackend {
debouncer: Debouncer<notify::RecommendedWatcher, RecommendedCache>, debouncer: Debouncer<notify::RecommendedWatcher>,
watcher_receiver: Receiver<VfsEvent>, watcher_receiver: Receiver<VfsEvent>,
watches: HashSet<PathBuf>, watches: HashSet<PathBuf>,
recursive_watches: HashSet<PathBuf>, recursive_watches: HashSet<PathBuf>,
@ -123,19 +119,25 @@ impl StdBackend {
event_tx: Sender<VfsEvent>, event_tx: Sender<VfsEvent>,
error_tx: Sender<WatcherCriticalError>, error_tx: Sender<WatcherCriticalError>,
error_handler: CriticalErrorHandler, error_handler: CriticalErrorHandler,
) -> Debouncer<notify::RecommendedWatcher, RecommendedCache> { ) -> Debouncer<notify::RecommendedWatcher> {
// Use 50ms debounce timeout (same as the old v4 implementation)
let debounce_timeout = Duration::from_millis(50); let debounce_timeout = Duration::from_millis(50);
new_debouncer( new_debouncer(
debounce_timeout, debounce_timeout,
None, // Use default tick rate move |result: DebounceEventResult| match result {
move |result: DebounceEventResult| {
match result {
Ok(events) => { Ok(events) => {
for event in events { for event in events {
let vfs_events = Self::convert_event(&event.event); let vfs_event = match event.kind {
for vfs_event in vfs_events { DebouncedEventKind::Any | DebouncedEventKind::AnyContinuous => {
if event.path.exists() {
VfsEvent::Write(event.path)
} else {
VfsEvent::Remove(event.path)
}
}
_ => continue,
};
log::trace!("VFS debounced event: {:?}", &vfs_event);
if let Err(err) = event_tx.send(vfs_event) { if let Err(err) = event_tx.send(vfs_event) {
let critical_err = let critical_err =
WatcherCriticalError::ChannelSendFailed(err.to_string()); WatcherCriticalError::ChannelSendFailed(err.to_string());
@ -146,113 +148,19 @@ impl StdBackend {
} }
} }
} }
} Err(error) => {
Err(errors) => {
for error in errors {
// Check if this is a rescan request
if error.paths.is_empty() {
let critical_err = WatcherCriticalError::RescanRequired;
let _ = error_tx.send(critical_err.clone());
if error_handler(critical_err) {
return;
}
} else {
let critical_err = WatcherCriticalError::WatcherError { let critical_err = WatcherCriticalError::WatcherError {
error: format!("{:?}", error.kind), error: format!("{:?}", error.kind),
path: error.paths.first().cloned(), path: error.paths.first().cloned(),
}; };
let _ = error_tx.send(critical_err.clone()); let _ = error_tx.send(critical_err.clone());
if error_handler(critical_err) { error_handler(critical_err);
return;
}
}
}
}
} }
}, },
) )
.expect("Failed to create file watcher debouncer") .expect("Failed to create file watcher debouncer")
} }
/// Convert a notify event to our VfsEvent(s)
fn convert_event(event: &notify::Event) -> Vec<VfsEvent> {
let mut vfs_events = Vec::new();
match &event.kind {
// Create events
EventKind::Create(CreateKind::File)
| EventKind::Create(CreateKind::Folder)
| EventKind::Create(CreateKind::Any)
| EventKind::Create(CreateKind::Other) => {
for path in &event.paths {
vfs_events.push(VfsEvent::Create(path.clone()));
}
}
// Modify events (treat as Write)
EventKind::Modify(ModifyKind::Data(_))
| EventKind::Modify(ModifyKind::Any)
| EventKind::Modify(ModifyKind::Other) => {
for path in &event.paths {
vfs_events.push(VfsEvent::Write(path.clone()));
}
}
// Metadata changes - we don't care about these for Rojo's purposes
EventKind::Modify(ModifyKind::Metadata(_)) => {}
// Name changes (renames) - the debouncer-full handles rename tracking
EventKind::Modify(ModifyKind::Name(RenameMode::Both)) => {
// Both paths present: old path at [0], new path at [1]
if event.paths.len() >= 2 {
vfs_events.push(VfsEvent::Remove(event.paths[0].clone()));
vfs_events.push(VfsEvent::Create(event.paths[1].clone()));
}
}
EventKind::Modify(ModifyKind::Name(RenameMode::From)) => {
// Only the old path - treat as removal
for path in &event.paths {
vfs_events.push(VfsEvent::Remove(path.clone()));
}
}
EventKind::Modify(ModifyKind::Name(RenameMode::To)) => {
// Only the new path - treat as creation
for path in &event.paths {
vfs_events.push(VfsEvent::Create(path.clone()));
}
}
EventKind::Modify(ModifyKind::Name(RenameMode::Any))
| EventKind::Modify(ModifyKind::Name(RenameMode::Other)) => {
// Ambiguous rename - treat as modification
for path in &event.paths {
vfs_events.push(VfsEvent::Write(path.clone()));
}
}
// Remove events
EventKind::Remove(RemoveKind::File)
| EventKind::Remove(RemoveKind::Folder)
| EventKind::Remove(RemoveKind::Any)
| EventKind::Remove(RemoveKind::Other) => {
for path in &event.paths {
vfs_events.push(VfsEvent::Remove(path.clone()));
}
}
// Access events - we don't care about these
EventKind::Access(_) => {}
// Other/Any events - treat as potential modifications
EventKind::Other | EventKind::Any => {
for path in &event.paths {
vfs_events.push(VfsEvent::Write(path.clone()));
}
}
}
vfs_events
}
/// Returns a receiver for critical errors from the watcher thread. /// Returns a receiver for critical errors from the watcher thread.
/// ///
/// Poll this alongside `event_receiver()` to detect when watching becomes unreliable. /// Poll this alongside `event_receiver()` to detect when watching becomes unreliable.
@ -347,7 +255,7 @@ impl VfsBackend for StdBackend {
} else { } else {
RecursiveMode::NonRecursive RecursiveMode::NonRecursive
}; };
match self.debouncer.watch(path, mode) { match self.debouncer.watcher().watch(path, mode) {
Ok(()) => { Ok(()) => {
log::debug!( log::debug!(
"Watching path ({}): {}", "Watching path ({}): {}",
@ -374,7 +282,7 @@ impl VfsBackend for StdBackend {
fn unwatch(&mut self, path: &Path) -> io::Result<()> { fn unwatch(&mut self, path: &Path) -> io::Result<()> {
let was_watched = self.watches.contains(path); let was_watched = self.watches.contains(path);
match self.debouncer.unwatch(path) { match self.debouncer.watcher().unwatch(path) {
Ok(()) => { Ok(()) => {
if was_watched { if was_watched {
log::info!("Unwatched path: {}", path.display()); log::info!("Unwatched path: {}", path.display());

View file

@ -79,7 +79,17 @@ impl SourcemapCommand {
let vfs = Vfs::new_default(); let vfs = Vfs::new_default();
vfs.set_watch_enabled(self.watch); vfs.set_watch_enabled(self.watch);
let session = ServeSession::new(vfs, project_path, None)?; let session_start = std::time::Instant::now();
let session = if self.watch {
ServeSession::new(vfs, project_path, None)?
} else {
ServeSession::new_oneshot(vfs, project_path)?
};
log::debug!(
"[PERF] ServeSession::new() total: {:.1?}",
session_start.elapsed()
);
let mut cursor = session.message_queue().cursor(); let mut cursor = session.message_queue().cursor();
let filter = if self.include_non_scripts { let filter = if self.include_non_scripts {
@ -93,6 +103,7 @@ impl SourcemapCommand {
.build_global() .build_global()
.ok(); .ok();
let sm_start = std::time::Instant::now();
write_sourcemap( write_sourcemap(
&session, &session,
self.output.as_deref(), self.output.as_deref(),
@ -100,6 +111,7 @@ impl SourcemapCommand {
self.absolute, self.absolute,
false, false,
)?; )?;
log::debug!("[PERF] write_sourcemap: {:.1?}", sm_start.elapsed());
if self.watch { if self.watch {
if !std::io::stdin().is_terminal() { if !std::io::stdin().is_terminal() {
@ -259,6 +271,7 @@ pub(crate) fn write_sourcemap(
use_absolute_paths: bool, use_absolute_paths: bool,
quiet: bool, quiet: bool,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let t0 = std::time::Instant::now();
let tree = session.tree(); let tree = session.tree();
let canonical_project_dir = session.root_dir().to_path_buf(); let canonical_project_dir = session.root_dir().to_path_buf();
@ -270,22 +283,32 @@ pub(crate) fn write_sourcemap(
filter, filter,
use_absolute_paths, use_absolute_paths,
); );
let t1 = std::time::Instant::now();
if let Some(output_path) = output { if let Some(output_path) = output {
// Use standard JSON (not JSON5) for sourcemaps - required by external tools like LSPs
let json_output = serde_json::to_string(&root_node)?; let json_output = serde_json::to_string(&root_node)?;
let t2 = std::time::Instant::now();
// Use atomic write (temp file + rename) to prevent file watchers from
// reading partial files. Rename is atomic on all major filesystems when
// source and destination are on the same filesystem.
write_atomic(output_path, json_output.as_bytes())?; write_atomic(output_path, json_output.as_bytes())?;
let t3 = std::time::Instant::now();
log::debug!(
"[PERF] write_sourcemap: tree_walk={:.1?}, json_serialize={:.1?} ({} bytes), write={:.1?}",
t1 - t0,
t2 - t1,
json_output.len(),
t3 - t2,
);
if !quiet { if !quiet {
println!("Created sourcemap at {}", output_path.display()); println!("Created sourcemap at {}", output_path.display());
} }
} else { } else {
// Use standard JSON (not JSON5) for sourcemaps - required by external tools like LSPs
let output = serde_json::to_string(&root_node)?; let output = serde_json::to_string(&root_node)?;
log::debug!(
"[PERF] write_sourcemap: tree_walk={:.1?}, json_serialize=inline",
t1 - t0
);
println!("{}", output); println!("{}", output);
} }

View file

@ -326,6 +326,22 @@ impl RefPathIndex {
} }
} }
/// Build from pre-collected entries (avoids re-walking the directory tree).
pub fn from_entries(entries: Vec<(String, PathBuf)>) -> Self {
let mut index = Self::new();
let count = entries.len();
for (ref_path, meta_file) in entries {
index.add(&ref_path, &meta_file);
}
if count > 0 {
log::debug!(
"RefPathIndex: built from {} collected Rojo_Ref_* entries",
count
);
}
index
}
/// Record that `meta_file` contains a `Rojo_Ref_*` attribute with value /// Record that `meta_file` contains a `Rojo_Ref_*` attribute with value
/// `ref_path` (the instance path string, e.g., "Workspace/Model/Part1"). /// `ref_path` (the instance path string, e.g., "Workspace/Model/Part1").
pub fn add(&mut self, ref_path: &str, meta_file: &Path) { pub fn add(&mut self, ref_path: &str, meta_file: &Path) {

View file

@ -326,12 +326,22 @@ pub fn collect_path_roots(node: &crate::project::ProjectNode, base: &Path, out:
impl ServeSession { impl ServeSession {
/// Shared initialization: loads the project and builds the initial /// Shared initialization: loads the project and builds the initial
/// snapshot tree. Used by both `new()` and `new_oneshot()`. /// snapshot tree. Used by both `new()` and `new_oneshot()`.
/// Returns the walked paths from prefetch (if available) for syncback reuse. /// Returns the walked paths from prefetch (if available) for syncback reuse,
/// plus collected ref path index entries for building RefPathIndex.
#[allow(clippy::type_complexity)]
fn init_tree( fn init_tree(
vfs: &Vfs, vfs: &Vfs,
start_path: &Path, start_path: &Path,
) -> Result<(Project, RojoTree, Option<HashSet<PathBuf>>, Vec<PathBuf>), ServeSessionError> ) -> Result<
{ (
Project,
RojoTree,
Option<HashSet<PathBuf>>,
Vec<PathBuf>,
Vec<(String, std::path::PathBuf)>,
),
ServeSessionError,
> {
log::trace!("Starting new ServeSession at path {}", start_path.display()); log::trace!("Starting new ServeSession at path {}", start_path.display());
let root_project = Project::load_initial_project(vfs, start_path)?; let root_project = Project::load_initial_project(vfs, start_path)?;
@ -413,48 +423,27 @@ impl ServeSession {
let snap_start = Instant::now(); let snap_start = Instant::now();
log::trace!("Generating snapshot of instances from VFS"); log::trace!("Generating snapshot of instances from VFS");
let was_watch_enabled = vfs.is_watch_enabled();
vfs.set_watch_enabled(false);
let snapshot = snapshot_from_vfs(&instance_context, vfs, start_path)?; let snapshot = snapshot_from_vfs(&instance_context, vfs, start_path)?;
vfs.set_watch_enabled(was_watch_enabled);
log::debug!("Snapshot built in {:.1?}", snap_start.elapsed()); log::debug!("Snapshot built in {:.1?}", snap_start.elapsed());
vfs.clear_prefetch_cache(); vfs.clear_prefetch_cache();
let patch_start = Instant::now(); let patch_start = Instant::now();
if vfs.is_watch_enabled() {
std::thread::scope(|s| {
s.spawn(|| {
let watch_start = Instant::now();
let mut watch_count: usize = 0;
for root in &path_roots {
let _ = vfs.watch(root);
watch_count += 1;
}
log::debug!(
"Set up {} watches in {:.1?}",
watch_count,
watch_start.elapsed(),
);
});
log::trace!("Computing initial patch set"); log::trace!("Computing initial patch set");
let patch_set = compute_patch_set(snapshot, &tree, root_id); let patch_set = compute_patch_set(snapshot, &tree, root_id);
log::trace!("Applying initial patch set"); log::trace!("Applying initial patch set");
apply_patch_set(&mut tree, patch_set); let applied = apply_patch_set(&mut tree, patch_set);
}); let ref_path_entries = applied.ref_path_index_entries;
} else { log::debug!("Patch computed + applied in {:.1?}", patch_start.elapsed());
log::trace!("Computing initial patch set");
let patch_set = compute_patch_set(snapshot, &tree, root_id);
log::trace!("Applying initial patch set"); Ok((
apply_patch_set(&mut tree, patch_set); root_project,
} tree,
log::debug!("Watch + patch in {:.1?}", patch_start.elapsed()); walked_paths,
path_roots,
Ok((root_project, tree, walked_paths, path_roots)) ref_path_entries,
))
} }
/// Start a new serve session from the given in-memory filesystem and start /// Start a new serve session from the given in-memory filesystem and start
@ -471,7 +460,10 @@ impl ServeSession {
let start_path = start_path.as_ref(); let start_path = start_path.as_ref();
let start_time = Instant::now(); let start_time = Instant::now();
let (root_project, tree, _walked_paths, _path_roots) = Self::init_tree(&vfs, start_path)?; let t_init_start = Instant::now();
let (root_project, tree, _walked_paths, _path_roots, ref_path_entries) =
Self::init_tree(&vfs, start_path)?;
let t_init_tree = Instant::now();
let session_id = SessionId::new(); let session_id = SessionId::new();
let message_queue = MessageQueue::new(); let message_queue = MessageQueue::new();
@ -482,29 +474,26 @@ impl ServeSession {
let (tree_mutation_sender, tree_mutation_receiver) = crossbeam_channel::unbounded(); let (tree_mutation_sender, tree_mutation_receiver) = crossbeam_channel::unbounded();
let suppressed_paths = Arc::new(Mutex::new(std::collections::HashMap::new())); let suppressed_paths = Arc::new(Mutex::new(std::collections::HashMap::new()));
let ref_path_index = { let ref_path_index = Arc::new(Mutex::new(crate::RefPathIndex::from_entries(
let mut index = crate::RefPathIndex::new(); ref_path_entries,
let tree_guard = tree.lock().unwrap(); )));
let mut ref_roots = Vec::new(); let t_ref_index = Instant::now();
collect_path_roots(
&root_project.tree,
root_project.folder_location(),
&mut ref_roots,
);
for root in &ref_roots {
index.populate_from_dir(root, &tree_guard);
}
drop(tree_guard);
Arc::new(Mutex::new(index))
};
let git_repo_root = crate::git::git_repo_root(root_project.folder_location()); let git_repo_root = crate::git::git_repo_root(root_project.folder_location());
let initial_head_commit = git_repo_root let initial_head_commit = git_repo_root
.as_deref() .as_deref()
.and_then(crate::git::git_head_commit); .and_then(crate::git::git_head_commit);
let t_git = Instant::now();
let path_ignore_rules = root_project.path_ignore_rules(); let path_ignore_rules = root_project.path_ignore_rules();
log::debug!(
"[PERF] ServeSession::new breakdown: init_tree={:.1?}, ref_path_index={:.1?}, git={:.1?}",
t_init_tree - t_init_start,
t_ref_index - t_init_tree,
t_git - t_ref_index,
);
log::trace!("Starting ChangeProcessor"); log::trace!("Starting ChangeProcessor");
let change_processor = ChangeProcessor::start( let change_processor = ChangeProcessor::start(
Arc::clone(&tree), Arc::clone(&tree),
@ -547,7 +536,8 @@ impl ServeSession {
let start_path = start_path.as_ref(); let start_path = start_path.as_ref();
let start_time = Instant::now(); let start_time = Instant::now();
let (root_project, tree, walked_paths, _path_roots) = Self::init_tree(&vfs, start_path)?; let (root_project, tree, walked_paths, _path_roots, _ref_entries) =
Self::init_tree(&vfs, start_path)?;
Ok(Self { Ok(Self {
change_processor: None, change_processor: None,

View file

@ -1,6 +1,6 @@
//! Defines the data structures used for describing instance patches. //! Defines the data structures used for describing instance patches.
use std::collections::HashSet; use std::{collections::HashSet, path::PathBuf};
use rbx_dom_weak::{ use rbx_dom_weak::{
types::{Ref, Variant}, types::{Ref, Variant},
@ -81,6 +81,11 @@ pub struct AppliedPatchSet {
pub removed: Vec<Ref>, pub removed: Vec<Ref>,
pub added: Vec<Ref>, pub added: Vec<Ref>,
pub updated: Vec<AppliedPatchUpdate>, pub updated: Vec<AppliedPatchUpdate>,
/// Collected during patch application: `(resolved_absolute_ref_path, meta_or_model_file_path)`.
/// Used to build `RefPathIndex` without re-walking the directory tree.
#[serde(skip)]
pub ref_path_index_entries: Vec<(String, PathBuf)>,
} }
impl AppliedPatchSet { impl AppliedPatchSet {
@ -89,6 +94,7 @@ impl AppliedPatchSet {
removed: Vec::new(), removed: Vec::new(),
added: Vec::new(), added: Vec::new(),
updated: Vec::new(), updated: Vec::new(),
ref_path_index_entries: Vec::new(),
} }
} }

View file

@ -3,6 +3,8 @@
use std::{ use std::{
collections::{HashMap, HashSet}, collections::{HashMap, HashSet},
mem::take, mem::take,
path::PathBuf,
time::Instant,
}; };
use rbx_dom_weak::{ use rbx_dom_weak::{
@ -24,14 +26,20 @@ use crate::{
/// tree in sync with Rojo's. /// tree in sync with Rojo's.
#[profiling::function] #[profiling::function]
pub fn apply_patch_set(tree: &mut RojoTree, patch_set: PatchSet) -> AppliedPatchSet { pub fn apply_patch_set(tree: &mut RojoTree, patch_set: PatchSet) -> AppliedPatchSet {
let t0 = Instant::now();
let mut context = PatchApplyContext::default(); let mut context = PatchApplyContext::default();
let removal_count = patch_set.removed_instances.len();
let addition_count = patch_set.added_instances.len();
let update_count = patch_set.updated_instances.len();
{ {
profiling::scope!("removals"); profiling::scope!("removals");
for removed_id in patch_set.removed_instances { for removed_id in patch_set.removed_instances {
apply_remove_instance(&mut context, tree, removed_id); apply_remove_instance(&mut context, tree, removed_id);
} }
} }
let t1 = Instant::now();
{ {
profiling::scope!("additions"); profiling::scope!("additions");
@ -39,6 +47,7 @@ pub fn apply_patch_set(tree: &mut RojoTree, patch_set: PatchSet) -> AppliedPatch
apply_add_child(&mut context, tree, add_patch.parent_id, add_patch.instance); apply_add_child(&mut context, tree, add_patch.parent_id, add_patch.instance);
} }
} }
let t2 = Instant::now();
{ {
profiling::scope!("updates"); profiling::scope!("updates");
@ -46,6 +55,22 @@ pub fn apply_patch_set(tree: &mut RojoTree, patch_set: PatchSet) -> AppliedPatch
apply_update_child(&mut context, tree, update_patch); apply_update_child(&mut context, tree, update_patch);
} }
} }
let t3 = Instant::now();
log::debug!(
"[PERF] patch_apply phases: removals={} in {:.1?}, additions={} in {:.1?}, updates={} in {:.1?}",
removal_count, t1 - t0,
addition_count, t2 - t1,
update_count, t3 - t2,
);
log::debug!(
"[PERF] patch_apply context: snapshot_id_map={}, has_refs={}, attr_refs={}, path_refs={}, cleanup={}",
context.snapshot_id_to_instance_id.len(),
context.has_refs_to_rewrite.len(),
context.attribute_refs_to_rewrite.keys().count(),
context.path_refs_to_rewrite.keys().count(),
context.instances_needing_attr_cleanup.len(),
);
finalize_patch_application(context, tree) finalize_patch_application(context, tree)
} }
@ -90,6 +115,10 @@ struct PatchApplyContext {
/// format and should not persist in the live game. /// format and should not persist in the live game.
instances_needing_attr_cleanup: HashSet<Ref>, instances_needing_attr_cleanup: HashSet<Ref>,
/// Collected `(resolved_absolute_ref_path, meta_or_model_file_path)` entries
/// for building `RefPathIndex` without a redundant directory walk.
ref_path_index_entries: Vec<(String, PathBuf)>,
/// The current applied patch result, describing changes made to the tree. /// The current applied patch result, describing changes made to the tree.
applied_patch_set: AppliedPatchSet, applied_patch_set: AppliedPatchSet,
} }
@ -105,7 +134,12 @@ struct PatchApplyContext {
/// where we build up a map of snapshot IDs to instance IDs as they're created, /// where we build up a map of snapshot IDs to instance IDs as they're created,
/// then apply properties all at once at the end. /// then apply properties all at once at the end.
#[profiling::function] #[profiling::function]
fn finalize_patch_application(context: PatchApplyContext, tree: &mut RojoTree) -> AppliedPatchSet { fn finalize_patch_application(
mut context: PatchApplyContext,
tree: &mut RojoTree,
) -> AppliedPatchSet {
let finalize_start = Instant::now();
for id in context.has_refs_to_rewrite { for id in context.has_refs_to_rewrite {
let mut instance = tree let mut instance = tree
.get_instance_mut(id) .get_instance_mut(id)
@ -119,11 +153,13 @@ fn finalize_patch_application(context: PatchApplyContext, tree: &mut RojoTree) -
} }
} }
} }
let t_refs_rewrite = Instant::now();
// IMPORTANT: Rojo_Target_* (legacy) must be resolved BEFORE Rojo_Ref_* // IMPORTANT: Rojo_Target_* (legacy) must be resolved BEFORE Rojo_Ref_*
// (path-based) so that path-based results overwrite legacy results when // (path-based) so that path-based results overwrite legacy results when
// both exist for the same property. Do not reorder these two blocks. // both exist for the same property. Do not reorder these two blocks.
let mut real_rewrites = Vec::new(); let mut real_rewrites = Vec::new();
let attr_ref_count = context.attribute_refs_to_rewrite.keys().count();
for (id, map) in context.attribute_refs_to_rewrite { for (id, map) in context.attribute_refs_to_rewrite {
for (prop_name, prop_value) in map { for (prop_name, prop_value) in map {
if let Some(target) = tree.get_specified_id(&RojoRef::new(prop_value)) { if let Some(target) = tree.get_specified_id(&RojoRef::new(prop_value)) {
@ -135,11 +171,29 @@ fn finalize_patch_application(context: PatchApplyContext, tree: &mut RojoTree) -
.expect("Invalid instance ID in deferred attribute ref map"); .expect("Invalid instance ID in deferred attribute ref map");
instance.properties_mut().extend(real_rewrites.drain(..)); instance.properties_mut().extend(real_rewrites.drain(..));
} }
let t_attr_refs = Instant::now();
// Handle path-based references (Rojo_Ref_) // Handle path-based references (Rojo_Ref_)
let mut path_rewrites = Vec::new(); let mut path_rewrites = Vec::new();
let _path_ref_count = context.path_refs_to_rewrite.keys().count();
let mut path_ref_total = 0usize;
let mut path_ref_resolved = 0usize;
let mut path_ref_failed = 0usize;
for (id, map) in context.path_refs_to_rewrite { for (id, map) in context.path_refs_to_rewrite {
let source_abs = crate::ref_target_path_from_tree(tree, id);
let meta_file = find_meta_or_model_path(tree, id);
for (prop_name, path) in map { for (prop_name, path) in map {
path_ref_total += 1;
if let Some(meta_path) = &meta_file {
let resolved = crate::resolve_ref_path_to_absolute(&path, &source_abs)
.unwrap_or_else(|| path.clone());
context
.ref_path_index_entries
.push((resolved, meta_path.clone()));
}
if let Some(target) = tree.resolve_ref_path(&path, id) { if let Some(target) = tree.resolve_ref_path(&path, id) {
log::trace!( log::trace!(
"Resolved path reference {} -> {} (path: {})", "Resolved path reference {} -> {} (path: {})",
@ -147,8 +201,10 @@ fn finalize_patch_application(context: PatchApplyContext, tree: &mut RojoTree) -
target, target,
path path
); );
path_ref_resolved += 1;
path_rewrites.push((prop_name, Variant::Ref(target))); path_rewrites.push((prop_name, Variant::Ref(target)));
} else { } else {
path_ref_failed += 1;
log::debug!( log::debug!(
"Could not resolve path reference {} at path '{}' - instance not found in tree", "Could not resolve path reference {} at path '{}' - instance not found in tree",
prop_name, prop_name,
@ -163,9 +219,11 @@ fn finalize_patch_application(context: PatchApplyContext, tree: &mut RojoTree) -
instance.properties_mut().extend(path_rewrites.drain(..)); instance.properties_mut().extend(path_rewrites.drain(..));
} }
} }
let t_path_refs = Instant::now();
// Clean up Rojo ref attributes - they're only used for transport in the file // Clean up Rojo ref attributes - they're only used for transport in the file
// format and should not persist in the live game // format and should not persist in the live game
let cleanup_count = context.instances_needing_attr_cleanup.len();
for id in context.instances_needing_attr_cleanup { for id in context.instances_needing_attr_cleanup {
let mut instance = match tree.get_instance_mut(id) { let mut instance = match tree.get_instance_mut(id) {
Some(inst) => inst, Some(inst) => inst,
@ -195,8 +253,25 @@ fn finalize_patch_application(context: PatchApplyContext, tree: &mut RojoTree) -
attributes.remove(key); attributes.remove(key);
} }
} }
let t_cleanup = Instant::now();
context.applied_patch_set log::debug!(
"[PERF] finalize_patch_application: refs_rewrite={:.1?}, attr_refs({})={:.1?}, path_refs({}, ok={}, fail={})={:.1?}, cleanup({})={:.1?}, total={:.1?}",
t_refs_rewrite - finalize_start,
attr_ref_count,
t_attr_refs - t_refs_rewrite,
path_ref_total,
path_ref_resolved,
path_ref_failed,
t_path_refs - t_attr_refs,
cleanup_count,
t_cleanup - t_path_refs,
t_cleanup - finalize_start,
);
let mut result = context.applied_patch_set;
result.ref_path_index_entries = context.ref_path_index_entries;
result
} }
fn apply_remove_instance(context: &mut PatchApplyContext, tree: &mut RojoTree, removed_id: Ref) { fn apply_remove_instance(context: &mut PatchApplyContext, tree: &mut RojoTree, removed_id: Ref) {
@ -319,6 +394,27 @@ fn apply_update_child(context: &mut PatchApplyContext, tree: &mut RojoTree, patc
context.applied_patch_set.updated.push(applied_patch) context.applied_patch_set.updated.push(applied_patch)
} }
fn is_meta_or_model(name: &str) -> bool {
name.ends_with(".meta.json5")
|| name.ends_with(".model.json5")
|| name.ends_with(".meta.json")
|| name.ends_with(".model.json")
}
fn find_meta_or_model_path(tree: &RojoTree, id: Ref) -> Option<PathBuf> {
let instance = tree.get_instance(id)?;
instance
.metadata()
.relevant_paths
.iter()
.find(|p| {
p.file_name()
.and_then(|n| n.to_str())
.is_some_and(is_meta_or_model)
})
.cloned()
}
/// Calculates manually-specified Ref properties and marks them in the provided /// Calculates manually-specified Ref properties and marks them in the provided
/// `PatchApplyContext` to be rewritten at the end of the patch application /// `PatchApplyContext` to be rewritten at the end of the patch application
/// process. /// process.