Repository navigation
Add a --constant-arrays-to-data pass #9274
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
31a4287
df6ade5
5ad7ffa
0f04095
6a1403f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,337 @@ | ||||||||
| /* | ||||||||
| * Copyright 2026 WebAssembly Community Group participants | ||||||||
| * | ||||||||
| * Licensed under the Apache License, Version 2.0 (the "License"); | ||||||||
| * you may not use this file except in compliance with the License. | ||||||||
| * You may obtain a copy of the License at | ||||||||
| * | ||||||||
| * http://www.apache.org/licenses/LICENSE-2.0 | ||||||||
| * | ||||||||
| * Unless required by applicable law or agreed to in writing, software | ||||||||
| * distributed under the License is distributed on an "AS IS" BASIS, | ||||||||
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||||||
| * See the License for the specific language governing permissions and | ||||||||
| * limitations under the License. | ||||||||
| */ | ||||||||
|
|
||||||||
| // | ||||||||
| // Converts array.new_fixed of constant numeric values in function bodies into | ||||||||
| // array.new_data from a passive data segment, when that is smaller. | ||||||||
| // | ||||||||
| // array.new_fixed needs a full *.const instruction per element, while a data | ||||||||
| // segment stores the raw bytes, so large arrays, or arrays whose values have | ||||||||
| // long encodings, are smaller as array.new_data. On the other hand, | ||||||||
| // array.new_fixed is a constant expression that many optimizations understand | ||||||||
| // (and OptimizeInstructions lowers array.new_data into it for that reason), so | ||||||||
| // this pass is only useful at the very end of the pipeline, where nothing can | ||||||||
| // benefit from array.new_fixed anymore. The default pipeline runs it as part of | ||||||||
| // the last set of optimizations. | ||||||||
| // | ||||||||
| // Global initializers are not modified, as array.new_data is not a constant | ||||||||
| // expression. | ||||||||
| // | ||||||||
| // Arrays with identical payloads share one copy of the bytes, from an existing | ||||||||
| // passive segment with identical contents that is never dropped, or else from a | ||||||||
| // new segment. Each payload gets its own new segment, so that if its arrays are | ||||||||
| // lowered to array.new_fixed and converted again later (e.g. with --converge), | ||||||||
| // they reuse that segment rather than duplicate it. A payload is converted if | ||||||||
| // that reduces the estimated binary size, including the size of its segment. | ||||||||
| // | ||||||||
| // TODO: Also reuse payloads that appear inside other data. That needs an | ||||||||
| // efficient substring search, to avoid quadratic behavior. | ||||||||
| // TODO: Convert arrays of constant references to array.new_elem. | ||||||||
| // | ||||||||
|
|
||||||||
| #include <cstring> | ||||||||
| #include <optional> | ||||||||
| #include <string_view> | ||||||||
| #include <unordered_map> | ||||||||
| #include <unordered_set> | ||||||||
| #include <vector> | ||||||||
|
|
||||||||
| #include "ir/debuginfo.h" | ||||||||
| #include "ir/module-utils.h" | ||||||||
| #include "ir/names.h" | ||||||||
| #include "pass.h" | ||||||||
| #include "support/insert_ordered.h" | ||||||||
| #include "wasm-binary.h" | ||||||||
| #include "wasm-builder.h" | ||||||||
| #include "wasm-traversal.h" | ||||||||
| #include "wasm.h" | ||||||||
|
|
||||||||
| namespace wasm { | ||||||||
|
|
||||||||
| namespace { | ||||||||
|
|
||||||||
| // Binary size estimates. | ||||||||
|
|
||||||||
| // The number of bytes |thing| (e.g. a LEB) takes in the binary. | ||||||||
| template<typename T> size_t getWrittenSize(const T& thing) { | ||||||||
| BufferWithRandomAccess buffer; | ||||||||
| buffer << thing; | ||||||||
| return buffer.size(); | ||||||||
| } | ||||||||
|
|
||||||||
| // The size of a *.const instruction. | ||||||||
| size_t getConstSize(const Literal& value) { | ||||||||
| switch (value.type.getBasic()) { | ||||||||
| case Type::i32: | ||||||||
| return 1 + getWrittenSize(S32LEB(value.geti32())); | ||||||||
| case Type::i64: | ||||||||
| return 1 + getWrittenSize(S64LEB(value.geti64())); | ||||||||
| case Type::f32: | ||||||||
| case Type::f64: | ||||||||
| return 1 + value.type.getByteSize(); | ||||||||
| case Type::v128: | ||||||||
| // The SIMD prefix and opcode, and the value. | ||||||||
| return 2 + 16; | ||||||||
| case Type::none: | ||||||||
| case Type::unreachable: | ||||||||
| break; | ||||||||
| } | ||||||||
| WASM_UNREACHABLE("unexpected const type"); | ||||||||
| } | ||||||||
|
|
||||||||
| // The size of an array.new_fixed of constants, apart from the prefix, opcode | ||||||||
| // and type index, which are the same in array.new_data. | ||||||||
| size_t getArrayNewFixedSize(ArrayNewFixed* curr) { | ||||||||
| size_t size = getWrittenSize(U32LEB(curr->values.size())); | ||||||||
|
kripken marked this conversation as resolved.
|
||||||||
| for (auto* value : curr->values) { | ||||||||
| size += getConstSize(value->cast<Const>()->value); | ||||||||
| } | ||||||||
| return size; | ||||||||
| } | ||||||||
|
|
||||||||
| // The size of an array.new_data that reads all of a segment, apart from the | ||||||||
| // prefix, opcode and type index. | ||||||||
| size_t getArrayNewDataSize(Index segment, Index size) { | ||||||||
| // The segment index, an i32.const 0 for the offset, and an i32.const for the | ||||||||
| // size. | ||||||||
| return getWrittenSize(U32LEB(segment)) + 2 + 1 + | ||||||||
| getWrittenSize(S32LEB(int32_t(size))); | ||||||||
|
Comment on lines
+110
to
+111
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. (seems better to reuse the Literal logic for size computation) |
||||||||
| } | ||||||||
|
|
||||||||
| // The size of a new passive data segment, apart from its data. | ||||||||
| size_t getNewSegmentOverhead(const Module& wasm, size_t dataSize) { | ||||||||
| // The flags and the size of the data. | ||||||||
| size_t size = 1 + getWrittenSize(U32LEB(dataSize)); | ||||||||
|
kripken marked this conversation as resolved.
|
||||||||
| if (wasm.dataSegments.empty()) { | ||||||||
| // The first segment also adds the data count and data sections, each with | ||||||||
| // an id, a size and a count. | ||||||||
| size += 2 * 3; | ||||||||
| } | ||||||||
| return size; | ||||||||
| } | ||||||||
|
|
||||||||
| // Candidates for conversion. | ||||||||
|
|
||||||||
| // Returns the bytes the array would have in a data segment, if its values are | ||||||||
| // numeric constants. | ||||||||
| std::optional<std::vector<char>> getPayload(ArrayNewFixed* curr) { | ||||||||
| // An unreachable array has no array type (and its values are not all | ||||||||
| // constants anyway), and an empty one is smaller as it is. | ||||||||
| if (curr->type == Type::unreachable || curr->values.empty()) { | ||||||||
| return std::nullopt; | ||||||||
| } | ||||||||
| const auto& element = curr->type.getHeapType().getArray().element; | ||||||||
| // array.new_data only supports numeric elements. | ||||||||
| if (!element.type.isNumber()) { | ||||||||
| return std::nullopt; | ||||||||
| } | ||||||||
| auto elemBytes = element.getByteSize(); | ||||||||
| std::vector<char> payload(curr->values.size() * elemBytes); | ||||||||
| for (Index i = 0; i < curr->values.size(); i++) { | ||||||||
| auto* c = curr->values[i]->dynCast<Const>(); | ||||||||
| if (!c) { | ||||||||
| return std::nullopt; | ||||||||
| } | ||||||||
| // Like stores to arrays in the interpreter, write the low bytes of the | ||||||||
| // value, which truncates it for packed fields. | ||||||||
| uint8_t bits[16]; | ||||||||
| c->value.getBits(bits); | ||||||||
| memcpy(&payload[i * elemBytes], bits, elemBytes); | ||||||||
| } | ||||||||
| return payload; | ||||||||
| } | ||||||||
|
|
||||||||
| // An array.new_fixed of numeric constants. | ||||||||
| struct Candidate { | ||||||||
| Function* func; | ||||||||
| // Where the array.new_fixed is. | ||||||||
| Expression** location; | ||||||||
| std::vector<char> payload; | ||||||||
| }; | ||||||||
|
|
||||||||
| // Candidates with identical payloads, which can share one copy of the bytes. | ||||||||
| struct PayloadGroup { | ||||||||
| std::string_view payload; | ||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why not have this as a std::string as well, like in Candidate? If it makes sense as a view, I think it is worth documenting what it is a view of, and why that thing outlives this view.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. (another option is to just do |
||||||||
| std::vector<Candidate*> candidates; | ||||||||
| }; | ||||||||
|
|
||||||||
| struct ConstantArraysToData : public Pass { | ||||||||
| // We only replace allocations with other allocations of the same type. | ||||||||
| bool requiresNonNullableLocalFixups() override { return false; } | ||||||||
|
|
||||||||
| Module* wasm = nullptr; | ||||||||
|
|
||||||||
| // All the candidates, in a deterministic order. Payload groups point into | ||||||||
| // them, so they must not change after grouping. | ||||||||
| std::vector<Candidate> candidates; | ||||||||
|
|
||||||||
| // Segments that are dropped somewhere. We cannot reuse them, as | ||||||||
| // array.new_data traps once its segment is dropped (unless it reads nothing). | ||||||||
| std::unordered_set<Name> droppedSegments; | ||||||||
|
|
||||||||
| void run(Module* module) override { | ||||||||
| wasm = module; | ||||||||
| // array.new_data requires bulk memory. | ||||||||
| if (!wasm->features.hasGC() || !wasm->features.hasBulkMemory()) { | ||||||||
| return; | ||||||||
| } | ||||||||
| findCandidates(); | ||||||||
| if (candidates.empty()) { | ||||||||
| return; | ||||||||
| } | ||||||||
| auto groups = groupByPayload(); | ||||||||
| convert(groups); | ||||||||
| } | ||||||||
|
|
||||||||
| void findCandidates() { | ||||||||
| struct Info { | ||||||||
| std::vector<Candidate> candidates; | ||||||||
| std::unordered_set<Name> droppedSegments; | ||||||||
| }; | ||||||||
|
|
||||||||
| struct Finder : public PostWalker<Finder> { | ||||||||
| Info& info; | ||||||||
|
|
||||||||
| Finder(Info& info) : info(info) {} | ||||||||
|
|
||||||||
| void visitArrayNewFixed(ArrayNewFixed* curr) { | ||||||||
| if (auto payload = getPayload(curr)) { | ||||||||
| info.candidates.push_back( | ||||||||
| {getFunction(), getCurrentPointer(), std::move(*payload)}); | ||||||||
| } | ||||||||
| } | ||||||||
|
|
||||||||
| void visitDataDrop(DataDrop* curr) { | ||||||||
| info.droppedSegments.insert(curr->segment); | ||||||||
| } | ||||||||
| }; | ||||||||
|
|
||||||||
| ModuleUtils::ParallelFunctionAnalysis<Info, Immutable, InsertOrderedMap> | ||||||||
| analysis(*wasm, [](Function* func, Info& info) { | ||||||||
| if (!func->imported()) { | ||||||||
| Finder(info).walkFunction(func); | ||||||||
| } | ||||||||
| }); | ||||||||
|
|
||||||||
| // The map is in function order, so this order is deterministic. | ||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It seems simpler to me to iterate on Also, this depends on the inserts to the InsertOrderedMap map to be in a deterministic order, and while that is the case in
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hmm, doing this is the only reason we allow configuring the function-parallel analysis with an
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ah, I forgot about these other uses. Another option might be to document that this is part of the contract of the ParallelFunctionAnalysis class (i.e. that the simple loop on |
||||||||
| for (auto& [func, info] : analysis.map) { | ||||||||
| for (auto& candidate : info.candidates) { | ||||||||
| candidates.push_back(std::move(candidate)); | ||||||||
| } | ||||||||
| droppedSegments.insert(info.droppedSegments.begin(), | ||||||||
| info.droppedSegments.end()); | ||||||||
| } | ||||||||
| } | ||||||||
|
|
||||||||
| std::vector<PayloadGroup> groupByPayload() { | ||||||||
| std::vector<PayloadGroup> groups; | ||||||||
| std::unordered_map<std::string_view, Index> groupIndexes; | ||||||||
| for (auto& candidate : candidates) { | ||||||||
| std::string_view payload(candidate.payload.data(), | ||||||||
| candidate.payload.size()); | ||||||||
| auto [it, inserted] = groupIndexes.insert({payload, groups.size()}); | ||||||||
| if (inserted) { | ||||||||
| groups.push_back({payload, {}}); | ||||||||
| } | ||||||||
| groups[it->second].candidates.push_back(&candidate); | ||||||||
| } | ||||||||
| return groups; | ||||||||
| } | ||||||||
|
|
||||||||
| // Converts the arrays of each payload group to read from an existing segment | ||||||||
| // with identical contents, or else from a new segment, if that is smaller. | ||||||||
| void convert(std::vector<PayloadGroup>& groups) { | ||||||||
| auto reusableSegments = getReusableSegments(); | ||||||||
| for (auto& group : groups) { | ||||||||
| if (auto it = reusableSegments.find(group.payload); | ||||||||
| it != reusableSegments.end()) { | ||||||||
| if (getCodeSavings(group, it->second) > 0) { | ||||||||
| replaceArrays(group, it->second); | ||||||||
| } | ||||||||
| continue; | ||||||||
| } | ||||||||
| // A new segment goes at the end. | ||||||||
| Index segment = wasm->dataSegments.size(); | ||||||||
| auto savings = | ||||||||
| getCodeSavings(group, segment) - int64_t(group.payload.size()) - | ||||||||
| int64_t(getNewSegmentOverhead(*wasm, group.payload.size())); | ||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Please explain what the last two terms are here. The first of them is unclear to me. |
||||||||
| if (savings > 0) { | ||||||||
| addSegment(group.payload); | ||||||||
| replaceArrays(group, segment); | ||||||||
| } | ||||||||
| } | ||||||||
| } | ||||||||
|
|
||||||||
| // Returns the passive segments that are never dropped, by their contents (the | ||||||||
| // first one, if several are identical). | ||||||||
| std::unordered_map<std::string_view, Index> getReusableSegments() { | ||||||||
| std::unordered_map<std::string_view, Index> reusableSegments; | ||||||||
| for (Index i = 0; i < wasm->dataSegments.size(); i++) { | ||||||||
| auto& segment = wasm->dataSegments[i]; | ||||||||
| if (segment->isPassive() && !droppedSegments.contains(segment->name)) { | ||||||||
| reusableSegments.insert( | ||||||||
| {std::string_view(segment->data.data(), segment->data.size()), i}); | ||||||||
| } | ||||||||
| } | ||||||||
| return reusableSegments; | ||||||||
| } | ||||||||
|
|
||||||||
| // The bytes saved in the code if the group's arrays read from the segment. | ||||||||
| int64_t getCodeSavings(const PayloadGroup& group, Index segment) { | ||||||||
| // Arrays with identical payloads can differ in size: their element types | ||||||||
| // can differ (e.g. 8 i8s or 2 i32s), and so can the constants for packed | ||||||||
| // fields, where only the low bits matter (e.g. -1 and 255 in an i8 array). | ||||||||
| // So we measure each array. | ||||||||
| int64_t savings = 0; | ||||||||
| for (auto* candidate : group.candidates) { | ||||||||
| auto* array = (*candidate->location)->cast<ArrayNewFixed>(); | ||||||||
| savings += int64_t(getArrayNewFixedSize(array)) - | ||||||||
| int64_t(getArrayNewDataSize(segment, array->values.size())); | ||||||||
| } | ||||||||
| return savings; | ||||||||
| } | ||||||||
|
|
||||||||
| void addSegment(std::string_view payload) { | ||||||||
| auto name = Names::getValidDataSegmentName(*wasm, "constant-array-data"); | ||||||||
| // Without a memory and an offset, the segment is passive. | ||||||||
| wasm->addDataSegment(Builder::makeDataSegment( | ||||||||
| name, Name(), nullptr, payload.data(), payload.size())); | ||||||||
| } | ||||||||
|
|
||||||||
| // Replaces the group's arrays with array.new_data of all of the segment. That | ||||||||
| // cannot trap, as the segment is never dropped. | ||||||||
| void replaceArrays(const PayloadGroup& group, Index segment) { | ||||||||
| Builder builder(*wasm); | ||||||||
| auto name = wasm->dataSegments[segment]->name; | ||||||||
| for (auto* candidate : group.candidates) { | ||||||||
| auto* array = (*candidate->location)->cast<ArrayNewFixed>(); | ||||||||
| auto* replacement = builder.makeArrayNewData( | ||||||||
| array->type.getHeapType(), | ||||||||
| name, | ||||||||
| builder.makeConst(int32_t(0)), | ||||||||
| builder.makeConst(int32_t(array->values.size()))); | ||||||||
| assert(replacement->type == array->type); | ||||||||
| debuginfo::copyOriginalToReplacement(array, replacement, candidate->func); | ||||||||
| *candidate->location = replacement; | ||||||||
| } | ||||||||
| } | ||||||||
| }; | ||||||||
|
|
||||||||
| } // anonymous namespace | ||||||||
|
|
||||||||
| Pass* createConstantArraysToDataPass() { return new ConstantArraysToData(); } | ||||||||
|
|
||||||||
| } // namespace wasm | ||||||||
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -129,6 +129,10 @@ void PassRegistry::registerPasses() { | |||||||||
| registerPass("cfp-reftest", | ||||||||||
| "propagate constant struct field values, using ref.test", | ||||||||||
| createConstantFieldPropagationRefTestPass); | ||||||||||
| registerPass("constant-arrays-to-data", | ||||||||||
| "convert constant array.new_fixed to array.new_data when it is " | ||||||||||
| "smaller", | ||||||||||
| createConstantArraysToDataPass); | ||||||||||
| registerPass("constraint-analysis", | ||||||||||
| "finds and uses mathematical constraints on locals", | ||||||||||
| createConstraintAnalysisPass); | ||||||||||
|
|
@@ -853,6 +857,13 @@ void PassRunner::addDefaultGlobalOptimizationPostPasses() { | |||||||||
| addIfNoDWARFIssues("simplify-globals"); | ||||||||||
| } | ||||||||||
| addIfNoDWARFIssues("remove-unused-module-elements"); | ||||||||||
| if (options.lastOpts && wasm->features.hasGC() && | ||||||||||
| (options.optimizeLevel >= 2 || options.shrinkLevel >= 1)) { | ||||||||||
|
Comment on lines
+860
to
+861
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Since the
Suggested change
|
||||||||||
| // Converting array.new_fixed to array.new_data inhibits optimizations that | ||||||||||
| // need constant expressions or know the contents of the array (and | ||||||||||
| // OptimizeInstructions may undo it), so only do it at the very end. | ||||||||||
| addIfNoDWARFIssues("constant-arrays-to-data"); | ||||||||||
| } | ||||||||||
| if (options.optimizeLevel >= 2 && wasm->features.hasStrings()) { | ||||||||||
| // Gather strings to globals right before reorder-globals, which will then | ||||||||||
| // sort them properly. | ||||||||||
|
|
||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A local AI review noticed that without this TODO, this pass will generate duplicate data segments when run with
--convergebecauselastOptsis true at the end of every convergence iteration and previous OptimizeInstructions runs will always give this pass more work to do. @kripken, maybe we should consider settinglastOptsto be true only after convergence has been achieved?Since we already calculate
reusableSegments, we could also avoid this problem by emitting a separate new segment for each group, rather than a single concatenated segment.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, we need to decide on what lastOpts means if converge is run, I guess. Possibly we should just disable it? It keeps running until a condition, and it checks the condition after running the passes, so it never knows which opts are last until later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Never running it would be unfortunate because then passes like this one would never be run. I would lean toward either doing one extra iteration after convergence with
lastOptsis true, or keeping the status quo and making sure round trips don't keep adding more cruft.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, a single last iteration might be better, good point.