Skip to content

Add asynchronous OpenMP batch matching - #6

Merged
ronag merged 5 commits into
mainfrom
codex/linux-openmp-test-many
Jul 21, 2026
Merged

Add asynchronous OpenMP batch matching#6
ronag merged 5 commits into
mainfrom
codex/linux-openmp-test-many

Conversation

@ronag

@ronag ronag commented Jul 21, 2026

Copy link
Copy Markdown
Member

Summary

  • add blocking testManySync() and non-blocking testManyAsync() to RE2 and RE2Set, while retaining testMany() as the synchronous compatibility alias
  • make async matching snapshot input bytes by default; { unsafe: true } retains and borrows normalized binary views until settlement
  • replace the custom process pool with GNU OpenMP in the Linux prebuild only; source and macOS builds remain sequential
  • retain compiled regex/set ownership with shared_ptr across Node workers and async work
  • report planned and observed OpenMP team sizes and expand the benchmark harness to compare scalar, Infinity, automatic, and swept explicit batching

Runtime behavior

testManySync() executes inline when parallelism is disabled. With Linux-prebuild parallelism, the caller participates in the OpenMP team and blocks at the region barrier.

testManyAsync() uses one N-API/libuv worker and may enter the same OpenMP region there. The safe default makes one contiguous byte snapshot before queueing. Unsafe mode avoids that byte copy, but backing stores must remain attached, unchanged, and the same size until the Promise settles.

The Linux planner requests at most half of uv_available_parallelism(), including the initiating thread, and about 16 scheduling chunks per requested thread. Infinity or batchSize >= inputCount disables intra-call OpenMP. A per-addon submission lock avoids overlapping OpenMP teams across concurrent Node environments. libgomp owns worker lifecycle.

Linux prebuilds dynamically link libgomp.so.1. Source builds, macOS, and other platforms do not compile or link OpenMP. The deployed nxtedition/node:26.5.0 image was verified to load and execute the prebuild without installing additional packages.

Benchmark

Measured in a container on an AMD EPYC 9355P with a 16-CPU cpuset. Parallel rows requested and observed 8 OpenMP threads. Medians use 10 runs after 3 warmups.

4,096 inputs x 4,096 bytes

operation selected scheduling scalar sync Infinity sync auto selected speedup vs Infinity
RE2 literal suffix Infinity 0.48 ms 0.60 ms 0.90 ms 1.00x
RE2 anchored early fail auto, batch 32 0.30 ms 0.47 ms 0.36 ms 1.31x
RE2 scan-heavy auto, batch 32 9.90 ms 10.01 ms 1.62 ms 6.17x
RE2Set, 256 patterns auto, batch 32 19.67 ms 19.86 ms 3.19 ms 6.22x

Selected async settlement medians:

operation scheduling and ownership admission total settlement
RE2 scan-heavy Infinity, safe snapshot 1.08 ms 10.94 ms
RE2 scan-heavy auto, safe snapshot 1.02 ms 2.53 ms
RE2 scan-heavy auto, unsafe borrowed 0.19 ms 1.68 ms
RE2Set Infinity, safe snapshot 1.16 ms 21.37 ms
RE2Set auto, safe snapshot 1.08 ms 4.55 ms
RE2Set auto, unsafe borrowed 0.19 ms 3.66 ms

Cold compileAsync() for 10,000 patterns settled in 17.39 ms; a native cache hit settled in 1.33 ms. Eight concurrent identical calls caused one compilation and seven native in-flight deduplications, settling in 16.41 ms.

Production-derived Deepstream workload

This uses the 77 exact listener patterns from deepstream/benchmark/regex.js over 65,536 balanced synthetic Deepstream-style inputs averaging 26 bytes. Medians use 12 runs after 3 warmups.

operation hit ratio selected scheduling sync Infinity sync auto
render service state 6.3% Infinity 9.33 ms 12.97 ms
asset fields 6.3% Infinity 9.12 ms 10.98 ms
event fields 6.3% Infinity 9.33 ms 10.42 ms
media operations 6.3% Infinity 9.81 ms 10.57 ms
RE2Set, 77 patterns 87.5% auto, batch 512 18.72 ms 18.53 ms

This is why the dependent listener-backfill change uses async matching with batchSize: Infinity: it leaves the event loop without paying OpenMP overhead for one cheap regex. The multi-pattern RE2Set path remains a candidate for automatic parallelism.

Validation

  • macOS arm64 source build: 54 passed, 3 Linux-only skips
  • Linux x64 OpenMP prebuild: 57 passed, 0 skipped
  • Linux ordinary source build: 54 passed, 3 OpenMP-only skips, and no libgomp dependency
  • 4-CPU cpuset: planner requested 2 threads and observed 2
  • 16-CPU cpuset with OMP_THREAD_LIMIT=1: planner requested 1 and observed 1
  • nxtedition/node:26.5.0: prebuild loaded and completed async matching with requested/observed 2 threads on a 4-CPU cpuset
  • TypeScript declaration tests and package-content tests pass
  • worker teardown tests cover safe and unsafe in-flight RE2 and RE2Set calls

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds asynchronous batch matching APIs and updates the Linux x64 prebuild to use GNU OpenMP for intra-call parallelism, while keeping source builds and non-Linux builds sequential. It also updates native ownership/lifetime handling to keep compiled regex/set state valid across Node workers and async work, and expands validation/benchmarks to cover the new behavior.

