Skip to content

fix(cli): relocate data into split modules in optimized builds - #60

Merged
WorldSEnder merged 15 commits into
WorldSEnder:mainfrom
sabify:fix/release-data-relocation
Sep 21, 2026
Merged

WorldSEnder merged 15 commits into
WorldSEnder:mainfrom
sabify:fix/release-data-relocation

Conversation

@sabify

@sabify sabify commented Sep 6, 2026

Copy link
Copy Markdown

In release builds practically all .rodata stayed in the main wasm and nothing was
relocated into split modules (leptos-rs/cargo-leptos#679).

wasm-ld deduplicates and tail-merges strings when optimizing, so several data
symbols share bytes, and those symbols do not always land in the same output
module. The layout was computed per module, so shared bytes were copied once per
module, the segment grew past its input and the whole segment fell back into main.

Changes, one per commit:

  • Lay out the data symbols of all modules at once and merge overlapping symbols
    into one range, keeping every symbol's alignment. Bytes shared with main are
    emitted from main.
  • Attribute a relocation to the smallest data symbol containing it. A module
    using only an inner symbol used to miss the relocation's target; the old
    fallback hid this.
  • Emit bytes shared between splits from the chunk those splits load, not main.
  • If a relocated segment still does not fit its input, move the smallest
    module's data into main and retry instead of giving up on the whole segment.
  • Place ranges by decreasing alignment so ordinary data needs no padding. Each
    module emits one data segment per run of its ranges; input segments keep their
    indices, extra segments are appended, data count and names follow.

Measured on the leptos lazy_routes example (release): main's data section
shrinks from 74,106 to 69,547 bytes, every split and chunk now carries its own
data, shipped gzip sizes are within 0.2% of before, and no --no-merge-data-segments
linker flag is needed.

@WorldSEnder

Copy link
Copy Markdown
Owner

Note that when I wrote this code I did not (and still do not now) fully understand how llvm intends to use the data segments. My current model is that each data segment corresponds to a section in elf, but I didn't investigate a clear mapping. Somewhere in the wasm is a memory address that serves as "start of heap" and "start of stack".

There are also instructions that take the immediate index of a data segment as argument, but these do for some reason not get relocations. I don't want to scan the whole wasm for these instructions, and llvm might not even emit them for any Rust code.

My conclusion was not to move, or add, additional segments to the wasm so that indices do not change. Instead, each split module gets a slice of the original address space of each segment to initialize, without overlaps so that loading a split module doesn't reinitialize read/write memory which might have been modified.

I will review the code in the coming days, especially to consider if easing the restriction to append extra segments needs a few more safeguards.

@WorldSEnder WorldSEnder left a comment •

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good approach, and takes care to keep existing data segment indices, thanks for that. Would hold off with merging until #59 is done, since there will be one (easily merged but still) conflicts and I think this order is easier.

Adding additional data segments at the end is a nice idea that I missed!

Comment thread crates/wasm_split_cli/src/emit.rs Outdated
Comment thread crates/wasm_split_cli/src/emit.rs
Comment thread crates/wasm_split_cli/src/emit.rs Outdated
Comment thread CHANGELOG.md Outdated
Comment thread crates/wasm_split_cli/src/emit.rs Outdated
Comment thread crates/wasm_split_cli/src/emit.rs Outdated
@sabify
sabify force-pushed the fix/release-data-relocation branch from 1815ff1 to c475495 Compare September 10, 2026 09:29
@sabify

sabify commented Sep 10, 2026

Copy link
Copy Markdown
Author

Addressed all comments, one commit each. Will wait for #59 and rebase once it lands.

One extra commit, c475495, for a gap in the overlap safeguard: a segment with a non-constant base address had an unknown extent and was skipped by the overlap check, so a neighbouring constant-address segment could still be fragmented and its appended fragments could initialize after it. Such a segment now counts as overlapping every constant-address segment, and they all stay in main. Regression test included.

On your note about segment indices: every input segment keeps its slot, passive segments are copied unchanged, and only extra fragments are appended, so memory.init / data.drop immediates stay valid. The data count section follows the same enumeration.

wasm-ld deduplicates and tail-merges strings when optimizing, so several
data symbols can share bytes. These symbols do not necessarily end up in
the same output module. The data layout was computed per module, so the
shared bytes were copied once per module, the segment grew past its
input length and the whole segment fell back into the main module. In
release builds this kept practically all `.rodata` in the main wasm and
nothing was relocated into split modules (leptos-rs/cargo-leptos#679).

Compute the layout over the symbols of all modules at once: sort them by
input position, merge overlapping symbols into one range, and emit a
range that is needed by more than one module from the main module, which
is always loaded first. A merged range keeps the strictest alignment of
its symbols and is placed congruent to its input start, so every symbol
in it stays aligned.

Pad each module's part of a segment only to the alignment its ranges
need instead of the segment's alignment; with many output modules that
padding alone overflowed the segment.

Add end-to-end tests built from hand-encoded linking and relocation
sections: a tail-merged symbol shared between the main and a split
module, which fails with the previous layout, and an aligned word
overlapped by a longer string.
A data symbol contained in another symbol never became a candidate
target for relocations, so a relocation inside it was attributed to its
container. A module that only used the inner symbol, typically the main
module using the tail of a split's symbol, then missed the relocation's
target and could read it before the split was loaded. The whole-segment
fallback used to hide this by copying the entire segment into main.

Keep the inner symbol as a candidate so relocations go to the smallest
symbol containing them, as the containment dependency already assumes.
Bytes needed by more than one output module were always emitted from the
main module. When none of those modules is main, a chunk that every
requiring split loads is a tighter place: it keeps the bytes out of the
initial download. Pick the chunk shared by exactly the requiring splits,
or else the smallest chunk loaded by all of them, and only fall back to
main when no such chunk exists.

Track the modules that actually need a range, separately from the module
chosen to emit it. Choosing from the emitting module instead would carry
the extra splits of a superset chunk into later requirements and could
send a range to main although a chunk for its final requirement exists.

Add end-to-end tests for two splits sharing a tail-merged string, and
for a range whose requirement grows through three contained symbols.
wasm-ld's --gc-sections leaves little unreferenced data, so a relocated
segment has almost no slack for the padding between the parts of the
output modules. When it overflowed, the whole segment was copied into the
main module unrelocated, which again kept all data out of the split
modules.

Order the parts by decreasing alignment so padding is only needed after
a part that mixes alignments, and when the segment still overflows, move
the data of the smallest non-main module into the main module and retry.
Merged ranges can need more padding than the input had, so the copy of
the whole segment remains as the last resort.

Add an end-to-end test where padding overflows the segment by one byte
and only the smaller split's data is folded into main.
Each output module got one contiguous part of every input data segment.
A part mixing aligned tables with strings forced padding before the next
part, and with many output modules that padding exceeded the slack left
by --gc-sections, so data had to be folded into the main module.

Place the ranges of all modules by decreasing alignment instead. A
range's length is a multiple of its alignment, so ordinary ranges pack
with no padding at all; only merged ranges, which keep their input
residue, can still need some. Each module then emits one data segment
per run of its ranges, in practice one per alignment its data uses.

The input's data segments keep their indices in every module and hold
the module's first fragment, further fragments are appended and the data
count and names follow. Indices in the code, passive segments and the
names subsection therefore stay valid without any guard. Appending only
preserves the initialization order if the input segments do not overlap
in memory, which wasm-ld guarantees; segments of a hand-made input that
do overlap are kept as they are, in their slots.

Folds that were still needed are now reported at info level, once per
segment, so that users notice when lazy data is moved into main.
Folding a module's data into the main module changes what ends up in
main and is worth acting on, the same as the overlong segment fallback
that follows it. Report it at the same level.
…wners

A range's emitting module is a function of the modules that need it.
Computing it on every merge scanned the output modules once per shared
symbol and let a range migrate several times. Collect the owners first,
then place every range in one pass.
Keep the how out of the changelog; it is not actionable when upgrading.
The pull request carries the details.
A merged range can start at a less aligned offset than a symbol inside
it, so it is placed congruent to its input start modulo its alignment
rather than at the next multiple. Add a unit test for the placement.
…nown

Extra data segments are appended after the input's segments, which only
preserves initialization order when the segments do not overlap in
memory. A segment whose base address is not a constant has an unknown
extent, so a segment that is fragmented next to it could initialize
after it and overwrite its bytes. Treat such a segment as overlapping
every constant-address segment and keep them all in the main module.
@sabify
sabify force-pushed the fix/release-data-relocation branch from c475495 to 39f435e Compare September 12, 2026 13:54
@sabify

sabify commented Sep 12, 2026

Copy link
Copy Markdown
Author

I just rebased.

@WorldSEnder

Copy link
Copy Markdown
Owner

I'm satisfied with the implementation part, just trying to thoroughly get through the test file, which is a bit dense to read. Trying to get some debugging going to visualize the data ranges, too. Nothing to do from your side at this point, just wanted to give a heads-up 👍

@WorldSEnder WorldSEnder left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got through it. Before any code changes, please answer the questions in the review comments.

In the best world, the tests would somehow differentiate between properties that the current approach takes by implementation detail and which properties we absolutely expect. For example, a lot of the tests for specific memory contents might be overly strict.

Comment on lines +689 to +692
assert_eq!(
single_data_segment(&output.main),
(SEGMENT_BASE, SHARED.to_vec()),
"main module should contain exactly the shared bytes at the segment base",

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This assertion is a bit strict. I could imagine a future version that breaks this by initializing the prefix in the split module, so that

  • main contains b"bytes\0" at SEGMENT_BASE + SHARED_TAIL_OFFSET
  • the split contains b"shared-" at SEGMENT_BASE and b"split-only\0" at SEGMENT_BASE + SHARED.len()

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, it pins which module emits the shared bytes, which is a choice, not a requirement. The property is that every module reads correct bytes at the addresses it references and no module's segments overlap another's. I'd rewrite the layout assertions in this file around that once the questions below are settled.

let (split_only_addr, split_only_bytes) = single_data_segment(split_module);
assert_eq!(split_only_bytes, SPLIT_ONLY);
assert!(
split_only_addr >= SEGMENT_BASE + SHARED.len() as u32,

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not quite clear to me that the split_only_addr is not allowed to come before the shared data in memory, these two should be non-overlapping and not dependent on one-another.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, only non-overlap is required. Order in the segment is an implementation choice.

Comment thread crates/wasm_split_cli/tests/shared_data_symbols_end_to_end.rs Outdated
Comment thread crates/wasm_split_cli/tests/shared_data_symbols_end_to_end.rs Outdated
Comment thread crates/wasm_split_cli/tests/shared_data_symbols_end_to_end.rs
Comment thread crates/wasm_split_cli/tests/shared_data_symbols_end_to_end.rs Outdated
Comment thread crates/wasm_split_cli/tests/shared_data_symbols_end_to_end.rs
Comment thread crates/wasm_split_cli/tests/shared_data_symbols_end_to_end.rs
@sabify

sabify commented Sep 17, 2026

Copy link
Copy Markdown
Author

Got through it. Before any code changes, please answer the questions in the review comments.

In the best world, the tests would somehow differentiate between properties that the current approach takes by implementation detail and which properties we absolutely expect. For example, a lot of the tests for specific memory contents might be overly strict.

Two of them change code: capping contained symbols by their container's alignment, which simplifies align_offset and removes the fold loop, and rejecting straddling relocations again. On the general point, I'd rework the layout assertions around what is required: correct bytes at every referenced address after initialization, no overlap between modules loaded together, and segments staying inside their input. Exact addresses would only remain where the layout is the point of the test. I'll wait for you before changing anything.

@WorldSEnder

WorldSEnder commented Sep 17, 2026 •

Copy link
Copy Markdown
Owner

It seems we are aligned on the layout issues. We have two cases of overlap

  • one symbol (call it F) is fully contained in another (S). This might be a field in a struct. In Rust in particular, the size of a type must be a multiple of its alignment. We can then deduce that alignof(F) <= alignof(S). Also the offset of the field in the struct must be a multiple of the field's alignment, which might further restrict it.
  • one symbol (S1) overlaps another (S2) without either being contained in the other. This can not be the result of a field in a struct, so it must be merged strings. You might diagnose that this happens only in segments with the WASM_SEGMENT_FLAG_STRINGS flag set in its segment info. We assume that we can pick an address for either, then add/subtract the offset to get the address of the other. Meaning that the offset between them must be a multiple of both alignof(S1) and alignof(S2).

Hopefully that reasoning makes sense to you.

@sabify

sabify commented Sep 19, 2026

Copy link
Copy Markdown
Author

It does make sense and I checked it against a real build (a leptos frontend, 33k data symbols in one .rodata): 3921 identical extents from deduplication, 7 containments, all of them tail-merged string suffixes with alignment 1 inside a 1- or 2-aligned container, and no partial overlap at all. LLVM's tail merging only produces suffixes, so partial overlap should never occur, but your invariant is the right guard if it does.

One thing does not work: the STRINGS flag. wasm-ld merges .rodata.str* and .rodata.cst* into a single .rodata output segment and writes flags 0 for it (p2align 6 in this build, which is also why the position-based inference over-assumes alignment). So I would not diagnose by flag, just warn on any partial overlap.

Here is the plan, unless you say otherwise:

  • A contained symbol no longer raises its container's alignment. A partially overlapping one does and the range start must stay a multiple of the result, otherwise the segment is kept whole in main with a warning. Merged ranges then always start aligned, align_offset becomes next_multiple_of, and the fold loop goes; the whole-segment fallback stays. The two synthetic alignment tests are replaced by one containment case and one partial-overlap case.
  • Straddling relocations are rejected again and that test is dropped.
  • The layout assertions you flagged become property checks: correct bytes at every referenced address after initialization, no overlap between modules loaded together, segments inside their input. The chunk-identity assertion stays strict for now, as you said.

@WorldSEnder

Copy link
Copy Markdown
Owner

Plan sounds good.

wasm-ld merges [...] into a single .rodata output segment and writes flags 0 for it

Ah yeah I at least assumed that some llvm versions omit this flag, glad to see it confirmed that we can't trust it to exist. Nevermind that flag then.

A symbol contained in another one is a struct field or a merged string
suffix, so it cannot be aligned stricter than its container and no longer
raises the range's alignment. Only a partially overlapping symbol does,
and the range start must remain a multiple of the result; otherwise the
segment is kept whole in the main module. Linker output never produces
such overlaps, so they are reported.

Every range now starts at a multiple of its alignment and is placed at the
next such multiple, which needs no padding, so the fold of modules into
main is removed and the whole-segment fallback remains as the safety net.
A relocation that starts in one symbol and ends in another is not
something the linker produces. Report it as an invalid input instead of
attributing it to the containing symbol. Inner symbols stay candidates so
that a relocation inside them is attributed to them.
Assert what a layout must satisfy instead of where the current layout puts
things: every function reads the input's bytes at the addresses it refers
to once its module and the modules loaded with it are initialized, the
segments of modules loaded together do not overlap, every segment stays
inside its input segment, and shared bytes are emitted once. Exact
addresses remain only where the layout itself is under test.

@WorldSEnder WorldSEnder left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks a lot.

Will probably still change things for overlapping ranges but do so outside the PR with some more experience and debug output setup.

@WorldSEnder
WorldSEnder merged commit 6916122 into WorldSEnder:main Sep 21, 2026
7 checks passed
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.

2 participants