Skip to content

Lower constant array.new_data in OptimizeInstructions - #9248

Merged
kripken merged 5 commits into
WebAssembly:mainfrom
gkdn:lower-constant-array-new-data
Oct 15, 2026
Merged

kripken merged 5 commits into
WebAssembly:mainfrom
gkdn:lower-constant-array-new-data

Conversation

@gkdn

@gkdn gkdn commented Oct 10, 2026

Copy link
Copy Markdown
Contributor

array.new_data is not a constant expression, which blocks optimizations
such as hoisting array literals into immutable globals, and passes like
Precompute, Heap2Local, GUFA and SimplifyGlobals don't understand it.

When the offset and size are constant and in bounds of a passive
segment, lower it to:

  • array.new_default, if all elements are zero
  • array.new, if all elements are equal
  • array.new_fixed, if there are at most 64 elements

Non-empty slices are only lowered with --traps-never-happen, since
array.new_data traps if the segment was dropped. An empty slice at
offset 0 can never trap, so it is always lowered.

The 64-element limit exists because array.new_fixed costs a full
*.const per element, while a data segment stores raw bytes. On large
J2Wasm applications this removes over 99.7% of passive data segments and
reduces binary size by ~1.8% (~1.3% gzipped).

array.new_data is not a constant expression, which blocks optimizations
such as hoisting array literals into immutable globals, and passes like
Precompute, Heap2Local, GUFA and SimplifyGlobals don't understand it.

When the offset and size are constant and in bounds of a passive
segment, lower it to:

  * array.new_default, if all elements are zero
  * array.new, if all elements are equal
  * array.new_fixed, if there are at most 64 elements

Non-empty slices are only lowered with --traps-never-happen, since
array.new_data traps if the segment was dropped. An empty slice at
offset 0 can never trap, so it is always lowered.

The 64-element limit exists because array.new_fixed costs a full
*.const per element, while a data segment stores raw bytes. On large
J2Wasm applications this removes over 99.7% of passive data segments and
reduces binary size by ~1.8% (~1.3% gzipped).
@gkdn
gkdn requested a review from a team as a code owner October 10, 2026 02:57
@gkdn
gkdn requested review from tlively and removed request for a team October 10, 2026 02:57
Comment thread src/passes/OptimizeInstructions.cpp
Comment thread src/passes/OptimizeInstructions.cpp Outdated
const auto& element = heapType.getArray().element;
if (!element.type.isNumber()) {
return;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Is this guarding against i31? (But I think that works too?)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Actually per

shouldBeTrue(field->type.isNumber(),
this should be always true.

Would you prefer an assert or should I just drop it?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Hmm, let's just drop this, that option seems best.

Comment thread src/passes/OptimizeInstructions.cpp Outdated

uint64_t offset = offsetConst->value.getUnsigned();
uint64_t size = sizeConst->value.getUnsigned();
uint64_t elemBytes = element.getByteSize();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

These uint64_ts can be Address.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done. Would you like me to also use std::ckd_add and remove the comment?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Yes, thanks.

Comment thread src/passes/OptimizeInstructions.cpp Outdated
Comment thread src/passes/OptimizeInstructions.cpp Outdated
if (first == Literal::makeZero(element.type)) {
replaceCurrent(builder.makeArrayNew(heapType, sizeExpr));
} else {
// visitArrayNew turns the size == 1 case into array.new_fixed.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

It also handles a default value, I think, so first == ..zero.. is not needed here?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Current implementation of makeArrayNew leaves code to be dropped (for the init value) which requires later Vacuum cleanup.

Maybe I remove this and then update visitArrayNew to only emit the drop when init has side effects. WDYT?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

In general it is simpler to leave cleanup for Vacuum. We usually check side effects when it enables an optimization.

Is there a particular reason you are concerned about the Vacuum here?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

No strong reason. I was just trying to avoid the extra step for optimization.

I suggested the visitArrayNew part as agent suggested couple of other places that did similarly (via getDroppedChildrenAndAppend) so I thought maybe you would prefer that approach.

Anyway I'm dropping the case here and won't update visitArrayNew. I will make sure lit results match.

// stores raw bytes, so only do this for small arrays. Empirically, 64 sits
// in a natural gap between small/medium array literals (2D array rows,
// enum/switch tables, etc.) and large bulk lookup tables (100+ elements).
constexpr uint64_t MaxArrayNewFixedSize = 64;

@kripken kripken Oct 12, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

How about putting this behind options.shrinkLevel == 0 (i.e. not doing it in -Os/-Oz)?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Though, even so, 64 elements is a large increase in size. How likely is it that it gets optimized later?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

For CalcWorker, this was actually the good cut off for the smallest size. The main goal here is to make these constant expressions so they can be used for global initialization which later enables further optimizations (in our case esp. clinit constant hoisting taking advantage of that).

But that might not be true for all apps. If you like, we can put it behind options.shrinkLevel == 0 if you prefer or make the number itself configurable.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Yeah, I am worried about the generalization to other codebases. But I don't have a clear intuition here...

Looks like Kotlin and Dart also use ArrayNewData. On some large binaries I saw no difference on Dart (no new optimization was possible), while on Kotlin this regressed size, with this diff:

  [globals]      : 13090   
  [imports]      : 568     
  [memories]     : 1       
- [memory-data]  : 1326742 
+ [memory-data]  : 1325066 
  [tables]       : 0       
  [tags]         : 1       
- [total]        : 1337835 
+ [total]        : 1338412 
  [vars]         : 33597   
  ArrayCopy      : 17      
  ArrayGet       : 2494    
  ArrayLen       : 950     
  ArrayNew       : 97      
- ArrayNewData   : 48      
- ArrayNewFixed  : 435     
+ ArrayNewData   : 23      
+ ArrayNewFixed  : 460     
  ArraySet       : 917     
  Binary         : 34293   
  Block          : 27894   
  Break          : 8450    
  Call           : 80567   
  CallRef        : 7548    
- Const          : 143375  
+ Const          : 143952  
  Drop           : 6746    
  GlobalGet      : 32620   
  GlobalSet      : 2941    

Looks like half the ArrayNewDatas were optimized out, into new and larger ArrayNewFixed which could not be optimized out.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Overall this added 786 bytes, but that is not significant in a 5 MB binary.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think we should have a high threshold here, then add a late-running pass that goes the other way and replaces large array.new_fixed with array.new_data/elem as a size optimization. Accepting the small size regression for some producers in the meantime seems fine.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Just note that if produces are not aware of that late pass, they will get a hit. Anyway I already starting experimenting with the late pass tonight; I need to work on what a good number here is.

Interesting that the j2cl pass is needed. I wonder if we should add a general deduplication pass (maybe that is what you mean here?)

Particular j2cl pass I was referring to is the one that moves things out of to be eager globals. The deduplication is a separate optimization (that's part of the my large report that I shared earlier)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Sounds good then, to keep the behavior in this PR, and consider the late deduplication pass. Now that we have a "late" stage that is safer than before, too.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Ok will do.

(Note that, I assume there was a typo though; the late pass will not be a late deduplication pass but the reverse conversion pass)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Yes, I meant some kind of reverse conversion (it would have the effect of deduplicating bytes in code into a single copy in data).

Comment thread src/wasm-interpreter.h Outdated

uint64_t end;
if (std::ckd_add(&end, offset, size * elemBytes) || end > seg.data.size()) {
// offset and size are u32 and elemBytes <= 16, so this can't overflow.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

offset and size are uint64_t as defined above. Is this referring to the fact that the wasm spec only allows i32s here? If so then I think it is better to keep the code more general (in case the spec allows i64).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done

Comment thread src/passes/OptimizeInstructions.cpp Outdated
// matter; sign-extending gives the shorter encoding (-1 vs 255).
return Literal::makeFromMemory(src, uint8_t(elemBytes));
}
return Literal::makeFromMemory(src, element.type);

@kripken kripken Oct 14, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Why do we need this if? Can't we always run line 2404, which has the right size?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Oh, is the issue that we need the type to handle floats? Then how about adding the byteSize as an optional param to the old form of makeFromMemory?

Comment thread test/lit/passes/optimize-instructions-array-new-data.wast

@kripken kripken left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

lgtm otherwise

Comment thread src/literal.h Outdated
// (1 or 2) can be given to read a sign-extended packed value. A byteSize of
// 0 means the full size of the type.
static Literal
makeFromMemory(const void* p, Type type, unsigned byteSize = 0);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Sorry for the nitpick but please make this

  makeFromMemory(const void* p, Type type, std::optional<unsigned> byteSize);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

(this is the convention we use more often elsewhere)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Updated. Pls let me know if it doesn't match the convention.

@kripken kripken left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Otherwise please run the fuzzer for a bit to rule out any crashes, and lgtm

@gkdn

gkdn commented Oct 15, 2026

Copy link
Copy Markdown
Contributor Author

Otherwise please run the fuzzer for a bit to rule out any crashes, and lgtm

I ran it for 30 mins, about 6.6k iterations.

@kripken kripken left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Great, thanks!

@kripken
kripken merged commit 2976586 into WebAssembly:main Oct 15, 2026
16 checks passed
@gkdn
gkdn deleted the lower-constant-array-new-data branch October 16, 2026 23:38
kripken pushed a commit that referenced this pull request Oct 21, 2026
Follow-up to #9248: a late pass that converts `array.new_fixed` of
constant numeric values in function bodies to `array.new_data` when that is
smaller (`array.new_fixed` needs a full `*.const` per element, a data segment
stores raw bytes).

Since `array.new_fixed` is a constant expression that other
optimizations understand, the default pipeline runs this only in the last set of opts
(`lastOpts`, #9238).

* Global initializers are left alone, as `array.new_data` is not a
constant expression there.
* Converted payloads go into one new passive segment. Identical payloads
are stored once, and a payload identical to an existing, never-dropped
passive segment reuses that segment.
* A group of identical payloads is converted only if the estimated
binary size is smaller, and the new segment is only added if the savings that depend
on it pay for its header.
* The segment is never dropped and all reads are in bounds, so the
result cannot trap.

Note that this does not make it safe to raise the 64-element cap in
OptimizeInstructions: the conversion is not always reversible. A larger
`array.new_fixed` can be hoisted into a global initializer, where it
cannot be turned back into `array.new_data`. On a large J2Wasm binary, removing
the cap moved the remaining big tables into globals and grew the binary (+0.9 KB
raw, +1.4 KB gzipped), which this pass cannot recover.
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