Skip to content

Commit e59c36d

Browse files
committed
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.
1 parent b71b29e commit e59c36d

9 files changed

Lines changed: 316 additions & 0 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ Current Trunk
2222
- Reject non-natural alignment for atomic memory operations at parse time (#8962)
2323
- Note that fast-math mode can ignore the difference between negative zero and
2424
zero (like clang and gcc). (#9056)
25+
- Add a new `--constant-pooling` pass, which pools repeated numeric constants
26+
into immutable globals. (#NNNN)
2527

2628
v132
2729
----

‎src/passes/CMakeLists.txt‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ set(passes_SOURCES
2626
CodeFolding.cpp
2727
ConstantFieldPropagation.cpp
2828
ConstHoisting.cpp
29+
ConstantPooling.cpp
2930
ConstraintAnalysis.cpp
3031
DataFlowOpts.cpp
3132
DeadArgumentElimination.cpp

‎src/passes/ConstantPooling.cpp‎

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
/*
2+
* Copyright 2026 WebAssembly Community Group participants
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
//
18+
// Pools repeated constants into immutable globals. A constant that is used
19+
// multiple times can often be stored in a global, replacing the bytes of
20+
// encoding the constant each time with the (usually smaller) bytes of a
21+
// global.get. We only do this when the estimated binary size strictly
22+
// decreases, so small constants (which are already very cheap) are left alone.
23+
//
24+
// WARNING: like const-hoisting, this shrinks raw size but can increase gzip
25+
// size, as removing repeated constants removes redundancy that
26+
// compressors use.
27+
//
28+
29+
#include <cstring>
30+
#include <map>
31+
#include <string>
32+
33+
#include "ir/module-utils.h"
34+
#include "pass.h"
35+
#include "wasm-binary.h"
36+
#include "wasm-builder.h"
37+
#include "wasm.h"
38+
39+
namespace wasm {
40+
41+
namespace {
42+
43+
// The types of constants we consider pooling. More types are possible, but
44+
// these are the ones where pooling can typically pay off.
45+
bool isPoolable(Type type) {
46+
return type == Type::i32 || type == Type::i64 || type == Type::f32 ||
47+
type == Type::f64 || type == Type::v128;
48+
}
49+
50+
// The number of bytes an unsigned LEB128 takes.
51+
Index sizeULEB(uint64_t value) {
52+
Index size = 1;
53+
while (value >= 0x80) {
54+
value >>= 7;
55+
size++;
56+
}
57+
return size;
58+
}
59+
60+
template<typename T> Index getWrittenSize(const T& thing) {
61+
BufferWithRandomAccess buffer;
62+
buffer << thing;
63+
return buffer.size();
64+
}
65+
66+
// The number of bytes a constant instruction takes in the binary, including
67+
// the opcode.
68+
Index getConstSize(const Literal& value) {
69+
auto type = value.type;
70+
if (type == Type::i32) {
71+
return 1 + getWrittenSize(S32LEB(value.geti32()));
72+
}
73+
if (type == Type::i64) {
74+
return 1 + getWrittenSize(S64LEB(value.geti64()));
75+
}
76+
if (type == Type::f32) {
77+
return 1 + 4;
78+
}
79+
if (type == Type::f64) {
80+
return 1 + 8;
81+
}
82+
assert(type == Type::v128);
83+
// v128.const has a two-byte prefixed opcode (0xfd 0x0c), unlike the other
84+
// constant instructions, followed by the 16 bytes of the value.
85+
return 2 + 16;
86+
}
87+
88+
// A deterministic key for a constant: the basic type and the raw bits. We
89+
// cannot use Literal directly as its comparison depends on Type IDs, which are
90+
// not stable between runs.
91+
struct PoolKey {
92+
Type::BasicType type;
93+
uint8_t bits[16];
94+
95+
bool operator<(const PoolKey& other) const {
96+
if (type != other.type) {
97+
return type < other.type;
98+
}
99+
return memcmp(bits, other.bits, 16) < 0;
100+
}
101+
};
102+
103+
struct ConstantPooling : public Pass {
104+
// We add globals, but do not change any of the locals in the module.
105+
bool requiresNonNullableLocalFixups() override { return false; }
106+
107+
void run(Module* module) override {
108+
using Ptrs = std::vector<Expression**>;
109+
110+
// Scan all the function bodies. We cannot handle constants in const-
111+
// expression positions (global initializers, segment offsets, etc.), as
112+
// those cannot contain a global.get (of a non-imported global), so we only
113+
// walk function bodies.
114+
struct Scanner : public PostWalker<Scanner> {
115+
Ptrs& ptrs;
116+
117+
Scanner(Ptrs& ptrs) : ptrs(ptrs) {}
118+
119+
void visitConst(Const* curr) {
120+
if (isPoolable(curr->type)) {
121+
ptrs.push_back(getCurrentPointer());
122+
}
123+
}
124+
};
125+
126+
ModuleUtils::ParallelFunctionAnalysis<Ptrs> analysis(
127+
*module, [&](Function* func, Ptrs& ptrs) {
128+
if (!func->imported()) {
129+
Scanner(ptrs).walk(func->body);
130+
}
131+
});
132+
133+
// Group the constants by their value.
134+
std::map<PoolKey, Ptrs> groups;
135+
for (auto& [func, ptrs] : analysis.map) {
136+
for (auto** ptr : ptrs) {
137+
auto* curr = (*ptr)->cast<Const>();
138+
PoolKey key;
139+
key.type = curr->type.getBasic();
140+
curr->value.getBits(key.bits);
141+
groups[key].push_back(ptr);
142+
}
143+
}
144+
145+
// Decide which constants to pool. A pooled constant adds one global
146+
// (whose entry encodes the constant plus overhead) and replaces each use
147+
// with a global.get.
148+
Index existingGlobals = module->globals.size();
149+
auto globalGetSize = [&](Index index) { return 1 + sizeULEB(index); };
150+
151+
struct ToPool {
152+
Const* value;
153+
Ptrs* ptrs;
154+
Name name;
155+
};
156+
std::vector<ToPool> toPool;
157+
158+
// The index a pooled global will get. We do not know that for sure, but we
159+
// compute a conservative bound: if there will be more than 128 globals
160+
// then some may need two bytes to encode, so assume that.
161+
Index numGlobals = existingGlobals + groups.size();
162+
Index getSize = globalGetSize(numGlobals > 0 ? numGlobals - 1 : 0);
163+
164+
// If the module has no globals at all, we must also pay for the global
165+
// section itself when we add the first one.
166+
bool firstPooled = true;
167+
168+
for (auto& [key, ptrs] : groups) {
169+
auto* value = (*ptrs[0])->cast<Const>();
170+
Index constSize = getConstSize(value->value);
171+
Index count = ptrs.size();
172+
// This is only valid if the global.get is smaller than the constant.
173+
if (constSize <= getSize) {
174+
continue;
175+
}
176+
int64_t savings =
177+
int64_t(count) * (constSize - getSize) - (constSize + 3);
178+
if (existingGlobals == 0 && firstPooled) {
179+
savings -= 3;
180+
}
181+
if (savings > 0) {
182+
toPool.push_back({value, &ptrs, Name()});
183+
firstPooled = false;
184+
}
185+
}
186+
187+
if (toPool.empty()) {
188+
return;
189+
}
190+
191+
// Create the globals, then replace the uses.
192+
Builder builder(*module);
193+
Index counter = 0;
194+
for (auto& entry : toPool) {
195+
Name name;
196+
while (true) {
197+
name = std::string("const$") + std::to_string(counter++);
198+
if (!module->getGlobalOrNull(name)) {
199+
break;
200+
}
201+
}
202+
entry.name = name;
203+
auto global = builder.makeGlobal(
204+
name, entry.value->type, entry.value, Builder::Immutable);
205+
module->addGlobal(std::move(global));
206+
}
207+
208+
for (auto& entry : toPool) {
209+
for (auto** ptr : *entry.ptrs) {
210+
*ptr = builder.makeGlobalGet(entry.name, entry.value->type);
211+
}
212+
}
213+
}
214+
};
215+
216+
} // anonymous namespace
217+
218+
Pass* createConstantPoolingPass() { return new ConstantPooling(); }
219+
220+
} // namespace wasm

‎src/passes/pass.cpp‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,9 @@ void PassRegistry::registerPasses() {
123123
registerPass("const-hoisting",
124124
"hoist repeated constants to a local",
125125
createConstHoistingPass);
126+
registerPass("constant-pooling",
127+
"pool repeated constants in globals",
128+
createConstantPoolingPass);
126129
registerPass("cfp",
127130
"propagate constant struct field values",
128131
createConstantFieldPropagationPass);

‎src/passes/passes.h‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ Pass* createCoalesceLocalsWithLearningPass();
3131
Pass* createCodeFoldingPass();
3232
Pass* createCodePushingPass();
3333
Pass* createConstHoistingPass();
34+
Pass* createConstantPoolingPass();
3435
Pass* createConstantFieldPropagationPass();
3536
Pass* createConstantFieldPropagationRefTestPass();
3637
Pass* createConstraintAnalysisPass();

‎test/lit/help/wasm-metadce.test‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,9 @@
109109
;; CHECK-NEXT: --const-hoisting hoist repeated constants to a
110110
;; CHECK-NEXT: local
111111
;; CHECK-EMPTY:
112+
;; CHECK-NEXT: --constant-pooling pool repeated constants in
113+
;; CHECK-NEXT: globals
114+
;; CHECK-EMPTY:
112115
;; CHECK-NEXT: --constraint-analysis finds and uses mathematical
113116
;; CHECK-NEXT: constraints on locals
114117
;; CHECK-EMPTY:

‎test/lit/help/wasm-opt.test‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,9 @@
145145
;; CHECK-NEXT: --const-hoisting hoist repeated constants to a
146146
;; CHECK-NEXT: local
147147
;; CHECK-EMPTY:
148+
;; CHECK-NEXT: --constant-pooling pool repeated constants in
149+
;; CHECK-NEXT: globals
150+
;; CHECK-EMPTY:
148151
;; CHECK-NEXT: --constraint-analysis finds and uses mathematical
149152
;; CHECK-NEXT: constraints on locals
150153
;; CHECK-EMPTY:

‎test/lit/help/wasm2js.test‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,9 @@
7373
;; CHECK-NEXT: --const-hoisting hoist repeated constants to a
7474
;; CHECK-NEXT: local
7575
;; CHECK-EMPTY:
76+
;; CHECK-NEXT: --constant-pooling pool repeated constants in
77+
;; CHECK-NEXT: globals
78+
;; CHECK-EMPTY:
7679
;; CHECK-NEXT: --constraint-analysis finds and uses mathematical
7780
;; CHECK-NEXT: constraints on locals
7881
;; CHECK-EMPTY:
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
;; NOTE: Assertions have been generated by update_lit_checks.py --all-items and should not be edited.
2+
;; RUN: foreach %s %t wasm-opt --all-features --constant-pooling -S -o - | filecheck %s
3+
4+
;; Repeated constants that are expensive to encode are pooled in globals. Cheap
5+
;; constants (like i32.const 0) are left alone, and constants in const
6+
;; expression positions (global initializers) are never replaced. Also, a
7+
;; large i32 constant needs more uses than a float to pay for its global.
8+
9+
(module
10+
;; CHECK: (type $0 (func (result i32)))
11+
12+
;; CHECK: (type $1 (func (result f64)))
13+
14+
;; CHECK: (type $2 (func (result v128)))
15+
16+
;; CHECK: (global $existing f64 (f64.const 1.5))
17+
(global $existing f64
18+
(f64.const 1.5)
19+
)
20+
;; CHECK: (global $const$0 i32 (i32.const 305419896))
21+
22+
;; CHECK: (global $const$1 f64 (f64.const 1.5))
23+
24+
;; CHECK: (global $const$2 v128 (v128.const i32x4 0x00000000 0x00000001 0x00000002 0x00000003))
25+
26+
;; CHECK: (func $double (type $1) (result f64)
27+
;; CHECK-NEXT: (global.get $const$1)
28+
;; CHECK-NEXT: )
29+
(func $double (result f64)
30+
(f64.const 1.5)
31+
)
32+
;; CHECK: (func $also-double (type $1) (result f64)
33+
;; CHECK-NEXT: (global.get $const$1)
34+
;; CHECK-NEXT: )
35+
(func $also-double (result f64)
36+
(f64.const 1.5)
37+
)
38+
;; CHECK: (func $zero (type $0) (result i32)
39+
;; CHECK-NEXT: (i32.const 0)
40+
;; CHECK-NEXT: )
41+
(func $zero (result i32)
42+
(i32.const 0)
43+
)
44+
;; CHECK: (func $also-zero (type $0) (result i32)
45+
;; CHECK-NEXT: (i32.const 0)
46+
;; CHECK-NEXT: )
47+
(func $also-zero (result i32)
48+
(i32.const 0)
49+
)
50+
;; CHECK: (func $vector (type $2) (result v128)
51+
;; CHECK-NEXT: (global.get $const$2)
52+
;; CHECK-NEXT: )
53+
(func $vector (result v128)
54+
(v128.const i32x4 0 1 2 3)
55+
)
56+
;; CHECK: (func $also-vector (type $2) (result v128)
57+
;; CHECK-NEXT: (global.get $const$2)
58+
;; CHECK-NEXT: )
59+
(func $also-vector (result v128)
60+
(v128.const i32x4 0 1 2 3)
61+
)
62+
;; CHECK: (func $fat-const (type $0) (result i32)
63+
;; CHECK-NEXT: (global.get $const$0)
64+
;; CHECK-NEXT: )
65+
(func $fat-const (result i32)
66+
(i32.const 0x12345678)
67+
)
68+
;; CHECK: (func $also-fat-const (type $0) (result i32)
69+
;; CHECK-NEXT: (global.get $const$0)
70+
;; CHECK-NEXT: )
71+
(func $also-fat-const (result i32)
72+
(i32.const 0x12345678)
73+
)
74+
;; CHECK: (func $third-fat-const (type $0) (result i32)
75+
;; CHECK-NEXT: (global.get $const$0)
76+
;; CHECK-NEXT: )
77+
(func $third-fat-const (result i32)
78+
(i32.const 0x12345678)
79+
)
80+
)

0 commit comments

Comments
 (0)