perf: 优化实例启动性能并修复过渡动画对齐
依赖校验戳缓存(18.6s→0.1s)、并发化库/资产校验、启动计时埋点、过渡动画200ms、窗口客户区精确对齐、遮罩即时显示、debug构建写文件日志。
This commit is contained in:
@ -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(
|
||||
|
||||
@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@ -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)]
|
||||
|
||||
@ -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();
|
||||
|
||||
Reference in New Issue
Block a user