Skip to content

Commit c25e432

Browse files
committed
Impl streaming
1 parent 909fa07 commit c25e432

8 files changed

Lines changed: 213 additions & 31 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ Feature flags:
136136
|---------|-----------|------|
137137
| `inprocess` | `vespera::inprocess` (= `vespera_inprocess`) | dispatch, register_app, envelopes |
138138
| `jni` | `vespera::jni` (= `vespera_jni`) + implies `inprocess` | RUNTIME, jni_app!, JNI symbol |
139+
| `mimalloc` | (with `jni`) mimalloc as the cdylib's `#[global_allocator]` | measured -15~19% on sync/direct dispatch vs Windows HeapAlloc |
139140

140141
## JNI ARCHITECTURE
141142

Cargo.lock

Lines changed: 19 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/vespera/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ default = [
2222
cron = ["dep:tokio-cron-scheduler"]
2323
inprocess = ["dep:vespera_inprocess"]
2424
jni = ["inprocess", "dep:vespera_jni"]
25+
# mimalloc as the cdylib's global allocator (see vespera_jni docs).
26+
# Weak dep syntax: only applies when the `jni` feature enables vespera_jni.
27+
mimalloc = ["vespera_jni?/mimalloc"]
2528
# Runtime validation: `#[derive(Schema)]` additionally emits
2629
# `impl garde::Validate` and the `Validated<T>` extractor is enabled.
2730
# The `garde` crate is bundled internally and never named by user code.

crates/vespera_jni/Cargo.toml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,17 @@ repository.workspace = true
1010
vespera_inprocess = { workspace = true }
1111
jni = "0.22"
1212
tokio = { version = "1", features = ["rt-multi-thread"] }
13+
# Optional high-performance global allocator for the final cdylib.
14+
# Opt-in because #[global_allocator] is process-wide and must be the
15+
# embedding crate's decision.
16+
mimalloc = { version = "0.1", optional = true }
17+
18+
[features]
19+
# Use mimalloc as the global allocator inside the JNI cdylib. The
20+
# default OS allocator (Windows HeapAlloc in particular) is measurably
21+
# slower on the allocation-heavy dispatch paths (input Vec, response
22+
# collect, wire response, streaming chunks).
23+
mimalloc = ["dep:mimalloc"]
1324

1425
[lints]
1526
workspace = true

crates/vespera_jni/src/jni_impl.rs

Lines changed: 152 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use std::{
2-
cell::Cell,
2+
cell::{Cell, RefCell},
33
ffi::c_void,
44
panic::{AssertUnwindSafe, catch_unwind, resume_unwind},
55
ptr,
@@ -39,6 +39,104 @@ static RUNTIME_WORKER_THREADS: std::sync::OnceLock<Option<usize>> = std::sync::O
3939

4040
thread_local! {
4141
static ASYNC_DAEMON_ENV: Cell<*mut jni::sys::JNIEnv> = const { Cell::new(ptr::null_mut()) };
42+
static STREAMING_PULL_BUFFER: RefCell<Option<CachedStreamingChunkBuffer>> = const { RefCell::new(None) };
43+
static STREAMING_PUSH_BUFFER: RefCell<Option<CachedStreamingChunkBuffer>> = const { RefCell::new(None) };
44+
}
45+
46+
type StreamingChunkBuffer = Global<JByteArray<'static>>;
47+
48+
#[derive(Clone, Copy)]
49+
enum StreamingBufferRole {
50+
Pull,
51+
Push,
52+
}
53+
54+
impl StreamingBufferRole {
55+
fn with_cache<R>(
56+
self,
57+
callback: impl FnOnce(&RefCell<Option<CachedStreamingChunkBuffer>>) -> R,
58+
) -> R {
59+
match self {
60+
Self::Pull => STREAMING_PULL_BUFFER.with(callback),
61+
Self::Push => STREAMING_PUSH_BUFFER.with(callback),
62+
}
63+
}
64+
}
65+
66+
struct CachedStreamingChunkBuffer {
67+
size: usize,
68+
array: StreamingChunkBuffer,
69+
checked_out: bool,
70+
}
71+
72+
// Released explicitly only after the streaming future returns normally. If a
73+
// panic unwinds through a bidirectional dispatch while the request producer may
74+
// still be in `InputStream.read`, the cache stays checked out and future
75+
// dispatches allocate fresh buffers instead of aliasing the Java array.
76+
struct StreamingChunkBufferLease {
77+
role: StreamingBufferRole,
78+
}
79+
80+
impl StreamingChunkBufferLease {
81+
const fn new(role: StreamingBufferRole) -> Self {
82+
Self { role }
83+
}
84+
85+
fn mark_reusable(self) {
86+
self.role.with_cache(|cache| {
87+
if let Some(cached) = cache.borrow_mut().as_mut() {
88+
cached.checked_out = false;
89+
}
90+
});
91+
}
92+
}
93+
94+
fn new_streaming_chunk_buffer(
95+
env: &mut jni::Env<'_>,
96+
size: usize,
97+
) -> jni::errors::Result<StreamingChunkBuffer> {
98+
let local = env.new_byte_array(size)?;
99+
env.new_global_ref(&local)
100+
}
101+
102+
fn checkout_streaming_chunk_buffer(
103+
env: &mut jni::Env<'_>,
104+
role: StreamingBufferRole,
105+
) -> jni::errors::Result<(StreamingChunkBuffer, Option<StreamingChunkBufferLease>)> {
106+
let size = streaming_chunk_size();
107+
role.with_cache(|cache| {
108+
let mut slot = cache.borrow_mut();
109+
let replace_cached = slot
110+
.as_ref()
111+
.is_none_or(|cached| cached.size != size && !cached.checked_out);
112+
113+
if replace_cached {
114+
*slot = Some(CachedStreamingChunkBuffer {
115+
size,
116+
array: new_streaming_chunk_buffer(env, size)?,
117+
checked_out: false,
118+
});
119+
}
120+
121+
let Some(cached) = slot.as_mut() else {
122+
return Ok((new_streaming_chunk_buffer(env, size)?, None));
123+
};
124+
125+
if cached.size != size || cached.checked_out {
126+
return Ok((new_streaming_chunk_buffer(env, size)?, None));
127+
}
128+
129+
let cached_array: &JByteArray<'static> = cached.array.as_ref();
130+
let dispatch_array = env.new_global_ref(cached_array)?;
131+
cached.checked_out = true;
132+
Ok((dispatch_array, Some(StreamingChunkBufferLease::new(role))))
133+
})
134+
}
135+
136+
fn mark_streaming_buffer_reusable(lease: Option<StreamingChunkBufferLease>) {
137+
if let Some(lease) = lease {
138+
lease.mark_reusable();
139+
}
42140
}
43141

44142
fn attach_async_daemon_thread(jvm: &jni::JavaVM) -> jni::errors::Result<*mut jni::sys::JNIEnv> {
@@ -509,18 +607,23 @@ pub extern "system" fn Java_com_devfive_vespera_bridge_VesperaBridge_dispatchStr
509607
let stream_global: Global<JObject<'static>> = env.new_global_ref(&output_stream)?;
510608
let jvm = env.get_java_vm()?;
511609

512-
// One reusable Java chunk buffer for the whole stream.
513-
let push_buf_local = env.new_byte_array(streaming_chunk_size())?;
514-
let push_buf: Global<jni::objects::JByteArray<'static>> =
515-
env.new_global_ref(&push_buf_local)?;
610+
// One per-thread reusable Java chunk buffer for the whole stream.
611+
let (push_buf, push_buf_lease) =
612+
checkout_streaming_chunk_buffer(env, StreamingBufferRole::Push)?;
516613

517614
let header_bytes = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
518615
RUNTIME.block_on(vespera_inprocess::dispatch_streaming_async(
519616
input,
520617
make_push_closure(jvm, stream_global, push_buf),
521618
))
522-
}))
523-
.unwrap_or_else(|_| vespera_inprocess::error_wire(500, "panic in Rust engine"));
619+
}));
620+
let header_bytes = header_bytes.map_or_else(
621+
|_| vespera_inprocess::error_wire(500, "panic in Rust engine"),
622+
|header_bytes| {
623+
mark_streaming_buffer_reusable(push_buf_lease);
624+
header_bytes
625+
},
626+
);
524627

525628
Ok(env.byte_array_from_slice(&header_bytes)?.into())
526629
})
@@ -576,15 +679,18 @@ pub extern "system" fn Java_com_devfive_vespera_bridge_VesperaBridge_dispatchFul
576679
let output_global: Global<JObject<'static>> = env.new_global_ref(&output_stream)?;
577680
let jvm = env.get_java_vm()?;
578681

579-
let chunk_size = streaming_chunk_size();
580682
// Pull and push run concurrently on different threads, so each
581-
// direction owns its own global-ref'd buffer.
582-
let pull_buf_local = env.new_byte_array(chunk_size)?;
583-
let pull_buf: Global<jni::objects::JByteArray<'static>> =
584-
env.new_global_ref(&pull_buf_local)?;
585-
let push_buf_local = env.new_byte_array(chunk_size)?;
586-
let push_buf: Global<jni::objects::JByteArray<'static>> =
587-
env.new_global_ref(&push_buf_local)?;
683+
// direction checks out its own per-thread cached buffer.
684+
let (pull_buf, pull_buf_lease) =
685+
checkout_streaming_chunk_buffer(env, StreamingBufferRole::Pull)?;
686+
let (push_buf, push_buf_lease) =
687+
match checkout_streaming_chunk_buffer(env, StreamingBufferRole::Push) {
688+
Ok(checked_out) => checked_out,
689+
Err(err) => {
690+
mark_streaming_buffer_reusable(pull_buf_lease);
691+
return Err(err);
692+
}
693+
};
588694

589695
// Closures capture clones of the JavaVM and Globals;
590696
// both types are Send+Sync.
@@ -604,8 +710,15 @@ pub extern "system" fn Java_com_devfive_vespera_bridge_VesperaBridge_dispatchFul
604710
// Runs on the tokio worker driving the dispatch.
605711
make_push_closure(push_jvm, push_global, push_buf),
606712
))
607-
}))
608-
.unwrap_or_else(|_| vespera_inprocess::error_wire(500, "panic in Rust engine"));
713+
}));
714+
let header_response = header_response.map_or_else(
715+
|_| vespera_inprocess::error_wire(500, "panic in Rust engine"),
716+
|header_response| {
717+
mark_streaming_buffer_reusable(pull_buf_lease);
718+
mark_streaming_buffer_reusable(push_buf_lease);
719+
header_response
720+
},
721+
);
609722

610723
Ok(env.byte_array_from_slice(&header_response)?.into())
611724
})
@@ -649,10 +762,9 @@ pub extern "system" fn Java_com_devfive_vespera_bridge_VesperaBridge_dispatchStr
649762
let stream_global: Global<JObject<'static>> = env.new_global_ref(&output_stream)?;
650763
let jvm = env.get_java_vm()?;
651764

652-
// One reusable Java chunk buffer for the whole stream.
653-
let push_buf_local = env.new_byte_array(streaming_chunk_size())?;
654-
let push_buf: Global<jni::objects::JByteArray<'static>> =
655-
env.new_global_ref(&push_buf_local)?;
765+
// One per-thread reusable Java chunk buffer for the whole stream.
766+
let (push_buf, push_buf_lease) =
767+
checkout_streaming_chunk_buffer(env, StreamingBufferRole::Push)?;
656768

657769
// Panic safety: catch_unwind absorbs Rust panics so the
658770
// JVM never sees an unwinding stack across the FFI
@@ -664,7 +776,7 @@ pub extern "system" fn Java_com_devfive_vespera_bridge_VesperaBridge_dispatchStr
664776
// rare — e.g. wire parse), the Java side will see a
665777
// dangling controller; document that follow-up callers
666778
// should set a timeout.
667-
let _panic_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
779+
let panic_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
668780
let header_for_cb = header_global;
669781
let jvm_for_cb = jvm.clone();
670782
let push = make_push_closure(jvm, stream_global, push_buf);
@@ -680,6 +792,9 @@ pub extern "system" fn Java_com_devfive_vespera_bridge_VesperaBridge_dispatchStr
680792
push,
681793
));
682794
}));
795+
if panic_result.is_ok() {
796+
mark_streaming_buffer_reusable(push_buf_lease);
797+
}
683798

684799
Ok(())
685800
});
@@ -718,14 +833,17 @@ pub extern "system" fn Java_com_devfive_vespera_bridge_VesperaBridge_dispatchFul
718833
let output_global: Global<JObject<'static>> = env.new_global_ref(&output_stream)?;
719834
let jvm = env.get_java_vm()?;
720835

721-
let chunk_size = streaming_chunk_size();
722836
// Pull and push run concurrently on different threads.
723-
let pull_buf_local = env.new_byte_array(chunk_size)?;
724-
let pull_buf: Global<jni::objects::JByteArray<'static>> =
725-
env.new_global_ref(&pull_buf_local)?;
726-
let push_buf_local = env.new_byte_array(chunk_size)?;
727-
let push_buf: Global<jni::objects::JByteArray<'static>> =
728-
env.new_global_ref(&push_buf_local)?;
837+
let (pull_buf, pull_buf_lease) =
838+
checkout_streaming_chunk_buffer(env, StreamingBufferRole::Pull)?;
839+
let (push_buf, push_buf_lease) =
840+
match checkout_streaming_chunk_buffer(env, StreamingBufferRole::Push) {
841+
Ok(checked_out) => checked_out,
842+
Err(err) => {
843+
mark_streaming_buffer_reusable(pull_buf_lease);
844+
return Err(err);
845+
}
846+
};
729847

730848
let pull_jvm = jvm.clone();
731849
let pull_global = input_global;
@@ -737,7 +855,7 @@ pub extern "system" fn Java_com_devfive_vespera_bridge_VesperaBridge_dispatchFul
737855
// See dispatchStreamingWithHeader: panic absorbed silently,
738856
// recovery semantics depend on which side of the header
739857
// callback the panic landed.
740-
let _panic_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
858+
let panic_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
741859
RUNTIME.block_on(
742860
vespera_inprocess::dispatch_bidirectional_streaming_with_header(
743861
header_input,
@@ -753,6 +871,10 @@ pub extern "system" fn Java_com_devfive_vespera_bridge_VesperaBridge_dispatchFul
753871
),
754872
);
755873
}));
874+
if panic_result.is_ok() {
875+
mark_streaming_buffer_reusable(pull_buf_lease);
876+
mark_streaming_buffer_reusable(push_buf_lease);
877+
}
756878

757879
Ok(())
758880
});

crates/vespera_jni/src/lib.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,18 @@
1717
pub use jni;
1818
pub use vespera_inprocess;
1919

20+
/// mimalloc as the process-wide allocator (feature `mimalloc`).
21+
///
22+
/// The JNI dispatch hot path allocates several times per call (input
23+
/// buffer, request body, response collection, wire response); the OS
24+
/// default allocator — Windows `HeapAlloc` in particular — is
25+
/// measurably slower than mimalloc on this pattern. Opt-in because a
26+
/// `#[global_allocator]` is process-wide and belongs to the final
27+
/// cdylib's build decision.
28+
#[cfg(feature = "mimalloc")]
29+
#[global_allocator]
30+
static GLOBAL_ALLOC: mimalloc::MiMalloc = mimalloc::MiMalloc;
31+
2032
/// Generate the `JNI_OnLoad` export that registers a single (default)
2133
/// app. Backward-compatible sugar for the single-app case; new code
2234
/// targeting multiple apps should use [`jni_apps!`] directly.

examples/rust-jni-demo/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ name = "rust-jni-demo"
1212
path = "src/main.rs"
1313

1414
[dependencies]
15-
vespera = { path = "../../crates/vespera", features = ["jni"] }
15+
vespera = { path = "../../crates/vespera", features = ["jni", "mimalloc"] }
1616
serde = { version = "1", features = ["derive"] }
1717
serde_json = "1"
1818
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net"] }

libs/vespera-bridge/docs/jni-before-after-2026-06-11.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,3 +202,17 @@ Protocol: same 3 JVM invocations; run 1 discarded as cold; retained value is the
202202
| `async_completable_future` | 22,756 | 21,474 | **1,282 ns/op faster** (-5.6%) |
203203

204204
The measured win is above the **100 ns/op** keep gate. Follow-up review found that the daemon-attached Tokio worker must explicitly clear pending Java exceptions after every completion callback because it no longer gets jni-rs scoped-detach cleanup. The implementation now clears pending exceptions after callback success, callback error, and callback unwind while preserving the callback return/error. A targeted regression guard, `AsyncDispatchExceptionHygieneTest.throwingFutureCompleteDoesNotPoisonNextAsyncCompletion`, first forces `CompletableFuture.complete()` to throw and then asserts a normal `dispatchAsync` still completes with status 200; it failed before the cleanup with a timeout and passes after the fix. A single post-fix sanity bench run measured `async_completable_future` at **16,107 ns/op** (informational only; not a replacement for the 3-JVM gate). Verification also passed `cargo clippy --workspace --all-targets -- -D warnings`, `cargo fmt --check`, `cargo test --workspace`, `cargo build -p rust-jni-demo --release`, and the full `:demo-app:test` Gradle suite (including `StreamingClosureStressTest` and the new hygiene guard).
205+
206+
## Addendum (same day, later session): allocator + streaming buffer pooling
207+
208+
Two further changes, paired same-session benches (GET /health, 100k iters, mimalloc build):
209+
210+
| mode | default alloc | + mimalloc | + chunk-buffer pooling | total delta |
211+
|---|---:|---:|---:|---|
212+
| sync_dispatch_bytes | 2,870 | 2,314 | 2,322 | **-19%** |
213+
| direct_pooled | 2,376 | 2,017 | 2,000 | **-16%** |
214+
| response_streaming | 18,617* | 17,610 | **2,434** | **-87%** |
215+
| bidirectional_streaming | 37,543* | 32,326 | **2,605** | **-93%** |
216+
| async_completable_future | 22,038 | 19,468 | ~15,000 | **-32%** |
217+
218+
\* with the 256 KiB chunk default: each streaming dispatch allocated+zeroed fresh 256 KiB Java arrays (bidi: two), costing ~10µs each — this addendum's TLS pooling (per-OS-thread cached Global<JByteArray>, fresh-alloc fallback when leased/reentrant) removes that per-dispatch cost entirely while keeping the 256 KiB throughput benefit for large transfers. mimalloc is opt-in via the vespera `mimalloc` cargo feature.

0 commit comments

Comments
 (0)