Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 40 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ A row at a time is a boundary crossing a cell, and a hundred crossings cost abou

## Getting rows in

Two ways, and which one you want follows from whether the database exists yet.
Two ways, and which one you want follows from whether the database exists yet. There is a third below for the rows that should not go in at all.

A loader builds one out of whole columns. It is the fastest way values get in and, while the engine has no DDL, it is the only way a table comes into being at all:

Expand Down Expand Up @@ -111,6 +111,45 @@ The first two lines are the copy: 0.68 ns a row over a hundred thousand rows is

The last line is the one to read twice. A statement per row parses, plans, runs and commits per row, and none of that work says anything the row before it did not already say. That is what an appender is for.

## Querying memory you already hold

The third way is not to get the rows in at all. A frame names columns your program is already holding as a table of one connection, and statements read your buffers where they lie:

```java
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));
}
}
```

Nothing is copied at registration and nothing is copied at read. A scan builds vectors pointing straight at your buffers wherever the layouts agree, and the layouts that agree are the ones Arrow and this engine both keep: 64-bit signed integers, doubles, one bit a row for a boolean, and characters end to end with offsets cutting them up. A narrower integer, an unsigned one, a single-precision float and Arrow's microseconds against the nanoseconds this engine keeps time in are widened a value at a time as a statement reaches them, so a frame of a hundred columns costs you the one the statement named.

Every buffer has to be direct, and one that is not is refused rather than copied. Everywhere else in this API 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 your data for the rest of the frame's life, which is a frame that is not a frame.

The buffers stay yours and the library never writes one, but a direct buffer is looked after for you: the frame holds a reference to everything handed to it 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. So a buffer cannot be collected out from under a running statement. The optional release callback runs at that same moment, and it is there for the other kind of buffer, one over memory you allocated yourself or one a lock has to be taken to let go of.

A frame is read only and has no edges. A statement that would write to one is refused, a name a stored table already holds is refused, a name another frame holds replaces that frame, and registering inside a transaction is refused because a table appearing halfway through one is not something the transaction could then be rolled back over.

A million rows, three columns, the same laptop:

| 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 |

The first two lines are the whole point. Half a millisecond to make a million rows queryable against six hundred to write them down, and the three lines after that say the query is not paying for it afterwards.

## How it binds

The Foreign Function and Memory API is the primary path. The downcall handles are written by hand against `zu.h` rather than generated with `jextract`, because the C ABI here is around seventy functions with a stable shape, and a hand-written layer is where the interesting decisions live: which calls are `Linker.Option.critical` because they are short pure accessors, where the out-parameter scratch space comes from so that a query does not allocate, and how a `zu_error` becomes a typed Java exception exactly once. There is no native code in this repository beyond `libzu` itself.
Expand Down
195 changes: 195 additions & 0 deletions zudb-bench/src/main/java/dev/zudb/bench/FrameBench.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
package dev.zudb.bench;

import dev.zudb.Connection;
import dev.zudb.Database;
import dev.zudb.Frame;
import dev.zudb.Loader;
import dev.zudb.Result;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.IntBuffer;
import java.nio.LongBuffer;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.BenchmarkMode;
import org.openjdk.jmh.annotations.Fork;
import org.openjdk.jmh.annotations.Measurement;
import org.openjdk.jmh.annotations.Mode;
import org.openjdk.jmh.annotations.OperationsPerInvocation;
import org.openjdk.jmh.annotations.OutputTimeUnit;
import org.openjdk.jmh.annotations.Scope;
import org.openjdk.jmh.annotations.Setup;
import org.openjdk.jmh.annotations.State;
import org.openjdk.jmh.annotations.TearDown;
import org.openjdk.jmh.annotations.Warmup;

