Skip to content

Query memory the caller already holds - #3

Merged
tamnd merged 1 commit into
mainfrom
frames
Aug 20, 2026
Merged

tamnd merged 1 commit into
mainfrom
frames

Conversation

@tamnd

@tamnd tamnd commented Aug 20, 2026

Copy link
Copy Markdown
Owner

A frame names columns a program is already holding as a table of one connection, and statements read those buffers where they lie. This is the other direction from a loader: a loader takes your columns and writes a database out of them, a frame writes nothing at all.

LongBuffer ids = ByteBuffer.allocateDirect(3 * 8).order(nativeOrder()).asLongBuffer();
ids.put(new long[] {1, 2, 3}).flip();

try (Frame people = Frame.of("Person", 3)) {
    people.column("id", ids);
    conn.register(people);
    try (Result r = conn.query("MATCH (p:Person) RETURN sum(p.id) AS total")) {
        System.out.println(r.row(0).getLong(0));
    }
}

A million rows over three columns on an M-series laptop, JDK 25:

How Per row
describing them and registering them 0.51 ns
the same million rows through a loader, write included 630 ns
sum(p.id) over a frame 1.06 ns
sum(p.id) over the same numbers in a database 1.14 ns
the same over a 32-bit column, which is widened as it is read 1.32 ns
a string comparison over a frame 2.26 ns
the same over a database 3.89 ns

Half a millisecond to make a million rows queryable against six hundred to write them down, and the query is not paying for it afterwards.

Every buffer has to be direct and one that is not is refused rather than copied. Everywhere else in this client a heap buffer costs a memcpy and nothing else, because the call reads it and is finished. A frame keeps the pointer for as long as it is registered, so a copy here would mean the engine reading a copy of the caller's data for the rest of the frame's life, which is a frame that is not a frame, and quietly making one is worse than saying so.

The direct buffer a caller hands over is looked after for it. A frame holds a reference to everything it was given and lets go at the moment the engine says it has finished, which is after the last statement reading the frame ends and is neither the unregister that preceded it nor the close. Without that, a buffer nothing else refers to has its memory freed by a cleaner while the engine is still pointing at it, which is what the float benchmark found before the frame held on to anything.

That moment is an upcall, and it is the first thing in this binding that hands the engine a pointer to Java code rather than the other way round. The stub has to outlive the frame, because the callback is the last thing to happen and may arrive after the frame was freed, so it cannot hang off the frame. It cannot free itself either, since closing the arena a stub lives in from inside a call through that same stub is closing the ground you are standing on. So a spent arena goes on a queue and the next stub to be made closes it, which costs nothing, needs no thread of ours, and bounds the outstanding stubs at the number of frames whose callbacks have not run yet. Nothing may be thrown out of an upcall, because an exception crossing one takes the whole JVM down, so the callback logs and swallows.

What is covered, per column shape: 64, 32 and 16 bit signed integers, an 8 bit unsigned one, a 32 bit unsigned one, doubles and single-precision floats, a bitmap of booleans, Arrow's Utf8 and LargeUtf8 and Utf8View, dates as Date32, and timestamps at Arrow's microseconds scaled to the nanoseconds this engine counts time in. Then the rules: one frame on two connections, the names a connection is holding, the release callback firing and not firing early, a frame no connection ever saw still letting go, a heap buffer refused, a column of the wrong length refused at that column, a name a stored table already holds refused, a frame replacing a frame of the same name, registering inside a transaction refused, a statement that would write to a frame refused, a slice being the slice and not the whole buffer, a closed frame saying so, and two hundred thousand rows read whole.

Twenty three new tests, 161 in all, green locally on JDK 25 with the release library.

A frame names columns a program is already holding as a table of one
connection, and statements read those buffers where they lie. Nothing is
copied at registration and nothing is copied at read. Describing a
million rows over three columns and registering them costs 0.51 ns a
row, against 630 ns a row to put the same rows through a loader, and the
scan afterwards is 1.06 ns a row against 1.14 for the same numbers in a
database, so the query is not paying for it later.

Every buffer has to be direct and one that is not is refused rather than
copied. Everywhere else in this client a heap buffer costs a memcpy and
nothing else, because the call reads it and is finished. A frame keeps
the pointer for as long as it is registered, so a copy here would mean
the engine reading a copy for the rest of the frame's life, which is a
frame that is not a frame.

The direct buffer a caller hands over is looked after for it. A frame
holds a reference to everything it was given and lets go at the moment
the engine says it has finished, which is after the last statement
reading the frame ends and is neither the unregister that preceded it
nor the close. Without that a buffer nothing else refers to is freed by
a cleaner while the engine is still pointing at it, which is what the
float benchmark found before this held on to anything.

That moment is an upcall, which is the first thing in this binding that
hands the engine a pointer to Java code rather than the other way round.
The stub has to outlive the frame, because the callback is the last
thing to happen, and it cannot free itself either, since closing the
arena a stub lives in from inside a call through that same stub is
closing the ground you are standing on. So a spent arena goes on a queue
and the next stub to be made closes it. Nothing may be thrown out of an
upcall, so the callback logs and swallows.

Twenty three tests over every column shape the ABI carries, including a
collector run against a frame nothing else refers to, and a benchmark
that puts a frame beside the database it is standing in for.
@tamnd
tamnd merged commit 0457676 into main Aug 20, 2026
9 checks passed
@tamnd
tamnd deleted the frames branch August 20, 2026 02:00
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.

1 participant