perf: 优化实例启动性能并修复过渡动画对齐

依赖校验戳缓存(18.6s→0.1s)、并发化库/资产校验、启动计时埋点、过渡动画200ms、窗口客户区精确对齐、遮罩即时显示、debug构建写文件日志。
This commit is contained in:
Xiao-no-love
2026-09-16 13:10:37 +08:00
parent 776f621436
commit ccb921f4bd
7 changed files with 457 additions and 535 deletions

View File

@ -75,8 +75,10 @@ async fn run_with_extra_launch_args_inner(
extra_launch_args: Option<Vec<String>>,
gc_intent: Option<GcLaunchIntent>,
) -> crate::Result<(ProcessMetadata, Option<GcLaunchReport>)> {
let __t_hosted = std::time::Instant::now();
let _hosted_guard =
crate::pack::hosted::prepare_launch(instance_id, offline_mode).await?;
tracing::info!("[launch-timing] hosted_prepare_launch: {}ms", __t_hosted.elapsed().as_millis());
let state = State::get().await?;
let launch_preparation_timeout =
crate::state::instances::commands::get_instance_launch_context(

View File

@ -293,30 +293,130 @@ pub(crate) fn linked_native_plan(
}))
}
/// Persistent cache of "this file was already hash-verified" stamps, keyed by
/// absolute path and validated against the file's size and mtime. Asset
/// objects and libraries number in the thousands on modern packs; re-hashing
/// every byte on each launch is pure disk churn (and dominates cold-start on
/// mechanical drives). Content-addressed files never change in place, so a
/// matching (size, mtime) stamp lets us trust a previous verification.
///
/// The cache lives in Axolotl's own caches directory, never in the linked
/// installation. It only ever *skips* a re-hash when the stamp matches;
/// a modified or replaced file falls back to a full SHA1 check.
static VERIFY_STAMPS: std::sync::OnceLock<
std::sync::Mutex<HashMap<String, (u64, u128)>>,
> = std::sync::OnceLock::new();
static VERIFY_STAMPS_PATH: std::sync::OnceLock<PathBuf> =
std::sync::OnceLock::new();
static VERIFY_STAMPS_LOADED: std::sync::OnceLock<()> =
std::sync::OnceLock::new();
static VERIFY_STAMPS_DIRTY: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
fn verify_stamps() -> &'static std::sync::Mutex<HashMap<String, (u64, u128)>>
{
VERIFY_STAMPS.get_or_init(|| std::sync::Mutex::new(HashMap::new()))
}
/// Loads the stamp cache once per process. Safe to call from every entry
/// point; only the first call does work.
async fn load_verify_stamps(st: &State) {
if VERIFY_STAMPS_LOADED.set(()).is_err() {
return;
}
let path = st
.directories
.caches_dir()
.join("linked-verify-stamps.json");
if let Ok(bytes) = tokio::fs::read(&path).await
&& let Ok(map) =
serde_json::from_slice::<HashMap<String, (u64, u128)>>(&bytes)
{
if let Ok(mut guard) = verify_stamps().lock() {
*guard = map;
}
}
let _ = VERIFY_STAMPS_PATH.set(path);
}
/// Writes the stamp cache back if anything changed this session. Best-effort:
/// a failure only costs us the next session's re-hash.
async fn flush_verify_stamps() {
if !VERIFY_STAMPS_DIRTY.swap(false, std::sync::atomic::Ordering::SeqCst) {
return;
}
let Some(path) = VERIFY_STAMPS_PATH.get() else {
return;
};
let snapshot = match verify_stamps().lock() {
Ok(guard) => guard.clone(),
Err(_) => return,
};
let Ok(json) = serde_json::to_vec(&snapshot) else {
return;
};
if let Some(parent) = path.parent() {
let _ = tokio::fs::create_dir_all(parent).await;
}
let _ = tokio::fs::write(path, json).await;
}
fn file_mtime_nanos(metadata: &std::fs::Metadata) -> Option<u128> {
metadata
.modified()
.ok()
.and_then(|time| {
time.duration_since(std::time::UNIX_EPOCH).ok()
})
.map(|duration| duration.as_nanos())
}
/// Whether the file exists and satisfies the metadata available for it.
/// SHA1 is authoritative when declared; otherwise a declared size still
/// protects against accepting a partial or truncated file. An unreadable file
/// counts as not current so it gets replaced.
/// counts as not current so it gets replaced. A previously recorded
/// (size, mtime) stamp short-circuits the SHA1 read for unchanged files.
async fn file_is_current(
path: &std::path::Path,
expected_sha1: Option<&str>,
expected_size: Option<u64>,
) -> bool {
if !path.is_file() {
return false;
}
let metadata = match std::fs::metadata(path) {
Ok(metadata) if metadata.is_file() => metadata,
_ => return false,
};
if let Some(expected_size) = expected_size
&& std::fs::metadata(path)
.map_or(true, |metadata| metadata.len() != expected_size)
&& metadata.len() != expected_size
{
return false;
}
match expected_sha1 {
Some(expected) => match fetch::sha1_file_async(path).await {
Ok((_, actual)) => actual.eq_ignore_ascii_case(expected),
Err(_) => false,
},
None => true,
let Some(expected) = expected_sha1 else {
return true;
};
let mtime = file_mtime_nanos(&metadata);
let key = path.to_string_lossy().into_owned();
if let Some(mtime) = mtime
&& let Ok(guard) = verify_stamps().lock()
&& let Some(&(size, stamp_mtime)) = guard.get(&key)
&& size == metadata.len()
&& stamp_mtime == mtime
{
return true;
}
match fetch::sha1_file_async(path).await {
Ok((_, actual)) if actual.eq_ignore_ascii_case(expected) => {
if let Some(mtime) = mtime
&& let Ok(mut guard) = verify_stamps().lock()
{
guard.insert(key, (metadata.len(), mtime));
VERIFY_STAMPS_DIRTY
.store(true, std::sync::atomic::Ordering::SeqCst);
}
true
}
_ => false,
}
}
@ -475,18 +575,48 @@ pub(crate) async fn ensure_linked_assets_from(
};
let objects_dir = direct.assets_dir().join("objects");
let mut missing = Vec::new();
for asset in index.objects.values() {
let hash = &asset.hash;
if hash.len() < 2 {
continue;
}
let destination = objects_dir.join(&hash[..2]).join(hash);
let size = u64::from(asset.size);
if !file_is_current(&destination, Some(hash), Some(size)).await {
missing.push((hash.clone(), size, destination));
}
}
// Assets number in the thousands on modern versions; hashing each object
// serially accounted for the bulk of launch latency. Fan the checks out
// across a bounded pool (same width the downloader uses).
let __t_asset_scan = std::time::Instant::now();
let asset_limit = download_util::task_concurrency_limit(st)
.map(|limit| limit.saturating_mul(2))
.unwrap_or(FALLBACK_CONCURRENCY);
let missing_slot = std::sync::Arc::new(std::sync::Mutex::new(
Vec::<(String, u64, PathBuf)>::new(),
));
let asset_entries: Vec<(String, u64)> = index
.objects
.values()
.filter(|asset| asset.hash.len() >= 2)
.map(|asset| (asset.hash.clone(), u64::from(asset.size)))
.collect();
stream::iter(asset_entries)
.map(Ok::<_, crate::Error>)
.try_for_each_concurrent(asset_limit, |(hash, size)| {
let missing_slot = std::sync::Arc::clone(&missing_slot);
let objects_dir = objects_dir.clone();
async move {
let destination = objects_dir.join(&hash[..2]).join(&hash);
if !file_is_current(
&destination,
Some(&hash),
Some(size),
)
.await
{
missing_slot.lock().unwrap().push((hash, size, destination));
}
Ok(())
}
})
.await?;
let missing: Vec<(String, u64, PathBuf)> = std::sync::Arc::try_unwrap(
missing_slot,
)
.map(|mutex| mutex.into_inner().unwrap())
.unwrap_or_default();
tracing::info!("[launch-timing] asset_scan: {}ms (checked {}, missing {})", __t_asset_scan.elapsed().as_millis(), index.objects.len(), missing.len());
if !missing.is_empty() {
tracing::info!(
count = missing.len(),
@ -620,6 +750,7 @@ pub(crate) async fn ensure_direct_launch_dependencies(
java_arch: &str,
minecraft_updated: bool,
) -> crate::Result<()> {
load_verify_stamps(st).await;
let mut plans = Vec::new();
if let Some(plan) = linked_client_plan(direct, version_info) {
plans.push(plan);
@ -648,14 +779,41 @@ pub(crate) async fn ensure_direct_launch_dependencies(
// Only fetch what is actually missing so a healthy installation performs
// zero network requests.
let mut pending = Vec::new();
for plan in plans {
if !file_is_current(&plan.destination, plan.sha1.as_deref(), plan.size)
.await
{
pending.push(plan);
}
}
let __t_libscan = std::time::Instant::now();
let plans_len_dbg = plans.len();
// Verify every planned file concurrently. Hashing a few hundred jars
// serially dominated cold-start time; the SHA1 reads are independent so
// they fan out across the same bounded pool the downloader uses.
let lib_limit = download_util::task_concurrency_limit(st)
.map(|limit| limit.saturating_mul(2))
.unwrap_or(FALLBACK_CONCURRENCY);
let pending_slot = std::sync::Arc::new(std::sync::Mutex::new(
Vec::<LinkedFilePlan>::new(),
));
stream::iter(plans)
.map(Ok::<_, crate::Error>)
.try_for_each_concurrent(lib_limit, |plan| {
let pending_slot = std::sync::Arc::clone(&pending_slot);
async move {
if !file_is_current(
&plan.destination,
plan.sha1.as_deref(),
plan.size,
)
.await
{
pending_slot.lock().unwrap().push(plan);
}
Ok(())
}
})
.await?;
let pending: Vec<LinkedFilePlan> = std::sync::Arc::try_unwrap(
pending_slot,
)
.map(|mutex| mutex.into_inner().unwrap())
.unwrap_or_default();
tracing::info!("[launch-timing] dep_lib_scan: {}ms (checked {}, missing {})", __t_libscan.elapsed().as_millis(), plans_len_dbg, pending.len());
if !pending.is_empty() {
tracing::info!(
count = pending.len(),
@ -672,6 +830,7 @@ pub(crate) async fn ensure_direct_launch_dependencies(
.await?;
}
let __t_assets = std::time::Instant::now();
ensure_linked_assets(
st,
direct,
@ -679,7 +838,11 @@ pub(crate) async fn ensure_direct_launch_dependencies(
version_info.assets == "legacy",
)
.await?;
tracing::info!("[launch-timing] dep_assets: {}ms", __t_assets.elapsed().as_millis());
let __t_logcfg = std::time::Instant::now();
ensure_linked_log_config(st, direct, version_info.logging.as_ref()).await?;
tracing::info!("[launch-timing] dep_log_config: {}ms", __t_logcfg.elapsed().as_millis());
flush_verify_stamps().await;
Ok(())
}

View File

@ -1525,8 +1525,11 @@ pub async fn launch_minecraft(
) -> crate::Result<ProcessMetadata> {
let instance = &context.instance;
let content_set = &context.applied_content_set;
let mut __lt = std::time::Instant::now();
let mut combined_java_args =
crate::pack::hosted::java_arguments(&instance.id).await?;
tracing::info!("[launch-timing] hosted_java_arguments: {}ms", __lt.elapsed().as_millis());
__lt = std::time::Instant::now();
combined_java_args.extend_from_slice(java_args);
let java_args = combined_java_args.as_slice();
@ -1832,6 +1835,8 @@ pub async fn launch_minecraft(
.await?;
}
}
tracing::info!("[launch-timing] resolve_version_info: {}ms", __lt.elapsed().as_millis());
__lt = std::time::Instant::now();
let java_version = if let Some(java) =
context.launch_overrides.java_path.as_ref()
@ -1886,6 +1891,8 @@ pub async fn launch_minecraft(
let java_version =
crate::api::jre::check_jre(java_version.path.clone().into()).await?;
validate_loader_java_version(&version_info, &java_version)?;
tracing::info!("[launch-timing] resolve_java: {}ms", __lt.elapsed().as_millis());
__lt = std::time::Instant::now();
// Runtime-verify and fall back for GC arguments against the *actual* JVM
// that will run Minecraft. The frontend supplies an ordered candidate
@ -1915,6 +1922,8 @@ pub async fn launch_minecraft(
}
*gc_report = Some(report);
}
tracing::info!("[launch-timing] resolve_gc: {}ms", __lt.elapsed().as_millis());
__lt = std::time::Instant::now();
// Apply the mappable linked-launcher private settings for directly
// associated instances on top of the resolved launch configuration.
@ -2020,6 +2029,8 @@ pub async fn launch_minecraft(
None => vanilla_client_path,
}
};
tracing::info!("[launch-timing] assemble_client: {}ms", __lt.elapsed().as_millis());
__lt = std::time::Instant::now();
let args = version_info.arguments.clone().unwrap_or_default();
let mut command = match wrapper {
@ -2065,6 +2076,7 @@ pub async fn launch_minecraft(
&& let (Some(direct), Some(libraries)) =
(&direct_launch, linked_libraries.as_deref())
{
let __t_ensure = std::time::Instant::now();
direct_ensure::ensure_direct_launch_dependencies(
&state,
direct,
@ -2074,6 +2086,7 @@ pub async fn launch_minecraft(
minecraft_updated,
)
.await?;
tracing::info!("[launch-timing] ensure_deps(libs+assets+log): {}ms", __t_ensure.elapsed().as_millis());
}
let natives_dir = if let Some(direct) = &direct_launch {
@ -2085,9 +2098,11 @@ pub async fn launch_minecraft(
};
// Linked native archives can change outside Axolotl, so rebuild only the
// Axolotl-owned linked cache on every launch. Never mutate linked folders.
let __t_rm = std::time::Instant::now();
if direct_launch.is_some() && natives_dir.exists() {
io::remove_dir_all(&natives_dir).await?;
}
tracing::info!("[launch-timing] remove_old_natives: {}ms", __t_rm.elapsed().as_millis());
if !natives_dir.exists() {
io::create_dir_all(&natives_dir).await?;
}
@ -2098,6 +2113,7 @@ pub async fn launch_minecraft(
// launch; the managed restore path below must never touch them.
let target = natives_dir.clone();
let java_arch = java_version.architecture.clone();
let __t_extract = std::time::Instant::now();
tokio::task::spawn_blocking(move || {
extract_linked_natives(
&direct,
@ -2108,6 +2124,7 @@ pub async fn launch_minecraft(
)
})
.await??;
tracing::info!("[launch-timing] extract_linked_natives: {}ms", __t_extract.elapsed().as_millis());
} else if direct_launch.is_none() {
if offline_mode {
natives::prepare_native_libraries(
@ -2137,6 +2154,8 @@ pub async fn launch_minecraft(
}
}
tracing::info!("[launch-timing] ensure_libraries_and_natives: {}ms", __lt.elapsed().as_millis());
__lt = std::time::Instant::now();
tracing::debug!(
"Found QuickPlayVersion for {}: {quick_play_version:?}",
content_set.game_version
@ -2399,7 +2418,7 @@ pub async fn launch_minecraft(
let logs_folder = state.directories.game_logs_dir(&instance_path);
// Create Minecraft child by inserting it into the state
// This also spawns the process and prepares the subsequent processes
state
let __launch_process = state
.process_manager
.insert_new_process(
&instance.id,
@ -2443,7 +2462,9 @@ pub async fn launch_minecraft(
Ok(())
},
)
.await
.await;
tracing::info!("[launch-timing] process_spawn: {}ms", __lt.elapsed().as_millis());
__launch_process
}
#[cfg(test)]

View File

@ -630,7 +630,10 @@ impl std::io::Write for TruncatedConsoleWriter {
// Handling for the live development logging
// This will log to the console, and will not log to a file
#[cfg(debug_assertions)]
pub fn start_logger(_app_identifier: &str) -> Option<()> {
pub fn start_logger(app_identifier: &str) -> Option<()> {
use crate::prelude::DirectoryInfo;
use chrono::Local;
use tracing_subscriber::fmt::time::ChronoLocal;
use tracing_subscriber::prelude::*;
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| {
@ -640,12 +643,39 @@ pub fn start_logger(_app_identifier: &str) -> Option<()> {
.add_directive("hyper=info".parse().ok()?)
.add_directive("hyper_util=info".parse().ok()?)
.add_directive("sqlx=warn".parse().ok()?);
let console_layer = tracing_subscriber::fmt::layer().with_writer(|| {
TruncatedConsoleWriter {
stdout: std::io::stdout(),
}
});
// Debug builds historically only logged to the console, so a launched
// GUI process left no trace once its window closed. Tee into the same
// launcher_logs directory the release build uses so debug launches can
// be inspected after the fact.
let file_layer = DirectoryInfo::launcher_logs_dir_path(app_identifier)
.and_then(|dir| {
std::fs::create_dir_all(&dir).ok()?;
let path = dir.join(format!(
"session_debug_{}.log",
Local::now().format("%Y%m%d_%H%M%S")
));
std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)
.ok()
})
.map(|file| {
tracing_subscriber::fmt::layer()
.with_writer(std::sync::Mutex::new(file))
.with_ansi(false)
.with_timer(ChronoLocal::rfc_3339())
});
tracing_subscriber::registry()
.with(tracing_subscriber::fmt::layer().with_writer(|| {
TruncatedConsoleWriter {
stdout: std::io::stdout(),
}
}))
.with(console_layer)
.with(file_layer)
.with(filter)
.with(tracing_error::ErrorLayer::default())
.init();