Skip to content

Commit 418a75d

Browse files
perf(wasm-privacy-coin): JIT-compile WASM + cache module/pool instances
The shielded merkle tree test suite spent ~452s, almost entirely in WASM module setup rather than tree operations. Three layers of fix: 1. Module caching — the parsed WasmModule is cached in a static volatile field (immutable, safely shared). Only the first WasmBridge pays the parse cost; subsequent trees reuse it. 2. Instance pooling — a ConcurrentLinkedDeque reuses Chicory Instances. close() drops the in-WASM tree via drop_tree and returns the Instance to the pool; the next WasmBridge borrows it and from_frontier / from_state re-initializes the tree state. Failed init uses discard() to avoid polluting the pool with a dirty Instance. 3. JIT compiler — upgraded Chicory 1.1.1 -> 1.7.5 and added the compiler module. MachineFactoryCompiler translates all WASM functions to JVM bytecode once per JVM, replacing the interpreter with native-speed execution that the JVM C2 JIT further optimizes. Results on a downstream shielded merkle tree test suite that consumes this package: MerkleTreeServiceTest 213s -> 2.96s (72x) MerkleTreePruningTest 194s -> 3.38s (57x) MerkleTreeEndToEndTest 45s -> 4.57s (10x) Total 452s -> 10.9s (41x) All 41 wasm-privacy-coin tests pass, plus the downstream consumer's merkle tree tests. The Chicory version bump (1.1.1 -> 1.7.5) is a breaking change for any consumer still on 1.1.1. Refs: T1-3728
1 parent 3c87592 commit 418a75d

3 files changed

Lines changed: 148 additions & 23 deletions

File tree

packages/wasm-privacy-coin/pom.xml

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,36 @@
2020
</properties>
2121

2222
<dependencies>
23-
<!-- Chicory: pure-JVM WASM runtime, no native/JNI dependency -->
23+
<!-- Chicory: pure-JVM WASM runtime, no native/JNI dependency. -->
2424
<dependency>
2525
<groupId>com.dylibso.chicory</groupId>
2626
<artifactId>runtime</artifactId>
27-
<version>1.1.1</version>
27+
<version>1.7.5</version>
28+
</dependency>
29+
30+
<!-- Chicory compiler: JIT-compiles WASM functions to JVM bytecode at runtime,
31+
~10x faster than the default interpreter for compute-heavy modules. -->
32+
<dependency>
33+
<groupId>com.dylibso.chicory</groupId>
34+
<artifactId>compiler</artifactId>
35+
<version>1.7.5</version>
36+
</dependency>
37+
38+
<!-- ASM: required by the Chicory compiler for bytecode generation -->
39+
<dependency>
40+
<groupId>org.ow2.asm</groupId>
41+
<artifactId>asm</artifactId>
42+
<version>9.9.1</version>
43+
</dependency>
44+
<dependency>
45+
<groupId>org.ow2.asm</groupId>
46+
<artifactId>asm-commons</artifactId>
47+
<version>9.9.1</version>
48+
</dependency>
49+
<dependency>
50+
<groupId>org.ow2.asm</groupId>
51+
<artifactId>asm-util</artifactId>
52+
<version>9.9.1</version>
2853
</dependency>
2954

3055
<!-- Protobuf runtime for reading/writing proto messages -->

packages/wasm-privacy-coin/src/main/java/com/bitgo/wasm/privacycoin/zcash/ShieldedMerkleTree.java

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ private static ShieldedMerkleTree create(Consumer<WasmBridge> init) {
4545
ok = true;
4646
return new ShieldedMerkleTree(bridge);
4747
} finally {
48-
if (!ok) bridge.close();
48+
if (!ok) bridge.discard();
4949
}
5050
}
5151

@@ -193,11 +193,7 @@ private static void requireU32(long value, String name) {
193193

194194
@Override
195195
public void close() {
196-
try {
197-
bridge.call("drop_tree");
198-
} catch (Exception ignored) {
199-
// Best-effort: drop the in-WASM tree before the instance goes away.
200-
}
196+
// WasmBridge.close() drops the in-WASM tree and returns the Instance to the pool.
201197
bridge.close();
202198
}
203199
}

packages/wasm-privacy-coin/src/main/java/com/bitgo/wasm/privacycoin/zcash/WasmBridge.java

Lines changed: 119 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,19 @@
33
import com.bitgo.wasm.privacycoin.WasmException;
44
import com.bitgo.wasm.privacycoin.proto.Response;
55
import com.bitgo.wasm.privacycoin.proto.WasmError;
6+
import com.dylibso.chicory.compiler.MachineFactoryCompiler;
67
import com.dylibso.chicory.runtime.ExportFunction;
78
import com.dylibso.chicory.runtime.Instance;
9+
import com.dylibso.chicory.runtime.Machine;
810
import com.dylibso.chicory.wasm.Parser;
11+
import com.dylibso.chicory.wasm.WasmModule;
912
import com.google.protobuf.InvalidProtocolBufferException;
1013

1114
import java.io.ByteArrayInputStream;
1215
import java.io.IOException;
1316
import java.io.InputStream;
17+
import java.util.concurrent.ConcurrentLinkedDeque;
18+
import java.util.function.Function;
1419

1520
/**
1621
* Low-level bridge to the shielded-tree WASM instance.
@@ -22,39 +27,119 @@
2227
* Each call writes a proto-encoded request into WASM linear memory, invokes the named
2328
* export, then reads the {@link Response} proto from the LAST_RESULT buffer.
2429
*
30+
* <p><b>Performance:</b> The parsed {@link WasmModule}, JIT-compiled machine factory, and
31+
* instantiated {@link Instance} objects are all cached/pooled. The module is parsed once per
32+
* JVM (it is immutable and safe to share). The WASM functions are JIT-compiled to JVM bytecode
33+
* once via {@link MachineFactoryCompiler} — this replaces Chicory's default interpreter with
34+
* native-speed bytecode execution. Instances are pooled: on {@link #close()}, the in-WASM tree
35+
* is dropped via the {@code drop_tree} export and the Instance is returned to a pool for reuse
36+
* by the next {@code WasmBridge}.
37+
*
2538
* <p><b>Thread safety:</b> Not thread-safe. One bridge per {@link ShieldedMerkleTree}.
39+
* The cached module and instance pool are safe for concurrent access.
2640
*/
2741
final class WasmBridge implements AutoCloseable {
2842

43+
/**
44+
* Lazily-parsed, JVM-wide cached WASM module. {@link WasmModule} is immutable after
45+
* construction, so it is safe to share across threads and to build multiple independent
46+
* {@link Instance}s from it.
47+
*/
48+
private static volatile WasmModule cachedModule;
49+
50+
/**
51+
* JIT-compiled machine factory cached alongside the module. The {@link MachineFactoryCompiler}
52+
* translates WASM functions into JVM bytecode at load time; the resulting factory is reused
53+
* by every {@link Instance}, so the compilation cost is paid once per JVM. This replaces
54+
* Chicory's default interpreter with native-speed bytecode execution.
55+
*/
56+
private static volatile Function<Instance, Machine> cachedMachineFactory;
57+
58+
/**
59+
* Pool of previously-used {@link Instance} objects whose in-WASM tree has been dropped.
60+
* Reusing an Instance avoids the instantiation cost; the {@code from_frontier} /
61+
* {@code from_state} export re-initializes the tree state in the existing linear memory.
62+
*/
63+
private static final ConcurrentLinkedDeque<Instance> instancePool = new ConcurrentLinkedDeque<>();
64+
2965
private final Instance instance;
3066
private final ExportFunction fnAlloc;
3167
private final ExportFunction fnDealloc;
3268
private final ExportFunction fnResultPtr;
3369
private final ExportFunction fnResultLen;
3470

3571
WasmBridge() {
36-
byte[] wasmBytes;
72+
Instance inst = instancePool.pollFirst();
73+
if (inst == null) {
74+
try {
75+
inst = Instance.builder(loadModule())
76+
.withMachineFactory(loadMachineFactory())
77+
.build();
78+
} catch (Exception e) {
79+
throw new IllegalStateException("Failed to instantiate WASM module", e);
80+
}
81+
}
82+
this.instance = inst;
83+
84+
this.fnAlloc = instance.export("alloc");
85+
this.fnDealloc = instance.export("dealloc");
86+
this.fnResultPtr = instance.export("last_result_ptr");
87+
this.fnResultLen = instance.export("last_result_len");
88+
}
89+
90+
/**
91+
* Returns the cached parsed module, parsing it on first use. Subsequent calls reuse
92+
* the same immutable {@link WasmModule}, eliminating redundant parse overhead when
93+
* multiple {@link ShieldedMerkleTree} instances are created in the same JVM (e.g.
94+
* in tests or when re-initializing after a reorg).
95+
*/
96+
private static WasmModule loadModule() {
97+
WasmModule module = cachedModule;
98+
if (module != null) {
99+
return module;
100+
}
101+
synchronized (WasmBridge.class) {
102+
module = cachedModule;
103+
if (module == null) {
104+
byte[] wasmBytes = readWasmBytes();
105+
module = Parser.parse(new ByteArrayInputStream(wasmBytes));
106+
cachedModule = module;
107+
}
108+
return module;
109+
}
110+
}
111+
112+
/**
113+
* Returns the cached JIT-compiled machine factory, compiling it on first use. The
114+
* {@link MachineFactoryCompiler} translates all WASM functions to JVM bytecode once;
115+
* the resulting {@link Machine} is then used by every {@link Instance} built from
116+
* the same module, providing native-speed execution instead of interpretation.
117+
*/
118+
private static Function<Instance, Machine> loadMachineFactory() {
119+
Function<Instance, Machine> factory = cachedMachineFactory;
120+
if (factory != null) {
121+
return factory;
122+
}
123+
synchronized (WasmBridge.class) {
124+
factory = cachedMachineFactory;
125+
if (factory == null) {
126+
factory = MachineFactoryCompiler.compile(loadModule());
127+
cachedMachineFactory = factory;
128+
}
129+
return factory;
130+
}
131+
}
132+
133+
private static byte[] readWasmBytes() {
37134
try (InputStream is = WasmBridge.class.getResourceAsStream("/wasm/privacy_coin.wasm")) {
38135
if (is == null) {
39136
throw new IllegalStateException(
40137
"WASM binary not found on classpath: /wasm/privacy_coin.wasm");
41138
}
42-
wasmBytes = is.readAllBytes();
139+
return is.readAllBytes();
43140
} catch (IOException e) {
44141
throw new IllegalStateException("Failed to load WASM binary", e);
45142
}
46-
47-
try {
48-
var module = Parser.parse(new ByteArrayInputStream(wasmBytes));
49-
this.instance = Instance.builder(module).build();
50-
} catch (Exception e) {
51-
throw new IllegalStateException("Failed to instantiate WASM module", e);
52-
}
53-
54-
this.fnAlloc = instance.export("alloc");
55-
this.fnDealloc = instance.export("dealloc");
56-
this.fnResultPtr = instance.export("last_result_ptr");
57-
this.fnResultLen = instance.export("last_result_len");
58143
}
59144

60145
private final class WasmBuffer implements AutoCloseable {
@@ -114,8 +199,27 @@ static WasmException toWasmException(WasmError error) {
114199
return new WasmException(error.getCode(), error.getMessage());
115200
}
116201

202+
/**
203+
* Drops the in-WASM tree and returns the {@link Instance} to the pool for reuse.
204+
* If {@code drop_tree} fails (e.g. the tree was never initialized), the Instance is
205+
* still pooled — the next {@code from_frontier} / {@code from_state} call will
206+
* re-initialize it regardless.
207+
*/
117208
@Override
118209
public void close() {
119-
// Chicory Instance has no explicit close method; nothing to do here.
210+
try {
211+
instance.export("drop_tree").apply();
212+
} catch (Exception ignored) {
213+
// Best-effort cleanup; the instance is still reusable.
214+
}
215+
instancePool.addFirst(instance);
216+
}
217+
218+
/**
219+
* Discards this bridge without returning the {@link Instance} to the pool. Use this
220+
* when initialization failed and the WASM linear memory may be in an inconsistent state.
221+
*/
222+
void discard() {
223+
// Instance is simply abandoned; Chicory has no explicit close.
120224
}
121225
}

0 commit comments

Comments
 (0)