Skip to content

perf: 加速整合包安装、加载器复用与模组列表刷新 - #6867

Closed
xianaldai wants to merge 2 commits into
HMCL-dev:mainfrom
xianaldai:perf/modpack-install-optimizations
Closed

xianaldai wants to merge 2 commits into
HMCL-dev:mainfrom
xianaldai:perf/modpack-install-optimizations

Conversation

@xianaldai

Copy link
Copy Markdown

perf: 加速整合包安装、加载器复用与模组列表刷新

分支:perf/modpack-install-optimizations

这个 PR 做什么

四块改动,都围绕最大复用:

  1. 整合包安装:解压与哈希并行化;CurseForge 元数据改用批量接口。
  2. CurseForge 元数据提前:从安装末尾挪到与下载并行。
  3. 加载器产物复用:同版本加载器再次安装时,跳过安装器下载和全部 processor。
  4. 模组列表刷新:并行解析元数据,并按文件状态缓存解析结果。

实测数据

测试环境:1.20.1 + Forge 47.3.22,同一个 CurseForge 整合包 (落幕曲1.5.3)(481 MB,5574 个 overrides 条目,241 个 manifest 条目,268 个模组),同一台机器。与未改动的代码在同一台机器、相同负载下、同一个整合包上对比。

整合包安装

阶段 改动前 改动后
overrides 哈希扫描 2425 ms 687-726 ms
overrides 解压 7925 ms 3336-4692 ms
安装阶段 7215 ms 3337-4697 ms
安装末尾的 CurseForge 元数据补全 10627 ms 1432-3318 ms
整个安装窗口(库与资源已就绪) 43.0 s 21.1 s

CurseForge 元数据提前

指标 改动前 改动后
元数据补全总耗时 2076 ms 60 ms
其中名称解析 1902 ms(位于关键路径末端) 3 ms
元数据解析 不存在独立任务(串在安装最后) 独立任务,1977 ms,与下载并行

241 个条目的名称解析合并为 1POST /v1/mods/files,class id 合并为 1POST /v1/mods

加载器产物复用

同一个整合包连续安装两次:

指标 第一次(无记录) 第二次(有记录) 改动前
Executing external processor 条数 4 0 4
forge-installer 出现 下载了 未出现 下载了
安装 Forge 阶段 34 s 1 s 34 s
解析整合包完成 → 安装完成 38 s 4 s 42 s
控制台日志体积 1.67 MB 0.15 MB 1.67 MB

「改动前」列指没有构建记录时每一次安装都会发生的事——也就是本 PR 之前的行为。第二次安装全程没有下载安装器,也没有启动任何 processor 进程。

模组列表刷新(268 个模组)

场景 上游 本 PR
首次刷新(缓存为空) 240-751 ms 338-483 ms
再次刷新(文件未改动) 240-751 ms 12-94 ms

上游每次刷新都重新解析全部 268 个 jar;本 PR 按 (size, lastModified) 缓存,文件未改动时每个文件只做一次 stat首次刷新两者区间重叠,不构成明确提升——首次刷新的收益来自并行解析,而上游那一组数据自身波动就有 3 倍,覆盖了这个量级。

列表界面本身在刷新时仍会整体重建(setAll),但图标改为按 (路径, 大小, 修改时间) 缓存在页面上,重建后的条目直接复用上一轮已解码的图,不再重新打开 jar。实测这一项占原刷新开销的绝大部分(29 个可见图标、589.6 ms)。

实现摘要

解压与哈希并行化。 Unzipper 把一个归档的条目分散到多个 reader 上,每个 worker 一个,因为 ZipArchiveReader 通过单一 channel 寻址,无法同时服务两个线程。条目数低于阈值时留在调用线程。MinecraftInstanceTask 以同样方式并行哈希 overrides。ZipFileTree 缓存扁平条目索引供这些并行流程共用。

CurseForge 批量请求。 POST /v1/modsPOST /v1/mods/files 一次可答多个 id,而整合包 manifest 恰好逐条指名了它钉住的文件。逐条请求时即使服务端很快也不划算,因为信号量是与所有 CurseForge 调用共用的,几百个条目大部分时间在排队等许可而不是在跟服务端交换。

元数据提前。 名称解析与 class id 查询只依赖 manifest,不碰实例里的任何文件,因此排在游戏下载与解压旁边,而不是排在它们之后。这两项是延迟受限而非带宽受限,与一个已经打满带宽的下载共用链路不额外花费时间;反之放在下载之后,每一次往返都落在关键路径上。

加载器产物复用。 加载器安装器只为少数 processor 声明 outputs 校验和。Forge 1.20.1-47.3.22 中 jarsplitteroutputs,而 installertoolsForgeAutoRenamingToolbinarypatcher 一个都不带。只信 profile 的启动器无法判断这些步骤是否还需要执行,于是每次安装都重跑一遍——哪怕上一次的产物还躺在共享的 libraries 目录里。本 PR 补上缺失的部分:记录每个 processor 刚写出的文件的校验和,同时保存安装器 profile 和加载器 manifest 原文,重复安装因此连安装器 JAR 都不必下载和解包。

构建键为 forge|<版本>|client / neoforge|<版本>|client。加载器版本串已经同时确定了 MC 版本和加载器,且在拉取安装器之前就已知:这正是让重复安装能省掉下载的原因。NeoForge 必须自己指定键:它报给 patch 的版本来自安装器 profile,而「要不要下载安装器」这个判断必须发生在读到 profile 之前。

模组列表缓存。 解析只读文件、不碰管理器状态,因此可以并发且不持锁地跑,结果之后再单线程合并。reader 划分只取决于扩展名和实例的加载器集合,每次刷新算一次而不是每个文件算一次。非模组文件在解析前就被过滤掉,解析阶段因此不必对它们做 stat

正确性

  • 并行解压与串行路径逐字节一致(5574 个文件,589529257 字节)。
  • 条目名解码未改变:CompressingUtils.resolveZipEncoding 精确复现 openZipFileWithPossibleEncoding,包括其 UTF-8 回退分支,在 42 组归档/字符集组合上验证过。
  • 加载器记录写入后复验 6 个产物,SHA-1 全部匹配(checked=6 mismatched=0)。任一产物缺失、校验和不符、format 版本不符或键不匹配,均判定为无效记录并退回完整构建。
  • 记录存放在仓库的 .hmcl/loader-build/ 下,路径按仓库根目录相对存储,与读取时的工作目录无关。
  • 模组解析缓存以文件大小与修改时间判定失效;非模组文件与已消失的文件会在刷新时从缓存中清掉。
  • CurseCompletionTask 在拿不到提前解析结果时(修复入口)仍会自行查询,行为与改动前一致。
  • 涉及的解析路径补了单元测试。

未验证

NeoForge 没有端到端实测——测试环境里只有 Forge/CurseForge 的整合包,没有 NeoForge 整合包。

Parallelise the file-processing passes over a modpack archive and remove
redundant CurseForge API round trips.

Measured on a 481 MB CurseForge modpack (5574 override entries, 241 manifest
mods, 28 threads) using the launcher's own instrumentation, comparing against
the unmodified code on the same machine:

  overrides hash scan    2425 ms ->  687-726 ms
  overrides extraction   7925 ms -> 3336-4692 ms
  install stage          7215 ms -> 3337-4697 ms
  curse.completion      10627 ms -> 1432-3318 ms

The whole install window went from 43.0 s to 21.1 s when assets and libraries
are already present; the cold run also takes 43.0 s, but now covers a full
asset and library download that the original measurement did not pay for.

What changed:

- Unzipper extracts an archive's entries across several readers, one per
  worker, because a ZipArchiveReader seeks through a single channel and cannot
  serve two threads. Entry counts below a threshold stay on the calling thread.
- MinecraftInstanceTask hashes the overrides in parallel the same way.
- CurseCompletionTask resolves addon class ids and file names in batches
  instead of one request per manifest entry.
- ZipFileTree caches its flat entry lookup, which the parallel passes share.
- PerfLog is the opt-in (hmcl.perf) instrumentation behind these numbers.

Correctness:

- Parallel extraction is verified against the sequential path to produce
  byte-identical output (5574 files, 589529257 bytes).
- The charset the parallel readers decode entry names with is resolved by
  CompressingUtils.resolveZipEncoding, which reproduces
  openZipFileWithPossibleEncoding exactly, including its UTF-8 fallback
  branch, so a parallel extraction writes the same file names as before.
  Both were compared over 42 archive/charset combinations.
- Unit tests cover the touched parsing paths.
Reuse the outputs of a previously built loader when the same loader version is
installed again. A loader installer only declares outputs for a few of its
processors, so a launcher that trusts the profile cannot tell whether the
remaining steps still need to run and repeats all of them on every install, even
when their outputs are already sitting in the shared libraries directory. Record
the SHA-1 of every file a processor writes, along with the installer profile and
the loader manifest, so a repeat installation skips both the installer download
and every processor. A record is discarded as soon as any output is missing or
its checksum no longer matches.

Move the CurseForge manifest lookup off the end of the installation and next to
the downloads. Resolving the file names and the addon class ids only depends on
the manifest, and both are latency bound, so they no longer sit on the critical
path behind a saturated download. Both lookups are also batched: one
POST /v1/mods/files and one POST /v1/mods answer for the whole manifest instead
of one request per entry, which matters because the request budget is shared
with every other CurseForge call.

Share decoded mod icons across list rebuilds, keyed by path, size and
modification time, so reloading the mod list no longer reopens every jar to
decode an icon it had already decoded.

Remove the PerfLog instrumentation that was added to measure the changes above.
It is not part of the upstream code and would not have a place there.
@github-actions github-actions Bot added the 2000+ label Sep 15, 2026
@ToobLac

ToobLac commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

内存占用有测试吗?感觉HMCL现在不缺加载的时间,需要优化的是下载和内存为主

@xianaldai

Copy link
Copy Markdown
Author

内存占用有测试吗?感觉HMCL现在不缺加载的时间,需要优化的是下载和内存为主

内存占用没有测试 这个把整合包安装时间缩短了一半左右(冷安装和温安装都是) modlist加载是顺手加速的,能增加用户体验

@Glavo

Glavo commented Sep 15, 2026

Copy link
Copy Markdown
Member

这是 GPT 写的吗?看起来有大量过度抽象和无意义修改,会增加很多维护成本。

请你检查其中有意义的部分,把有意义的优化逐个分别作为单独的 PR 提交,而不是用 AI 一次性弄出这么一大坨出来,看的很头疼。

@Glavo Glavo closed this Sep 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants