From 31a4287ba1314e10bce807bb9ae95a1689d09441 Mon Sep 17 00:00:00 2001 From: Goktug Gokdogan Date: Fri, 16 Oct 2026 17:16:49 -0700 Subject: [PATCH 1/6] Add a ConstantArraysToData pass Convert array.new_fixed of constant numeric values in function bodies to array.new_data when that is smaller in the binary. array.new_fixed needs a full *.const per element, while a data segment stores raw bytes. As array.new_fixed is a constant expression that other optimizations understand, this is only done in the last set of default optimizations (lastOpts). All converted payloads go into a single new passive segment. Identical payloads are stored once, and payloads that already appear in the new segment or in an existing passive segment that is never dropped reuse those bytes. --- CHANGELOG.md | 4 + scripts/fuzz_opt.py | 1 + src/passes/CMakeLists.txt | 1 + src/passes/ConstantArraysToData.cpp | 319 ++++++++++++++++++ src/passes/pass.cpp | 11 + src/passes/passes.h | 1 + test/lit/help/wasm-metadce.test | 4 + test/lit/help/wasm-opt.test | 4 + test/lit/help/wasm2js.test | 4 + .../constant-arrays-to-data-last-opts.wast | 45 +++ test/lit/passes/constant-arrays-to-data.wast | 310 +++++++++++++++++ 11 files changed, 704 insertions(+) create mode 100644 src/passes/ConstantArraysToData.cpp create mode 100644 test/lit/passes/constant-arrays-to-data-last-opts.wast create mode 100644 test/lit/passes/constant-arrays-to-data.wast diff --git a/CHANGELOG.md b/CHANGELOG.md index 42537ea3595..e8bffa8bda3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,10 @@ Current Trunk - Add a new wasm-embed tool for extracting Wasm modules embedded as byte arrays in JS source files or replacing such modules in the JS source (#9127) - Support reading and writing label names in the name section (#9166) + - Add a `--constant-arrays-to-data` pass that converts `array.new_fixed` of + constant numeric values to `array.new_data` when that is smaller. It runs + in the last set of default optimizations (when + `--expect-later-toolchain-opts` is not passed). v133 ---- diff --git a/scripts/fuzz_opt.py b/scripts/fuzz_opt.py index 3339df8e4fe..c97d9cb252e 100755 --- a/scripts/fuzz_opt.py +++ b/scripts/fuzz_opt.py @@ -2692,6 +2692,7 @@ def write_commands(commands, filename): ("--code-pushing",), ("--code-folding",), ("--const-hoisting",), + ("--constant-arrays-to-data",), ("--constraint-analysis",), ("--dae",), ("--dae-optimizing",), diff --git a/src/passes/CMakeLists.txt b/src/passes/CMakeLists.txt index c158d5cd31b..bcaffb8ac1b 100644 --- a/src/passes/CMakeLists.txt +++ b/src/passes/CMakeLists.txt @@ -24,6 +24,7 @@ set(passes_SOURCES CoalesceLocals.cpp CodePushing.cpp CodeFolding.cpp + ConstantArraysToData.cpp ConstantFieldPropagation.cpp ConstHoisting.cpp ConstraintAnalysis.cpp diff --git a/src/passes/ConstantArraysToData.cpp b/src/passes/ConstantArraysToData.cpp new file mode 100644 index 00000000000..1b8dd1de1a4 --- /dev/null +++ b/src/passes/ConstantArraysToData.cpp @@ -0,0 +1,319 @@ +/* + * 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. +// +// All the new payloads are stored in a single new passive segment. A payload +// that appears more than once is stored once, and payloads that are already +// present in the new segment or in an existing passive segment that is never +// dropped reuse those bytes. +// +// The decision is made for each group of identical payloads using an estimate +// of the binary size: +// +// array.new_fixed $T N (c0) ... (cN-1) +// = prefix typeidx u32(N) size(c0) ... size(cN-1) +// array.new_data $T $seg (i32.const off) (i32.const N) +// = prefix typeidx u32(seg) 0x41 s32(off) 0x41 s32(N) +// +// plus the payload bytes (N * elemBytes) if they are not already present. A +// group is converted if the estimate is smaller. +// + +#include +#include +#include +#include +#include +#include + +#include "ir/find_all.h" +#include "ir/module-utils.h" +#include "ir/names.h" +#include "pass.h" +#include "wasm-builder.h" +#include "wasm-traversal.h" +#include "wasm.h" + +namespace wasm { + +namespace { + +size_t getU32LEBSize(uint64_t value) { + size_t size = 1; + while (value >= 0x80) { + value >>= 7; + size++; + } + return size; +} + +size_t getSLEBSize(int64_t value) { + size_t size = 0; + while (true) { + uint8_t byte = value & 0x7f; + value >>= 7; + size++; + if ((value == 0 && !(byte & 0x40)) || (value == -1 && (byte & 0x40))) { + return size; + } + } +} + +// The size of a *.const instruction in the binary format. +size_t getConstSize(const Literal& value) { + switch (value.type.getBasic()) { + case Type::i32: + return 1 + getSLEBSize(value.geti32()); + case Type::i64: + return 1 + getSLEBSize(value.geti64()); + case Type::f32: + return 1 + 4; + case Type::f64: + return 1 + 8; + case Type::v128: + return 2 + 16; + default: + WASM_UNREACHABLE("unexpected const type"); + } +} + +struct Candidate { + // Where the array.new_fixed is. + Expression** location; + // The bytes the array would have in a data segment. + std::string payload; + // The size of the elements count and the operands of the array.new_fixed + // (the prefix and type index are the same in both forms, so we ignore them). + size_t fixedSize; +}; + +using Candidates = std::vector; + +struct Finder : public PostWalker { + Candidates& candidates; + + Finder(Candidates& candidates) : candidates(candidates) {} + + void visitArrayNewFixed(ArrayNewFixed* curr) { + if (curr->type == Type::unreachable || curr->values.empty()) { + return; + } + const auto& element = curr->type.getHeapType().getArray().element; + if (!element.type.isNumber()) { + return; + } + auto elemBytes = element.getByteSize(); + + std::string payload; + payload.reserve(curr->values.size() * elemBytes); + size_t fixedSize = getU32LEBSize(curr->values.size()); + for (auto* value : curr->values) { + auto* c = value->dynCast(); + if (!c) { + return; + } + fixedSize += getConstSize(c->value); + // getBits writes little-endian, so the first bytes are what a packed + // field stores, which matches the truncation array.new_fixed does. + uint8_t bits[16]; + c->value.getBits(bits); + payload.append(reinterpret_cast(bits), elemBytes); + } + candidates.push_back({getCurrentPointer(), std::move(payload), fixedSize}); + } +}; + +struct ConstantArraysToData : public Pass { + // We only replace allocations with other allocations of the same type. + bool requiresNonNullableLocalFixups() override { return false; } + + void run(Module* module) override { + if (!module->features.hasGC() || !module->features.hasBulkMemory()) { + return; + } + + ModuleUtils::ParallelFunctionAnalysis analysis( + *module, [&](Function* func, Candidates& candidates) { + if (!func->imported()) { + Finder(candidates).walk(func->body); + } + }); + + // Group identical payloads, in a deterministic order. + struct Group { + std::string_view payload; + std::vector candidates; + }; + std::vector groups; + std::unordered_map groupIndexes; + for (auto& func : module->functions) { + for (auto& candidate : analysis.map[func.get()]) { + auto [it, inserted] = + groupIndexes.insert({candidate.payload, groups.size()}); + if (inserted) { + groups.push_back({candidate.payload, {}}); + } + groups[it->second].candidates.push_back(&candidate); + } + } + if (groups.empty()) { + return; + } + + // Find the existing passive segments whose bytes we can reuse. A segment + // that is dropped cannot be read afterwards, so ignore those. + std::unordered_set droppedSegments; + for (auto& func : module->functions) { + if (!func->imported()) { + for (auto* drop : FindAll(func->body).list) { + droppedSegments.insert(drop->segment); + } + } + } + struct ExistingSegment { + Name name; + Index index; + std::string_view data; + }; + std::vector existingSegments; + for (Index i = 0; i < module->dataSegments.size(); i++) { + auto& segment = module->dataSegments[i]; + if (segment->isPassive() && !droppedSegments.contains(segment->name) && + !segment->data.empty()) { + existingSegments.push_back( + {segment->name, + i, + std::string_view(segment->data.data(), segment->data.size())}); + } + } + + // Handle larger payloads first, so that smaller ones have a better chance + // to be found inside them. + std::stable_sort(groups.begin(), groups.end(), [](auto& a, auto& b) { + return a.payload.size() > b.payload.size(); + }); + + // The contents of the new segment. + std::string newData; + Index newSegmentIndex = module->dataSegments.size(); + + struct Decision { + Group* group; + // The segment to read from, or a null name for the new segment. + Name segment; + Index offset; + }; + std::vector decisions; + int64_t totalSavings = 0; + + for (auto& group : groups) { + auto payload = group.payload; + Name segment; + Index segmentIndex = newSegmentIndex; + size_t offset = newData.find(payload); + size_t dataCost = 0; + if (offset == std::string::npos) { + for (auto& existing : existingSegments) { + offset = existing.data.find(payload); + if (offset != std::string_view::npos) { + segment = existing.name; + segmentIndex = existing.index; + break; + } + } + } + if (offset == std::string::npos) { + offset = newData.size(); + dataCost = payload.size(); + } + + int64_t savings = -int64_t(dataCost); + for (auto* candidate : group.candidates) { + auto* fixed = (*candidate->location)->cast(); + size_t dataSize = getU32LEBSize(segmentIndex) + 1 + + getSLEBSize(int64_t(offset)) + 1 + + getSLEBSize(int64_t(fixed->values.size())); + savings += int64_t(candidate->fixedSize) - int64_t(dataSize); + } + if (savings <= 0) { + continue; + } + if (dataCost) { + newData.append(payload); + } + totalSavings += savings; + decisions.push_back({&group, segment, Index(offset)}); + } + if (decisions.empty()) { + return; + } + + Name newSegmentName; + if (!newData.empty()) { + // Account for the new segment's flags and size, and for the data count + // and data section headers if this is the first segment. + int64_t overhead = 1 + getU32LEBSize(newData.size()); + if (module->dataSegments.empty()) { + overhead += 6; + } + if (totalSavings <= overhead) { + return; + } + newSegmentName = + Names::getValidDataSegmentName(*module, "constant-array-data"); + auto segment = std::make_unique(); + segment->setName(newSegmentName, false); + segment->data.assign(newData.begin(), newData.end()); + module->addDataSegment(std::move(segment)); + } + + Builder builder(*module); + for (auto& decision : decisions) { + auto segment = decision.segment ? decision.segment : newSegmentName; + for (auto* candidate : decision.group->candidates) { + auto* fixed = (*candidate->location)->cast(); + auto* data = builder.makeArrayNewData( + fixed->type.getHeapType(), + segment, + builder.makeConst(int32_t(decision.offset)), + builder.makeConst(int32_t(fixed->values.size()))); + assert(data->type == fixed->type); + *candidate->location = data; + } + } + } +}; + +} // anonymous namespace + +Pass* createConstantArraysToDataPass() { return new ConstantArraysToData(); } + +} // namespace wasm diff --git a/src/passes/pass.cpp b/src/passes/pass.cpp index 55b20e05e7d..5b7a630a4d5 100644 --- a/src/passes/pass.cpp +++ b/src/passes/pass.cpp @@ -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)) { + // 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. diff --git a/src/passes/passes.h b/src/passes/passes.h index 1324edf1af5..d7eac10bd60 100644 --- a/src/passes/passes.h +++ b/src/passes/passes.h @@ -31,6 +31,7 @@ Pass* createCoalesceLocalsWithLearningPass(); Pass* createCodeFoldingPass(); Pass* createCodePushingPass(); Pass* createConstHoistingPass(); +Pass* createConstantArraysToDataPass(); Pass* createConstantFieldPropagationPass(); Pass* createConstantFieldPropagationRefTestPass(); Pass* createConstraintAnalysisPass(); diff --git a/test/lit/help/wasm-metadce.test b/test/lit/help/wasm-metadce.test index ea45a3ce6fe..15c0452507d 100644 --- a/test/lit/help/wasm-metadce.test +++ b/test/lit/help/wasm-metadce.test @@ -109,6 +109,10 @@ ;; CHECK-NEXT: --const-hoisting hoist repeated constants to a ;; CHECK-NEXT: local ;; CHECK-EMPTY: +;; CHECK-NEXT: --constant-arrays-to-data convert constant array.new_fixed +;; CHECK-NEXT: to array.new_data when it is +;; CHECK-NEXT: smaller +;; CHECK-EMPTY: ;; CHECK-NEXT: --constraint-analysis finds and uses mathematical ;; CHECK-NEXT: constraints on locals ;; CHECK-EMPTY: diff --git a/test/lit/help/wasm-opt.test b/test/lit/help/wasm-opt.test index 90c3b2990a3..bc304c4f679 100644 --- a/test/lit/help/wasm-opt.test +++ b/test/lit/help/wasm-opt.test @@ -154,6 +154,10 @@ ;; CHECK-NEXT: --const-hoisting hoist repeated constants to a ;; CHECK-NEXT: local ;; CHECK-EMPTY: +;; CHECK-NEXT: --constant-arrays-to-data convert constant array.new_fixed +;; CHECK-NEXT: to array.new_data when it is +;; CHECK-NEXT: smaller +;; CHECK-EMPTY: ;; CHECK-NEXT: --constraint-analysis finds and uses mathematical ;; CHECK-NEXT: constraints on locals ;; CHECK-EMPTY: diff --git a/test/lit/help/wasm2js.test b/test/lit/help/wasm2js.test index fdce36ac338..fec9a3487a7 100644 --- a/test/lit/help/wasm2js.test +++ b/test/lit/help/wasm2js.test @@ -73,6 +73,10 @@ ;; CHECK-NEXT: --const-hoisting hoist repeated constants to a ;; CHECK-NEXT: local ;; CHECK-EMPTY: +;; CHECK-NEXT: --constant-arrays-to-data convert constant array.new_fixed +;; CHECK-NEXT: to array.new_data when it is +;; CHECK-NEXT: smaller +;; CHECK-EMPTY: ;; CHECK-NEXT: --constraint-analysis finds and uses mathematical ;; CHECK-NEXT: constraints on locals ;; CHECK-EMPTY: diff --git a/test/lit/passes/constant-arrays-to-data-last-opts.wast b/test/lit/passes/constant-arrays-to-data-last-opts.wast new file mode 100644 index 00000000000..cbcabbbcdff --- /dev/null +++ b/test/lit/passes/constant-arrays-to-data-last-opts.wast @@ -0,0 +1,45 @@ +;; NOTE: Assertions have been generated by update_lit_checks.py and should not be edited. +;; The default pipeline runs constant-arrays-to-data only in the last set of +;; opts, as converting to array.new_data inhibits other opts. + +;; RUN: wasm-opt %s -O2 -all -S -o - | filecheck %s --check-prefix=LAST +;; RUN: wasm-opt %s -O2 --expect-later-toolchain-opts -all -S -o - | filecheck %s --check-prefix=NOT-LAST +;; RUN: wasm-opt %s -O2 --type-finalizing -all -S -o - | filecheck %s --check-prefix=LAST +;; RUN: wasm-opt %s -O2 -O2 -all -S -o - | filecheck %s --check-prefix=LAST + +(module + ;; LAST: (type $i32 (array (mut i32))) + ;; NOT-LAST: (type $i32 (array (mut i32))) + (type $i32 (array (mut i32))) + + ;; LAST: (func $i32 (type $1) (result (ref $i32)) + ;; LAST-NEXT: (array.new_data $i32 $constant-array-data + ;; LAST-NEXT: (i32.const 0) + ;; LAST-NEXT: (i32.const 8) + ;; LAST-NEXT: ) + ;; LAST-NEXT: ) + ;; NOT-LAST: (func $i32 (type $1) (result (ref $i32)) + ;; NOT-LAST-NEXT: (array.new_fixed $i32 8 + ;; NOT-LAST-NEXT: (i32.const 1000000000) + ;; NOT-LAST-NEXT: (i32.const -2000000000) + ;; NOT-LAST-NEXT: (i32.const 1100000000) + ;; NOT-LAST-NEXT: (i32.const 1200000000) + ;; NOT-LAST-NEXT: (i32.const 1300000000) + ;; NOT-LAST-NEXT: (i32.const 1400000000) + ;; NOT-LAST-NEXT: (i32.const 1500000000) + ;; NOT-LAST-NEXT: (i32.const 1600000000) + ;; NOT-LAST-NEXT: ) + ;; NOT-LAST-NEXT: ) + (func $i32 (export "i32") (result (ref $i32)) + (array.new_fixed $i32 8 + (i32.const 1000000000) + (i32.const -2000000000) + (i32.const 1100000000) + (i32.const 1200000000) + (i32.const 1300000000) + (i32.const 1400000000) + (i32.const 1500000000) + (i32.const 1600000000) + ) + ) +) diff --git a/test/lit/passes/constant-arrays-to-data.wast b/test/lit/passes/constant-arrays-to-data.wast new file mode 100644 index 00000000000..ab2ceef11db --- /dev/null +++ b/test/lit/passes/constant-arrays-to-data.wast @@ -0,0 +1,310 @@ +;; NOTE: Assertions have been generated by update_lit_checks.py and should not be edited. +;; RUN: wasm-opt %s --constant-arrays-to-data -all -S -o - | filecheck %s + +(module + + ;; CHECK: (type $i32 (array (mut i32))) + + ;; CHECK: (type $i8 (array (mut i8))) + (type $i8 (array (mut i8))) + ;; CHECK: (type $i16 (array (mut i16))) + (type $i16 (array (mut i16))) + (type $i32 (array (mut i32))) + ;; CHECK: (type $i64 (array (mut i64))) + (type $i64 (array (mut i64))) + ;; CHECK: (type $f32 (array (mut f32))) + (type $f32 (array (mut f32))) + ;; CHECK: (type $f64 (array (mut f64))) + (type $f64 (array (mut f64))) + ;; CHECK: (type $v128 (array (mut v128))) + (type $v128 (array (mut v128))) + ;; CHECK: (type $ref (array (mut anyref))) + (type $ref (array (mut anyref))) + + ;; An existing passive segment, whose bytes can be reused. The 4 i32s starting + ;; at offset 4 are 10000, 20000, 30000 and 40000. + + ;; CHECK: (global $global (ref $i32) (array.new_fixed $i32 4 + ;; CHECK-NEXT: (i32.const 100000) + ;; CHECK-NEXT: (i32.const 200000) + ;; CHECK-NEXT: (i32.const 300000) + ;; CHECK-NEXT: (i32.const 400000) + ;; CHECK-NEXT: )) + + ;; CHECK: (data $existing "\ff\ff\ff\ff\10\'\00\00 N\00\000u\00\00@\9c\00\00") + (data $existing "\ff\ff\ff\ff\10\27\00\00\20\4e\00\00\30\75\00\00\40\9c\00\00") + + ;; A segment that is dropped, whose bytes cannot be reused. It contains + ;; 50000, 60000, 70000 and 80000. + + ;; CHECK: (data $dropped "P\c3\00\00`\ea\00\00p\11\01\00\808\01\00") + (data $dropped "\50\c3\00\00\60\ea\00\00\70\11\01\00\80\38\01\00") + + ;; Global initializers must remain constant expressions. + (global $global (ref $i32) (array.new_fixed $i32 4 + (i32.const 100000) + (i32.const 200000) + (i32.const 300000) + (i32.const 400000) + )) + + ;; CHECK: (func $i32 (type $1) (result (ref $i32)) + ;; CHECK-NEXT: (array.new_data $i32 $constant-array-data + ;; CHECK-NEXT: (i32.const 128) + ;; CHECK-NEXT: (i32.const 8) + ;; CHECK-NEXT: ) + ;; CHECK-NEXT: ) + (func $i32 (export "i32") (result (ref $i32)) + ;; This is smaller as array.new_data. + (array.new_fixed $i32 8 + (i32.const 1000000) + (i32.const -2000000) + (i32.const 3000000) + (i32.const 4000000) + (i32.const 5000000) + (i32.const 6000000) + (i32.const 7000000) + (i32.const 8000000) + ) + ) + + ;; CHECK: (func $i32-same (type $1) (result (ref $i32)) + ;; CHECK-NEXT: (array.new_data $i32 $constant-array-data + ;; CHECK-NEXT: (i32.const 128) + ;; CHECK-NEXT: (i32.const 8) + ;; CHECK-NEXT: ) + ;; CHECK-NEXT: ) + (func $i32-same (export "i32-same") (result (ref $i32)) + ;; The same payload as before, which reuses the same bytes. + (array.new_fixed $i32 8 + (i32.const 1000000) + (i32.const -2000000) + (i32.const 3000000) + (i32.const 4000000) + (i32.const 5000000) + (i32.const 6000000) + (i32.const 7000000) + (i32.const 8000000) + ) + ) + + ;; CHECK: (func $i32-subsequence (type $1) (result (ref $i32)) + ;; CHECK-NEXT: (array.new_data $i32 $constant-array-data + ;; CHECK-NEXT: (i32.const 136) + ;; CHECK-NEXT: (i32.const 2) + ;; CHECK-NEXT: ) + ;; CHECK-NEXT: ) + (func $i32-subsequence (export "i32-subsequence") (result (ref $i32)) + ;; A subsequence of the previous payload, which reuses its bytes, even + ;; though alone it would not be worth adding. + (array.new_fixed $i32 2 + (i32.const 3000000) + (i32.const 4000000) + ) + ) + + ;; CHECK: (func $i32-existing (type $1) (result (ref $i32)) + ;; CHECK-NEXT: (array.new_data $i32 $existing + ;; CHECK-NEXT: (i32.const 4) + ;; CHECK-NEXT: (i32.const 4) + ;; CHECK-NEXT: ) + ;; CHECK-NEXT: ) + (func $i32-existing (export "i32-existing") (result (ref $i32)) + ;; Bytes in an existing segment are reused. + (array.new_fixed $i32 4 + (i32.const 10000) + (i32.const 20000) + (i32.const 30000) + (i32.const 40000) + ) + ) + + ;; CHECK: (func $i32-dropped (type $1) (result (ref $i32)) + ;; CHECK-NEXT: (data.drop $dropped) + ;; CHECK-NEXT: (array.new_fixed $i32 4 + ;; CHECK-NEXT: (i32.const 50000) + ;; CHECK-NEXT: (i32.const 60000) + ;; CHECK-NEXT: (i32.const 70000) + ;; CHECK-NEXT: (i32.const 80000) + ;; CHECK-NEXT: ) + ;; CHECK-NEXT: ) + (func $i32-dropped (export "i32-dropped") (result (ref $i32)) + ;; The bytes in the dropped segment cannot be reused, and it is not worth + ;; adding new ones. + (data.drop $dropped) + (array.new_fixed $i32 4 + (i32.const 50000) + (i32.const 60000) + (i32.const 70000) + (i32.const 80000) + ) + ) + + ;; CHECK: (func $i32-small (type $1) (result (ref $i32)) + ;; CHECK-NEXT: (array.new_fixed $i32 4 + ;; CHECK-NEXT: (i32.const 1) + ;; CHECK-NEXT: (i32.const 3) + ;; CHECK-NEXT: (i32.const 5) + ;; CHECK-NEXT: (i32.const 7) + ;; CHECK-NEXT: ) + ;; CHECK-NEXT: ) + (func $i32-small (export "i32-small") (result (ref $i32)) + ;; Small values have short encodings, so this is smaller as it is. + (array.new_fixed $i32 4 + (i32.const 1) + (i32.const 3) + (i32.const 5) + (i32.const 7) + ) + ) + + ;; CHECK: (func $i32-nonconstant (type $9) (param $x i32) (result (ref $i32)) + ;; CHECK-NEXT: (array.new_fixed $i32 4 + ;; CHECK-NEXT: (local.get $x) + ;; CHECK-NEXT: (i32.const 2000000) + ;; CHECK-NEXT: (i32.const 3000000) + ;; CHECK-NEXT: (i32.const 4000000) + ;; CHECK-NEXT: ) + ;; CHECK-NEXT: ) + (func $i32-nonconstant (export "i32-nonconstant") (param $x i32) (result (ref $i32)) + ;; Non-constant values prevent the optimization. + (array.new_fixed $i32 4 + (local.get $x) + (i32.const 2000000) + (i32.const 3000000) + (i32.const 4000000) + ) + ) + + ;; CHECK: (func $i8 (type $10) (result (ref $i8)) + ;; CHECK-NEXT: (array.new_data $i8 $constant-array-data + ;; CHECK-NEXT: (i32.const 224) + ;; CHECK-NEXT: (i32.const 8) + ;; CHECK-NEXT: ) + ;; CHECK-NEXT: ) + (func $i8 (export "i8") (result (ref $i8)) + ;; Packed fields store the low bits of each value. + (array.new_fixed $i8 8 + (i32.const 200) + (i32.const 201) + (i32.const 202) + (i32.const 203) + (i32.const 204) + (i32.const 205) + (i32.const -1) + (i32.const 0x1234) + ) + ) + + ;; CHECK: (func $i16 (type $11) (result (ref $i16)) + ;; CHECK-NEXT: (array.new_data $i16 $constant-array-data + ;; CHECK-NEXT: (i32.const 232) + ;; CHECK-NEXT: (i32.const 4) + ;; CHECK-NEXT: ) + ;; CHECK-NEXT: ) + (func $i16 (export "i16") (result (ref $i16)) + (array.new_fixed $i16 4 + (i32.const 40000) + (i32.const 40001) + (i32.const 40002) + (i32.const 40003) + ) + ) + + ;; CHECK: (func $i64 (type $12) (result (ref $i64)) + ;; CHECK-NEXT: (array.new_data $i64 $constant-array-data + ;; CHECK-NEXT: (i32.const 160) + ;; CHECK-NEXT: (i32.const 4) + ;; CHECK-NEXT: ) + ;; CHECK-NEXT: ) + (func $i64 (export "i64") (result (ref $i64)) + (array.new_fixed $i64 4 + (i64.const 0x7fffffffffffffff) + (i64.const 0x7ffffffffffffffe) + (i64.const 0x7ffffffffffffffd) + (i64.const 0x7ffffffffffffffc) + ) + ) + + ;; CHECK: (func $f32 (type $13) (result (ref $f32)) + ;; CHECK-NEXT: (array.new_data $f32 $constant-array-data + ;; CHECK-NEXT: (i32.const 192) + ;; CHECK-NEXT: (i32.const 8) + ;; CHECK-NEXT: ) + ;; CHECK-NEXT: ) + (func $f32 (export "f32") (result (ref $f32)) + (array.new_fixed $f32 8 + (f32.const 0.5) + (f32.const 1.5) + (f32.const 2.5) + (f32.const 3.5) + (f32.const 4.5) + (f32.const 5.5) + (f32.const 6.5) + (f32.const 7.5) + ) + ) + + ;; CHECK: (func $f64 (type $14) (result (ref $f64)) + ;; CHECK-NEXT: (array.new_data $f64 $constant-array-data + ;; CHECK-NEXT: (i32.const 0) + ;; CHECK-NEXT: (i32.const 8) + ;; CHECK-NEXT: ) + ;; CHECK-NEXT: ) + (func $f64 (export "f64") (result (ref $f64)) + (array.new_fixed $f64 8 + (f64.const 0.1) + (f64.const 0.2) + (f64.const 0.3) + (f64.const 0.4) + (f64.const 0.5) + (f64.const 0.6) + (f64.const 0.7) + (f64.const nan:0x4) + ) + ) + + ;; CHECK: (func $v128 (type $15) (result (ref $v128)) + ;; CHECK-NEXT: (array.new_data $v128 $constant-array-data + ;; CHECK-NEXT: (i32.const 64) + ;; CHECK-NEXT: (i32.const 4) + ;; CHECK-NEXT: ) + ;; CHECK-NEXT: ) + (func $v128 (export "v128") (result (ref $v128)) + (array.new_fixed $v128 4 + (v128.const i32x4 1 2 3 4) + (v128.const i32x4 5 6 7 8) + (v128.const i32x4 9 10 11 12) + (v128.const i32x4 13 14 15 16) + ) + ) + + ;; CHECK: (func $ref (type $16) (result (ref $ref)) + ;; CHECK-NEXT: (array.new_fixed $ref 2 + ;; CHECK-NEXT: (ref.null none) + ;; CHECK-NEXT: (ref.null none) + ;; CHECK-NEXT: ) + ;; CHECK-NEXT: ) + (func $ref (export "ref") (result (ref $ref)) + ;; Non-numeric arrays are not optimized. + (array.new_fixed $ref 2 + (ref.null none) + (ref.null none) + ) + ) + + ;; CHECK: (func $empty (type $1) (result (ref $i32)) + ;; CHECK-NEXT: (array.new_fixed $i32 0) + ;; CHECK-NEXT: ) + (func $empty (export "empty") (result (ref $i32)) + ;; Empty arrays are not optimized. + (array.new_fixed $i32 0) + ) + + ;; CHECK: (func $global (type $1) (result (ref $i32)) + ;; CHECK-NEXT: (global.get $global) + ;; CHECK-NEXT: ) + (func $global (export "global") (result (ref $i32)) + (global.get $global) + ) +) From df6ade5039469ad6bcb079cd1dce1011f7a9c2c6 Mon Sep 17 00:00:00 2001 From: Goktug Gokdogan Date: Fri, 16 Oct 2026 20:08:43 -0700 Subject: [PATCH 2/6] Preserve debug info, reuse helpers, and simplify payload reuse * Copy debug locations to the new array.new_data. * Use FindAllPointers and FindAll in the parallel analysis (which now also collects the dropped segments), Builder::makeDataSegment, and the LEB writers to measure sizes. * Only add the new segment if the conversions that use it pay for its header, and still reuse existing segments otherwise. * Reuse existing segments only if they are identical to the payload, to avoid a quadratic substring search (left as a TODO, along with array.new_elem). * Check all module items in the lit tests (--all-items), including the new segment's contents and debug locations. --- src/passes/ConstantArraysToData.cpp | 264 ++++++++---------- .../constant-arrays-to-data-last-opts.wast | 12 +- test/lit/passes/constant-arrays-to-data.wast | 166 +++++++++-- 3 files changed, 267 insertions(+), 175 deletions(-) diff --git a/src/passes/ConstantArraysToData.cpp b/src/passes/ConstantArraysToData.cpp index 1b8dd1de1a4..7f221eb16c2 100644 --- a/src/passes/ConstantArraysToData.cpp +++ b/src/passes/ConstantArraysToData.cpp @@ -30,82 +30,73 @@ // Global initializers are not modified, as array.new_data is not a constant // expression. // -// All the new payloads are stored in a single new passive segment. A payload -// that appears more than once is stored once, and payloads that are already -// present in the new segment or in an existing passive segment that is never -// dropped reuse those bytes. +// The new payloads are stored in a single new passive segment. Identical +// payloads are stored once, and a payload that is identical to an existing +// passive segment that is never dropped reuses that segment. // -// The decision is made for each group of identical payloads using an estimate -// of the binary size: +// Each group of identical payloads is converted if that reduces the estimated +// binary size: // // array.new_fixed $T N (c0) ... (cN-1) // = prefix typeidx u32(N) size(c0) ... size(cN-1) // array.new_data $T $seg (i32.const off) (i32.const N) // = prefix typeidx u32(seg) 0x41 s32(off) 0x41 s32(N) // -// plus the payload bytes (N * elemBytes) if they are not already present. A -// group is converted if the estimate is smaller. +// plus the payload bytes (N * elemBytes) if they are not already present. +// +// 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 +#include #include #include #include #include #include +#include "ir/debuginfo.h" #include "ir/find_all.h" #include "ir/module-utils.h" #include "ir/names.h" #include "pass.h" +#include "wasm-binary.h" #include "wasm-builder.h" -#include "wasm-traversal.h" #include "wasm.h" namespace wasm { namespace { -size_t getU32LEBSize(uint64_t value) { - size_t size = 1; - while (value >= 0x80) { - value >>= 7; - size++; - } - return size; -} - -size_t getSLEBSize(int64_t value) { - size_t size = 0; - while (true) { - uint8_t byte = value & 0x7f; - value >>= 7; - size++; - if ((value == 0 && !(byte & 0x40)) || (value == -1 && (byte & 0x40))) { - return size; - } - } +template size_t getWrittenSize(const T& thing) { + BufferWithRandomAccess buffer; + buffer << thing; + return buffer.size(); } // The size of a *.const instruction in the binary format. size_t getConstSize(const Literal& value) { switch (value.type.getBasic()) { case Type::i32: - return 1 + getSLEBSize(value.geti32()); + return 1 + getWrittenSize(S32LEB(value.geti32())); case Type::i64: - return 1 + getSLEBSize(value.geti64()); + return 1 + getWrittenSize(S64LEB(value.geti64())); case Type::f32: - return 1 + 4; case Type::f64: - return 1 + 8; + return 1 + value.type.getByteSize(); case Type::v128: + // The SIMD prefix and opcode, and the value. return 2 + 16; - default: - WASM_UNREACHABLE("unexpected const type"); + case Type::none: + case Type::unreachable: + break; } + WASM_UNREACHABLE("unexpected const type"); } struct Candidate { + Function* func; // Where the array.new_fixed is. Expression** location; // The bytes the array would have in a data segment. @@ -115,40 +106,38 @@ struct Candidate { size_t fixedSize; }; -using Candidates = std::vector; - -struct Finder : public PostWalker { - Candidates& candidates; - - Finder(Candidates& candidates) : candidates(candidates) {} - - void visitArrayNewFixed(ArrayNewFixed* curr) { - if (curr->type == Type::unreachable || curr->values.empty()) { - return; - } - const auto& element = curr->type.getHeapType().getArray().element; - if (!element.type.isNumber()) { - return; - } - auto elemBytes = element.getByteSize(); +std::optional getCandidate(Function* func, Expression** location) { + auto* curr = (*location)->cast(); + if (curr->type == Type::unreachable || curr->values.empty()) { + return std::nullopt; + } + const auto& element = curr->type.getHeapType().getArray().element; + if (!element.type.isNumber()) { + return std::nullopt; + } + auto elemBytes = element.getByteSize(); - std::string payload; - payload.reserve(curr->values.size() * elemBytes); - size_t fixedSize = getU32LEBSize(curr->values.size()); - for (auto* value : curr->values) { - auto* c = value->dynCast(); - if (!c) { - return; - } - fixedSize += getConstSize(c->value); - // getBits writes little-endian, so the first bytes are what a packed - // field stores, which matches the truncation array.new_fixed does. - uint8_t bits[16]; - c->value.getBits(bits); - payload.append(reinterpret_cast(bits), elemBytes); + Candidate candidate{ + func, location, {}, getWrittenSize(U32LEB(curr->values.size()))}; + candidate.payload.reserve(curr->values.size() * elemBytes); + for (auto* value : curr->values) { + auto* c = value->dynCast(); + if (!c) { + return std::nullopt; } - candidates.push_back({getCurrentPointer(), std::move(payload), fixedSize}); + candidate.fixedSize += getConstSize(c->value); + // getBits writes little-endian, so the first bytes are what a packed field + // stores, which matches the truncation array.new_fixed does. + uint8_t bits[16]; + c->value.getBits(bits); + candidate.payload.append(reinterpret_cast(bits), elemBytes); } + return candidate; +} + +struct FunctionInfo { + std::vector candidates; + std::unordered_set droppedSegments; }; struct ConstantArraysToData : public Pass { @@ -160,10 +149,19 @@ struct ConstantArraysToData : public Pass { return; } - ModuleUtils::ParallelFunctionAnalysis analysis( - *module, [&](Function* func, Candidates& candidates) { - if (!func->imported()) { - Finder(candidates).walk(func->body); + ModuleUtils::ParallelFunctionAnalysis analysis( + *module, [](Function* func, FunctionInfo& info) { + if (func->imported()) { + return; + } + for (auto** location : + FindAllPointers(func->body).list) { + if (auto candidate = getCandidate(func, location)) { + info.candidates.push_back(std::move(*candidate)); + } + } + for (auto* drop : FindAll(func->body).list) { + info.droppedSegments.insert(drop->segment); } }); @@ -174,8 +172,10 @@ struct ConstantArraysToData : public Pass { }; std::vector groups; std::unordered_map groupIndexes; + std::unordered_set droppedSegments; for (auto& func : module->functions) { - for (auto& candidate : analysis.map[func.get()]) { + auto& info = analysis.map[func.get()]; + for (auto& candidate : info.candidates) { auto [it, inserted] = groupIndexes.insert({candidate.payload, groups.size()}); if (inserted) { @@ -183,121 +183,90 @@ struct ConstantArraysToData : public Pass { } groups[it->second].candidates.push_back(&candidate); } + droppedSegments.insert(info.droppedSegments.begin(), + info.droppedSegments.end()); } if (groups.empty()) { return; } - // Find the existing passive segments whose bytes we can reuse. A segment - // that is dropped cannot be read afterwards, so ignore those. - std::unordered_set droppedSegments; - for (auto& func : module->functions) { - if (!func->imported()) { - for (auto* drop : FindAll(func->body).list) { - droppedSegments.insert(drop->segment); - } - } - } - struct ExistingSegment { - Name name; - Index index; - std::string_view data; - }; - std::vector existingSegments; + // Passive segments that are never dropped can be reused by identical + // payloads. + std::unordered_map existingSegments; for (Index i = 0; i < module->dataSegments.size(); i++) { auto& segment = module->dataSegments[i]; - if (segment->isPassive() && !droppedSegments.contains(segment->name) && - !segment->data.empty()) { - existingSegments.push_back( - {segment->name, - i, - std::string_view(segment->data.data(), segment->data.size())}); + if (segment->isPassive() && !droppedSegments.contains(segment->name)) { + existingSegments.insert( + {std::string_view(segment->data.data(), segment->data.size()), i}); } } - // Handle larger payloads first, so that smaller ones have a better chance - // to be found inside them. - std::stable_sort(groups.begin(), groups.end(), [](auto& a, auto& b) { - return a.payload.size() > b.payload.size(); - }); - - // The contents of the new segment. - std::string newData; - Index newSegmentIndex = module->dataSegments.size(); - + // Decide which groups to convert. Payloads that are not in an existing + // segment are appended to the contents of a new segment. struct Decision { Group* group; - // The segment to read from, or a null name for the new segment. - Name segment; + Index segment; Index offset; }; std::vector decisions; - int64_t totalSavings = 0; - + Index newSegment = module->dataSegments.size(); + std::string newData; + int64_t newSegmentSavings = 0; for (auto& group : groups) { - auto payload = group.payload; - Name segment; - Index segmentIndex = newSegmentIndex; - size_t offset = newData.find(payload); + Index segment = newSegment; + Index offset = 0; size_t dataCost = 0; - if (offset == std::string::npos) { - for (auto& existing : existingSegments) { - offset = existing.data.find(payload); - if (offset != std::string_view::npos) { - segment = existing.name; - segmentIndex = existing.index; - break; - } - } - } - if (offset == std::string::npos) { + if (auto it = existingSegments.find(group.payload); + it != existingSegments.end()) { + segment = it->second; + } else { offset = newData.size(); - dataCost = payload.size(); + dataCost = group.payload.size(); } int64_t savings = -int64_t(dataCost); for (auto* candidate : group.candidates) { auto* fixed = (*candidate->location)->cast(); - size_t dataSize = getU32LEBSize(segmentIndex) + 1 + - getSLEBSize(int64_t(offset)) + 1 + - getSLEBSize(int64_t(fixed->values.size())); + size_t dataSize = getWrittenSize(U32LEB(segment)) + 1 + + getWrittenSize(S32LEB(int32_t(offset))) + 1 + + getWrittenSize(S32LEB(int32_t(fixed->values.size()))); savings += int64_t(candidate->fixedSize) - int64_t(dataSize); } if (savings <= 0) { continue; } - if (dataCost) { - newData.append(payload); + if (segment == newSegment) { + newData.append(group.payload); + newSegmentSavings += savings; } - totalSavings += savings; - decisions.push_back({&group, segment, Index(offset)}); - } - if (decisions.empty()) { - return; + decisions.push_back({&group, segment, offset}); } - Name newSegmentName; if (!newData.empty()) { - // Account for the new segment's flags and size, and for the data count - // and data section headers if this is the first segment. - int64_t overhead = 1 + getU32LEBSize(newData.size()); + // The new segment needs its flags and size, and if it is the first + // segment, also the headers of the data count and data sections (id, size + // and count, which are 3 bytes each). + int64_t overhead = 1 + getWrittenSize(U32LEB(newData.size())); if (module->dataSegments.empty()) { - overhead += 6; + overhead += 2 * 3; } - if (totalSavings <= overhead) { - return; + if (newSegmentSavings > overhead) { + auto name = + Names::getValidDataSegmentName(*module, "constant-array-data"); + module->addDataSegment(Builder::makeDataSegment( + name, Name(), nullptr, newData.data(), newData.size())); + } else { + // The new segment is not worth it, but we can still reuse existing + // ones. + std::erase_if(decisions, [&](const Decision& decision) { + return decision.segment == newSegment; + }); } - newSegmentName = - Names::getValidDataSegmentName(*module, "constant-array-data"); - auto segment = std::make_unique(); - segment->setName(newSegmentName, false); - segment->data.assign(newData.begin(), newData.end()); - module->addDataSegment(std::move(segment)); } Builder builder(*module); for (auto& decision : decisions) { - auto segment = decision.segment ? decision.segment : newSegmentName; + auto segment = module->dataSegments[decision.segment]->name; for (auto* candidate : decision.group->candidates) { auto* fixed = (*candidate->location)->cast(); auto* data = builder.makeArrayNewData( @@ -306,6 +275,7 @@ struct ConstantArraysToData : public Pass { builder.makeConst(int32_t(decision.offset)), builder.makeConst(int32_t(fixed->values.size()))); assert(data->type == fixed->type); + debuginfo::copyOriginalToReplacement(fixed, data, candidate->func); *candidate->location = data; } } diff --git a/test/lit/passes/constant-arrays-to-data-last-opts.wast b/test/lit/passes/constant-arrays-to-data-last-opts.wast index cbcabbbcdff..893e3dcbbd7 100644 --- a/test/lit/passes/constant-arrays-to-data-last-opts.wast +++ b/test/lit/passes/constant-arrays-to-data-last-opts.wast @@ -1,4 +1,4 @@ -;; NOTE: Assertions have been generated by update_lit_checks.py and should not be edited. +;; NOTE: Assertions have been generated by update_lit_checks.py --all-items and should not be edited. ;; The default pipeline runs constant-arrays-to-data only in the last set of ;; opts, as converting to array.new_data inhibits other opts. @@ -12,12 +12,22 @@ ;; NOT-LAST: (type $i32 (array (mut i32))) (type $i32 (array (mut i32))) + ;; LAST: (type $1 (func (result (ref $i32)))) + + ;; LAST: (data $constant-array-data "\00\ca\9a;\00l\ca\88\00\ab\90A\00\8c\86G\00m|M\00NrS\00/hY\00\10^_") + + ;; LAST: (export "i32" (func $i32)) + ;; LAST: (func $i32 (type $1) (result (ref $i32)) ;; LAST-NEXT: (array.new_data $i32 $constant-array-data ;; LAST-NEXT: (i32.const 0) ;; LAST-NEXT: (i32.const 8) ;; LAST-NEXT: ) ;; LAST-NEXT: ) + ;; NOT-LAST: (type $1 (func (result (ref $i32)))) + + ;; NOT-LAST: (export "i32" (func $i32)) + ;; NOT-LAST: (func $i32 (type $1) (result (ref $i32)) ;; NOT-LAST-NEXT: (array.new_fixed $i32 8 ;; NOT-LAST-NEXT: (i32.const 1000000000) diff --git a/test/lit/passes/constant-arrays-to-data.wast b/test/lit/passes/constant-arrays-to-data.wast index ab2ceef11db..70746889620 100644 --- a/test/lit/passes/constant-arrays-to-data.wast +++ b/test/lit/passes/constant-arrays-to-data.wast @@ -1,10 +1,11 @@ -;; NOTE: Assertions have been generated by update_lit_checks.py and should not be edited. -;; RUN: wasm-opt %s --constant-arrays-to-data -all -S -o - | filecheck %s +;; NOTE: Assertions have been generated by update_lit_checks.py --all-items and should not be edited. +;; RUN: foreach %s %t wasm-opt --constant-arrays-to-data -all -S -o - | filecheck %s (module - ;; CHECK: (type $i32 (array (mut i32))) + ;; CHECK: (type $1 (func (result (ref $i32)))) + ;; CHECK: (type $i8 (array (mut i8))) (type $i8 (array (mut i8))) ;; CHECK: (type $i16 (array (mut i16))) @@ -21,8 +22,23 @@ ;; CHECK: (type $ref (array (mut anyref))) (type $ref (array (mut anyref))) - ;; An existing passive segment, whose bytes can be reused. The 4 i32s starting - ;; at offset 4 are 10000, 20000, 30000 and 40000. + ;; An existing passive segment, which can be reused. It contains the i32s + ;; 10000, 20000, 30000 and 40000. + ;; CHECK: (type $9 (func (param i32) (result (ref $i32)))) + + ;; CHECK: (type $10 (func (result (ref $i8)))) + + ;; CHECK: (type $11 (func (result (ref $i16)))) + + ;; CHECK: (type $12 (func (result (ref $i64)))) + + ;; CHECK: (type $13 (func (result (ref $f32)))) + + ;; CHECK: (type $14 (func (result (ref $f64)))) + + ;; CHECK: (type $15 (func (result (ref $v128)))) + + ;; CHECK: (type $16 (func (result (ref $ref)))) ;; CHECK: (global $global (ref $i32) (array.new_fixed $i32 4 ;; CHECK-NEXT: (i32.const 100000) @@ -31,12 +47,11 @@ ;; CHECK-NEXT: (i32.const 400000) ;; CHECK-NEXT: )) - ;; CHECK: (data $existing "\ff\ff\ff\ff\10\'\00\00 N\00\000u\00\00@\9c\00\00") - (data $existing "\ff\ff\ff\ff\10\27\00\00\20\4e\00\00\30\75\00\00\40\9c\00\00") + ;; CHECK: (data $existing "\10\'\00\00 N\00\000u\00\00@\9c\00\00") + (data $existing "\10\27\00\00\20\4e\00\00\30\75\00\00\40\9c\00\00") - ;; A segment that is dropped, whose bytes cannot be reused. It contains + ;; A segment that is dropped, which cannot be reused. It contains the i32s ;; 50000, 60000, 70000 and 80000. - ;; CHECK: (data $dropped "P\c3\00\00`\ea\00\00p\11\01\00\808\01\00") (data $dropped "\50\c3\00\00\60\ea\00\00\70\11\01\00\80\38\01\00") @@ -48,14 +63,52 @@ (i32.const 400000) )) + ;; CHECK: (data $constant-array-data "@B\0f\00\80{\e1\ff\c0\c6-\00\00\t=\00@KL\00\80\8d[\00\c0\cfj\00\00\12z\00\c8\c9\ca\cb\cc\cd\ff4@\9cA\9cB\9cC\9c\ff\ff\ff\ff\ff\ff\ff\7f\fe\ff\ff\ff\ff\ff\ff\7f\fd\ff\ff\ff\ff\ff\ff\7f\fc\ff\ff\ff\ff\ff\ff\7f\00\00\00?\00\00\c0?\00\00 @\00\00`@\00\00\90@\00\00\b0@\00\00\d0@\00\00\f0@\9a\99\99\99\99\99\b9?\9a\99\99\99\99\99\c9?333333\d3?\9a\99\99\99\99\99\d9?\00\00\00\00\00\00\e0?333333\e3?ffffff\e6?\04\00\00\00\00\00\f0\7f\01\00\00\00\02\00\00\00\03\00\00\00\04\00\00\00\05\00\00\00\06\00\00\00\07\00\00\00\08\00\00\00\t\00\00\00\n\00\00\00\0b\00\00\00\0c\00\00\00\r\00\00\00\0e\00\00\00\0f\00\00\00\10\00\00\00") + + ;; CHECK: (export "i32" (func $i32)) + + ;; CHECK: (export "i32-same" (func $i32-same)) + + ;; CHECK: (export "i32-subsequence" (func $i32-subsequence)) + + ;; CHECK: (export "i32-existing" (func $i32-existing)) + + ;; CHECK: (export "i32-dropped" (func $i32-dropped)) + + ;; CHECK: (export "i32-small" (func $i32-small)) + + ;; CHECK: (export "i32-nonconstant" (func $i32-nonconstant)) + + ;; CHECK: (export "i8" (func $i8)) + + ;; CHECK: (export "i16" (func $i16)) + + ;; CHECK: (export "i64" (func $i64)) + + ;; CHECK: (export "f32" (func $f32)) + + ;; CHECK: (export "f64" (func $f64)) + + ;; CHECK: (export "v128" (func $v128)) + + ;; CHECK: (export "ref" (func $ref)) + + ;; CHECK: (export "empty" (func $empty)) + + ;; CHECK: (export "global" (func $global)) + ;; CHECK: (func $i32 (type $1) (result (ref $i32)) + ;; CHECK-NEXT: ;;@ src.cpp:10:5 ;; CHECK-NEXT: (array.new_data $i32 $constant-array-data - ;; CHECK-NEXT: (i32.const 128) + ;; CHECK-NEXT: ;;@ + ;; CHECK-NEXT: (i32.const 0) + ;; CHECK-NEXT: ;;@ ;; CHECK-NEXT: (i32.const 8) ;; CHECK-NEXT: ) ;; CHECK-NEXT: ) (func $i32 (export "i32") (result (ref $i32)) - ;; This is smaller as array.new_data. + ;; This is smaller as array.new_data. The debug location is kept. + ;;@ src.cpp:10:5 (array.new_fixed $i32 8 (i32.const 1000000) (i32.const -2000000) @@ -70,7 +123,7 @@ ;; CHECK: (func $i32-same (type $1) (result (ref $i32)) ;; CHECK-NEXT: (array.new_data $i32 $constant-array-data - ;; CHECK-NEXT: (i32.const 128) + ;; CHECK-NEXT: (i32.const 0) ;; CHECK-NEXT: (i32.const 8) ;; CHECK-NEXT: ) ;; CHECK-NEXT: ) @@ -89,14 +142,15 @@ ) ;; CHECK: (func $i32-subsequence (type $1) (result (ref $i32)) - ;; CHECK-NEXT: (array.new_data $i32 $constant-array-data - ;; CHECK-NEXT: (i32.const 136) - ;; CHECK-NEXT: (i32.const 2) + ;; CHECK-NEXT: (array.new_fixed $i32 2 + ;; CHECK-NEXT: (i32.const 3000000) + ;; CHECK-NEXT: (i32.const 4000000) ;; CHECK-NEXT: ) ;; CHECK-NEXT: ) (func $i32-subsequence (export "i32-subsequence") (result (ref $i32)) - ;; A subsequence of the previous payload, which reuses its bytes, even - ;; though alone it would not be worth adding. + ;; A subsequence of the previous payload. We do not look for payloads inside + ;; other data (see the TODO in the pass), and alone this is not worth + ;; converting. (array.new_fixed $i32 2 (i32.const 3000000) (i32.const 4000000) @@ -105,12 +159,12 @@ ;; CHECK: (func $i32-existing (type $1) (result (ref $i32)) ;; CHECK-NEXT: (array.new_data $i32 $existing - ;; CHECK-NEXT: (i32.const 4) + ;; CHECK-NEXT: (i32.const 0) ;; CHECK-NEXT: (i32.const 4) ;; CHECK-NEXT: ) ;; CHECK-NEXT: ) (func $i32-existing (export "i32-existing") (result (ref $i32)) - ;; Bytes in an existing segment are reused. + ;; An existing segment with identical contents is reused. (array.new_fixed $i32 4 (i32.const 10000) (i32.const 20000) @@ -129,8 +183,8 @@ ;; CHECK-NEXT: ) ;; CHECK-NEXT: ) (func $i32-dropped (export "i32-dropped") (result (ref $i32)) - ;; The bytes in the dropped segment cannot be reused, and it is not worth - ;; adding new ones. + ;; The dropped segment cannot be reused, and it is not worth adding new + ;; bytes. (data.drop $dropped) (array.new_fixed $i32 4 (i32.const 50000) @@ -178,7 +232,7 @@ ;; CHECK: (func $i8 (type $10) (result (ref $i8)) ;; CHECK-NEXT: (array.new_data $i8 $constant-array-data - ;; CHECK-NEXT: (i32.const 224) + ;; CHECK-NEXT: (i32.const 32) ;; CHECK-NEXT: (i32.const 8) ;; CHECK-NEXT: ) ;; CHECK-NEXT: ) @@ -198,7 +252,7 @@ ;; CHECK: (func $i16 (type $11) (result (ref $i16)) ;; CHECK-NEXT: (array.new_data $i16 $constant-array-data - ;; CHECK-NEXT: (i32.const 232) + ;; CHECK-NEXT: (i32.const 40) ;; CHECK-NEXT: (i32.const 4) ;; CHECK-NEXT: ) ;; CHECK-NEXT: ) @@ -213,7 +267,7 @@ ;; CHECK: (func $i64 (type $12) (result (ref $i64)) ;; CHECK-NEXT: (array.new_data $i64 $constant-array-data - ;; CHECK-NEXT: (i32.const 160) + ;; CHECK-NEXT: (i32.const 48) ;; CHECK-NEXT: (i32.const 4) ;; CHECK-NEXT: ) ;; CHECK-NEXT: ) @@ -228,7 +282,7 @@ ;; CHECK: (func $f32 (type $13) (result (ref $f32)) ;; CHECK-NEXT: (array.new_data $f32 $constant-array-data - ;; CHECK-NEXT: (i32.const 192) + ;; CHECK-NEXT: (i32.const 80) ;; CHECK-NEXT: (i32.const 8) ;; CHECK-NEXT: ) ;; CHECK-NEXT: ) @@ -247,7 +301,7 @@ ;; CHECK: (func $f64 (type $14) (result (ref $f64)) ;; CHECK-NEXT: (array.new_data $f64 $constant-array-data - ;; CHECK-NEXT: (i32.const 0) + ;; CHECK-NEXT: (i32.const 112) ;; CHECK-NEXT: (i32.const 8) ;; CHECK-NEXT: ) ;; CHECK-NEXT: ) @@ -266,7 +320,7 @@ ;; CHECK: (func $v128 (type $15) (result (ref $v128)) ;; CHECK-NEXT: (array.new_data $v128 $constant-array-data - ;; CHECK-NEXT: (i32.const 64) + ;; CHECK-NEXT: (i32.const 176) ;; CHECK-NEXT: (i32.const 4) ;; CHECK-NEXT: ) ;; CHECK-NEXT: ) @@ -308,3 +362,61 @@ (global.get $global) ) ) + +;; When the savings do not pay for a new segment, it is not added, but existing +;; segments are still reused. +(module + ;; CHECK: (type $i32 (array (mut i32))) + (type $i32 (array (mut i32))) + + ;; CHECK: (type $1 (func (result (ref $i32)))) + + ;; CHECK: (data $existing "\10\'\00\00 N\00\000u\00\00@\9c\00\00") + (data $existing "\10\27\00\00\20\4e\00\00\30\75\00\00\40\9c\00\00") + + ;; CHECK: (export "existing" (func $existing)) + + ;; CHECK: (export "new" (func $new)) + + ;; CHECK: (func $existing (type $1) (result (ref $i32)) + ;; CHECK-NEXT: (array.new_data $i32 $existing + ;; CHECK-NEXT: (i32.const 0) + ;; CHECK-NEXT: (i32.const 4) + ;; CHECK-NEXT: ) + ;; CHECK-NEXT: ) + (func $existing (export "existing") (result (ref $i32)) + (array.new_fixed $i32 4 + (i32.const 10000) + (i32.const 20000) + (i32.const 30000) + (i32.const 40000) + ) + ) + + ;; CHECK: (func $new (type $1) (result (ref $i32)) + ;; CHECK-NEXT: (array.new_fixed $i32 8 + ;; CHECK-NEXT: (i32.const 1000000) + ;; CHECK-NEXT: (i32.const -1000000) + ;; CHECK-NEXT: (i32.const 1000001) + ;; CHECK-NEXT: (i32.const 4000000) + ;; CHECK-NEXT: (i32.const 5000000) + ;; CHECK-NEXT: (i32.const 6000000) + ;; CHECK-NEXT: (i32.const 7000000) + ;; CHECK-NEXT: (i32.const 8000000) + ;; CHECK-NEXT: ) + ;; CHECK-NEXT: ) + (func $new (export "new") (result (ref $i32)) + ;; This would save 1 byte, which does not pay for the new segment's flags + ;; and size. + (array.new_fixed $i32 8 + (i32.const 1000000) + (i32.const -1000000) + (i32.const 1000001) + (i32.const 4000000) + (i32.const 5000000) + (i32.const 6000000) + (i32.const 7000000) + (i32.const 8000000) + ) + ) +) From 5ad7ffa5bb3c7f27f56f4578881862c0b949934e Mon Sep 17 00:00:00 2001 From: Goktug Gokdogan Date: Fri, 16 Oct 2026 20:39:30 -0700 Subject: [PATCH 3/6] Split ConstantArraysToData into helpers * Move the size estimates into helpers: getArrayNewFixedSize, getArrayNewDataSize and getNewSegmentOverhead. * Split run() into findCandidates, groupByPayload, decide and replace. * Build payloads as std::vector (like DataSegment::data) with getBits and memcpy, as the interpreter does, instead of casting. No functional change. --- src/passes/ConstantArraysToData.cpp | 296 ++++++++++++++++------------ 1 file changed, 173 insertions(+), 123 deletions(-) diff --git a/src/passes/ConstantArraysToData.cpp b/src/passes/ConstantArraysToData.cpp index 7f221eb16c2..12dd9c4c331 100644 --- a/src/passes/ConstantArraysToData.cpp +++ b/src/passes/ConstantArraysToData.cpp @@ -32,25 +32,17 @@ // // The new payloads are stored in a single new passive segment. Identical // payloads are stored once, and a payload that is identical to an existing -// passive segment that is never dropped reuses that segment. -// -// Each group of identical payloads is converted if that reduces the estimated -// binary size: -// -// array.new_fixed $T N (c0) ... (cN-1) -// = prefix typeidx u32(N) size(c0) ... size(cN-1) -// array.new_data $T $seg (i32.const off) (i32.const N) -// = prefix typeidx u32(seg) 0x41 s32(off) 0x41 s32(N) -// -// plus the payload bytes (N * elemBytes) if they are not already present. +// passive segment that is never dropped reuses that segment. Each group of +// identical payloads is converted if that reduces the estimated binary size, +// and the new segment is only added if it pays for itself. // // 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 #include -#include #include #include #include @@ -69,13 +61,15 @@ namespace wasm { namespace { +// Size estimates for the binary format. + template size_t getWrittenSize(const T& thing) { BufferWithRandomAccess buffer; buffer << thing; return buffer.size(); } -// The size of a *.const instruction in the binary format. +// The size of a *.const instruction. size_t getConstSize(const Literal& value) { switch (value.type.getBasic()) { case Type::i32: @@ -95,19 +89,40 @@ size_t getConstSize(const Literal& value) { WASM_UNREACHABLE("unexpected const type"); } -struct Candidate { - Function* func; - // Where the array.new_fixed is. - Expression** location; - // The bytes the array would have in a data segment. - std::string payload; - // The size of the elements count and the operands of the array.new_fixed - // (the prefix and type index are the same in both forms, so we ignore them). - size_t fixedSize; -}; +// 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())); + for (auto* value : curr->values) { + size += getConstSize(value->cast()->value); + } + return size; +} + +// The size of an array.new_data with a constant offset and size, apart from the +// prefix, opcode and type index. +size_t getArrayNewDataSize(Index segment, Index offset, Index size) { + // The segment index, and an i32.const each for the offset and the size. + return getWrittenSize(U32LEB(segment)) + 1 + + getWrittenSize(S32LEB(int32_t(offset))) + 1 + + getWrittenSize(S32LEB(int32_t(size))); +} -std::optional getCandidate(Function* func, Expression** location) { - auto* curr = (*location)->cast(); +// The size of a new passive data segment, apart from its data. +size_t getNewSegmentOverhead(const Module& module, size_t dataSize) { + // The flags and the size of the data. + size_t size = 1 + getWrittenSize(U32LEB(dataSize)); + if (module.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; +} + +// Returns the bytes the array would have in a data segment, if its values are +// numeric constants. +std::optional> getPayload(ArrayNewFixed* curr) { if (curr->type == Type::unreachable || curr->values.empty()) { return std::nullopt; } @@ -116,48 +131,82 @@ std::optional getCandidate(Function* func, Expression** location) { return std::nullopt; } auto elemBytes = element.getByteSize(); - - Candidate candidate{ - func, location, {}, getWrittenSize(U32LEB(curr->values.size()))}; - candidate.payload.reserve(curr->values.size() * elemBytes); - for (auto* value : curr->values) { - auto* c = value->dynCast(); + std::vector payload(curr->values.size() * elemBytes); + for (Index i = 0; i < curr->values.size(); i++) { + auto* c = curr->values[i]->dynCast(); if (!c) { return std::nullopt; } - candidate.fixedSize += getConstSize(c->value); - // getBits writes little-endian, so the first bytes are what a packed field - // stores, which matches the truncation array.new_fixed does. + // 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); - candidate.payload.append(reinterpret_cast(bits), elemBytes); + memcpy(&payload[i * elemBytes], bits, elemBytes); } - return candidate; + return payload; } -struct FunctionInfo { - std::vector candidates; - std::unordered_set droppedSegments; +// An array.new_fixed of numeric constants. +struct Candidate { + Function* func; + Expression** location; + std::vector payload; + + ArrayNewFixed* get() const { return (*location)->cast(); } +}; + +// Candidates with identical payloads, which can share their bytes. +struct Group { + std::string_view payload; + std::vector candidates; +}; + +// A group to convert, and where its arrays read their payload from. +struct Decision { + Group* group; + Index segment; + Index offset; }; struct ConstantArraysToData : public Pass { // We only replace allocations with other allocations of the same type. bool requiresNonNullableLocalFixups() override { return false; } - void run(Module* module) override { + Module* module = nullptr; + + // All the candidates, in a deterministic order. + std::vector candidates; + + // Segments that are dropped somewhere, which we cannot reuse. + std::unordered_set droppedSegments; + + void run(Module* module_) override { + module = module_; if (!module->features.hasGC() || !module->features.hasBulkMemory()) { return; } + findCandidates(); + if (candidates.empty()) { + return; + } + auto groups = groupByPayload(); + replace(decide(groups)); + } - ModuleUtils::ParallelFunctionAnalysis analysis( - *module, [](Function* func, FunctionInfo& info) { + void findCandidates() { + struct Info { + std::vector candidates; + std::unordered_set droppedSegments; + }; + ModuleUtils::ParallelFunctionAnalysis analysis( + *module, [](Function* func, Info& info) { if (func->imported()) { return; } for (auto** location : FindAllPointers(func->body).list) { - if (auto candidate = getCandidate(func, location)) { - info.candidates.push_back(std::move(*candidate)); + if (auto payload = getPayload((*location)->cast())) { + info.candidates.push_back({func, location, std::move(*payload)}); } } for (auto* drop : FindAll(func->body).list) { @@ -165,118 +214,119 @@ struct ConstantArraysToData : public Pass { } }); - // Group identical payloads, in a deterministic order. - struct Group { - std::string_view payload; - std::vector candidates; - }; - std::vector groups; - std::unordered_map groupIndexes; - std::unordered_set droppedSegments; for (auto& func : module->functions) { auto& info = analysis.map[func.get()]; for (auto& candidate : info.candidates) { - auto [it, inserted] = - groupIndexes.insert({candidate.payload, groups.size()}); - if (inserted) { - groups.push_back({candidate.payload, {}}); - } - groups[it->second].candidates.push_back(&candidate); + candidates.push_back(std::move(candidate)); } droppedSegments.insert(info.droppedSegments.begin(), info.droppedSegments.end()); } - if (groups.empty()) { - return; + } + + std::vector groupByPayload() { + std::vector groups; + std::unordered_map 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; + } - // Passive segments that are never dropped can be reused by identical - // payloads. - std::unordered_map existingSegments; + // Returns the segments that identical payloads can reuse, by their contents. + std::unordered_map getReusableSegments() { + std::unordered_map reusable; for (Index i = 0; i < module->dataSegments.size(); i++) { auto& segment = module->dataSegments[i]; if (segment->isPassive() && !droppedSegments.contains(segment->name)) { - existingSegments.insert( + reusable.insert( {std::string_view(segment->data.data(), segment->data.size()), i}); } } + return reusable; + } - // Decide which groups to convert. Payloads that are not in an existing - // segment are appended to the contents of a new segment. - struct Decision { - Group* group; - Index segment; - Index offset; - }; - std::vector decisions; + // The bytes saved in the code if the group's arrays read their payload from + // the given segment and offset. + int64_t getCodeSavings(const Group& group, Index segment, Index offset) { + int64_t savings = 0; + for (auto* candidate : group.candidates) { + auto* curr = candidate->get(); + savings += + int64_t(getArrayNewFixedSize(curr)) - + int64_t(getArrayNewDataSize(segment, offset, curr->values.size())); + } + return savings; + } + + // Decides which groups to convert and where they read their payloads from, + // and adds a new segment for the payloads that are not present yet, if that + // is worth it. + std::vector decide(std::vector& groups) { + auto reusable = getReusableSegments(); Index newSegment = module->dataSegments.size(); - std::string newData; + std::vector newData; int64_t newSegmentSavings = 0; + std::vector decisions; for (auto& group : groups) { - Index segment = newSegment; - Index offset = 0; - size_t dataCost = 0; - if (auto it = existingSegments.find(group.payload); - it != existingSegments.end()) { - segment = it->second; - } else { - offset = newData.size(); - dataCost = group.payload.size(); - } - - int64_t savings = -int64_t(dataCost); - for (auto* candidate : group.candidates) { - auto* fixed = (*candidate->location)->cast(); - size_t dataSize = getWrittenSize(U32LEB(segment)) + 1 + - getWrittenSize(S32LEB(int32_t(offset))) + 1 + - getWrittenSize(S32LEB(int32_t(fixed->values.size()))); - savings += int64_t(candidate->fixedSize) - int64_t(dataSize); - } - if (savings <= 0) { + if (auto it = reusable.find(group.payload); it != reusable.end()) { + if (getCodeSavings(group, it->second, 0) > 0) { + decisions.push_back({&group, it->second, 0}); + } continue; } - if (segment == newSegment) { - newData.append(group.payload); + Index offset = newData.size(); + auto savings = getCodeSavings(group, newSegment, offset) - + int64_t(group.payload.size()); + if (savings > 0) { + newData.insert( + newData.end(), group.payload.begin(), group.payload.end()); newSegmentSavings += savings; + decisions.push_back({&group, newSegment, offset}); } - decisions.push_back({&group, segment, offset}); } - if (!newData.empty()) { - // The new segment needs its flags and size, and if it is the first - // segment, also the headers of the data count and data sections (id, size - // and count, which are 3 bytes each). - int64_t overhead = 1 + getWrittenSize(U32LEB(newData.size())); - if (module->dataSegments.empty()) { - overhead += 2 * 3; - } - if (newSegmentSavings > overhead) { - auto name = - Names::getValidDataSegmentName(*module, "constant-array-data"); - module->addDataSegment(Builder::makeDataSegment( - name, Name(), nullptr, newData.data(), newData.size())); - } else { - // The new segment is not worth it, but we can still reuse existing - // ones. - std::erase_if(decisions, [&](const Decision& decision) { - return decision.segment == newSegment; - }); - } + if (newData.empty()) { + return decisions; + } + if (newSegmentSavings > + int64_t(getNewSegmentOverhead(*module, newData.size()))) { + addSegment(newData); + } else { + std::erase_if(decisions, [&](const Decision& decision) { + return decision.segment == newSegment; + }); } + return decisions; + } + + void addSegment(const std::vector& data) { + auto name = Names::getValidDataSegmentName(*module, "constant-array-data"); + module->addDataSegment(Builder::makeDataSegment( + name, Name(), nullptr, data.data(), data.size())); + } + void replace(const std::vector& decisions) { Builder builder(*module); for (auto& decision : decisions) { auto segment = module->dataSegments[decision.segment]->name; for (auto* candidate : decision.group->candidates) { - auto* fixed = (*candidate->location)->cast(); - auto* data = builder.makeArrayNewData( - fixed->type.getHeapType(), + auto* curr = candidate->get(); + auto* replacement = builder.makeArrayNewData( + curr->type.getHeapType(), segment, builder.makeConst(int32_t(decision.offset)), - builder.makeConst(int32_t(fixed->values.size()))); - assert(data->type == fixed->type); - debuginfo::copyOriginalToReplacement(fixed, data, candidate->func); - *candidate->location = data; + builder.makeConst(int32_t(curr->values.size()))); + assert(replacement->type == curr->type); + debuginfo::copyOriginalToReplacement( + curr, replacement, candidate->func); + *candidate->location = replacement; } } } From 0f0409507b2421ff4b59a48629d75b12304f5725 Mon Sep 17 00:00:00 2001 From: Goktug Gokdogan Date: Fri, 16 Oct 2026 21:05:06 -0700 Subject: [PATCH 4/6] Clarify ConstantArraysToData structure, names and comments * Order the pass top-down: run, then each step in the order it is called, with the helpers of planConversions after it. * Rename Group to PayloadGroup, Decision to Conversion, decide to planConversions, replace to applyConversions, and addSegment to addNewSegment. Store the array in Candidate instead of a get() method. * Plan without modifying the module: the new segment's contents are gathered in newSegmentData, and run() adds the segment. * Comment the non-obvious parts: why unreachable, empty and non-numeric arrays are skipped, why dropped segments cannot be reused, why the result cannot trap, pointer stability of the candidates, and why the new segment's cost is checked after all groups. No functional change. --- src/passes/ConstantArraysToData.cpp | 183 ++++++++++++++++------------ 1 file changed, 106 insertions(+), 77 deletions(-) diff --git a/src/passes/ConstantArraysToData.cpp b/src/passes/ConstantArraysToData.cpp index 12dd9c4c331..3fe87fa6a9b 100644 --- a/src/passes/ConstantArraysToData.cpp +++ b/src/passes/ConstantArraysToData.cpp @@ -61,8 +61,9 @@ namespace wasm { namespace { -// Size estimates for the binary format. +// Binary size estimates. +// The number of bytes |thing| (e.g. a LEB) takes in the binary. template size_t getWrittenSize(const T& thing) { BufferWithRandomAccess buffer; buffer << thing; @@ -120,13 +121,18 @@ size_t getNewSegmentOverhead(const Module& module, size_t dataSize) { return size; } +// Candidates for conversion. + // Returns the bytes the array would have in a data segment, if its values are // numeric constants. std::optional> 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; } @@ -149,21 +155,22 @@ std::optional> getPayload(ArrayNewFixed* curr) { // An array.new_fixed of numeric constants. struct Candidate { Function* func; + ArrayNewFixed* array; + // Where |array| is, so that we can replace it. Expression** location; std::vector payload; - - ArrayNewFixed* get() const { return (*location)->cast(); } }; -// Candidates with identical payloads, which can share their bytes. -struct Group { +// Candidates with identical payloads, which can share one copy of the bytes. +// Their arrays may have different types, as only the bytes matter. +struct PayloadGroup { std::string_view payload; std::vector candidates; }; -// A group to convert, and where its arrays read their payload from. -struct Decision { - Group* group; +// A payload group to convert, and where its arrays will read the payload from. +struct Conversion { + PayloadGroup* group; Index segment; Index offset; }; @@ -174,14 +181,20 @@ struct ConstantArraysToData : public Pass { Module* module = nullptr; - // All the candidates, in a deterministic order. + // All the candidates, in a deterministic order. Payload groups point into + // them, so they must not change after grouping. std::vector candidates; - // Segments that are dropped somewhere, which we cannot reuse. + // 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 droppedSegments; + // The contents of the new segment, if we add one. + std::vector newSegmentData; + void run(Module* module_) override { module = module_; + // array.new_data requires bulk memory. if (!module->features.hasGC() || !module->features.hasBulkMemory()) { return; } @@ -190,7 +203,11 @@ struct ConstantArraysToData : public Pass { return; } auto groups = groupByPayload(); - replace(decide(groups)); + auto conversions = planConversions(groups); + if (!newSegmentData.empty()) { + addNewSegment(); + } + applyConversions(conversions); } void findCandidates() { @@ -205,8 +222,10 @@ struct ConstantArraysToData : public Pass { } for (auto** location : FindAllPointers(func->body).list) { - if (auto payload = getPayload((*location)->cast())) { - info.candidates.push_back({func, location, std::move(*payload)}); + auto* array = (*location)->cast(); + if (auto payload = getPayload(array)) { + info.candidates.push_back( + {func, array, location, std::move(*payload)}); } } for (auto* drop : FindAll(func->body).list) { @@ -214,6 +233,8 @@ struct ConstantArraysToData : public Pass { } }); + // Gather the results in function order, so that the output is + // deterministic. for (auto& func : module->functions) { auto& info = analysis.map[func.get()]; for (auto& candidate : info.candidates) { @@ -224,8 +245,8 @@ struct ConstantArraysToData : public Pass { } } - std::vector groupByPayload() { - std::vector groups; + std::vector groupByPayload() { + std::vector groups; std::unordered_map groupIndexes; for (auto& candidate : candidates) { std::string_view payload(candidate.payload.data(), @@ -239,93 +260,101 @@ struct ConstantArraysToData : public Pass { return groups; } - // Returns the segments that identical payloads can reuse, by their contents. + // Decides which payload groups to convert, and where their arrays read the + // payload from: an existing segment with identical contents, or a new segment + // whose contents we gather in |newSegmentData|. + std::vector planConversions(std::vector& groups) { + auto reusableSegments = getReusableSegments(); + // If we add a new segment, it goes at the end. + Index newSegmentIndex = module->dataSegments.size(); + int64_t newSegmentSavings = 0; + std::vector conversions; + for (auto& group : groups) { + if (auto it = reusableSegments.find(group.payload); + it != reusableSegments.end()) { + // The segment is identical to the payload, so we read all of it. + if (getCodeSavings(group, it->second, 0) > 0) { + conversions.push_back({&group, it->second, 0}); + } + continue; + } + Index offset = newSegmentData.size(); + auto savings = getCodeSavings(group, newSegmentIndex, offset) - + int64_t(group.payload.size()); + if (savings > 0) { + newSegmentData.insert( + newSegmentData.end(), group.payload.begin(), group.payload.end()); + newSegmentSavings += savings; + conversions.push_back({&group, newSegmentIndex, offset}); + } + } + + // The overhead of the new segment is shared by all the payloads in it, so + // only now can we check if it pays for itself. If it does not, we still + // convert the groups that reuse existing segments. + if (!newSegmentData.empty() && + newSegmentSavings <= + int64_t(getNewSegmentOverhead(*module, newSegmentData.size()))) { + newSegmentData.clear(); + std::erase_if(conversions, [&](const Conversion& conversion) { + return conversion.segment == newSegmentIndex; + }); + } + return conversions; + } + + // Returns the passive segments that are never dropped, by their contents (the + // first one, if several are identical). std::unordered_map getReusableSegments() { - std::unordered_map reusable; + std::unordered_map reusableSegments; for (Index i = 0; i < module->dataSegments.size(); i++) { auto& segment = module->dataSegments[i]; if (segment->isPassive() && !droppedSegments.contains(segment->name)) { - reusable.insert( + reusableSegments.insert( {std::string_view(segment->data.data(), segment->data.size()), i}); } } - return reusable; + return reusableSegments; } // The bytes saved in the code if the group's arrays read their payload from - // the given segment and offset. - int64_t getCodeSavings(const Group& group, Index segment, Index offset) { + // the given segment and offset. The arrays may have different types, so we + // measure each one. + int64_t + getCodeSavings(const PayloadGroup& group, Index segment, Index offset) { int64_t savings = 0; for (auto* candidate : group.candidates) { - auto* curr = candidate->get(); + auto* array = candidate->array; savings += - int64_t(getArrayNewFixedSize(curr)) - - int64_t(getArrayNewDataSize(segment, offset, curr->values.size())); + int64_t(getArrayNewFixedSize(array)) - + int64_t(getArrayNewDataSize(segment, offset, array->values.size())); } return savings; } - // Decides which groups to convert and where they read their payloads from, - // and adds a new segment for the payloads that are not present yet, if that - // is worth it. - std::vector decide(std::vector& groups) { - auto reusable = getReusableSegments(); - Index newSegment = module->dataSegments.size(); - std::vector newData; - int64_t newSegmentSavings = 0; - std::vector decisions; - for (auto& group : groups) { - if (auto it = reusable.find(group.payload); it != reusable.end()) { - if (getCodeSavings(group, it->second, 0) > 0) { - decisions.push_back({&group, it->second, 0}); - } - continue; - } - Index offset = newData.size(); - auto savings = getCodeSavings(group, newSegment, offset) - - int64_t(group.payload.size()); - if (savings > 0) { - newData.insert( - newData.end(), group.payload.begin(), group.payload.end()); - newSegmentSavings += savings; - decisions.push_back({&group, newSegment, offset}); - } - } - - if (newData.empty()) { - return decisions; - } - if (newSegmentSavings > - int64_t(getNewSegmentOverhead(*module, newData.size()))) { - addSegment(newData); - } else { - std::erase_if(decisions, [&](const Decision& decision) { - return decision.segment == newSegment; - }); - } - return decisions; - } - - void addSegment(const std::vector& data) { + void addNewSegment() { auto name = Names::getValidDataSegmentName(*module, "constant-array-data"); + // Without a memory and an offset, the segment is passive. module->addDataSegment(Builder::makeDataSegment( - name, Name(), nullptr, data.data(), data.size())); + name, Name(), nullptr, newSegmentData.data(), newSegmentData.size())); } - void replace(const std::vector& decisions) { + // Replaces the arrays with array.new_data. That cannot trap, as it reads in + // bounds from a segment that is never dropped. + void applyConversions(const std::vector& conversions) { Builder builder(*module); - for (auto& decision : decisions) { - auto segment = module->dataSegments[decision.segment]->name; - for (auto* candidate : decision.group->candidates) { - auto* curr = candidate->get(); + for (auto& conversion : conversions) { + auto segment = module->dataSegments[conversion.segment]->name; + for (auto* candidate : conversion.group->candidates) { + auto* array = candidate->array; auto* replacement = builder.makeArrayNewData( - curr->type.getHeapType(), + array->type.getHeapType(), segment, - builder.makeConst(int32_t(decision.offset)), - builder.makeConst(int32_t(curr->values.size()))); - assert(replacement->type == curr->type); + builder.makeConst(int32_t(conversion.offset)), + builder.makeConst(int32_t(array->values.size()))); + assert(replacement->type == array->type); debuginfo::copyOriginalToReplacement( - curr, replacement, candidate->func); + array, replacement, candidate->func); *candidate->location = replacement; } } From 6a1403ff0fd29ce372f6d68273c39ca69e246188 Mon Sep 17 00:00:00 2001 From: Goktug Gokdogan Date: Mon, 19 Oct 2026 19:24:32 -0700 Subject: [PATCH 5/6] Address review feedback * Give each new payload its own segment instead of concatenating them. With --converge, lastOpts is set in every iteration, so arrays that OptimizeInstructions lowers back to array.new_fixed were converted again, duplicating their bytes in a new segment. Now they reuse their old segment if it is still alive (it is identical to the payload), or replace it if it was removed. Add a test for that. * Find candidates and data.drops in one walk, and use an InsertOrderedMap to gather the results in function order. * Keep only the location in Candidate, and rename module to wasm. * Explain why arrays with identical payloads are measured separately. * In the test, move the global up, use hex for packed values and describe their bytes, and remove the unneeded global getter. --- src/passes/ConstantArraysToData.cpp | 203 ++++++++---------- .../constant-arrays-to-data-converge.wast | 72 +++++++ test/lit/passes/constant-arrays-to-data.wast | 121 +++++------ 3 files changed, 212 insertions(+), 184 deletions(-) create mode 100644 test/lit/passes/constant-arrays-to-data-converge.wast diff --git a/src/passes/ConstantArraysToData.cpp b/src/passes/ConstantArraysToData.cpp index 3fe87fa6a9b..2767f8ec40c 100644 --- a/src/passes/ConstantArraysToData.cpp +++ b/src/passes/ConstantArraysToData.cpp @@ -30,11 +30,12 @@ // Global initializers are not modified, as array.new_data is not a constant // expression. // -// The new payloads are stored in a single new passive segment. Identical -// payloads are stored once, and a payload that is identical to an existing -// passive segment that is never dropped reuses that segment. Each group of -// identical payloads is converted if that reduces the estimated binary size, -// and the new segment is only added if it pays for itself. +// 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. @@ -49,12 +50,13 @@ #include #include "ir/debuginfo.h" -#include "ir/find_all.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 { @@ -100,20 +102,20 @@ size_t getArrayNewFixedSize(ArrayNewFixed* curr) { return size; } -// The size of an array.new_data with a constant offset and size, apart from the +// 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 offset, Index size) { - // The segment index, and an i32.const each for the offset and the size. - return getWrittenSize(U32LEB(segment)) + 1 + - getWrittenSize(S32LEB(int32_t(offset))) + 1 + +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))); } // The size of a new passive data segment, apart from its data. -size_t getNewSegmentOverhead(const Module& module, size_t dataSize) { +size_t getNewSegmentOverhead(const Module& wasm, size_t dataSize) { // The flags and the size of the data. size_t size = 1 + getWrittenSize(U32LEB(dataSize)); - if (module.dataSegments.empty()) { + 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; @@ -155,31 +157,22 @@ std::optional> getPayload(ArrayNewFixed* curr) { // An array.new_fixed of numeric constants. struct Candidate { Function* func; - ArrayNewFixed* array; - // Where |array| is, so that we can replace it. + // Where the array.new_fixed is. Expression** location; std::vector payload; }; // Candidates with identical payloads, which can share one copy of the bytes. -// Their arrays may have different types, as only the bytes matter. struct PayloadGroup { std::string_view payload; std::vector candidates; }; -// A payload group to convert, and where its arrays will read the payload from. -struct Conversion { - PayloadGroup* group; - Index segment; - Index offset; -}; - struct ConstantArraysToData : public Pass { // We only replace allocations with other allocations of the same type. bool requiresNonNullableLocalFixups() override { return false; } - Module* module = nullptr; + Module* wasm = nullptr; // All the candidates, in a deterministic order. Payload groups point into // them, so they must not change after grouping. @@ -189,13 +182,10 @@ struct ConstantArraysToData : public Pass { // array.new_data traps once its segment is dropped (unless it reads nothing). std::unordered_set droppedSegments; - // The contents of the new segment, if we add one. - std::vector newSegmentData; - - void run(Module* module_) override { - module = module_; + void run(Module* module) override { + wasm = module; // array.new_data requires bulk memory. - if (!module->features.hasGC() || !module->features.hasBulkMemory()) { + if (!wasm->features.hasGC() || !wasm->features.hasBulkMemory()) { return; } findCandidates(); @@ -203,11 +193,7 @@ struct ConstantArraysToData : public Pass { return; } auto groups = groupByPayload(); - auto conversions = planConversions(groups); - if (!newSegmentData.empty()) { - addNewSegment(); - } - applyConversions(conversions); + convert(groups); } void findCandidates() { @@ -215,28 +201,33 @@ struct ConstantArraysToData : public Pass { std::vector candidates; std::unordered_set droppedSegments; }; - ModuleUtils::ParallelFunctionAnalysis analysis( - *module, [](Function* func, Info& info) { - if (func->imported()) { - return; - } - for (auto** location : - FindAllPointers(func->body).list) { - auto* array = (*location)->cast(); - if (auto payload = getPayload(array)) { - info.candidates.push_back( - {func, array, location, std::move(*payload)}); - } + + struct Finder : public PostWalker { + 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)}); } - for (auto* drop : FindAll(func->body).list) { - info.droppedSegments.insert(drop->segment); + } + + void visitDataDrop(DataDrop* curr) { + info.droppedSegments.insert(curr->segment); + } + }; + + ModuleUtils::ParallelFunctionAnalysis + analysis(*wasm, [](Function* func, Info& info) { + if (!func->imported()) { + Finder(info).walkFunction(func); } }); - // Gather the results in function order, so that the output is - // deterministic. - for (auto& func : module->functions) { - auto& info = analysis.map[func.get()]; + // The map is in function order, so this order is deterministic. + for (auto& [func, info] : analysis.map) { for (auto& candidate : info.candidates) { candidates.push_back(std::move(candidate)); } @@ -260,55 +251,36 @@ struct ConstantArraysToData : public Pass { return groups; } - // Decides which payload groups to convert, and where their arrays read the - // payload from: an existing segment with identical contents, or a new segment - // whose contents we gather in |newSegmentData|. - std::vector planConversions(std::vector& 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& groups) { auto reusableSegments = getReusableSegments(); - // If we add a new segment, it goes at the end. - Index newSegmentIndex = module->dataSegments.size(); - int64_t newSegmentSavings = 0; - std::vector conversions; for (auto& group : groups) { if (auto it = reusableSegments.find(group.payload); it != reusableSegments.end()) { - // The segment is identical to the payload, so we read all of it. - if (getCodeSavings(group, it->second, 0) > 0) { - conversions.push_back({&group, it->second, 0}); + if (getCodeSavings(group, it->second) > 0) { + replaceArrays(group, it->second); } continue; } - Index offset = newSegmentData.size(); - auto savings = getCodeSavings(group, newSegmentIndex, offset) - - int64_t(group.payload.size()); + // 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())); if (savings > 0) { - newSegmentData.insert( - newSegmentData.end(), group.payload.begin(), group.payload.end()); - newSegmentSavings += savings; - conversions.push_back({&group, newSegmentIndex, offset}); + addSegment(group.payload); + replaceArrays(group, segment); } } - - // The overhead of the new segment is shared by all the payloads in it, so - // only now can we check if it pays for itself. If it does not, we still - // convert the groups that reuse existing segments. - if (!newSegmentData.empty() && - newSegmentSavings <= - int64_t(getNewSegmentOverhead(*module, newSegmentData.size()))) { - newSegmentData.clear(); - std::erase_if(conversions, [&](const Conversion& conversion) { - return conversion.segment == newSegmentIndex; - }); - } - return conversions; } // Returns the passive segments that are never dropped, by their contents (the // first one, if several are identical). std::unordered_map getReusableSegments() { std::unordered_map reusableSegments; - for (Index i = 0; i < module->dataSegments.size(); i++) { - auto& segment = module->dataSegments[i]; + 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}); @@ -317,46 +289,43 @@ struct ConstantArraysToData : public Pass { return reusableSegments; } - // The bytes saved in the code if the group's arrays read their payload from - // the given segment and offset. The arrays may have different types, so we - // measure each one. - int64_t - getCodeSavings(const PayloadGroup& group, Index segment, Index offset) { + // 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->array; - savings += - int64_t(getArrayNewFixedSize(array)) - - int64_t(getArrayNewDataSize(segment, offset, array->values.size())); + auto* array = (*candidate->location)->cast(); + savings += int64_t(getArrayNewFixedSize(array)) - + int64_t(getArrayNewDataSize(segment, array->values.size())); } return savings; } - void addNewSegment() { - auto name = Names::getValidDataSegmentName(*module, "constant-array-data"); + void addSegment(std::string_view payload) { + auto name = Names::getValidDataSegmentName(*wasm, "constant-array-data"); // Without a memory and an offset, the segment is passive. - module->addDataSegment(Builder::makeDataSegment( - name, Name(), nullptr, newSegmentData.data(), newSegmentData.size())); + wasm->addDataSegment(Builder::makeDataSegment( + name, Name(), nullptr, payload.data(), payload.size())); } - // Replaces the arrays with array.new_data. That cannot trap, as it reads in - // bounds from a segment that is never dropped. - void applyConversions(const std::vector& conversions) { - Builder builder(*module); - for (auto& conversion : conversions) { - auto segment = module->dataSegments[conversion.segment]->name; - for (auto* candidate : conversion.group->candidates) { - auto* array = candidate->array; - auto* replacement = builder.makeArrayNewData( - array->type.getHeapType(), - segment, - builder.makeConst(int32_t(conversion.offset)), - builder.makeConst(int32_t(array->values.size()))); - assert(replacement->type == array->type); - debuginfo::copyOriginalToReplacement( - array, replacement, candidate->func); - *candidate->location = replacement; - } + // 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(); + 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; } } }; diff --git a/test/lit/passes/constant-arrays-to-data-converge.wast b/test/lit/passes/constant-arrays-to-data-converge.wast new file mode 100644 index 00000000000..d4a5089294c --- /dev/null +++ b/test/lit/passes/constant-arrays-to-data-converge.wast @@ -0,0 +1,72 @@ +;; NOTE: Assertions have been generated by update_lit_checks.py --all-items and should not be edited. +;; With --converge, the last set of opts runs in every iteration. Arrays that +;; OptimizeInstructions lowers back to array.new_fixed are then converted again, +;; and must not duplicate their bytes. + +;; RUN: wasm-opt %s -O2 --converge --traps-never-happen -all -S -o - | filecheck %s + +(module + ;; CHECK: (type $i32 (array (mut i32))) + (type $i32 (array (mut i32))) + + ;; CHECK: (type $1 (func (result (ref $i32)))) + + ;; CHECK: (data $constant-array-data "\00\00\00@\01\00\00@\02\00\00@\03\00\00@\04\00\00@\05\00\00@\06\00\00@\07\00\00@\08\00\00@\t\00\00@\n\00\00@\0b\00\00@\0c\00\00@\r\00\00@\0e\00\00@\0f\00\00@\10\00\00@\11\00\00@\12\00\00@\13\00\00@\14\00\00@\15\00\00@\16\00\00@\17\00\00@\18\00\00@\19\00\00@\1a\00\00@\1b\00\00@\1c\00\00@\1d\00\00@\1e\00\00@\1f\00\00@ \00\00@!\00\00@\"\00\00@#\00\00@$\00\00@%\00\00@&\00\00@\'\00\00@(\00\00@)\00\00@*\00\00@+\00\00@,\00\00@-\00\00@.\00\00@/\00\00@0\00\00@1\00\00@2\00\00@3\00\00@4\00\00@5\00\00@6\00\00@7\00\00@8\00\00@9\00\00@:\00\00@;\00\00@<\00\00@=\00\00@>\00\00@?\00\00@@\00\00@") + + ;; CHECK: (data $constant-array-data_1 "\00\01\00@\01\01\00@\02\01\00@\03\01\00@\04\01\00@\05\01\00@\06\01\00@\07\01\00@") + + ;; CHECK: (export "large" (func $large)) + + ;; CHECK: (export "small" (func $small)) + + ;; CHECK: (func $large (type $1) (result (ref $i32)) + ;; CHECK-NEXT: (array.new_data $i32 $constant-array-data + ;; CHECK-NEXT: (i32.const 0) + ;; CHECK-NEXT: (i32.const 65) + ;; CHECK-NEXT: ) + ;; CHECK-NEXT: ) + (func $large (export "large") (result (ref $i32)) + ;; This has more than 64 elements, so OptimizeInstructions does not lower + ;; it back to array.new_fixed, and its segment remains. + (array.new_fixed $i32 65 + (i32.const 0x40000000) (i32.const 0x40000001) (i32.const 0x40000002) + (i32.const 0x40000003) (i32.const 0x40000004) (i32.const 0x40000005) + (i32.const 0x40000006) (i32.const 0x40000007) (i32.const 0x40000008) + (i32.const 0x40000009) (i32.const 0x4000000a) (i32.const 0x4000000b) + (i32.const 0x4000000c) (i32.const 0x4000000d) (i32.const 0x4000000e) + (i32.const 0x4000000f) (i32.const 0x40000010) (i32.const 0x40000011) + (i32.const 0x40000012) (i32.const 0x40000013) (i32.const 0x40000014) + (i32.const 0x40000015) (i32.const 0x40000016) (i32.const 0x40000017) + (i32.const 0x40000018) (i32.const 0x40000019) (i32.const 0x4000001a) + (i32.const 0x4000001b) (i32.const 0x4000001c) (i32.const 0x4000001d) + (i32.const 0x4000001e) (i32.const 0x4000001f) (i32.const 0x40000020) + (i32.const 0x40000021) (i32.const 0x40000022) (i32.const 0x40000023) + (i32.const 0x40000024) (i32.const 0x40000025) (i32.const 0x40000026) + (i32.const 0x40000027) (i32.const 0x40000028) (i32.const 0x40000029) + (i32.const 0x4000002a) (i32.const 0x4000002b) (i32.const 0x4000002c) + (i32.const 0x4000002d) (i32.const 0x4000002e) (i32.const 0x4000002f) + (i32.const 0x40000030) (i32.const 0x40000031) (i32.const 0x40000032) + (i32.const 0x40000033) (i32.const 0x40000034) (i32.const 0x40000035) + (i32.const 0x40000036) (i32.const 0x40000037) (i32.const 0x40000038) + (i32.const 0x40000039) (i32.const 0x4000003a) (i32.const 0x4000003b) + (i32.const 0x4000003c) (i32.const 0x4000003d) (i32.const 0x4000003e) + (i32.const 0x4000003f) (i32.const 0x40000040) + ) + ) + + ;; CHECK: (func $small (type $1) (result (ref $i32)) + ;; CHECK-NEXT: (array.new_data $i32 $constant-array-data_1 + ;; CHECK-NEXT: (i32.const 0) + ;; CHECK-NEXT: (i32.const 8) + ;; CHECK-NEXT: ) + ;; CHECK-NEXT: ) + (func $small (export "small") (result (ref $i32)) + ;; OptimizeInstructions lowers this back to array.new_fixed in the next + ;; iteration, and then we convert it again. + (array.new_fixed $i32 8 + (i32.const 0x40000100) (i32.const 0x40000101) (i32.const 0x40000102) + (i32.const 0x40000103) (i32.const 0x40000104) (i32.const 0x40000105) + (i32.const 0x40000106) (i32.const 0x40000107) + ) + ) +) diff --git a/test/lit/passes/constant-arrays-to-data.wast b/test/lit/passes/constant-arrays-to-data.wast index 70746889620..30a9e338317 100644 --- a/test/lit/passes/constant-arrays-to-data.wast +++ b/test/lit/passes/constant-arrays-to-data.wast @@ -22,8 +22,7 @@ ;; CHECK: (type $ref (array (mut anyref))) (type $ref (array (mut anyref))) - ;; An existing passive segment, which can be reused. It contains the i32s - ;; 10000, 20000, 30000 and 40000. + ;; Global initializers must remain constant expressions. ;; CHECK: (type $9 (func (param i32) (result (ref $i32)))) ;; CHECK: (type $10 (func (result (ref $i8)))) @@ -46,7 +45,15 @@ ;; CHECK-NEXT: (i32.const 300000) ;; CHECK-NEXT: (i32.const 400000) ;; CHECK-NEXT: )) + (global $global (ref $i32) (array.new_fixed $i32 4 + (i32.const 100000) + (i32.const 200000) + (i32.const 300000) + (i32.const 400000) + )) + ;; An existing passive segment, which can be reused. It contains the i32s + ;; 10000, 20000, 30000 and 40000. ;; CHECK: (data $existing "\10\'\00\00 N\00\000u\00\00@\9c\00\00") (data $existing "\10\27\00\00\20\4e\00\00\30\75\00\00\40\9c\00\00") @@ -55,15 +62,19 @@ ;; CHECK: (data $dropped "P\c3\00\00`\ea\00\00p\11\01\00\808\01\00") (data $dropped "\50\c3\00\00\60\ea\00\00\70\11\01\00\80\38\01\00") - ;; Global initializers must remain constant expressions. - (global $global (ref $i32) (array.new_fixed $i32 4 - (i32.const 100000) - (i32.const 200000) - (i32.const 300000) - (i32.const 400000) - )) + ;; CHECK: (data $constant-array-data "@B\0f\00\80{\e1\ff\c0\c6-\00\00\t=\00@KL\00\80\8d[\00\c0\cfj\00\00\12z\00") + + ;; CHECK: (data $constant-array-data_3 "\c8\c9\ca\cb\cc\cd\ff4") - ;; CHECK: (data $constant-array-data "@B\0f\00\80{\e1\ff\c0\c6-\00\00\t=\00@KL\00\80\8d[\00\c0\cfj\00\00\12z\00\c8\c9\ca\cb\cc\cd\ff4@\9cA\9cB\9cC\9c\ff\ff\ff\ff\ff\ff\ff\7f\fe\ff\ff\ff\ff\ff\ff\7f\fd\ff\ff\ff\ff\ff\ff\7f\fc\ff\ff\ff\ff\ff\ff\7f\00\00\00?\00\00\c0?\00\00 @\00\00`@\00\00\90@\00\00\b0@\00\00\d0@\00\00\f0@\9a\99\99\99\99\99\b9?\9a\99\99\99\99\99\c9?333333\d3?\9a\99\99\99\99\99\d9?\00\00\00\00\00\00\e0?333333\e3?ffffff\e6?\04\00\00\00\00\00\f0\7f\01\00\00\00\02\00\00\00\03\00\00\00\04\00\00\00\05\00\00\00\06\00\00\00\07\00\00\00\08\00\00\00\t\00\00\00\n\00\00\00\0b\00\00\00\0c\00\00\00\r\00\00\00\0e\00\00\00\0f\00\00\00\10\00\00\00") + ;; CHECK: (data $constant-array-data_4 "@\9cA\9cB\9cC\9c") + + ;; CHECK: (data $constant-array-data_5 "\ff\ff\ff\ff\ff\ff\ff\7f\fe\ff\ff\ff\ff\ff\ff\7f\fd\ff\ff\ff\ff\ff\ff\7f\fc\ff\ff\ff\ff\ff\ff\7f") + + ;; CHECK: (data $constant-array-data_6 "\00\00\00?\00\00\c0?\00\00 @\00\00`@\00\00\90@\00\00\b0@\00\00\d0@\00\00\f0@") + + ;; CHECK: (data $constant-array-data_7 "\9a\99\99\99\99\99\b9?\9a\99\99\99\99\99\c9?333333\d3?\9a\99\99\99\99\99\d9?\00\00\00\00\00\00\e0?333333\e3?ffffff\e6?\04\00\00\00\00\00\f0\7f") + + ;; CHECK: (data $constant-array-data_8 "\01\00\00\00\02\00\00\00\03\00\00\00\04\00\00\00\05\00\00\00\06\00\00\00\07\00\00\00\08\00\00\00\t\00\00\00\n\00\00\00\0b\00\00\00\0c\00\00\00\r\00\00\00\0e\00\00\00\0f\00\00\00\10\00\00\00") ;; CHECK: (export "i32" (func $i32)) @@ -95,8 +106,6 @@ ;; CHECK: (export "empty" (func $empty)) - ;; CHECK: (export "global" (func $global)) - ;; CHECK: (func $i32 (type $1) (result (ref $i32)) ;; CHECK-NEXT: ;;@ src.cpp:10:5 ;; CHECK-NEXT: (array.new_data $i32 $constant-array-data @@ -128,7 +137,7 @@ ;; CHECK-NEXT: ) ;; CHECK-NEXT: ) (func $i32-same (export "i32-same") (result (ref $i32)) - ;; The same payload as before, which reuses the same bytes. + ;; The same payload as before, which reuses the same segment. (array.new_fixed $i32 8 (i32.const 1000000) (i32.const -2000000) @@ -183,8 +192,8 @@ ;; CHECK-NEXT: ) ;; CHECK-NEXT: ) (func $i32-dropped (export "i32-dropped") (result (ref $i32)) - ;; The dropped segment cannot be reused, and it is not worth adding new - ;; bytes. + ;; The dropped segment cannot be reused, and it is not worth adding a new + ;; one. (data.drop $dropped) (array.new_fixed $i32 4 (i32.const 50000) @@ -231,43 +240,45 @@ ) ;; CHECK: (func $i8 (type $10) (result (ref $i8)) - ;; CHECK-NEXT: (array.new_data $i8 $constant-array-data - ;; CHECK-NEXT: (i32.const 32) + ;; CHECK-NEXT: (array.new_data $i8 $constant-array-data_3 + ;; CHECK-NEXT: (i32.const 0) ;; CHECK-NEXT: (i32.const 8) ;; CHECK-NEXT: ) ;; CHECK-NEXT: ) (func $i8 (export "i8") (result (ref $i8)) - ;; Packed fields store the low bits of each value. + ;; Packed fields store the low byte of each value, so the new segment has + ;; the bytes c8 c9 ca cb cc cd ff 34. (array.new_fixed $i8 8 - (i32.const 200) - (i32.const 201) - (i32.const 202) - (i32.const 203) - (i32.const 204) - (i32.const 205) - (i32.const -1) + (i32.const 0xc8) + (i32.const 0xc9) + (i32.const 0xca) + (i32.const 0xcb) + (i32.const 0xcc) + (i32.const 0xcd) + (i32.const 0xffffffff) (i32.const 0x1234) ) ) ;; CHECK: (func $i16 (type $11) (result (ref $i16)) - ;; CHECK-NEXT: (array.new_data $i16 $constant-array-data - ;; CHECK-NEXT: (i32.const 40) + ;; CHECK-NEXT: (array.new_data $i16 $constant-array-data_4 + ;; CHECK-NEXT: (i32.const 0) ;; CHECK-NEXT: (i32.const 4) ;; CHECK-NEXT: ) ;; CHECK-NEXT: ) (func $i16 (export "i16") (result (ref $i16)) + ;; The new segment has the bytes 40 9c 41 9c 42 9c 43 9c. (array.new_fixed $i16 4 - (i32.const 40000) - (i32.const 40001) - (i32.const 40002) - (i32.const 40003) + (i32.const 0x9c40) + (i32.const 0x9c41) + (i32.const 0x9c42) + (i32.const 0x9c43) ) ) ;; CHECK: (func $i64 (type $12) (result (ref $i64)) - ;; CHECK-NEXT: (array.new_data $i64 $constant-array-data - ;; CHECK-NEXT: (i32.const 48) + ;; CHECK-NEXT: (array.new_data $i64 $constant-array-data_5 + ;; CHECK-NEXT: (i32.const 0) ;; CHECK-NEXT: (i32.const 4) ;; CHECK-NEXT: ) ;; CHECK-NEXT: ) @@ -281,8 +292,8 @@ ) ;; CHECK: (func $f32 (type $13) (result (ref $f32)) - ;; CHECK-NEXT: (array.new_data $f32 $constant-array-data - ;; CHECK-NEXT: (i32.const 80) + ;; CHECK-NEXT: (array.new_data $f32 $constant-array-data_6 + ;; CHECK-NEXT: (i32.const 0) ;; CHECK-NEXT: (i32.const 8) ;; CHECK-NEXT: ) ;; CHECK-NEXT: ) @@ -300,8 +311,8 @@ ) ;; CHECK: (func $f64 (type $14) (result (ref $f64)) - ;; CHECK-NEXT: (array.new_data $f64 $constant-array-data - ;; CHECK-NEXT: (i32.const 112) + ;; CHECK-NEXT: (array.new_data $f64 $constant-array-data_7 + ;; CHECK-NEXT: (i32.const 0) ;; CHECK-NEXT: (i32.const 8) ;; CHECK-NEXT: ) ;; CHECK-NEXT: ) @@ -319,8 +330,8 @@ ) ;; CHECK: (func $v128 (type $15) (result (ref $v128)) - ;; CHECK-NEXT: (array.new_data $v128 $constant-array-data - ;; CHECK-NEXT: (i32.const 176) + ;; CHECK-NEXT: (array.new_data $v128 $constant-array-data_8 + ;; CHECK-NEXT: (i32.const 0) ;; CHECK-NEXT: (i32.const 4) ;; CHECK-NEXT: ) ;; CHECK-NEXT: ) @@ -354,45 +365,21 @@ ;; Empty arrays are not optimized. (array.new_fixed $i32 0) ) - - ;; CHECK: (func $global (type $1) (result (ref $i32)) - ;; CHECK-NEXT: (global.get $global) - ;; CHECK-NEXT: ) - (func $global (export "global") (result (ref $i32)) - (global.get $global) - ) ) -;; When the savings do not pay for a new segment, it is not added, but existing -;; segments are still reused. +;; A new segment must pay for its flags and size. (There is already a segment, +;; so it does not also need to pay for the data count and data sections.) (module ;; CHECK: (type $i32 (array (mut i32))) (type $i32 (array (mut i32))) ;; CHECK: (type $1 (func (result (ref $i32)))) - ;; CHECK: (data $existing "\10\'\00\00 N\00\000u\00\00@\9c\00\00") - (data $existing "\10\27\00\00\20\4e\00\00\30\75\00\00\40\9c\00\00") - - ;; CHECK: (export "existing" (func $existing)) + ;; CHECK: (data $unrelated "\00") + (data $unrelated "\00") ;; CHECK: (export "new" (func $new)) - ;; CHECK: (func $existing (type $1) (result (ref $i32)) - ;; CHECK-NEXT: (array.new_data $i32 $existing - ;; CHECK-NEXT: (i32.const 0) - ;; CHECK-NEXT: (i32.const 4) - ;; CHECK-NEXT: ) - ;; CHECK-NEXT: ) - (func $existing (export "existing") (result (ref $i32)) - (array.new_fixed $i32 4 - (i32.const 10000) - (i32.const 20000) - (i32.const 30000) - (i32.const 40000) - ) - ) - ;; CHECK: (func $new (type $1) (result (ref $i32)) ;; CHECK-NEXT: (array.new_fixed $i32 8 ;; CHECK-NEXT: (i32.const 1000000) From ad29fde4b39409f2574ccafacfffcbad78fa0040 Mon Sep 17 00:00:00 2001 From: Goktug Gokdogan Date: Tue, 20 Oct 2026 23:45:45 -0700 Subject: [PATCH 6/6] Address second round of review feedback * Measure the size operand of array.new_data with getConstSize. * Replace getNewSegmentOverhead with getNewSegmentSize, which includes the data, so the check reads: convert if the code shrinks by more than the size of the new segment. * Remove the view from PayloadGroup and get the payload from its first candidate. Store payloads as std::string, and document the remaining views. * Document that ParallelFunctionAnalysis fills its map in function order, which makes iterating an InsertOrderedMap deterministic. No functional change. --- src/ir/module-utils.h | 3 ++ src/passes/ConstantArraysToData.cpp | 56 +++++++++++++++-------------- 2 files changed, 33 insertions(+), 26 deletions(-) diff --git a/src/ir/module-utils.h b/src/ir/module-utils.h index f232b76e025..70297c7cee2 100644 --- a/src/ir/module-utils.h +++ b/src/ir/module-utils.h @@ -264,6 +264,9 @@ inline void iterModuleItems(const Module& wasm, auto&& visitor) { // default - otherwise, set the Mutability to Mutable. (This is not enforced at // compile time - TODO find a way - but at runtime in pass-debug mode it is // checked.) +// The map has an entry for each function, inserted in the order of +// wasm.functions, so with MapT = InsertOrderedMap, iterating over it visits the +// functions in a deterministic order. template using DefaultMap = std::map; template #include +#include #include #include #include @@ -105,16 +106,16 @@ size_t getArrayNewFixedSize(ArrayNewFixed* curr) { // 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))); + // The segment index, an i32.const 0 for the offset (2 bytes), and an + // i32.const for the size. + return getWrittenSize(U32LEB(segment)) + 2 + + getConstSize(Literal(int32_t(size))); } -// 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)); +// The size of a new passive data segment with the given amount of data. +size_t getNewSegmentSize(const Module& wasm, size_t dataSize) { + // The flags, the size of the data, and the data itself. + size_t size = 1 + getWrittenSize(U32LEB(dataSize)) + dataSize; if (wasm.dataSegments.empty()) { // The first segment also adds the data count and data sections, each with // an id, a size and a count. @@ -127,7 +128,7 @@ size_t getNewSegmentOverhead(const Module& wasm, size_t dataSize) { // Returns the bytes the array would have in a data segment, if its values are // numeric constants. -std::optional> getPayload(ArrayNewFixed* curr) { +std::optional 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()) { @@ -139,7 +140,7 @@ std::optional> getPayload(ArrayNewFixed* curr) { return std::nullopt; } auto elemBytes = element.getByteSize(); - std::vector payload(curr->values.size() * elemBytes); + std::string payload(curr->values.size() * elemBytes, '\0'); for (Index i = 0; i < curr->values.size(); i++) { auto* c = curr->values[i]->dynCast(); if (!c) { @@ -159,13 +160,14 @@ struct Candidate { Function* func; // Where the array.new_fixed is. Expression** location; - std::vector payload; + std::string payload; }; // Candidates with identical payloads, which can share one copy of the bytes. struct PayloadGroup { - std::string_view payload; std::vector candidates; + + const std::string& getPayload() const { return candidates[0]->payload; } }; struct ConstantArraysToData : public Pass { @@ -226,7 +228,8 @@ struct ConstantArraysToData : public Pass { } }); - // The map is in function order, so this order is deterministic. + // The map is in function order (see ParallelFunctionAnalysis), so this + // order is deterministic. for (auto& [func, info] : analysis.map) { for (auto& candidate : info.candidates) { candidates.push_back(std::move(candidate)); @@ -238,13 +241,13 @@ struct ConstantArraysToData : public Pass { std::vector groupByPayload() { std::vector groups; + // The keys are views of the candidates' payloads. std::unordered_map groupIndexes; for (auto& candidate : candidates) { - std::string_view payload(candidate.payload.data(), - candidate.payload.size()); - auto [it, inserted] = groupIndexes.insert({payload, groups.size()}); + auto [it, inserted] = + groupIndexes.insert({candidate.payload, groups.size()}); if (inserted) { - groups.push_back({payload, {}}); + groups.emplace_back(); } groups[it->second].candidates.push_back(&candidate); } @@ -256,27 +259,28 @@ struct ConstantArraysToData : public Pass { void convert(std::vector& groups) { auto reusableSegments = getReusableSegments(); for (auto& group : groups) { - if (auto it = reusableSegments.find(group.payload); + const auto& payload = group.getPayload(); + if (auto it = reusableSegments.find(payload); it != reusableSegments.end()) { if (getCodeSavings(group, it->second) > 0) { replaceArrays(group, it->second); } continue; } - // A new segment goes at the end. + // A new segment goes at the end. It is worth adding if the code shrinks + // by more than the size of the segment. Index segment = wasm->dataSegments.size(); - auto savings = - getCodeSavings(group, segment) - int64_t(group.payload.size()) - - int64_t(getNewSegmentOverhead(*wasm, group.payload.size())); - if (savings > 0) { - addSegment(group.payload); + if (getCodeSavings(group, segment) > + int64_t(getNewSegmentSize(*wasm, payload.size()))) { + addSegment(payload); replaceArrays(group, segment); } } } // Returns the passive segments that are never dropped, by their contents (the - // first one, if several are identical). + // first one, if several are identical). The keys are views of the segments' + // data, which stays in place as we add more segments. std::unordered_map getReusableSegments() { std::unordered_map reusableSegments; for (Index i = 0; i < wasm->dataSegments.size(); i++) { @@ -304,7 +308,7 @@ struct ConstantArraysToData : public Pass { return savings; } - void addSegment(std::string_view payload) { + void addSegment(const std::string& payload) { auto name = Names::getValidDataSegmentName(*wasm, "constant-array-data"); // Without a memory and an offset, the segment is passive. wasm->addDataSegment(Builder::makeDataSegment(