Skip to content

Commit 1fd21e8

Browse files
various slop before making the point of the situation
1 parent aa1f7d5 commit 1fd21e8

7 files changed

Lines changed: 300 additions & 134 deletions

File tree

sdk-core/build.gradle.kts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,24 @@ sourceSets["test"].compileClasspath += java23.output
103103

104104
sourceSets["test"].runtimeClasspath += java23.output
105105

106+
// ---------------------------------------------------------------------------
107+
// JMH benchmarks (manual wiring; run with `./gradlew :sdk-core:jmh`). Mirrors the `test` setup so
108+
// benchmarks drive the FFM (java23) state machine directly and reuse the test `ProtoUtils` helpers
109+
// (and the native lib, which ships in `main`'s resources). Add new benchmarks under src/jmh/java.
110+
// ---------------------------------------------------------------------------
111+
sourceSets.create("jmh") { java.setSrcDirs(listOf("src/jmh/java")) }
112+
113+
sourceSets["jmh"].compileClasspath +=
114+
sourceSets["main"].output + java23.output + sourceSets["test"].output
115+
116+
sourceSets["jmh"].runtimeClasspath +=
117+
sourceSets["main"].output + java23.output + sourceSets["test"].output
118+
119+
// Give the benchmarks everything the tests have (ProtoUtils' deps: protobuf, sdk-common, …).
120+
configurations["jmhImplementation"].extendsFrom(configurations["testImplementation"])
121+
122+
configurations["jmhRuntimeOnly"].extendsFrom(configurations["testRuntimeOnly"])
123+
106124
// jextract bindings are generated into the java23 (FFM) source set only — they
107125
// reference java.lang.foreign, which is not available at the Java 17 base level.
108126
jextract.libraries {
@@ -224,6 +242,11 @@ dependencies {
224242
testImplementation(libs.vertx.junit5)
225243
testImplementation(libs.vertx.kotlin.coroutines)
226244
testRuntimeOnly(libs.junit.platform.launcher)
245+
246+
// JMH benchmarks (src/jmh). Manual wiring — no champeau plugin (keeps us off its Gradle-9
247+
// compat).
248+
"jmhImplementation"("org.openjdk.jmh:jmh-core:1.37")
249+
"jmhAnnotationProcessor"("org.openjdk.jmh:jmh-generator-annprocess:1.37")
227250
}
228251

229252
// ---------------------------------------------------------------------------
@@ -327,3 +350,22 @@ ksp {
327350
)
328351
arg("dev.restate.codegen.disabledClasses", disabledClassesCodegen.joinToString(","))
329352
}
353+
354+
// ---------------------------------------------------------------------------
355+
// JMH run task. `./gradlew :sdk-core:jmh` runs all benchmarks; pass JMH CLI args via
356+
// `-PjmhArgs="SysCall -f 1 -wi 3 -i 5 -prof gc"`. Runs on the JDK 25 toolchain (FFM enabled).
357+
// ---------------------------------------------------------------------------
358+
val jmh by
359+
tasks.registering(JavaExec::class) {
360+
group = "benchmark"
361+
description = "Run JMH benchmarks (FfmStateMachine / shared-core boundary)"
362+
dependsOn("jmhClasses", copyNativeLib)
363+
javaLauncher.set(javaToolchains.launcherFor { languageVersion = JavaLanguageVersion.of(25) })
364+
mainClass.set("org.openjdk.jmh.Main")
365+
classpath = sourceSets["jmh"].runtimeClasspath
366+
// JDK 25 enables FFM by default; silence the native-access warning.
367+
jvmArgs("--enable-native-access=ALL-UNNAMED")
368+
(project.findProperty("jmhArgs") as String?)?.let {
369+
if (it.isNotBlank()) args(it.trim().split(" "))
370+
}
371+
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
// Copyright (c) 2023 - Restate Software, Inc., Restate GmbH
2+
//
3+
// This file is part of the Restate Java SDK,
4+
// which is released under the MIT license.
5+
//
6+
// You can find a copy of the license in file LICENSE in the root
7+
// directory of this repository or package, or at
8+
// https://github.com/restatedev/sdk-java/blob/main/LICENSE
9+
package dev.restate.sdk.core.benchmarks;
10+
11+
import dev.restate.common.Slice;
12+
import dev.restate.common.Target;
13+
import dev.restate.sdk.core.statemachine.JavaStateMachine;
14+
import dev.restate.sdk.core.statemachine.ProtoUtils;
15+
import dev.restate.sdk.core.statemachine.StateMachine;
16+
import dev.restate.sdk.core.statemachine.ffm.FfmStateMachine;
17+
import dev.restate.sdk.endpoint.HeadersAccessor;
18+
import java.util.Map;
19+
import java.util.Random;
20+
import java.util.concurrent.TimeUnit;
21+
import org.openjdk.jmh.annotations.*;
22+
import org.openjdk.jmh.infra.Blackhole;
23+
24+
/**
25+
* Benchmarks the cost of a single {@code sys_call} — argument marshalling (the FFM {@code
26+
* CallArguments} build), the native downcall, the Rust-side decode + {@code VM::sys_call}, and the
27+
* result decode. Parameterized over both state machines ({@code ffmStateMachine}) so the FFM
28+
* boundary can be read against the pure-Java baseline, and over {@code payloadSize} to see how the
29+
* "allocate the payload in Rust (ownership transfer) vs. copy" cost scales with payload size.
30+
*
31+
* <p>{@code sys_call} is stateful — each call appends a command + notification handles to the VM's
32+
* journal — so we prime a <b>fresh</b> VM per measured op ({@link Level#Invocation}, not measured)
33+
* and do a single {@code sys_call} (draining its buffered output via {@code takeOutput}) in the
34+
* measured body; the VM is discarded each op, bounding journal growth.
35+
*
36+
* <p>Run with {@code ./gradlew :sdk-core:jmh -PjmhArgs="SysCall"} (JDK 25 toolchain, FFM active).
37+
*/
38+
@BenchmarkMode(Mode.AverageTime)
39+
@OutputTimeUnit(TimeUnit.MICROSECONDS)
40+
@Warmup(iterations = 5, time = 1)
41+
@Measurement(iterations = 5, time = 1)
42+
@Fork(1)
43+
@State(Scope.Thread)
44+
public class SysCallBenchmark {
45+
46+
// Immutable inputs, computed once.
47+
private Slice startMessage;
48+
private Slice inputMessage;
49+
private Target target;
50+
private Slice payload;
51+
private HeadersAccessor headersAccessor;
52+
53+
// The primed VM, recreated per measured op.
54+
private StateMachine sm;
55+
56+
@Param({"true", "false"})
57+
boolean ffmStateMachine;
58+
59+
// Payload size in bytes — exercises how much the copy-vs-transfer cost scales with payload size.
60+
@Param({"16", "1024", "65536"})
61+
int payloadSize;
62+
63+
@Setup(Level.Trial)
64+
public void prepareInputs() {
65+
startMessage = ProtoUtils.encodeMessageToSlice(ProtoUtils.startMessage(1).build());
66+
inputMessage = ProtoUtils.encodeMessageToSlice(ProtoUtils.inputCmd("benchmark-input"));
67+
target = Target.service("BenchmarkService", "benchmarkHandler");
68+
// Random payload of the parameterized length; fixed seed so runs are reproducible.
69+
byte[] payloadBytes = new byte[payloadSize];
70+
new Random().nextBytes(payloadBytes);
71+
payload = Slice.wrap(payloadBytes);
72+
headersAccessor =
73+
HeadersAccessor.wrap(
74+
Map.of("content-type", ProtoUtils.serviceProtocolContentTypeHeader(true)));
75+
}
76+
77+
@Setup(Level.Invocation)
78+
public void primeVm() {
79+
sm =
80+
ffmStateMachine
81+
? new FfmStateMachine(headersAccessor)
82+
: new JavaStateMachine(headersAccessor);
83+
sm.notifyInput(startMessage);
84+
sm.notifyInput(inputMessage);
85+
sm.notifyInputClosed();
86+
sm.isReadyToExecute();
87+
sm.input(); // consume the input command -> Processing, ready for sys_call
88+
}
89+
90+
@TearDown(Level.Invocation)
91+
public void freeVm() {
92+
sm.close();
93+
}
94+
95+
@Benchmark
96+
public void sysCall(Blackhole bh) {
97+
bh.consume(sm.call(target, payload, null, null, null, null));
98+
bh.consume(sm.takeOutput());
99+
}
100+
}

sdk-core/src/main/java23/dev/restate/sdk/core/statemachine/ffm/FfmStateMachine.java

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -158,12 +158,15 @@ public Slice takeOutput() {
158158
return Slice.EMPTY;
159159
}
160160
try (Arena arena = Arena.ofConfined()) {
161-
// take_output never fails: it writes a bare Slice (not a BufferResult). Hand the buffer over
162-
// zero-copy (GC-tied): it flows straight into the transport (Netty wraps
163-
// asReadOnlyByteBuffer()) without a copy-out.
161+
// take_output never fails: it writes a bare Slice (not a BufferResult). Copy the buffer out
162+
// into a heap byte[] and free the native buffer immediately (takeSliceBytes). This is the
163+
// safe
164+
// default while we chase a GC-tied-lifetime flake in the bidi path: the zero-copy
165+
// wrapOwnedSlice variant handed the buffer to the async output stream and could be collected
166+
// before the stream consumed it, intermittently dropping a flushed chunk.
164167
MemorySegment out = FfmEncoding.allocateSliceStruct(arena);
165168
SharedCoreNative.vm_take_output(vmHandle, out);
166-
return FfmEncoding.wrapOwnedSlice(out);
169+
return Slice.wrap(FfmEncoding.takeSliceBytes(out));
167170
}
168171
}
169172

sdk-core/src/main/rust/src/lib.rs

Lines changed: 37 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,10 @@ pub struct Slice {
160160
}
161161

162162
impl Slice {
163-
const EMPTY: Slice = Slice { ptr: std::ptr::null(), len: 0 };
163+
const EMPTY: Slice = Slice {
164+
ptr: std::ptr::null(),
165+
len: 0,
166+
};
164167

165168
/// Leak an owned buffer into a `Slice` the caller frees via `free_buffer`.
166169
#[inline]
@@ -169,7 +172,7 @@ impl Slice {
169172
return Self::EMPTY;
170173
}
171174
let len = v.len();
172-
let ptr = Box::into_raw(v.into_boxed_slice()) as *mut u8;
175+
let ptr = Box::into_raw(v.into_boxed_slice()) as *mut u8;
173176
Slice { ptr, len }
174177
}
175178

@@ -188,7 +191,8 @@ impl Slice {
188191
/// Re-take ownership of a transferred-in payload (Java filled an `alloc_buffer` buffer) as
189192
/// `Bytes`, zero-copy. The caller must not touch the buffer after the call.
190193
#[inline]
191-
unsafe fn take(self) -> Bytes { let ptr = self.ptr as *mut u8;
194+
unsafe fn take(self) -> Bytes {
195+
let ptr = self.ptr as *mut u8;
192196
let len = self.len;
193197
if ptr.is_null() || len == 0 {
194198
Bytes::new()
@@ -227,21 +231,20 @@ impl ForeignSlice {
227231
/// Borrow the foreign bytes for the duration of the call (Java keeps ownership and frees them).
228232
#[inline]
229233
unsafe fn as_slice<'a>(self) -> &'a [u8] {
230-
if self.ptr.is_null() || self.len == 0 {
231-
&[]
232-
} else {
233-
slice::from_raw_parts(self.ptr, self.len)
234-
}
234+
if self.ptr.is_null() || self.len == 0 {
235+
&[]
236+
} else {
237+
slice::from_raw_parts(self.ptr, self.len)
235238
}
239+
}
236240

237241
/// Borrow the foreign bytes as str for the duration of the call (Java keeps ownership and frees them).
238242
#[inline]
239243
unsafe fn as_str<'a>(self) -> &'a str {
240244
if self.ptr.is_null() || self.len == 0 {
241245
""
242246
} else {
243-
str::from_utf8_unchecked(
244-
slice::from_raw_parts(self.ptr, self.len))
247+
str::from_utf8_unchecked(slice::from_raw_parts(self.ptr, self.len))
245248
}
246249
}
247250
}
@@ -454,7 +457,12 @@ fn vm_take_output(vm: &mut CoreVM) -> Slice {
454457
#[export_name = "vm_get_response_content_type"]
455458
pub unsafe extern "C" fn _vm_get_response_content_type(handle: *mut VmHandle, out: *mut Slice) {
456459
let h = vm_mut(handle);
457-
write_out(out, vm_get_response_content_type(&mut h.vm).map(Slice::from_string).unwrap_or( Slice::EMPTY));
460+
write_out(
461+
out,
462+
vm_get_response_content_type(&mut h.vm)
463+
.map(Slice::from_string)
464+
.unwrap_or(Slice::EMPTY),
465+
);
458466
}
459467

460468
#[inline]
@@ -727,10 +735,7 @@ pub unsafe extern "C" fn _vm_sys_state_set(
727735
out: *mut EmptyResult,
728736
) {
729737
let h = vm_mut(handle);
730-
write_out(
731-
out,
732-
vm_sys_state_set(&mut h.vm, key.as_str(), value.take()),
733-
);
738+
write_out(out, vm_sys_state_set(&mut h.vm, key.as_str(), value.take()));
734739
}
735740

736741
#[inline]
@@ -872,7 +877,11 @@ fn complete_awakeable(vm: &mut CoreVM, id: &str, value: NonEmptyValue) -> EmptyR
872877
// =========================================================================
873878

874879
#[export_name = "vm_sys_call"]
875-
pub unsafe extern "C" fn _vm_sys_call(handle: *mut VmHandle, args: CallArguments, out: *mut CallResult) {
880+
pub unsafe extern "C" fn _vm_sys_call(
881+
handle: *mut VmHandle,
882+
args: CallArguments,
883+
out: *mut CallResult,
884+
) {
876885
let h = vm_mut(handle);
877886
let target = args.borrow();
878887
let input = args.input.take();
@@ -1368,10 +1377,12 @@ impl CallArguments {
13681377
/// actual `Target` is built by the safe `BorrowedTarget::into_core`.
13691378
#[inline]
13701379
unsafe fn borrow<'a>(&self) -> BorrowedTarget<'a> {
1371-
let opt = |fs: ForeignSlice| (!fs.ptr.is_null()).then(|| fs.as_slice());
1380+
// Strings cross as already-valid UTF-8 (Java encoded them), so borrow them unchecked as
1381+
// `&str`; `headers` is the encoded header-list blob and stays raw bytes.
1382+
let opt = |fs: ForeignSlice| (!fs.ptr.is_null()).then(|| fs.as_str());
13721383
BorrowedTarget {
1373-
service: self.service.as_slice(),
1374-
handler: self.handler.as_slice(),
1384+
service: self.service.as_str(),
1385+
handler: self.handler.as_str(),
13751386
key: opt(self.key),
13761387
idempotency_key: opt(self.idempotency_key),
13771388
scope: opt(self.scope),
@@ -1391,19 +1402,19 @@ struct BorrowedTarget<'a> {
13911402
idempotency_key: Option<&'a str>,
13921403
scope: Option<&'a str>,
13931404
limit_key: Option<&'a str>,
1394-
headers: &'a str,
1405+
headers: &'a [u8],
13951406
}
13961407

13971408
impl BorrowedTarget<'_> {
13981409
#[inline]
13991410
fn into_core(self) -> Target {
14001411
Target {
1401-
service: utf8(self.service).to_owned(),
1402-
handler: utf8(self.handler).to_owned(),
1403-
key: self.key.map(|k| utf8(k).to_owned()),
1404-
idempotency_key: self.idempotency_key.map(|k| utf8(k).to_owned()),
1405-
scope: self.scope.map(|s| utf8(s).to_owned()),
1406-
limit_key: self.limit_key.map(|s| utf8(s).to_owned()),
1412+
service: self.service.to_owned(),
1413+
handler: self.handler.to_owned(),
1414+
key: self.key.map(str::to_owned),
1415+
idempotency_key: self.idempotency_key.map(str::to_owned),
1416+
scope: self.scope.map(str::to_owned),
1417+
limit_key: self.limit_key.map(str::to_owned),
14071418
headers: decode_header_list(self.headers)
14081419
.into_iter()
14091420
.map(|(k, v)| Header {

0 commit comments

Comments
 (0)