Skip to content

Commit 3fc91d4

Browse files
committed
Add memory tester
1 parent c25e432 commit 3fc91d4

1 file changed

Lines changed: 234 additions & 0 deletions

File tree

Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
package kr.go.demo;
2+
3+
import com.devfive.vespera.bridge.VesperaBridge;
4+
import com.sun.management.ThreadMXBean;
5+
import java.io.ByteArrayInputStream;
6+
import java.io.IOException;
7+
import java.io.OutputStream;
8+
import java.lang.management.ManagementFactory;
9+
import java.nio.ByteBuffer;
10+
import java.util.Map;
11+
import java.util.concurrent.CompletableFuture;
12+
import java.util.concurrent.ExecutionException;
13+
import java.util.concurrent.TimeUnit;
14+
import java.util.concurrent.TimeoutException;
15+
import org.junit.jupiter.api.Assumptions;
16+
import org.junit.jupiter.api.BeforeAll;
17+
import org.junit.jupiter.api.Test;
18+
import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
19+
20+
/**
21+
* E2E JNI <strong>allocation</strong> benchmark gated behind
22+
* {@code -Dvespera.bench=true} — companion to {@link SmallRequestLatencyBenchTest}.
23+
*
24+
* <p>Measures <strong>JVM bytes allocated per dispatch</strong> on the calling
25+
* thread for each of the five dispatch modes, using
26+
* {@link com.sun.management.ThreadMXBean#getThreadAllocatedBytes(long)}.
27+
* Quantifies the memory dimension that the recently-landed streaming
28+
* chunk-buffer TLS pooling targets.
29+
*
30+
* <h2>Why calling-thread measurement captures the pooling win</h2>
31+
*
32+
* <p>The streaming JNI entries
33+
* ({@code Java_..._dispatchFullStreamingWithHeader} and friends in
34+
* {@code crates/vespera_jni/src/jni_impl.rs}) allocate the Java byte[] chunk
35+
* buffers via {@code env.new_byte_array(...)} on the <em>JNI entry thread</em>
36+
* — i.e. the calling thread. Same for {@code set_region} / {@code get_region}
37+
* on those arrays. Before TLS pooling those landed as fresh JVM allocations
38+
* per dispatch; after pooling the same {@code GlobalRef<JByteArray>} is
39+
* reused across calls, so the calling-thread allocation count drops to
40+
* effectively the request/response wire bytes plus a few small Java objects.
41+
*
42+
* <h2>Async caveat (honest)</h2>
43+
*
44+
* <p>For {@code async_completable_future}, the {@code CompletableFuture}
45+
* completion happens on a Rust Tokio worker thread (a daemon-attached
46+
* cached worker), not the calling thread. This measurement therefore
47+
* captures only what the <em>caller pays</em>: encoding the request,
48+
* constructing the future, and {@code future.get()}-side allocations.
49+
* Completion-side allocations on the daemon thread are not visible here
50+
* and would require per-thread {@code getThreadAllocatedBytes} on the
51+
* worker, which we don't observe by design.
52+
*
53+
* <h2>Protocol</h2>
54+
*
55+
* <ul>
56+
* <li>{@code WARMUP=5_000} iterations to stabilize JIT / inlining /
57+
* TLS-pool fill.
58+
* <li>{@code MEASURE=20_000} iterations; bytes/op =
59+
* {@code (allocAfter - allocBefore) / MEASURE}.
60+
* <li>Single-threaded loop, pinned to one calling thread.
61+
* <li>Loop body keeps no per-iteration objects in Java besides what the
62+
* dispatch helpers themselves create — the measurement-harness's own
63+
* per-op allocation is intentionally zero (a {@code long} blackhole
64+
* accumulator only).
65+
* </ul>
66+
*
67+
* <h2>Output</h2>
68+
*
69+
* <p>One line per mode (parseable, same style as {@code VESPERA_BENCH}):
70+
* <pre>VESPERA_ALLOC &lt;mode&gt; bytes_per_op=&lt;N&gt;</pre>
71+
*
72+
* <p>Assertion: weak sanity only ({@code bytes_per_op &gt;= 0}). This is a
73+
* measurement tool, not a pass/fail gate — exact numbers are
74+
* machine/JDK-dependent.
75+
*/
76+
@EnabledIfSystemProperty(named = "vespera.bench", matches = "true")
77+
class AllocationBenchTest {
78+
79+
private static final int WARMUP = 5_000;
80+
private static final int MEASURE = 20_000;
81+
private static final Map<String, String> HEADERS = Map.of("accept", "application/json");
82+
83+
@BeforeAll
84+
static void setUp() {
85+
VesperaBridge.init("rust_jni_demo");
86+
}
87+
88+
private static final class CountingOutputStream extends OutputStream {
89+
long count;
90+
91+
@Override
92+
public void write(int b) {
93+
count++;
94+
}
95+
96+
@Override
97+
public void write(byte[] b, int off, int len) {
98+
count += len;
99+
}
100+
}
101+
102+
// --- Mode implementations: kept byte-for-byte equivalent to
103+
// SmallRequestLatencyBenchTest so the latency and allocation
104+
// numbers describe the same code path. ---
105+
106+
private static int syncOnce() {
107+
byte[] wire = VesperaBridge.encodeRequest(null, "GET", "/health", null, HEADERS, null);
108+
return VesperaBridge.decodeResponse(VesperaBridge.dispatchBytes(wire)).status();
109+
}
110+
111+
private static int directOnce() {
112+
ByteBuffer resp =
113+
VesperaBridge.dispatchDirectPooled(null, "GET", "/health", null, HEADERS, null, true);
114+
byte[] out = new byte[resp.remaining()];
115+
resp.get(out);
116+
return VesperaBridge.decodeResponse(out).status();
117+
}
118+
119+
private static int streamingOnce() throws IOException {
120+
byte[] wireHeader = VesperaBridge.encodeRequestHeader("GET", "/health", null, HEADERS);
121+
CountingOutputStream sink = new CountingOutputStream();
122+
int[] status = new int[1];
123+
VesperaBridge.dispatchFullStreamingWithHeader(
124+
wireHeader,
125+
headerBytes -> status[0] = VesperaBridge.decodeResponse(headerBytes).status(),
126+
new ByteArrayInputStream(new byte[0]),
127+
sink);
128+
return status[0];
129+
}
130+
131+
private static int asyncOnce() {
132+
byte[] wire = VesperaBridge.encodeRequest(null, "GET", "/health", null, HEADERS, null);
133+
CompletableFuture<byte[]> future = new CompletableFuture<>();
134+
VesperaBridge.dispatchAsync(future, wire);
135+
try {
136+
byte[] resp = future.get(30, TimeUnit.SECONDS);
137+
return VesperaBridge.decodeResponse(resp).status();
138+
} catch (InterruptedException | ExecutionException | TimeoutException e) {
139+
throw new RuntimeException(e);
140+
}
141+
}
142+
143+
private static int responseStreamingOnce() {
144+
byte[] wire = VesperaBridge.encodeRequest(null, "GET", "/health", null, HEADERS, null);
145+
CountingOutputStream sink = new CountingOutputStream();
146+
int[] status = new int[1];
147+
VesperaBridge.dispatchStreamingWithHeader(
148+
wire,
149+
headerBytes -> status[0] = VesperaBridge.decodeResponse(headerBytes).status(),
150+
sink);
151+
return status[0];
152+
}
153+
154+
private interface Op {
155+
int run() throws IOException;
156+
}
157+
158+
/**
159+
* Measure bytes allocated by the calling thread across MEASURE
160+
* iterations. Returns bytes/op (integer). The loop body contains no
161+
* Java allocations besides the {@code long} blackhole and what the
162+
* dispatch helpers themselves do — so the per-op number describes the
163+
* dispatch path's calling-thread allocation footprint.
164+
*/
165+
private static long measureAlloc(String mode, Op op, ThreadMXBean tmx) throws IOException {
166+
long tid = Thread.currentThread().getId();
167+
168+
// Warmup — let JIT settle, TLS pools fill, classes load.
169+
for (int i = 0; i < WARMUP; i++) {
170+
if (op.run() != 200) {
171+
throw new IllegalStateException(mode + " warmup non-200");
172+
}
173+
}
174+
175+
long blackhole = 0;
176+
long allocBefore = tmx.getThreadAllocatedBytes(tid);
177+
for (int i = 0; i < MEASURE; i++) {
178+
blackhole += op.run();
179+
}
180+
long allocAfter = tmx.getThreadAllocatedBytes(tid);
181+
182+
long delta = allocAfter - allocBefore;
183+
long bytesPerOp = delta / MEASURE;
184+
185+
System.out.printf(
186+
"VESPERA_ALLOC %s bytes_per_op=%d (total_delta=%d iters=%d blackhole=%d)%n",
187+
mode, bytesPerOp, delta, MEASURE, blackhole);
188+
189+
if (bytesPerOp < 0) {
190+
throw new AssertionError(
191+
mode + " bytes_per_op<0 (delta=" + delta + " iters=" + MEASURE + ")");
192+
}
193+
return bytesPerOp;
194+
}
195+
196+
@Test
197+
void allocationPerDispatchByMode() throws IOException {
198+
java.lang.management.ThreadMXBean base = ManagementFactory.getThreadMXBean();
199+
Assumptions.assumeTrue(
200+
base instanceof ThreadMXBean,
201+
"platform ThreadMXBean is not com.sun.management.ThreadMXBean — non-HotSpot JVM?");
202+
ThreadMXBean tmx = (ThreadMXBean) base;
203+
Assumptions.assumeTrue(
204+
tmx.isThreadAllocatedMemorySupported(),
205+
"ThreadMXBean.isThreadAllocatedMemorySupported()==false on this JVM");
206+
if (!tmx.isThreadAllocatedMemoryEnabled()) {
207+
tmx.setThreadAllocatedMemoryEnabled(true);
208+
}
209+
210+
long sync = measureAlloc("sync_dispatch_bytes", AllocationBenchTest::syncOnce, tmx);
211+
long direct = measureAlloc("direct_pooled", AllocationBenchTest::directOnce, tmx);
212+
long respStreaming =
213+
measureAlloc(
214+
"response_streaming_only",
215+
AllocationBenchTest::responseStreamingOnce,
216+
tmx);
217+
long streaming =
218+
measureAlloc(
219+
"bidirectional_streaming",
220+
AllocationBenchTest::streamingOnce,
221+
tmx);
222+
long async =
223+
measureAlloc(
224+
"async_completable_future",
225+
AllocationBenchTest::asyncOnce,
226+
tmx);
227+
228+
System.out.printf(
229+
"VESPERA_ALLOC summary sync=%d direct=%d resp_streaming=%d bidi_streaming=%d"
230+
+ " async_caller_side=%d (async completion lands on a Rust Tokio worker"
231+
+ " thread — not measured here)%n",
232+
sync, direct, respStreaming, streaming, async);
233+
}
234+
}

0 commit comments

Comments
 (0)