-
Notifications
You must be signed in to change notification settings - Fork 0
research concurrency alternatives comparison
Research terrain map for issue #57
answering: once Duckling.parse stops holding the GVL for the duration of
its native call, what dispatches the call so it actually runs
concurrently with the calling Fiber's sibling Fibers, and which dispatch
strategy should this gem ship first?
This document was originally written concurrently with, and provisionally ahead of, the sibling research topic Fiber-Scheduler Mechanism Spike, which has since landed and confirmed the working hypothesis this document depends on:
Releasing the GVL alone (e.g. a raw
rb_thread_call_without_gvlcallback invoked on the calling Fiber's own OS thread) is not sufficient. This gem's Ruby floor is>= 3.2.0(duckling.gemspec#L15), and CI currently tests3.3.6(.github/workflows/main.yml), both below Ruby 3.4.Fiber::Scheduler#blocking_operation_wait— the hook a 3.4+ VM uses to let a scheduler run a GVL-released blocking operation without stalling sibling fibers on the same OS thread — does not exist in the 3.3Fiber::Schedulerhook list and is present in 3.4's. Without that hook, a GVL release on the calling thread doesn't hand control back to the reactor's fiber scheduler — the OS thread is still physically busy running Rust code either way, so sibling Fibers cooperatively scheduled on that same OS thread still don't run. The fix therefore needs to dispatch eachDuckling.parsecall onto a genuine background OS thread (with the GVL released inside the native call on that background thread), not just release the GVL in place.
Fiber-Scheduler Mechanism Spike
went further than this working hypothesis: it found that GVL-release-alone
fails even on Ruby 3.4.5, because rb_thread_call_without_gvl's convenience
wrapper never sets the RB_NOGVL_OFFLOAD_SAFE flag that
blocking_operation_wait's auto-offload requires. Only the combination of
GVL release and a background Thread spawn passed, 11/11 runs across
Ruby 3.3.6 and 3.4.5. This document's comparison of how to dispatch onto
that thread stands as originally written.
ext/duckling/src/lib.rs's parse function
(current implementation)
calls duckling::parse directly on whatever thread invoked the Magnus
method — no GVL release, no thread dispatch. The repo's own
benchmark-ips suite (issue #59)
already captured what that costs under concurrency: 10 Ruby threads calling
Duckling.parse today reach only 10.1% of ideal linear scaling (1661
ops/sec measured vs. 1643.7 ops/sec single-threaded) — see the
concurrency block in
docs/benchmarks/local/0.2.0.json.
That's the GVL doing exactly what it's designed to do: serializing every
call. Whatever dispatch strategy issue #57 lands on needs to actually
unlock concurrency once the GVL is released for the call itself — a
dispatch strategy that still serializes all callers behind one worker
would leave that number close to where it is today.
The same benchmark file gives per-call latency by scenario, which grounds the "is thread-spawn overhead worth worrying about" question below:
| Scenario | µs/call |
|---|---|
empty |
24.1 |
no_match |
213.4 |
short |
678.3 |
medium |
690.5 |
long |
3772.4 |
camping_trip_email (pathological) |
791,063.3 |
| Thread-per-call | Worker-pool | Process isolation | |
|---|---|---|---|
| Spawn cost per call | ~1 OS thread create/teardown (tens of µs, measured below) | ~0 (thread already running); queue push/pop only | Full IPC round-trip + serialization |
| Concurrency ceiling | Bounded only by the OS and the wrapped crate's own thread-safety | Bounded by pool size (1 worker = fully serialized) | Bounded by process count |
| Implementation complexity | Low — one Thread.new { ... }.value per call |
Medium — queue(s), result correlation, worker lifecycle, shutdown | High — process management, wire protocol |
| Panic/exception propagation | Free, via Thread#value re-raise semantics |
Manual — must rescue in the worker loop and repost | Must reconstruct across a serialization boundary |
| Fits issue #57's scope (no throughput optimization) | Yes | Partially — adds complexity in service of a goal that's out of scope | No |
Full analysis: Thread-Per-Call Dispatch, Persistent Worker-Pool Dispatch.
Issue #57 explicitly scopes out "general parse-throughput optimization
beyond removing the reactor-blocking behavior." Process isolation (a
separate worker process, results serialized over a pipe or socket as JSON
or similar) doesn't just fail to help with that non-goal — it actively
works against the reason this gem exists at all. Per AGENTS.md's framing,
duckling wraps duckling via
Magnus "so Ruby code can extract entities ... without running a separate
HTTP service." A local pipe/socket to a sibling process is a smaller
version of the exact cost (serialize request, cross a process boundary,
deserialize response, deserialize Entity results back into Ruby objects)
that embedding a Rust NER engine in-process was chosen specifically to
avoid. Given the benchmark table above shows most real calls complete in
well under a millisecond, adding IPC/serialization overhead of a
comparable or larger magnitude for every call would be a regression
disguised as a fix. Not investigated further here.
Thread-per-call. Rationale:
-
No throughput-optimization goal is in scope for issue #57 — the
acceptance criteria are about not blocking sibling Fibers and preserving
catch_unwindpanic-safety, not about maximizing calls/sec. Thread-per-call satisfies both directly: every call gets its own OS thread, so it can never be head-of-line blocked behind another in-flightDuckling.parsecall the way a single-worker pool would serialize concurrent callers. -
Measured spawn overhead is small relative to real call costs, and
the worst case (a pathological,
out_of_scopeinput likecamping_trip_emailat 791ms) makes spawn overhead a rounding error. Measured locally on this machine (Ruby 3.3.6,Thread.new{}.joinin a tight loop, 2000 iterations): ~70µs/thread average. That's real overhead relative to the fastest scenarios (emptyat 24.1µs,no_matchat 213.4µs) — plausibly doubling or tripling wall-clock latency on those — but negligible againstshort/medium(~680-690µs, roughly 10% overhead) and vanishing againstlong(3.8ms) or the pathological case. Given issue #57's explicit non-goal of throughput optimization, this tradeoff (worse constant-factor latency on the fastest inputs, in exchange for correctness and simplicity) is the right one to accept now. -
Panic-safety composes for free. As detailed in
Thread-Per-Call Dispatch,
a panic that reaches Magnus's own FFI dispatch boundary is already converted
to a raised Ruby exception by Magnus itself, on whichever thread is
executing the call — regardless of whether that's the main thread or a
Thread.new-spawned one.Thread#value's standard re-raise semantics then carry that exception back to the caller with no bespoke code. A worker-pool needs to hand-roll this (rescue in the worker loop, repost the exception object across the response queue, re-raise it in the caller's context). -
Simplicity matches the codebase's current size. This gem has one
public method. A persistent worker pool's lifecycle questions (start
eagerly at
requiretime or lazily on first call? clean shutdown viaat_exit? what happens to in-flight work if the process exits mid-call?) are real design surface that thread-per-call sidesteps entirely — there is no persistent state to manage, so there is nothing to get wrong.
A worker-pool is legitimate future optimization work if per-call spawn
overhead is ever shown to matter in practice (e.g. a production workload
dominated by many back-to-back empty/no_match-shaped calls where the
~70µs/call floor becomes a measurable fraction of total latency) — but
that's throughput optimization, explicitly out of scope for issue #57.
Building it now would be solving a problem this issue doesn't have yet, at
the cost of correctness-relevant complexity (queue management, result
correlation, lifecycle) that the simpler approach avoids.
- The ~70µs/thread figure above is a local, single-machine measurement
(
Thread.new{}.joinin a tight loop on this sandbox's Ruby 3.3.6), not a guarantee for every deployment target — CI runners and production hosts will differ. It's cited here as an order-of-magnitude sanity check against the benchmark-ips scenario latencies, not as a portable constant. If thread-per-call ships and a worker-pool is ever revisited, re-measure on the actual target environment rather than reusing this number. - The thread-per-call dispatch strategy comparison itself (as opposed to
the underlying GVL-release + Thread-spawn mechanism, which
Fiber-Scheduler Mechanism Spike
did prototype and measure against
test/falcon_fiber_blocking_test.rb) has not been separately re-verified end-to-end in a real implementation PR — this document reasons about the choice of thread-per-call vs. worker-pool in the abstract, grounded in the spike's measurements. - Not investigated: whether a bounded worker pool (more than 1 thread, but fewer than "one per call") could capture most of thread-per-call's concurrency while amortizing spawn cost — this document only compared the two endpoints (1 vs. unbounded) named in the issue's framing. Worth a look if a future throughput-optimization pass ever gets scoped in.