Skip to content

JVM read path: fewer crossings per call (get_raw, one, count, lookup_by_key, transaction mark) - #214

Merged
tae898 merged 6 commits into
mainfrom
perf/jvm-read-crossings
Nov 5, 2026
Merged

tae898 merged 6 commits into
mainfrom
perf/jvm-read-crossings

Conversation

@tae898

@tae898 tae898 commented Nov 3, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #213

The JVM wheel crossed into the JVM several times per read. A JPype call is a few microseconds of dispatch, and an untimed async-profiler pass put 10.7% of a warm point-read workload in JNI/libjvm and 5.2% in JPype C++, besides 5.4% of Java glue. This PR cuts the number of crossings of five common calls, each as its own commit, with the answer, the errors and the result-set state unchanged. It is a port of humemai/arcadedb-embedded-python#524 onto main (the layout moved from bindings/python/src/arcadedb_embedded to src/arcadedb; the Java and the Python hunks are otherwise the ones of that branch, re-applied and re-checked against the current code).

What changes (crossings per call, counted by the new tests on a proxy of the Java object and the bridge classes)

  • Document.get_raw() (so get(), Vertex.get(), Edge.get()): 2 crossings and 2 scans of the record (has() then get()) become 1 crossing and 1 scan. get() answers null for a property the record does not have, as it does for one set to null, and Python reads None either way; has_property() still tells the two apart.
  • ResultSet.one(): RowAccess.upToTwoAndClose returns the first two rows and closes the set in one call, where the loop took hasNext()/next() per row plus the close. Zero rows, several rows, an error while reading and a closed database raise the same exceptions and leave the same _closed/_exhausted state as the plain path, which stays for a missing bridge class.
  • ResultSet.count(): RowAccess.countAndClose counts the rows left and drains and closes the set in one call, where the loop crossed twice per row. A statement error surfaces as before.
  • Database.lookup_by_key(): it was already one crossing, but built a String[] and an Object[] through JPype first. DbCalls.lookupFirstKey takes one key as scalars and lookupFirstKeys takes any number as the varargs of one call (names then values, so a different number of names and values reaches the engine as before). lookupFirst, which takes the arrays, stays.
  • The transaction-stack mark taken before every begin(): DbCalls.stackMark returns active and nesting depth as one number, so with db.transaction() goes from 4 crossings to 3 and run_in_transaction() from 6 to 5. The mark is still taken before begin(), so the interrupt and refused-begin rules of #9970, #9813 and #7916 are unchanged.

The native backend is not touched: nothing under src/arcadedb/_native or native/ changes, and tests/test_read_crossings.py is listed in NATIVE_NOT_APPLICABLE because it counts JPype crossings.

Tests tests/test_read_crossings.py (21 tests, added with the commit each belongs to): crossing counts plus equivalence with the plain path, including typed and composite keys, error cases and the engine's own stack numbers. The counts fail on main and the equivalence tests pass on both. Run in CI against the wheel built from this branch (the bridge jar is rebuilt there); nothing was run on the author's laptop, so CI is the first run of this port.

What was measured A pre-registered timed A/B of this branch's JVM wheel against main's under concurrency (A/A control, rule written before the run) shipped by its rule: figures, intervals and scope in this comment and in the new docs section. The earlier timed A/B without threads ended with too few clean pairs for a verdict, so nothing from it is reported.

Docs: how-to/concurrency.adoc has a section with the concurrency figures of the timed study (mixed window at 2 Python threads on arcadedb-jvm, with the 1-thread row, the reader and writer shares, the control and what was not measured), added in ab15053.

🤖 Generated with Claude Code

Edited 2026-11-05: the status line now gives the timed result; the PR is ready for review.

@tae898

tae898 commented Nov 4, 2026

Copy link
Copy Markdown
Collaborator Author

Timed A/B of this PR under concurrency on the JVM wheel (laptop timed lane, two timed cores, tools/ab.py --noise, ABBA order, round 0 dropped, an A/A control). Arm A = the main CI JVM wheel 413f54ffe1, arm B = this PR's head bfe397b0c2. Time per operation, B/A median with its 95% interval (below 1 = this PR is faster):

workload role B/A median [95% CI] A/A median [95% CI]
mixed window, 2 Python threads (one reader, one writer) gating 0.489 [0.475, 0.499] 0.987 [0.960, 1.006]
mixed window, 1 thread guard 0.510 [0.507, 0.514] 0.988 [0.974, 1.014]
control: Cypher 2-hop count and .first() readers with a writer (changed only by one crossing per writer transaction), 2 threads control 1.016 [0.978, 1.037] 1.007 [0.967, 1.038]
reader's share of the 2-thread window info 0.476 [0.461, 0.487] 0.986 [0.957, 1.007]
writer's share of the 2-thread window info 0.992 [0.945, 1.007] 0.990 [0.965, 1.012]

Clean pairs per figure: 10. By the rule written before the run (gating upper end below 0.98, the 1-thread guard not clearly worse, the control median inside [0.97, 1.03], a clean A/A) this is a SHIP: the mixed window costs 0.489 [0.475, 0.499] of main's time at 2 threads, the gain is in the reader's share (0.476 [0.461, 0.487]), and the control stays inside its band.

The single-thread study of the same head (TA-3) ended WEAK at its round cap, because its transaction-commit figure could not narrow enough on a busy laptop, so it reports nothing; the claim above rests on this study alone. The PR leaves draft at the end of the current batch of timed studies that pin main, with its docs, and merges on green. I can re-run either study on the branch after any change.

@tae898
tae898 marked this pull request as ready for review November 4, 2026 21:47
@tae898
tae898 force-pushed the perf/jvm-read-crossings branch from ab15053 to e979d3d Compare November 4, 2026 22:57
@tae898 tae898 added this to the 26.11.1 milestone Nov 5, 2026
@tae898 tae898 added concurrency Threads, transactions and conflicts jvm The arcadedb-jvm package (JPype build) performance Speed or memory of the bindings, with a measurement labels Nov 5, 2026
@tae898
tae898 force-pushed the perf/jvm-read-crossings branch from e979d3d to 789ac47 Compare November 5, 2026 01:29
tae898 and others added 6 commits November 5, 2026 11:56
…has() then get()

get() answers null for a property the record does not have, as it does for one set to null; Python reads None either way, so the has() call only repeated the scan. has_property() still tells the two apart.

test_read_crossings.py counts the calls into the JVM on a proxy of the Java object and pins that the answers are the plain path's (absent, null-valued, mutable, vertex and edge). It is JVM-only (NATIVE_NOT_APPLICABLE); the native backend is unchanged.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…st()

one() read hasNext()/next() per row and closed afterwards; count() crossed twice per row. RowAccess.upToTwoAndClose and countAndClose do the same work Java-side with the same errors and the same result-set state; the plain path stays for a missing bridge class.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…no Java arrays

DbCalls.lookupFirstKey takes one key as scalars and lookupFirstKeys takes any number as the varargs of one call, so neither side builds a String[] and an Object[] through JPype per lookup. The engine receives the same arrays, and a different number of names and values reaches it as before. lookupFirst, which takes the arrays, stays.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The mark taken before every begin() (with db.transaction(), run_in_transaction) asked isTransactionActive() and getNestedTransactions() separately. DbCalls.stackMark returns both as one number, so with db.transaction() goes from 4 crossings to 3 and run_in_transaction from 6 to 5. The mark is still taken before begin(), so the interrupt and refused-begin rules of #9970 and #9813 are unchanged.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ndow at 2 Python threads)

The study that adopts this PR is a mixed read/write window at 2 Python threads on the arcadedb-jvm package. The page gets the gating figure with its 95% interval, the 1-thread guard, the reader and writer shares and the Cypher control, all generated from the study's ab-score.md by script, and says what was not measured (4 or more threads, native, other hardware). The one-thread row is stated plainly: the gain is not specific to concurrency.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e skip list

#214 and #233 each add one NATIVE_NOT_APPLICABLE entry at the same place, so merging one made the other conflict and cost a full CI run per rebase. Both branches now carry the same two entries in the same order; an entry for a test file that is not on this branch is only a collect_ignore name and changes nothing here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@tae898
tae898 force-pushed the perf/jvm-read-crossings branch from 789ac47 to 93df706 Compare November 5, 2026 02:56
tae898 added a commit that referenced this pull request Nov 5, 2026
…e skip list

#214 and #233 each add one NATIVE_NOT_APPLICABLE entry at the same place, so merging one made the other conflict and cost a full CI run per rebase. Both branches now carry the same two entries in the same order; an entry for a test file that is not on this branch is only a collect_ignore name and changes nothing here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@tae898
tae898 merged commit 78e48ef into main Nov 5, 2026
41 checks passed
@tae898
tae898 deleted the perf/jvm-read-crossings branch November 5, 2026 04:28
tae898 added a commit that referenced this pull request Nov 5, 2026
…et iteration in one crossing each (#233)

* JVM readers: a record or row to dict, the property names and result-set iteration in one crossing each (#227)

Document.to_dict took 18 reads of Java objects for 3 properties, Result.to_dict 14, a
property-names call 12, and each row of a result set two calls (hasNext, next). The new
bridge class ReaderCalls writes a record or a row as the typed JSON of TypedRows (one call,
one string read, the C json decoder), returns the names as one JSON text, and hands over
the next row in one call. Values keep the types convert_java_to_python gives them; the side
array is read only when the JSON refers to it; a result set is still read one row per call.
Without the class the old paths run. Document.to_dict now also refreshes the cached names,
so a property set after an earlier read is no longer missing from get_property_names().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Docs: the timed figures of the JVM reader crossings (to_dict and result iteration)

The study that adopts this PR timed record to dict at 3 and 12 properties and iteration into dicts at 100 and 10,000 rows on the arcadedb-jvm package. The Results section gets those four gating figures with their 95% intervals, the unit (time per record or row), the control that the change does not touch, and the scope (JVM build, these workloads, one laptop). The numbers are generated from the study's ab-score.md by script.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* tests: list the read- and reader-crossing tests together in the native skip list

#214 and #233 each add one NATIVE_NOT_APPLICABLE entry at the same place, so merging one made the other conflict and cost a full CI run per rebase. Both branches now carry the same two entries in the same order; an entry for a test file that is not on this branch is only a collect_ignore name and changes nothing here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

concurrency Threads, transactions and conflicts jvm The arcadedb-jvm package (JPype build) performance Speed or memory of the bindings, with a measurement

Projects

None yet

Development

Successfully merging this pull request may close these issues.

JVM wheel: the read path crosses into the JVM more often than it needs to (get_raw, one, count, lookup_by_key, transaction mark)

1 participant