/**
* What it costs to query a million rows the program is already holding,
* against what the same million rows cost once they are in a database.
*
* <p>Two things are being measured here and they answer different questions.
* {@code register} is how long it takes to make a million rows queryable,
* which is the number to read against a load: a load writes a file and a frame
* writes nothing. The scans are what a statement costs afterwards, and the
* point of those is that a frame is not a slower way of reading, it is the
* same read against somebody else's memory.
*/
@State(Scope.Benchmark)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Warmup(iterations = 3, time = 2)
@Measurement(iterations = 5, time = 2)
@Fork(value = 1, jvmArgs = {"--enable-native-access=ALL-UNNAMED"})
public class FrameBench {

/** How many rows both sides carry. */
private static final int ROWS = 1_000_000;

private Path dir;

private LongBuffer ids;
private IntBuffer narrow;
private IntBuffer offsets;
private ByteBuffer characters;

private Database memory;
private Connection frames;
private Connection spare;
private Frame frame;

private Database stored;
private Connection disk;

@Setup
public void fill() throws IOException {
dir = Files.createTempDirectory("zu-frame-bench");

ids = direct(ROWS * Long.BYTES).asLongBuffer();
narrow = direct(ROWS * Integer.BYTES).asIntBuffer();
List<String> names = new ArrayList<>(ROWS);
for (int i = 0; i < ROWS; i++) {
ids.put(i);
narrow.put(i);
names.add("n" + i);
}
ids.flip();
narrow.flip();

byte[] all = String.join("", names).getBytes(StandardCharsets.UTF_8);
characters = direct(all.length);
characters.put(all).flip();
offsets = direct((ROWS + 1) * Integer.BYTES).asIntBuffer();
int at = 0;
offsets.put(0);
for (String name : names) {
at += name.length();
offsets.put(at);
}
offsets.flip();

memory = Database.memory();
frames = memory.connect();
spare = memory.connect();
frame = describe();
frames.register(frame);

Path path = dir.resolve("people.zu");
try (Loader loader = Loader.create(path)) {
loader.table("Person", "Knows", ROWS);
loader.column("id", copy());
loader.column("name", names);
loader.finish();
}
stored = Database.open(path);
disk = stored.connect();
}

@TearDown
public void clean() throws IOException {
disk.close();
stored.close();
frames.close();
spare.close();
frame.close();
memory.close();
Temp.deleteTree(dir);
}

/**
* Describing a million rows and naming them as a table, which is the whole
* of what a frame costs before a statement can read it.
*/
@Benchmark
@OperationsPerInvocation(ROWS)
public void register() {
try (Frame one = describe()) {
spare.register(one);
spare.unregister("Person");
}
}

/** A scan of the lane, which is the column the engine reads as it stands. */
@Benchmark
@OperationsPerInvocation(ROWS)
public long scanFrame() {
return sum(frames, "MATCH (p:Person) RETURN sum(p.id)");
}

/** The same scan over the same numbers, once they are in a database. */
@Benchmark
@OperationsPerInvocation(ROWS)
public long scanStored() {
return sum(disk, "MATCH (p:Person) RETURN sum(p.id)");
}

/** A scan of a 32-bit column, which is widened a value at a time as it is read. */
@Benchmark
@OperationsPerInvocation(ROWS)
public long scanNarrowFrame() {
return sum(frames, "MATCH (p:Person) RETURN sum(p.small)");
}

/** A scan of the string column, whose characters never move either way. */
@Benchmark
@OperationsPerInvocation(ROWS)
public long scanFrameStrings() {
return sum(frames, "MATCH (p:Person) WHERE p.name = 'n999999' RETURN count(p)");
}

/** The same, out of the database. */
@Benchmark
@OperationsPerInvocation(ROWS)
public long scanStoredStrings() {
return sum(disk, "MATCH (p:Person) WHERE p.name = 'n999999' RETURN count(p)");
}

private Frame describe() {
Frame one = Frame.of("Person", ROWS);
one.column("id", ids.duplicate());
one.column("small", narrow.duplicate());
one.strings("name", offsets.duplicate(), characters.duplicate());
return one;
}

private static long sum(Connection conn, String statement) {
try (Result r = conn.query(statement)) {
return r.row(0).getLong(0);
}
}

private long[] copy() {
long[] out = new long[ROWS];
ids.duplicate().get(out);
return out;
}

private static ByteBuffer direct(int bytes) {
return ByteBuffer.allocateDirect(bytes).order(ByteOrder.nativeOrder());
}
}
80 changes: 80 additions & 0 deletions zudb-ffm/src/main/java/dev/zudb/ffm/Abi.java
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,18 @@ final class Abi {
final MethodHandle appenderClose;
final MethodHandle appenderFree;

final MethodHandle frameNew;
final MethodHandle frameColInt;
final MethodHandle frameColFloat;
final MethodHandle frameColBool;
final MethodHandle frameColStr;
final MethodHandle frameColView;
final MethodHandle frameFree;
final MethodHandle connRegister;
final MethodHandle connUnregister;
final MethodHandle connRegisteredCount;
final MethodHandle connRegisteredName;

final MethodHandle valueType;
final MethodHandle valueBool;
final MethodHandle valueI64;
Expand Down Expand Up @@ -316,6 +328,74 @@ final class Abi {
h("zu_appender_close", FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, ADDRESS));
appenderFree = h("zu_appender_free", FunctionDescriptor.ofVoid(ADDRESS));

frameNew =
h(
"zu_frame_new",
FunctionDescriptor.of(
JAVA_INT, ADDRESS, SIZE_T, JAVA_LONG, ADDRESS, ADDRESS, ADDRESS, ADDRESS));
frameColInt =
h(
"zu_frame_col_int",
FunctionDescriptor.of(
JAVA_INT,
ADDRESS,
ADDRESS,
SIZE_T,
ADDRESS,
JAVA_LONG,
JAVA_INT,
JAVA_INT,
JAVA_LONG,
JAVA_INT,
ADDRESS));
frameColFloat =
h(
"zu_frame_col_float",
FunctionDescriptor.of(
JAVA_INT, ADDRESS, ADDRESS, SIZE_T, ADDRESS, JAVA_LONG, JAVA_INT, ADDRESS));
frameColBool =
h(
"zu_frame_col_bool",
FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, SIZE_T, ADDRESS, JAVA_LONG, ADDRESS));
frameColStr =
h(
"zu_frame_col_str",
FunctionDescriptor.of(
JAVA_INT,
ADDRESS,
ADDRESS,
SIZE_T,
ADDRESS,
JAVA_INT,
ADDRESS,
SIZE_T,
JAVA_LONG,
ADDRESS));
frameColView =
h(
"zu_frame_col_view",
FunctionDescriptor.of(
JAVA_INT,
ADDRESS,
ADDRESS,
SIZE_T,
ADDRESS,
ADDRESS,
ADDRESS,
SIZE_T,
JAVA_LONG,
ADDRESS));
frameFree = h("zu_frame_free", FunctionDescriptor.ofVoid(ADDRESS));
connRegister = h("zu_conn_register", FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, ADDRESS));
connUnregister =
h(
"zu_conn_unregister",
FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, SIZE_T, ADDRESS, ADDRESS));
connRegisteredCount =
h("zu_conn_registered_count", FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS));
connRegisteredName =
h("zu_conn_registered_name", FunctionDescriptor.of(ADDRESS, ADDRESS, JAVA_LONG, ADDRESS));

valueType = critical("zu_value_type", FunctionDescriptor.of(JAVA_INT, ADDRESS));
valueBool = h("zu_value_bool", FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS));
valueI64 = h("zu_value_i64", FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS));
Expand Down
Loading
Loading