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
58 changes: 56 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ The rows are an array, so iterating them is `for (const row of rows)` and nothin

## What works today

`connect`, `query`, `exec`, `stream`, `close`, `dispose` and `await using`. Named parameters both ways, including lists, records and nesting. Every scalar the engine has, plus nodes, edges and paths with their tables named rather than numbered, and `ZuDate`, `ZuTime`, `ZuTimestamp` and `ZuDuration`, with `{ temporal: true }` and `toTemporal()` for the runtimes that have `Temporal`. Read-only connections, memory and thread limits. `bigIntMode`, per statement or per connection. An `AbortSignal` on any statement. The full error surface above, and `isZuError` to recognize it. Streaming, as an async iterable, as batches and as a Web Stream. Transactions, with `inTransaction` on the connection. An appender, for loading rows a batch at a time, and `load` for building a whole database out of columns and an edge list. Registered frames, so an Arrow table or an object of typed arrays is something a statement can match on without the rows being copied. Both module formats, typed separately.
`connect`, `query`, `exec`, `stream`, `close`, `dispose` and `await using`. Named parameters both ways, including lists, records and nesting. Every scalar the engine has, plus nodes, edges and paths with their tables named rather than numbered, and `ZuDate`, `ZuTime`, `ZuTimestamp` and `ZuDuration`, with `{ temporal: true }` and `toTemporal()` for the runtimes that have `Temporal`. Read-only connections, memory and thread limits. `bigIntMode`, per statement or per connection. An `AbortSignal` on any statement. The full error surface above, and `isZuError` to recognize it. Streaming, as an async iterable, as batches and as a Web Stream. Transactions, with `inTransaction` on the connection. An appender, for loading rows a batch at a time, and `load` for building a whole database out of columns and an edge list. Registered frames, so an Arrow table or an object of typed arrays is something a statement can match on without the rows being copied. `columnar`, for a result read down its columns as the buffers themselves rather than across its rows as objects. Both module formats, typed separately.

Build it with `npm run build`, and run the suite with `npm test`. Nothing is published yet, so `npm i zudb` is not a thing you can type at anybody's terminal, but everything it will do is built and installed on every run of the release workflow.

Expand Down Expand Up @@ -186,6 +186,60 @@ The frame belongs to the connection it was registered on and goes when that conn

Registering the same name again replaces what it stands for, columns and all. Registering over a table the database already holds is refused, since a statement naming it would mean the stored one. A frame with no rows is a table to match on and answers nothing, because a frame knows its columns without being told by a row. A null anywhere is refused by column and row, since a property that is null is one no row of this engine holds, and registering inside a transaction is refused because a frame is registered on the session, which is the thing the transaction is running on.

## Reading a result as columns

`query` builds an object a row and a JavaScript value a cell, which is what a program reading a hundred rows wants and the wrong shape for a million. `columnar` runs the same statement and hands back the buffers instead:

```ts
const read = await conn.columnar(`MATCH (p:person) RETURN p.age AS age`);
read.rows; // 1000000
read.columns[0].values; // a BigInt64Array of every age, and not one object
```

The buffers are the engine's own, moved rather than read: the pointer V8 is given is the pointer the engine filled, and the allocation is freed when the typed array is collected. So a column of a million integers crosses the boundary as a pointer and a length. On this machine, with `npm run bench:columnar` over a million rows:

```
one integer column, columnar 38.4 ms 38 ns/row
one integer column, rows 243.1 ms 243 ns/row
a string column, columnar 50.3 ms 50 ns/row
a string column, rows 262.0 ms 262 ns/row
three columns, columnar 75.8 ms 76 ns/row
three columns, rows 632.2 ms 632 ns/row
```

Walking what came back costs the same either way, at about 14 ns a row for a sum over the buffer and the same over the rows, which is worth saying because it is where the win is not. V8 reads a property of a small object about as fast as an element of a typed array. What it cannot do is make a million of those objects for nothing, and that is the whole of the six to eight times above.

Every column says what it is, and reading one is a switch on `type` rather than a series of tests for what is there. `values` carries everything of a fixed width: a `BigInt64Array` of integers, nanoseconds or months, a `Float64Array` of floats, an `Int32Array` of days, and for booleans a `Uint8Array` of one bit a row, least significant bit first. A string column has `data`, the bytes of every string end to end, and `offsets`, one more than there are rows, so row `i` is `data.subarray(offsets[i], offsets[i + 1])`. `validity` is one bit a row again, set meaning the row has a value, and it is null when nothing in the column is, so the common case costs a reader nothing to skip. `unit` says whether a cell counts days, nanoseconds or months, and `zone` is the minutes east of UTC a column of zoned times was written with.