Changes:

  • Introduces testManySync()/testManyAsync() on RE2 and RE2Set, keeping testMany() as a synchronous compatibility alias.
  • Implements async batch input handling with safe-by-default byte snapshotting and an { unsafe: true } borrowed mode.
  • Switches Linux prebuild parallelism from a custom pool to dynamically-linked GNU OpenMP, plus new planner/observed parallelism reporting and benchmark coverage.

Reviewed changes

Copilot reviewed 34 out of 34 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
type-tests/index.ts Adds type-level coverage for the new sync/async batch APIs and async-only unsafe option.
test/release.test.js Asserts release artifacts/inputs explicitly disable OpenMP except for the Linux prebuild path.
test/native-boundary.test.js Extends native boundary tests for observed parallelism and async context mismatch handling; updates tarball contents list.
test.js Adds JS-level tests for testManySync/testManyAsync, safe snapshot semantics, unsafe borrowed semantics, worker termination behavior, and OpenMP linkage checks.
src/re2.ts Adds testManySync() and testManyAsync(); keeps testMany() as a compatibility alias.
src/re2-set.ts Adds testManySync() and testManyAsync(); keeps testMany() as a compatibility alias.
src/index.ts Exports TestManyAsyncOptions type.
src/binding.ts Extends native binding interface with async batch methods and observed parallelism probe.
src/binary.ts Adds TestManyAsyncOptions and shared option normalization for sync vs async (unsafe validation).
scripts/build-darwin-prebuild.sh Forces NODE_RE2_OPENMP=0 for Darwin prebuild builds.
release.sh Exports NODE_RE2_OPENMP=0 by default to prevent accidental OpenMP enabling outside Linux prebuild.
README.md Documents new sync/async APIs, safe snapshot vs unsafe borrowing semantics, and OpenMP/Linux-prebuild behavior.
native/thread-pool.h Removes the custom native thread pool interface (superseded by OpenMP on Linux prebuild).
native/thread-pool.cc Removes the custom native thread pool implementation.
native/text-batch.h Introduces a packed snapshot representation for safe async batch input bytes.
native/set-binding.cc Adds async batch matching for RE2Set and factors shared matching helpers for sync/async paths.
native/regex-binding.cc Adds async batch matching for RE2 and switches regex ownership to shared_ptr via a tagged context wrapper.
native/parallel-for.h Replaces thread-pool-based parallel loop with an OpenMP-backed implementation (guarded by NODE_RE2_OPENMP).
native/openmp-runtime.h Adds OpenMP submission mutex and observed team-size probing helper.
native/napi-utils.h Adds GetTextBatch, GetBoolean, and RejectDeferred declarations used by async paths.
native/napi-utils.cc Implements packed snapshot batching, shared array input collection, boolean option parsing, and deferred rejection helper.
native/batch-plan.h Updates batch planning to use OpenMP runtime thread limits (when enabled).
native/batch-binding.cc Exposes batch_observed_parallelism alongside planned parallelism.
native/async-batch.h Defines async batch input container supporting safe snapshots vs unsafe borrowing with N-API refs.
native/async-batch.cc Implements safe snapshotting and unsafe borrowed input retention/release.
native/addon-lifecycle.h Removes addon environment lifecycle hook API (previously used for thread-pool lifecycle).
native/addon-lifecycle.cc Removes addon environment lifecycle hook implementation.
fixtures/unsafe-batch.js Adds fixture validating safe snapshot vs unsafe borrowed mutation outcomes under worker-pool load.
fixtures/terminate-match-worker.js Adds worker fixture to queue async match then terminate, exercising teardown resilience.
fixtures/batch-worker.js Updates worker fixture to exercise both sync and async batch APIs (including alternating unsafe mode).
Dockerfile Enables OpenMP for Linux prebuild build step and asserts the prebuild dynamically links libgomp.so.1.
binding.gyp Adds NODE_RE2_OPENMP build toggle and OpenMP compile/link flags for the Linux prebuild configuration.
binding.cc Removes addon lifecycle registration; registers regex/set/batch bindings (including new exports).
benchmark.js Expands benchmarking to compare scalar/sync modes, reports requested vs observed OpenMP threads, and measures safe vs unsafe async admission/settlement.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread native/parallel-for.h
Comment on lines 18 to 37
@@ -32,9 +36,8 @@ void ParallelFor(size_t size, size_t total_bytes, size_t requested_batch_size, F
error = std::current_exception();
}
Comment thread native/openmp-runtime.h
Comment on lines +22 to +37
inline size_t ObserveBatchParallelism(const BatchPlan& plan) {
#ifdef NODE_RE2_OPENMP
if (plan.thread_count > 1) {
std::lock_guard submission_lock(OpenMpSubmissionMutex());
size_t observed = 1;

#pragma omp parallel num_threads(plan.thread_count)
{
#pragma omp single
observed = static_cast<size_t>(omp_get_num_threads());
}
return observed;
}
#endif
return plan.thread_count;
}
Comment thread native/napi-utils.cc
Comment on lines +315 to 320
bool GetTextBatch(napi_env env, napi_value value, TextBatch* texts) {
std::vector<napi_value> inputs;
inputs.reserve(input_count);
for (uint32_t index = 0; index < input_count; ++index) {
napi_value input;
if (!Check(env, napi_get_element(env, value, index, &input), "Failed to read input")) {
return false;
}
inputs.push_back(input);
if (!GetInputValues(env, value, &inputs)) {
return false;
}

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 34 out of 34 changed files in this pull request and generated 2 comments.

Comment thread native/regex-binding.cc
Comment thread native/set-binding.cc

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 34 out of 34 changed files in this pull request and generated 5 comments.

Comment thread Dockerfile Outdated
Comment on lines +27 to +29
# The published Linux binary intentionally relies on Debian's GNU OpenMP
# runtime instead of embedding another thread-pool implementation.
RUN ldd prebuilds/linux-x64/@nxtedition+re2.glibc.node | grep -F libgomp.so.1
Comment thread test.js
Comment on lines +669 to +676
() => {
const bindingPath = nodeGypBuild.path(import.meta.dirname)
const result = spawnSync('ldd', [bindingPath], { encoding: 'utf8' })

assert.equal(result.signal, null)
assert.equal(result.status, 0, result.stderr)
assert.match(result.stdout, /libgomp[.]so[.]1/)
}
Comment thread README.md
Comment on lines +22 to +26
Patterns may be strings, Buffers, TypedArrays, or DataViews. Input must be a Buffer, TypedArray, or DataView. Both APIs operate on bytes; optional `byteOffset` and `byteLength` values select the input range. Negative values clamp to zero, values past the view clamp to its bounds, and fractional values are truncated.

The published Linux x64 prebuild uses a process-wide pool when a batch contains enough input bytes to amortize dispatch. It selects at most half of the container-affinity CPUs, including the caller, so matching leaves capacity for other work. Helpers block when idle and the pool shuts down after the last Node.js environment; source builds and other platforms match the batch sequentially regardless of `batchSize`.
`testManySync()` blocks until matching completes. Without intra-call parallelism it runs inline; with parallelism the caller joins the OpenMP team and blocks until every chunk completes. `testManyAsync()` returns a Promise and performs matching from the Node.js worker pool, optionally entering OpenMP from that worker. It snapshots all input bytes before returning by default, so later mutation, resizing, transfer, or detachment cannot affect the result. `{ unsafe: true }` skips that copy; every backing store must then remain attached, the same size, and unmodified until the Promise settles. SharedArrayBuffer-backed views follow the same rule. `testMany()` remains a synchronous compatibility alias for `testManySync()`.

All batch methods accept up to 1,048,576 inputs and preserve their outer result order. Their optional `batchSize` is the number of inputs per native scheduling chunk. `Infinity` or a value greater than or equal to the input count disables intra-call OpenMP: synchronous matching stays on the caller thread, while asynchronous matching stays on one Node.js worker. Omitting `batchSize` keeps automatic scheduling at about 16 chunks per requested OpenMP thread.
Comment thread native/regex-binding.cc
Comment thread native/set-binding.cc

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 34 out of 34 changed files in this pull request and generated 2 comments.

Comment thread binding.gyp
"-pthread"
],
"conditions": [
["node_re2_openmp==1", {
Comment thread test/release.test.js
assert.match(dockerfile, /NODE_RE2_OPENMP=1 NODE_RE2_MARCH=znver3/)
assert.match(dockerfile, /ldd .* \| grep -E 'libgomp\[\.\]so\[\.\]1 => \/'/)
assert.match(binding, /node_re2_openmp%.*NODE_RE2_OPENMP === '1'/)
assert.match(binding, /node_re2_openmp==1.*-fopenmp/s)
@ronag
ronag marked this pull request as ready for review July 21, 2026 08:04
@ronag
ronag requested a review from Copilot July 21, 2026 08:05

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 34 out of 34 changed files in this pull request and generated 1 comment.

Comment thread native/async-batch.cc
Comment on lines +34 to +41
napi_status ReleaseAsyncBatch(napi_env env, AsyncBatch* batch) {
if (batch->borrowed_inputs == nullptr) {
return napi_ok;
}
const napi_ref reference = batch->borrowed_inputs;
batch->borrowed_inputs = nullptr;
return napi_delete_reference(env, reference);
}

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 34 out of 34 changed files in this pull request and generated no new comments.

@ronag
ronag merged commit 5940b98 into main Jul 21, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants