Skip to content

perf: Faster split merge with less memory, and resource table encode speed-ups - #1

Merged
LisoUseInAIKyrios merged 8 commits into
MorpheApp:mainfrom
rotundtapir:morphe/arsclib-sync
Sep 29, 2026
Merged

LisoUseInAIKyrios merged 8 commits into
MorpheApp:mainfrom
rotundtapir:morphe/arsclib-sync

Conversation

@rotundtapir

Copy link
Copy Markdown

Brings in REAndroid#104 and three changes that are being submitted upstream, per MorpheApp/morphe-patcher#225. All 8 commits apply directly on upstream main (b9c2266).

  • Resource table encode speed-ups (optimize resource table build REAndroid/ARSCLib#104, 4 commits)
  • ArrayCollection growth when inserting at an index, and an ensureCapacity fix
  • Merge bundle tables without copying the base table; stored dex kept stored
  • Less memory per table entry (open addressing set for string references, offsets in fields)

Google app bundle merge: 75 s to 25 s, and it now works in a 640m heap instead of 1280m. Tested against current dev across multiple apps and patch sources with no regressions; details in the issue. Builds on JitPack (checked on 0e43605).

Once merged, I'll open the patcher and manager PRs to switch the pin to the fork.

BlockList.sort() registered a swap listener that called setIndex() on both
items of every quicksort swap. For items that maintain back references, such
as StringItem, each setIndex() triggers onIndexChanged() which rewrites every
reference to the item, so a single sort of a large string pool performed
O(swaps x references) work while all intermediate index values were
immediately overwritten. The updateIndex() pass that already runs after the
sort assigns each item its final index exactly once, firing onIndexChanged()
a single time per moved item, so the per-swap updates are redundant.

Sorting a resource table string pool of a large apk gets several times
faster; the resulting order and all reference values are unchanged.
BlockList.sort() ran the full quicksort even when the list was already
sorted, costing O(n log n) comparisons per call. The string pools re-sort on
every refresh whenever the sort-required flag is set, and operations that
preserve order, like removing unused strings, set that flag too, so a
resource table refreshed several times during encoding paid for the same
sort repeatedly.

Use the existing needsSort() linear scan to detect the already-sorted case
and return false, matching the previous return value since the quicksort
performs no swaps on a sorted list. needsSort() also covers the size() < 2
check. A genuinely unsorted list pays one extra O(n) scan, which is small
against the O(n log n) sort that follows.
encodeValues() refreshed every package block right after encoding it, which
recomputes sizes, offsets and header fields of the whole package, and
re-sorts its string pools. All of that is thrown away: the table is refreshed
at the end of scanResourceFiles() and again by ApkModuleEncoder.refreshTable(),
and nothing between the per-package refresh and those passes reads the
computed sizes or offsets. Keep sortTypes(), which later steps do rely on,
and let the table-level refresh do the byte-level bookkeeping once.
TypeBlock.getOrCreateDefinedEntry() started with getEntry(name), which walks
every entry of the whole package referencing the spec string with that name
and tests each for membership in this type block. While encoding values XML
the lookup almost always misses, because the current type block is the one
being filled, so each encoded item paid for a walk over all previously
encoded configurations of the same resource: quadratic in the number of
configurations, and the dominant cost of building a resource table with many
locales. The subsequent resolveResourceId() walked the same references again.

Resolve the id first (the reference walk stops at the first entry of this
type), then address the entry array directly by entry id and compare the
name. The scan by name is kept only for the ambiguous case where the defined
id already holds a different name. Behavior is unchanged: a defined entry is
returned when present, created at its defined id when not, and an undefined
name still returns null.
add(index, item) went through slideRight(), which reserved exactly the
missing slot, so every insertion reallocated and copied the whole array.
Building a type block entry by entry, as merging tables does, was quadratic
because of it.

Also fix ensureCapacity(int) allocating too little when some capacity is
already free: it subtracted the free slots from the request and then added
the result to the size instead of to the current length, which could leave
fewer free slots than asked for.
ApkBundle.mergeModules() merged the base into an empty module, which copied
the whole base table entry by entry into a new TableBlock while the base kept
its own parsed copy, and TableBlock.merge() refreshed the whole table after
every module. On a large app (Google app, 87 MB resources.arsc) that is two
full tables on the heap and one full refresh per split.

A table that is only parsed for the merge is now taken over as the merged
table when the target has none, a split's parsed table is dropped once
merged, and TableBlock.merge(table, refresh) lets the bundle refresh once at
the end. The public merge() methods still refresh. The largest table is
picked by entry size, so a bundle without a base does not parse every table
up front.

Taking the base table over also keeps its strings as they are: the copy
merged the string pool by XML text, which could turn a plain string
containing markup into the styled string of the same text.

Also keep a stored dex stored under the name it is renumbered to, instead of
deflating it while writing.
Every string and spec string keeps its references in a HashSetStore, which
became a java.util.HashSet once it held more than one. A large table has
millions of references, so the 32 byte HashMap node per element added up
(about 130 MB on an 87 MB resources.arsc). The set is now an open addressing
table with linear probing and backward shift deletion, costing one array
slot per element, with the same equals/hashCode semantics.
There is an OffsetItem per entry and per string of every table, and each one
carried its own 2 or 4 byte array next to the object. The offset (and index
for sparse entries) now lives in int fields and is encoded when the item is
written, which drops an array per item for every table load. The written
bytes are unchanged.
@MarcaDian

MarcaDian commented Sep 28, 2026 •

Copy link
Copy Markdown

Split skipping before unpacking is already on dev in MorpheApp/morphe-manager@ab112d7, so no need to open a PR for it. It decides from the archive's entry names, but a module whose manifest declares no split is never skipped.

On my device (arm64) bundle preparation went from 6.38 s to 5.94 s on Reddit and from 3.89 s to 3.56 s on the Travian .xapk from MorpheApp/morphe-manager#996. Merged output is identical in both.

@LisoUseInAIKyrios LisoUseInAIKyrios changed the title Faster split merge with less memory, and resource table encode speed-ups perf: Faster split merge with less memory, and resource table encode speed-ups Sep 29, 2026
@LisoUseInAIKyrios
LisoUseInAIKyrios merged commit 0a68333 into MorpheApp:main Sep 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants