From 2e6368d08638ed6f00c3b5582322a690acee010b Mon Sep 17 00:00:00 2001 From: artemkulyk Date: Sat, 26 Sep 2026 14:57:37 +0200 Subject: [PATCH] Add a constant-pooling pass Pool repeated numeric constants into immutable globals. An f64.const is 9 bytes at every use while a global.get is 2, so two or more uses of an f64 constant already pay for the global entry. Pool only when the estimated binary size strictly decreases, using the real encoded size of the constant, the eventual global.get, and the global entry. Constants in const-expression positions cannot hold a global.get, so they are left alone. The pass is not enabled by default. --- CHANGELOG.md | 2 + src/passes/CMakeLists.txt | 1 + src/passes/ConstantPooling.cpp | 220 ++++++++++++++++++++++++++ src/passes/pass.cpp | 3 + src/passes/passes.h | 1 + test/lit/help/wasm-metadce.test | 3 + test/lit/help/wasm-opt.test | 3 + test/lit/help/wasm2js.test | 3 + test/lit/passes/constant-pooling.wast | 80 ++++++++++ 9 files changed, 316 insertions(+) create mode 100644 src/passes/ConstantPooling.cpp create mode 100644 test/lit/passes/constant-pooling.wast diff --git a/CHANGELOG.md b/CHANGELOG.md index b6cab75178f..2e6f310dcde 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,8 @@ 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) +- Add a new `--constant-pooling` pass, which pools repeated numeric constants + into immutable globals. (#9163) v133 ---- diff --git a/src/passes/CMakeLists.txt b/src/passes/CMakeLists.txt index ab08d3a410b..02f7eef7a93 100644 --- a/src/passes/CMakeLists.txt +++ b/src/passes/CMakeLists.txt @@ -26,6 +26,7 @@ set(passes_SOURCES CodeFolding.cpp ConstantFieldPropagation.cpp ConstHoisting.cpp + ConstantPooling.cpp ConstraintAnalysis.cpp DataFlowOpts.cpp DeadArgumentElimination.cpp diff --git a/src/passes/ConstantPooling.cpp b/src/passes/ConstantPooling.cpp new file mode 100644 index 00000000000..7e15e5b7c54 --- /dev/null +++ b/src/passes/ConstantPooling.cpp @@ -0,0 +1,220 @@ +/* + * 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. + */ + +// +// Pools repeated constants into immutable globals. A constant that is used +// multiple times can often be stored in a global, replacing the bytes of +// encoding the constant each time with the (usually smaller) bytes of a +// global.get. We only do this when the estimated binary size strictly +// decreases, so small constants (which are already very cheap) are left alone. +// +// WARNING: like const-hoisting, this shrinks raw size but can increase gzip +// size, as removing repeated constants removes redundancy that +// compressors use. +// + +#include +#include +#include + +#include "ir/module-utils.h" +#include "pass.h" +#include "wasm-binary.h" +#include "wasm-builder.h" +#include "wasm.h" + +namespace wasm { + +namespace { + +// The types of constants we consider pooling. More types are possible, but +// these are the ones where pooling can typically pay off. +bool isPoolable(Type type) { + return type == Type::i32 || type == Type::i64 || type == Type::f32 || + type == Type::f64 || type == Type::v128; +} + +// The number of bytes an unsigned LEB128 takes. +Index sizeULEB(uint64_t value) { + Index size = 1; + while (value >= 0x80) { + value >>= 7; + size++; + } + return size; +} + +template Index getWrittenSize(const T& thing) { + BufferWithRandomAccess buffer; + buffer << thing; + return buffer.size(); +} + +// The number of bytes a constant instruction takes in the binary, including +// the opcode. +Index getConstSize(const Literal& value) { + auto type = value.type; + if (type == Type::i32) { + return 1 + getWrittenSize(S32LEB(value.geti32())); + } + if (type == Type::i64) { + return 1 + getWrittenSize(S64LEB(value.geti64())); + } + if (type == Type::f32) { + return 1 + 4; + } + if (type == Type::f64) { + return 1 + 8; + } + assert(type == Type::v128); + // v128.const has a two-byte prefixed opcode (0xfd 0x0c), unlike the other + // constant instructions, followed by the 16 bytes of the value. + return 2 + 16; +} + +// A deterministic key for a constant: the basic type and the raw bits. We +// cannot use Literal directly as its comparison depends on Type IDs, which are +// not stable between runs. +struct PoolKey { + Type::BasicType type; + uint8_t bits[16]; + + bool operator<(const PoolKey& other) const { + if (type != other.type) { + return type < other.type; + } + return memcmp(bits, other.bits, 16) < 0; + } +}; + +struct ConstantPooling : public Pass { + // We add globals, but do not change any of the locals in the module. + bool requiresNonNullableLocalFixups() override { return false; } + + void run(Module* module) override { + using Ptrs = std::vector; + + // Scan all the function bodies. We cannot handle constants in const- + // expression positions (global initializers, segment offsets, etc.), as + // those cannot contain a global.get (of a non-imported global), so we only + // walk function bodies. + struct Scanner : public PostWalker { + Ptrs& ptrs; + + Scanner(Ptrs& ptrs) : ptrs(ptrs) {} + + void visitConst(Const* curr) { + if (isPoolable(curr->type)) { + ptrs.push_back(getCurrentPointer()); + } + } + }; + + ModuleUtils::ParallelFunctionAnalysis analysis( + *module, [&](Function* func, Ptrs& ptrs) { + if (!func->imported()) { + Scanner(ptrs).walk(func->body); + } + }); + + // Group the constants by their value. + std::map groups; + for (auto& [func, ptrs] : analysis.map) { + for (auto** ptr : ptrs) { + auto* curr = (*ptr)->cast(); + PoolKey key; + key.type = curr->type.getBasic(); + curr->value.getBits(key.bits); + groups[key].push_back(ptr); + } + } + + // Decide which constants to pool. A pooled constant adds one global + // (whose entry encodes the constant plus overhead) and replaces each use + // with a global.get. + Index existingGlobals = module->globals.size(); + auto globalGetSize = [&](Index index) { return 1 + sizeULEB(index); }; + + struct ToPool { + Const* value; + Ptrs* ptrs; + Name name; + }; + std::vector toPool; + + // The index a pooled global will get. We do not know that for sure, but we + // compute a conservative bound: if there will be more than 128 globals + // then some may need two bytes to encode, so assume that. + Index numGlobals = existingGlobals + groups.size(); + Index getSize = globalGetSize(numGlobals > 0 ? numGlobals - 1 : 0); + + // If the module has no globals at all, we must also pay for the global + // section itself when we add the first one. + bool firstPooled = true; + + for (auto& [key, ptrs] : groups) { + auto* value = (*ptrs[0])->cast(); + Index constSize = getConstSize(value->value); + Index count = ptrs.size(); + // This is only valid if the global.get is smaller than the constant. + if (constSize <= getSize) { + continue; + } + int64_t savings = + int64_t(count) * (constSize - getSize) - (constSize + 3); + if (existingGlobals == 0 && firstPooled) { + savings -= 3; + } + if (savings > 0) { + toPool.push_back({value, &ptrs, Name()}); + firstPooled = false; + } + } + + if (toPool.empty()) { + return; + } + + // Create the globals, then replace the uses. + Builder builder(*module); + Index counter = 0; + for (auto& entry : toPool) { + Name name; + while (true) { + name = std::string("const$") + std::to_string(counter++); + if (!module->getGlobalOrNull(name)) { + break; + } + } + entry.name = name; + auto global = builder.makeGlobal( + name, entry.value->type, entry.value, Builder::Immutable); + module->addGlobal(std::move(global)); + } + + for (auto& entry : toPool) { + for (auto** ptr : *entry.ptrs) { + *ptr = builder.makeGlobalGet(entry.name, entry.value->type); + } + } + } +}; + +} // anonymous namespace + +Pass* createConstantPoolingPass() { return new ConstantPooling(); } + +} // namespace wasm diff --git a/src/passes/pass.cpp b/src/passes/pass.cpp index db7e769c505..0ce83128c0d 100644 --- a/src/passes/pass.cpp +++ b/src/passes/pass.cpp @@ -123,6 +123,9 @@ void PassRegistry::registerPasses() { registerPass("const-hoisting", "hoist repeated constants to a local", createConstHoistingPass); + registerPass("constant-pooling", + "pool repeated constants in globals", + createConstantPoolingPass); registerPass("cfp", "propagate constant struct field values", createConstantFieldPropagationPass); diff --git a/src/passes/passes.h b/src/passes/passes.h index 8a27158ecb0..bace3dd2e4c 100644 --- a/src/passes/passes.h +++ b/src/passes/passes.h @@ -31,6 +31,7 @@ Pass* createCoalesceLocalsWithLearningPass(); Pass* createCodeFoldingPass(); Pass* createCodePushingPass(); Pass* createConstHoistingPass(); +Pass* createConstantPoolingPass(); Pass* createConstantFieldPropagationPass(); Pass* createConstantFieldPropagationRefTestPass(); Pass* createConstraintAnalysisPass(); diff --git a/test/lit/help/wasm-metadce.test b/test/lit/help/wasm-metadce.test index 0be805fa01f..ad58255f8e2 100644 --- a/test/lit/help/wasm-metadce.test +++ b/test/lit/help/wasm-metadce.test @@ -109,6 +109,9 @@ ;; CHECK-NEXT: --const-hoisting hoist repeated constants to a ;; CHECK-NEXT: local ;; CHECK-EMPTY: +;; CHECK-NEXT: --constant-pooling pool repeated constants in +;; CHECK-NEXT: globals +;; 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 116c3260dcb..69bb58319d1 100644 --- a/test/lit/help/wasm-opt.test +++ b/test/lit/help/wasm-opt.test @@ -145,6 +145,9 @@ ;; CHECK-NEXT: --const-hoisting hoist repeated constants to a ;; CHECK-NEXT: local ;; CHECK-EMPTY: +;; CHECK-NEXT: --constant-pooling pool repeated constants in +;; CHECK-NEXT: globals +;; 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 14388cd93cb..8928a187198 100644 --- a/test/lit/help/wasm2js.test +++ b/test/lit/help/wasm2js.test @@ -73,6 +73,9 @@ ;; CHECK-NEXT: --const-hoisting hoist repeated constants to a ;; CHECK-NEXT: local ;; CHECK-EMPTY: +;; CHECK-NEXT: --constant-pooling pool repeated constants in +;; CHECK-NEXT: globals +;; CHECK-EMPTY: ;; CHECK-NEXT: --constraint-analysis finds and uses mathematical ;; CHECK-NEXT: constraints on locals ;; CHECK-EMPTY: diff --git a/test/lit/passes/constant-pooling.wast b/test/lit/passes/constant-pooling.wast new file mode 100644 index 00000000000..dd4423529b9 --- /dev/null +++ b/test/lit/passes/constant-pooling.wast @@ -0,0 +1,80 @@ +;; NOTE: Assertions have been generated by update_lit_checks.py --all-items and should not be edited. +;; RUN: foreach %s %t wasm-opt --all-features --constant-pooling -S -o - | filecheck %s + +;; Repeated constants that are expensive to encode are pooled in globals. Cheap +;; constants (like i32.const 0) are left alone, and constants in const +;; expression positions (global initializers) are never replaced. Also, a +;; large i32 constant needs more uses than a float to pay for its global. + +(module + ;; CHECK: (type $0 (func (result i32))) + + ;; CHECK: (type $1 (func (result f64))) + + ;; CHECK: (type $2 (func (result v128))) + + ;; CHECK: (global $existing f64 (f64.const 1.5)) + (global $existing f64 + (f64.const 1.5) + ) + ;; CHECK: (global $const$0 i32 (i32.const 305419896)) + + ;; CHECK: (global $const$1 f64 (f64.const 1.5)) + + ;; CHECK: (global $const$2 v128 (v128.const i32x4 0x00000000 0x00000001 0x00000002 0x00000003)) + + ;; CHECK: (func $double (type $1) (result f64) + ;; CHECK-NEXT: (global.get $const$1) + ;; CHECK-NEXT: ) + (func $double (result f64) + (f64.const 1.5) + ) + ;; CHECK: (func $also-double (type $1) (result f64) + ;; CHECK-NEXT: (global.get $const$1) + ;; CHECK-NEXT: ) + (func $also-double (result f64) + (f64.const 1.5) + ) + ;; CHECK: (func $zero (type $0) (result i32) + ;; CHECK-NEXT: (i32.const 0) + ;; CHECK-NEXT: ) + (func $zero (result i32) + (i32.const 0) + ) + ;; CHECK: (func $also-zero (type $0) (result i32) + ;; CHECK-NEXT: (i32.const 0) + ;; CHECK-NEXT: ) + (func $also-zero (result i32) + (i32.const 0) + ) + ;; CHECK: (func $vector (type $2) (result v128) + ;; CHECK-NEXT: (global.get $const$2) + ;; CHECK-NEXT: ) + (func $vector (result v128) + (v128.const i32x4 0 1 2 3) + ) + ;; CHECK: (func $also-vector (type $2) (result v128) + ;; CHECK-NEXT: (global.get $const$2) + ;; CHECK-NEXT: ) + (func $also-vector (result v128) + (v128.const i32x4 0 1 2 3) + ) + ;; CHECK: (func $fat-const (type $0) (result i32) + ;; CHECK-NEXT: (global.get $const$0) + ;; CHECK-NEXT: ) + (func $fat-const (result i32) + (i32.const 0x12345678) + ) + ;; CHECK: (func $also-fat-const (type $0) (result i32) + ;; CHECK-NEXT: (global.get $const$0) + ;; CHECK-NEXT: ) + (func $also-fat-const (result i32) + (i32.const 0x12345678) + ) + ;; CHECK: (func $third-fat-const (type $0) (result i32) + ;; CHECK-NEXT: (global.get $const$0) + ;; CHECK-NEXT: ) + (func $third-fat-const (result i32) + (i32.const 0x12345678) + ) +)