Repository navigation
Conversation
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.
* 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.
* Move the size estimates into helpers: getArrayNewFixedSize, getArrayNewDataSize and getNewSegmentOverhead. * Split run() into findCandidates, groupByPayload, decide and replace. * Build payloads as std::vector<char> (like DataSegment::data) with getBits and memcpy, as the interpreter does, instead of casting. No functional change.
* 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.
There was a problem hiding this comment.
Code Review
This pull request introduces the --constant-arrays-to-data optimization pass, which converts array.new_fixed instructions of constant numeric values into array.new_data from passive data segments when it reduces binary size. The pass is integrated into the end of the default optimization pipeline. The review feedback suggests avoiding registering the pass if the bulk-memory feature is disabled, and recommends replacing functional-style casts with explicit static_cast calls to avoid potential narrowing warnings on 64-bit platforms and conform to modern C++ practices.
| if (options.lastOpts && wasm->features.hasGC() && | ||
| (options.optimizeLevel >= 2 || options.shrinkLevel >= 1)) { |
There was a problem hiding this comment.
Since the constant-arrays-to-data pass requires the bulk-memory feature (as checked in ConstantArraysToData::run), we can avoid adding the pass to the runner entirely if bulk-memory is not enabled. This saves a small amount of overhead.
| if (options.lastOpts && wasm->features.hasGC() && | |
| (options.optimizeLevel >= 2 || options.shrinkLevel >= 1)) { | |
| if (options.lastOpts && wasm->features.hasGC() && wasm->features.hasBulkMemory() && | |
| (options.optimizeLevel >= 2 || options.shrinkLevel >= 1)) { |
| return getWrittenSize(U32LEB(segment)) + 1 + | ||
| getWrittenSize(S32LEB(int32_t(offset))) + 1 + | ||
| getWrittenSize(S32LEB(int32_t(size))); |
There was a problem hiding this comment.
Using C-style or functional-style casts like int32_t(offset) is discouraged in modern C++. It is safer and more idiomatic to use static_cast<int32_t> per the Google C++ Style Guide.
| return getWrittenSize(U32LEB(segment)) + 1 + | |
| getWrittenSize(S32LEB(int32_t(offset))) + 1 + | |
| getWrittenSize(S32LEB(int32_t(size))); | |
| return getWrittenSize(U32LEB(segment)) + 1 + | |
| getWrittenSize(S32LEB(static_cast<int32_t>(offset))) + 1 + | |
| getWrittenSize(S32LEB(static_cast<int32_t>(size))); |
| auto savings = getCodeSavings(group, newSegmentIndex, offset) - | ||
| int64_t(group.payload.size()); |
There was a problem hiding this comment.
Using C-style or functional-style casts like int64_t(group.payload.size()) is discouraged in modern C++. It is safer and more idiomatic to use static_cast<int64_t> per the Google C++ Style Guide.
| auto savings = getCodeSavings(group, newSegmentIndex, offset) - | |
| int64_t(group.payload.size()); | |
| auto savings = getCodeSavings(group, newSegmentIndex, offset) - | |
| static_cast<int64_t>(group.payload.size()); |
| newSegmentSavings <= | ||
| int64_t(getNewSegmentOverhead(*module, newSegmentData.size()))) { |
There was a problem hiding this comment.
Using C-style or functional-style casts like int64_t(...) is discouraged in modern C++. It is safer and more idiomatic to use static_cast<int64_t> per the Google C++ Style Guide.
| newSegmentSavings <= | |
| int64_t(getNewSegmentOverhead(*module, newSegmentData.size()))) { | |
| newSegmentSavings <= | |
| static_cast<int64_t>(getNewSegmentOverhead(*module, newSegmentData.size()))) { |
| savings += | ||
| int64_t(getArrayNewFixedSize(array)) - | ||
| int64_t(getArrayNewDataSize(segment, offset, array->values.size())); |
There was a problem hiding this comment.
Using C-style or functional-style casts like int64_t(...) is discouraged in modern C++. It is safer and more idiomatic to use static_cast<int64_t> per the Google C++ Style Guide.
| savings += | |
| int64_t(getArrayNewFixedSize(array)) - | |
| int64_t(getArrayNewDataSize(segment, offset, array->values.size())); | |
| savings += | |
| static_cast<int64_t>(getArrayNewFixedSize(array)) - | |
| static_cast<int64_t>(getArrayNewDataSize(segment, offset, array->values.size())); |
| builder.makeConst(int32_t(conversion.offset)), | ||
| builder.makeConst(int32_t(array->values.size()))); |
There was a problem hiding this comment.
Using C-style or functional-style casts like int32_t(...) is discouraged in modern C++. It is safer and more idiomatic to use static_cast<int32_t> per the Google C++ Style Guide.
| builder.makeConst(int32_t(conversion.offset)), | |
| builder.makeConst(int32_t(array->values.size()))); | |
| builder.makeConst(static_cast<int32_t>(conversion.offset)), | |
| builder.makeConst(static_cast<int32_t>(array->values.size()))); |
| // TODO: Also reuse payloads that appear inside other data. That needs an | ||
| // efficient substring search, to avoid quadratic behavior. |
There was a problem hiding this comment.
A local AI review noticed that without this TODO, this pass will generate duplicate data segments when run with --converge because lastOpts is true at the end of every convergence iteration and previous OptimizeInstructions runs will always give this pass more work to do. @kripken, maybe we should consider setting lastOpts to be true only after convergence has been achieved?
Since we already calculate reusableSegments, we could also avoid this problem by emitting a separate new segment for each group, rather than a single concatenated segment.
There was a problem hiding this comment.
Yes, we need to decide on what lastOpts means if converge is run, I guess. Possibly we should just disable it? It keeps running until a condition, and it checks the condition after running the passes, so it never knows which opts are last until later.
There was a problem hiding this comment.
Never running it would be unfortunate because then passes like this one would never be run. I would lean toward either doing one extra iteration after convergence with lastOpts is true, or keeping the status quo and making sure round trips don't keep adding more cruft.
There was a problem hiding this comment.
Yeah, a single last iteration might be better, good point.
| // the given segment and offset. The arrays may have different types, so we | ||
| // measure each one. |
There was a problem hiding this comment.
How do the different types matter here?
* 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.
|
@tlively should I follow the above feedback from gemini? |
|
No, you can ignore that if you'd like. Either way is fine by me. |
| return getWrittenSize(U32LEB(segment)) + 2 + 1 + | ||
| getWrittenSize(S32LEB(int32_t(size))); |
There was a problem hiding this comment.
| return getWrittenSize(U32LEB(segment)) + 2 + 1 + | |
| getWrittenSize(S32LEB(int32_t(size))); | |
| return getWrittenSize(U32LEB(segment)) + 2 + getConstSize(Literal(int32_t(size))); |
There was a problem hiding this comment.
(seems better to reuse the Literal logic for size computation)
|
|
||
| // Candidates with identical payloads, which can share one copy of the bytes. | ||
| struct PayloadGroup { | ||
| std::string_view payload; |
There was a problem hiding this comment.
Why not have this as a std::string as well, like in Candidate?
If it makes sense as a view, I think it is worth documenting what it is a view of, and why that thing outlives this view.
There was a problem hiding this comment.
(another option is to just do candidates[0].payload, perhaps as a helper function)
| } | ||
| }); | ||
|
|
||
| // The map is in function order, so this order is deterministic. |
There was a problem hiding this comment.
It seems simpler to me to iterate on wasm->functions to get the deterministic ordering there. This does add a lookup, but then InsertOrderedMap adds a lookup structure, so I doubt it is any faster.
Also, this depends on the inserts to the InsertOrderedMap map to be in a deterministic order, and while that is the case in ModuleUtils::ParallelFunctionAnalysis atm, it might change in theory (that is an internal detail).
There was a problem hiding this comment.
Hmm, doing this is the only reason we allow configuring the function-parallel analysis with an InsertOrderedMap in the first place, though (introduced in #3793). If we don't think that's a good idea, we should remove that functionality.
There was a problem hiding this comment.
Ah, I forgot about these other uses. Another option might be to document that this is part of the contract of the ParallelFunctionAnalysis class (i.e. that the simple loop on wasm->functions in the constructor is not an internal implementation detail). That's probably good enough.
| Index segment = wasm->dataSegments.size(); | ||
| auto savings = | ||
| getCodeSavings(group, segment) - int64_t(group.payload.size()) - | ||
| int64_t(getNewSegmentOverhead(*wasm, group.payload.size())); |
There was a problem hiding this comment.
Please explain what the last two terms are here. The first of them is unclear to me.
Follow-up to #9248: a late pass that converts
array.new_fixedof constantnumeric values in function bodies to
array.new_datawhen that is smaller(
array.new_fixedneeds a full*.constper element, a data segment stores rawbytes).
Since
array.new_fixedis a constant expression that other optimizationsunderstand, the default pipeline runs this only in the last set of opts
(
lastOpts, #9238).array.new_datais not a constantexpression there.
stored once, and a payload identical to an existing, never-dropped passive
segment reuses that segment.
is smaller, and the new segment is only added if the savings that depend on it
pay for its header.
cannot trap.
Note that this does not make it safe to raise the 64-element cap in
OptimizeInstructions: the conversion is not always reversible. A larger
array.new_fixedcan be hoisted into a global initializer, where it cannot beturned back into
array.new_data. On a large J2Wasm binary, removing the capmoved the remaining big tables into globals and grew the binary (+0.9 KB raw,
+1.4 KB gzipped), which this pass cannot recover.