That layout is Arrow's, which is the point of it. `apache-arrow` wraps a buffer of this shape without copying it, so a table is eleven lines and no dependency of this package:

```ts
// with apache-arrow installed, and nothing in zudb importing it
const table = new Table(
Object.fromEntries(
read.columns.map((column) => [
column.name,
new Vector([
makeData({
type: arrow(column), // Int64, Float64, Bool, Utf8, DateDay, TimestampNanosecond
length: column.length,
nullCount: column.nulls,
nullBitmap: column.validity ?? undefined,
data: column.data ?? column.values,
valueOffsets: column.offsets ?? undefined,
}),
]),
]),
),
);
```

The same memory is on both sides of that: `table.getChild("age").data[0].values` is the array the engine filled, not a copy of it. The recipe is printed here rather than shipped because a client that hands out an Arrow object has to agree with one version of Arrow forever, and a client that hands out the bytes agrees with all of them. It is run in the test suite, so it is checked rather than believed.

Two things are not buffers, and both are named by the type rather than found out by looking. A column of nodes, rels, paths, lists or records has no fixed width cell, so it arrives as `items`, holding the same JavaScript values `query` would have made. A column of nothing but nulls has a length and nothing else, because there is nothing to put in a buffer. A column that mixes two types is refused, naming the column and the row that did it, since a columnar result holds one type per column and a column that quietly became strings is worse than one that would not build.

`bigIntMode` says nothing here. A columnar read has one physical layout per type and an INT64 column is 64 bit cells however a caller would rather read one, which is the difference between a buffer and a value. The mode still decides what is inside `items`, where this client is making objects anyway.

## Asking for numbers instead of bigints

`bigIntMode` says how INT64 is spelled on the way out. It goes on one statement, or on a connection for all of them, and a statement on a connection that named one may still name the other:
Expand Down Expand Up @@ -278,7 +332,7 @@ typedoc rather than api-documenter, which would have been the obvious pick since

Anything outside that table has no binary and no source build to fall back on, so the install resolves nothing and the first `require` says so. The browser and the platforms nobody builds for are what the WASM target answers, later.

`npm run bench` measures what this package adds to the engine, which is a row object and one JavaScript value per column: the same scan with the rows dropped is the floor, and the difference between the two is what the boundary costs. Run it against a release build, since a debug build of the engine moves the floor by an order of magnitude and not the rest of it. `npm run bench:append` does the same for the appender, `npm run bench:load` for building a database out of columns, and `npm run bench:register` for registered frames, where what is being watched is that the registration does not scale with the rows.
`npm run bench` measures what this package adds to the engine, which is a row object and one JavaScript value per column: the same scan with the rows dropped is the floor, and the difference between the two is what the boundary costs. Run it against a release build, since a debug build of the engine moves the floor by an order of magnitude and not the rest of it. `npm run bench:append` does the same for the appender, `npm run bench:load` for building a database out of columns, `npm run bench:register` for registered frames, where what is being watched is that the registration does not scale with the rows, and `npm run bench:columnar` for a result read down its columns against the same result read across its rows.

## Still to come

Expand Down
126 changes: 126 additions & 0 deletions bench/columnar.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
// What a result costs read down its columns against read across its
// rows.
//
// The two calls run the same statement and differ only in what they
// build out of the answer: `query` makes an object a row and a value a
// cell, and `columnar` moves one buffer a column. So the gap between
// the two lines of a pair is the cost of making JavaScript values, which
// is what this is measuring and the only reason the second call exists.
//
// The last block is what a caller does next. A sum over a typed array
// against a sum over an array of objects is the honest comparison,
// because a program that asked for a million rows is going to walk them,
// and the buffer is quicker to walk as well as quicker to make.
//
// Run it against a release build, for the reason bench/query.mjs gives.
//
// npm run build && npm run bench:columnar

import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'

import { connect } from 'zudb'

const ROWS = Number(process.env.ZU_BENCH_ROWS ?? 1_000_000)
const REPEATS = Number(process.env.ZU_BENCH_REPEATS ?? 5)

const dir = await mkdtemp(join(tmpdir(), 'zu-bench-columnar-'))
const conn = await connect(join(dir, 'bench.zu1'))

await conn.exec("INSERT (p:person {uid: 1, score: 1.5, name: 'n1'})")
{
const rows = await conn.appender('person')
for (let ix = 2; ix <= ROWS; ix++) rows.appendRow([BigInt(ix), ix / 3, `n${ix}`])
await rows.close()
}

/// The fastest of `REPEATS` runs, in milliseconds, after one warmup.
///
/// The fastest for the reason bench/query.mjs gives: everything that
/// makes a run slower than the work itself is something that happened to
/// it rather than something about it.
async function time(run) {
await run()
let best = Infinity
for (let round = 0; round < REPEATS; round++) {
const started = performance.now()
await run()
best = Math.min(best, performance.now() - started)
}
return best
}

function report(name, ms) {
const each = (ms * 1e6) / ROWS
console.log(
`${name.padEnd(30)} ${ms.toFixed(1).padStart(8)} ms ${Math.round(each).toString().padStart(6)} ns/row`,
)
}

const cases = [
{
name: 'one integer column, columnar',
run: () => conn.columnar('MATCH (p:person) RETURN p.uid AS uid'),
},
{
name: 'one integer column, rows',
run: () => conn.query('MATCH (p:person) RETURN p.uid AS uid'),
},
{
name: 'a float column, columnar',
run: () => conn.columnar('MATCH (p:person) RETURN p.score AS score'),
},
{
name: 'a float column, rows',
run: () => conn.query('MATCH (p:person) RETURN p.score AS score'),
},
{
name: 'a string column, columnar',
run: () => conn.columnar('MATCH (p:person) RETURN p.name AS name'),
},
{
name: 'a string column, rows',
run: () => conn.query('MATCH (p:person) RETURN p.name AS name'),
},
{
name: 'three columns, columnar',
run: () =>
conn.columnar('MATCH (p:person) RETURN p.uid AS uid, p.score AS score, p.name AS name'),
},
{
name: 'three columns, rows',
run: () => conn.query('MATCH (p:person) RETURN p.uid AS uid, p.score AS score, p.name AS name'),
},
]

console.log(`reading ${ROWS} rows, fastest of ${REPEATS}`)
for (const { name, run } of cases) report(name, await time(run))

// What the caller does with what they were handed. The statement is not
// timed here: both sides already have the whole answer and the question
// is what walking it costs.
const read = await conn.columnar('MATCH (p:person) RETURN p.uid AS uid')
const rows = await conn.query('MATCH (p:person) RETURN p.uid AS uid')

console.log('')
console.log('summing what came back')
report(
'over the buffer',
await time(async () => {
let total = 0n
for (const value of read.columns[0].values) total += value
return total
}),
)
report(
'over the rows',
await time(async () => {
let total = 0n
for (const row of rows) total += row.uid
return total
}),
)

await conn.close()
await rm(dir, { recursive: true, force: true })
99 changes: 99 additions & 0 deletions binding.d.cts
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,93 @@ export interface ZuNotice {
readonly docUrl: string
}

/**
* What a column of a columnar read turned out to hold.
*
* Narrower than the type the statement declared, because the question
* here is which buffer arrived: a time with an offset and a time
* without are the same 64 bit cells, and the offset rides beside as
* `zone`. `value` is the fallback for what no fixed width cell covers,
* which is nodes, rels, paths, lists and records, and `null` is a
* column that held nothing else.
*/
export type ZuColumnType =
| 'null'
| 'bool'
| 'int'
| 'float'
| 'string'
| 'date'
| 'time'
| 'datetime'
| 'duration'
| 'value'

/**
* One column of a result, as the buffer holding it.
*
* Every field is present on every column and holds null where it does
* not apply, so reading one is a switch on `type` rather than a series
* of tests for what is there. Which field carries the values follows
* from the type: `values` for everything of a fixed width, `data` and
* `offsets` for strings, `items` for what no buffer covers, and none of
* them for a column of nulls.
*
* The buffers are the engine's own, handed over rather than copied, and
* they are laid out the way Arrow lays them out: values end to end, a
* boolean as one bit a row, a string column as its bytes and `length +
* 1` offsets into them, where row `i` spans `offsets[i]` to `offsets[i
* + 1]`.
*/
export interface ZuColumn {
readonly name: string
readonly type: ZuColumnType
readonly length: number
/**
* The cells, for a column of a fixed width: `BigInt64Array` for
* integers, nanoseconds and months, `Float64Array` for floats,
* `Int32Array` for days, and a `Uint8Array` of packed bits for
* booleans, least significant bit first.
*/
readonly values: BigInt64Array | Float64Array | Int32Array | Uint8Array | null
/** The bytes of every string end to end, for a string column. */
readonly data: Uint8Array | null
/**
* `length + 1` offsets into `data`, for a string column. Narrow until
* the bytes pass what a 32 bit offset addresses, which is the
* difference Arrow calls Utf8 against LargeUtf8.
*/
readonly offsets: Int32Array | BigInt64Array | null
/** The values themselves, for a column of type `value`. */
readonly items: ZuValue[] | null
/**
* One bit a row, least significant bit first, set meaning the row has
* a value. Null when every row has one, which is the common case and
* the one where a reader gets to skip the test.
*/
readonly validity: Uint8Array | null
/** How many rows are null, which is zero when `validity` is null. */
readonly nulls: number
/** What one cell counts: `days`, `nanos` or `months`. */
readonly unit: 'days' | 'nanos' | 'months' | null
/** Minutes east of UTC, for a column of zoned times or datetimes. */
readonly zone: number | null
}

/**
* A whole result read down its columns.
*
* `rows` is every column's length, and is the answer for a statement
* that projected nothing at all. `gqlstatus` and `notices` are the
* statement's, exactly as they are on the rows.
*/
export interface ZuColumnar {
readonly rows: number
readonly columns: ZuColumn[]
readonly gqlstatus: string
readonly notices: ZuNotice[]
}

/**
* The rows a statement gave back.
*
Expand Down Expand Up @@ -707,6 +794,18 @@ export declare class Connection {
* reads still costs a row object per row on the way out.
*/
exec(statement: string, params?: Record<string, ZuParam> | null, options?: ZuStatementOptions | null): Promise<void>
/**
* Runs one statement and gives back its columns rather than its
* rows.
*
* The same statement as [`Connection::query`], read down instead
* of across: what comes back is one buffer a column, in the layout
* Arrow already uses, and no object a row. That is the way out for
* anything that is going to be counted, plotted or handed to a
* dataframe, and it is the way out that does not build a million
* JavaScript values on the way.
*/
columnar(statement: string, params?: Record<string, ZuParam> | null, options?: ZuStatementOptions | null): Promise<ZuColumnar>
/**
* Runs one statement and gives back a cursor over its rows.
*
Expand Down
44 changes: 44 additions & 0 deletions etc/zudb.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export function connect(path: string, options?: ConnectOptions | undefined | nul
export class Connection {
appender(table: string): Promise<Appender>
close(): void
columnar(statement: string, params?: Record<string, ZuParam> | null, options?: ZuStatementOptions | null): Promise<ZuColumnar>
cursor(statement: string, params?: Record<string, ZuParam> | null, options?: ZuStreamOptions | null): ZuCursor
dispose(): Promise<void>
exec(statement: string, params?: Record<string, ZuParam> | null, options?: ZuStatementOptions | null): Promise<void>
Expand Down Expand Up @@ -99,6 +100,49 @@ export interface ZuBatch<Row = Record<string, ZuValue>> extends Array<Row> {
// @public
export type ZuBigIntMode = 'bigint' | 'number'

// @public
export interface ZuColumn {
readonly data: Uint8Array | null
readonly items: ZuValue[] | null
// (undocumented)
readonly length: number
// (undocumented)
readonly name: string
readonly nulls: number
readonly offsets: Int32Array | BigInt64Array | null
// (undocumented)
readonly type: ZuColumnType
readonly unit: 'days' | 'nanos' | 'months' | null
readonly validity: Uint8Array | null
readonly values: BigInt64Array | Float64Array | Int32Array | Uint8Array | null
readonly zone: number | null
}

// @public
export interface ZuColumnar {
// (undocumented)
readonly columns: ZuColumn[]
// (undocumented)
readonly gqlstatus: string
// (undocumented)
readonly notices: ZuNotice[]
// (undocumented)
readonly rows: number
}

// @public
export type ZuColumnType =
| 'null'
| 'bool'
| 'int'
| 'float'
| 'string'
| 'date'
| 'time'
| 'datetime'
| 'duration'
| 'value'

// @public
export class ZuCursor {
cancel(): Promise<void>
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@
"reference": "node tools/reference.mjs reference",
"bench": "node bench/query.mjs",
"bench:append": "node bench/append.mjs",
"bench:columnar": "node bench/columnar.mjs",
"bench:load": "node bench/load.mjs",
"bench:register": "node bench/register.mjs",
"bench:temporal": "node --harmony-temporal bench/query.mjs"
Expand Down
Loading
Loading