neo vs DuckDB · library 1.5.6

neo vs DuckDB.

The same input and the same kernels, in neo's C++ and in DuckDB 1.5.6. Across 24 measurements neo finished 10.6× faster in the middle and 112× at most; DuckDB was not quicker on any.

01 · The race

One kernel at a time

Each piece of work below has its own race, what it measures, how each side does it, the code that ran and every number taken for it. In a race both ships run for the same stretch: DuckDB completes the work once, and neo completes it as many times as the measured ratio says, turning around at each end.

  1. 01.1 · load the CSV, parse and type18.7×
  2. 01.2 · filter amount > 500, count33.5×
  3. 01.3 · filter into a new table106×
  4. 01.4 · sort, as a permutationnot measured
  5. 01.5 · sort into a new table8.62×
  6. 01.6 · sum by region112×
  7. 01.7 · load worldcitiespop8.08×
  8. 01.8 · load NYC taxi · 1M12.5×
  9. 01.9 · load NYC taxi · 10M6.07×
  10. 01.10 · load ClickBench · 1M15.0×
  11. 01.11 · load ClickBench · 10M4.43×

01.1 · kernel · 1,000,000 rows

load the CSV, parse and type

neo 2.77 ms · DuckDB 51.8 ms · 18.7× neo. Against every engine →

neo

DuckDB

0 runs

0 runs

What this measures

Read a 46 MB CSV of a million rows and six columns into a table: split the bytes into fields, parse the amount column into numbers, and dictionary-encode the region column. Each engine's typed read, best of two, with the first read of a fresh process kept separately.

How neo does it

neo's parser finds the structural characters with SIMD, splits the file into blocks and parses them on every core above a megabyte, with results identical to the serial path. Typing and the dictionary happen once, at load, on the pool, so no kernel after it touches text. In the ledger's breakdown that is 2.8 ms of parse, 0.2 ms of typing and about nothing for the dictionary.

How DuckDB does it

read_csv into a new table inside DuckDB, with DuckDB choosing the column types.

DuckDB, this kernel · neon-cpp/bench/lang/python/yardstick.py
res["load_first_ms"], res["load_ms"] = load_twice(lambda: con.execute("CREATE OR REPLACE TABLE t AS SELECT * FROM read_csv(?, header=true)", [args.csv]))

neo's side: the call and the contract

the harness, calling the engine · neon-cpp/bench/bench_engine_options.cpp:194
l.t = csv::parse(raw);
the parser's contract · neon-cpp/core/csv.h:55
Table parse(std::string_view raw);  // parallel above 1 MB, serial below; identical results

Every number taken for it

measurementneoDuckDB× neo
time, runs in a row, msThe typed read, best of two, with the first read of a fresh process kept separately.2.7751.818.7×
energy, runs in a row, mJMillijoules a run, in the same run as the time: the energy over the timed runs and as many more as fill 200 ms, divided by the runs, less the draw at rest. A mean, where the time is a best.1721,4218.26×

01.2 · kernel · 1,000,000 rows

filter amount > 500, count

neo 0.018 ms · DuckDB 0.602 ms · 33.5× neo. Against every engine →

neo

DuckDB

0 runs

0 runs

What this measures

Count the rows whose amount is over 500: a million comparisons on a typed column, the answer one integer. Each library's count is written the way its users write it.

How neo does it

neo's filter produces a bitmap: one bit per row, 125 KB for a million rows, written at memory speed, with indices made only when something needs them. Below about a million values it runs on one thread, above that on the pool. The contract is the JavaScript node's: NaN never matches.

How DuckDB does it

A plain SELECT count(*) with the predicate in its WHERE clause, and the one row fetched.

DuckDB, this kernel · neon-cpp/bench/lang/python/yardstick.py
timed("filter", lambda: con.execute("SELECT count(*) FROM t WHERE amount > 500").fetchone())

neo's side: the call and the contract

the harness, calling the engine · neon-cpp/bench/bench_engine_options.cpp:337
const double f5 = timeIt([&] { Bitmap b = compareDoubles(amt.data(), n, Cmp::Gt, 500, &pool); sink = b.words[0]; }, 7);
the filter's contract · neon-cpp/core/selection.h:1
// selection.h — the result of a filter: one bit per row. 1M rows = 125 KB,
// written at memory speed; indices are produced only when something needs them.

struct Bitmap {
    std::vector<uint64_t> words;
    size_t bits = 0;
    bool test(size_t i) const { return (words[i >> 6] >> (i & 63)) & 1; }
    size_t count() const;
    std::vector<uint32_t> indices() const;  // ascending row indices of the set bits
};

// JS measurefilter semantics: NaN never matches. Sequential below ~1M values; the pool otherwise.
Bitmap compareDoubles(const double* v, size_t n, Cmp op, double threshold, Pool* pool);

Every number taken for it

measurementneoDuckDB× neo
time, runs in a row, msBest of three, runs in a row: every thread awake and the cores at speed.0.0180.60233.5×
energy, runs in a row, mJMillijoules a run, in the same run as the time: the energy over the timed runs and as many more as fill 200 ms, divided by the runs, less the draw at rest. A mean, where the time is a best.1.0814.813.7×
time, one run from rest, msThe call made once after 50 ms of nothing, mean of ten: what a click finds.0.7821.321.69×
energy, one run from rest, mJThe same single call from rest, in millijoules. From rest a run costs less than in a row where the work is large, and more where it is tiny, because the threads have to be woken for it.8.729.401.08×

01.3 · kernel · 1,000,000 rows

filter into a new table

neo 0.380 ms · DuckDB 40.3 ms · 106× neo. Against every engine →

neo

DuckDB

0 runs

0 runs

What this measures

Keep the rows whose amount is over 500 and build a new table of them, about half the file, every column copied.

How neo does it

The bitmap from the filter drives a parallel gather of every column, and the dictionary column copies its ids rather than its strings.

How DuckDB does it

CREATE TABLE AS with the predicate: SQL's form of a materialised result, so DuckDB writes the rows into a table of its own.

DuckDB, this kernel · neon-cpp/bench/lang/python/yardstick.py
timed("filter_mat", lambda: con.execute("CREATE OR REPLACE TABLE f AS SELECT * FROM t WHERE amount > 500"))

neo's side: the call and the contract

the filter's contract · neon-cpp/core/selection.h:1
// selection.h — the result of a filter: one bit per row. 1M rows = 125 KB,
// written at memory speed; indices are produced only when something needs them.

struct Bitmap {
    std::vector<uint64_t> words;
    size_t bits = 0;
    bool test(size_t i) const { return (words[i >> 6] >> (i & 63)) & 1; }
    size_t count() const;
    std::vector<uint32_t> indices() const;  // ascending row indices of the set bits
};

// JS measurefilter semantics: NaN never matches. Sequential below ~1M values; the pool otherwise.
Bitmap compareDoubles(const double* v, size_t n, Cmp op, double threshold, Pool* pool);

Every number taken for it

measurementneoDuckDB× neo
time, runs in a row, msBest of three, runs in a row: every thread awake and the cores at speed.0.38040.3106×
energy, runs in a row, mJMillijoules a run, in the same run as the time: the energy over the timed runs and as many more as fill 200 ms, divided by the runs, less the draw at rest. A mean, where the time is a best.21.656826.3×

01.4 · kernel · 1,000,000 rows

sort, as a permutation

DuckDB: not measured. Against every engine →

What this measures

Produce the stable ascending order of the amount column as a permutation of row indices, without moving any other column. SQL engines have no such form, so DuckDB and Spark sit this one out.

How neo does it

neo turns each double into a sortable 64-bit key and runs an LSD radix sort on the pool: O(n), stable, with the JavaScript rule that NaN sorts as negative infinity and ties keep their order. The ledger's design levers show the step from std::stable_sort at 84 ms, to radix at 14 ms, to the pool at 1 ms.

How DuckDB does it

SQL has no form for an ordering apart from the table it orders, so nothing is timed here and the cell is left blank. The sorted table, in the segment after this one, is the form DuckDB has.

DuckDB, this kernel · neon-cpp/bench/lang/python/yardstick.py
res["sort_perm_ms"] = None  # SQL has no permutation; the sorted table is the only form

neo's side: the call and the contract

the harness, calling the engine · neon-cpp/bench/bench_engine_options.cpp:372
const double s4 = timeIt([&] { sortIndexByDouble(amt.data(), n, perm, &pool); sink = perm[0]; }, 3);
the sort's contract · neon-cpp/core/sort.h:1
// sort.h — stable O(n) sorting of row indices by a double key (parallel radix).
// JS numeric sort semantics: NaN sorts as -Infinity; ties keep input order.

// Fills `idx` with the stable ascending order of keys[0..n). pool may be null.
void sortIndexByDouble(const double* keys, size_t n, std::vector<uint32_t>& idx, Pool* pool);

Every number taken for it

measurementneoDuckDB× neo
time, runs in a row, msBest of three, runs in a row: every thread awake and the cores at speed.1.03——
energy, runs in a row, mJMillijoules a run, in the same run as the time: the energy over the timed runs and as many more as fill 200 ms, divided by the runs, less the draw at rest. A mean, where the time is a best.64.5——
time, one run from rest, msThe call made once after 50 ms of nothing, mean of ten: what a click finds.2.73——
energy, one run from rest, mJThe same single call from rest, in millijoules. From rest a run costs less than in a row where the work is large, and more where it is tiny, because the threads have to be woken for it.44.8——

01.5 · kernel · 1,000,000 rows

sort into a new table

neo 2.72 ms · DuckDB 23.4 ms · 8.62× neo. Against every engine →

neo

DuckDB

0 runs

0 runs

What this measures

Sort by amount and build the whole sorted table, every column reordered.

How neo does it

The permutation from the radix sort drives a parallel gather of every column, with the dictionary column moved as ids. The ledger times the gather alone at under three milliseconds for a million rows.

How DuckDB does it

CREATE TABLE AS with ORDER BY amount: the sorted table written into DuckDB's own storage.

DuckDB, this kernel · neon-cpp/bench/lang/python/yardstick.py
timed("sort_mat", lambda: con.execute("CREATE OR REPLACE TABLE s AS SELECT * FROM t ORDER BY amount"))

neo's side: the call and the contract

the sort's contract · neon-cpp/core/sort.h:1
// sort.h — stable O(n) sorting of row indices by a double key (parallel radix).
// JS numeric sort semantics: NaN sorts as -Infinity; ties keep input order.

// Fills `idx` with the stable ascending order of keys[0..n). pool may be null.
void sortIndexByDouble(const double* keys, size_t n, std::vector<uint32_t>& idx, Pool* pool);

Every number taken for it

measurementneoDuckDB× neo
time, runs in a row, msBest of three, runs in a row: every thread awake and the cores at speed.2.7223.48.62×
energy, runs in a row, mJMillijoules a run, in the same run as the time: the energy over the timed runs and as many more as fill 200 ms, divided by the runs, less the draw at rest. A mean, where the time is a best.1449006.26×

01.6 · kernel · 1,000,000 rows

sum by region

neo 0.020 ms · DuckDB 2.24 ms · 112× neo. Against every engine →

neo

DuckDB

0 runs

0 runs

What this measures

Total the amount column for each of five regions: a grouped sum, the kernel every dashboard runs.

How neo does it

The region column was dictionary-encoded at load, so its ids are the group numbers and no string is hashed. The sum is taken on the pool in any order only when the column is whole numbers small enough that no addition can round; otherwise it is added in row order on one thread, because a floating-point sum depends on its order, and the contract is the JavaScript loop's result to the bit.

How DuckDB does it

A GROUP BY on region with sum(amount), and every row of the answer fetched.

DuckDB, this kernel · neon-cpp/bench/lang/python/yardstick.py
timed("agg", lambda: con.execute("SELECT region, sum(amount) FROM t GROUP BY region").fetchall())

neo's side: the call and the contract

the harness, calling the engine · neon-cpp/bench/bench_engine_options.cpp:454
const double a5 = G <= 16 ? timeIt([&] { groupSumAnyOrder(amtP, idP, n, G, &pool, pairTotal); sink = static_cast<size_t>(pairTotal[0]); }, 9) : 0;
the group sum's contract · neon-cpp/core/groupsum.h:1
// groupsum.h — a typed column's sum, count and extremes per group: what the aggregate node's measures need
// once the column is doubles (toDoubles) and every row has a group number.
//
// The results are the ones a loop over the rows in order gives — `if (isFinite(v)) { sum += v; n++;
// if (v < min) min = v; if (v > max) max = v; }` a group, which is what the JavaScript does — to the bit:
//   • counts and extremes are taken a part of the rows at a time on the pool and the parts joined in row
//     order (a strict comparison keeps the earlier of two equal values, as the loop does);
//   • a sum is added in row order on one thread, because a floating-point sum depends on its order —
//     unless the column is whole numbers small enough that no addition can round (wholeNumbers below),
//     where every order gives the same bits and the rows are summed on the pool, two a step.
// bench/bench_agg_node.cpp times each path against the node and checks it; PHD.md §30.

// The sum alone, any order, two rows a step; for tests and the benchmark (groupStats calls it when it may).
// False, and nothing written, when there are more groups than it is built for.
bool groupSumAnyOrder(const double* x, const uint32_t* id, size_t n, size_t groups, Pool* pool, double* sums);

Every number taken for it

measurementneoDuckDB× neo
time, runs in a row, msBest of three, runs in a row: every thread awake and the cores at speed.0.0202.24112×
energy, runs in a row, mJMillijoules a run, in the same run as the time: the energy over the timed runs and as many more as fill 200 ms, divided by the runs, less the draw at rest. A mean, where the time is a best.1.5572.346.7×
time, one run from rest, msThe call made once after 50 ms of nothing, mean of ten: what a click finds.0.7585.687.49×
energy, one run from rest, mJThe same single call from rest, in millijoules. From rest a run costs less than in a row where the work is large, and more where it is tiny, because the threads have to be woken for it.9.1138.04.17×

01.7 · public data · a file this project did not make

load worldcitiespop

neo 11.4 ms · DuckDB 91.8 ms · 8.08× neo. Against every engine →

neo

DuckDB

0 runs

0 runs

What this measures

Read worldcitiespop from its path to a table in memory: 3,173,958 rows by 7 columns, 151 MB of place names, the file the CSV tools benchmark on. Every engine in a fresh process each round, the file in the OS cache, each engine's start-up done before the clock; warm is the median of the loads after the first. neo's table is text, with no types, no nulls and no UTF-8 check, so the rows as text are the like-for-like ones.

How neo does it

The same parser as the synthetic load, on a file this project did not make, checked column by column and row by row against DuckDB's reading of it before any time is taken.

How DuckDB does it

read_csv with all_varchar into a new table, so every column is text. One query is run first, untimed, so that the binding's own set-up is not counted as loading.

DuckDB reading the public files · neon-cpp/bench/lang/python/standards.py:40
def load_duckdb(path, mode, loads, opt=None):
    import duckdb
    con = duckdb.connect()
    con.execute("SELECT 42").fetchall()  # untimed: what the binding sets up on its first query (it imports pandas) is not CSV loading
    opts = ", all_varchar=true" if mode == "text" else ""
    out, rows, cols = [], 0, 0
    for _ in range(loads):
        con.execute("DROP TABLE IF EXISTS t")
        t0 = time.perf_counter()
        con.execute(f"CREATE TABLE t AS SELECT * FROM read_csv(?, header=true{opts})", [path])
        out.append(ms_since(t0))
        rows = con.execute("SELECT count(*) FROM t").fetchone()[0]
        cols = len(con.execute("SELECT * FROM t LIMIT 0").description)
    threads = con.execute("SELECT current_setting('threads')").fetchone()[0]
    return duckdb.__version__, out, rows, cols, int(threads)

neo's side: the call and the contract

the parser's contract · neon-cpp/core/csv.h:55
Table parse(std::string_view raw);  // parallel above 1 MB, serial below; identical results

Every number taken for it

measurementneoDuckDB× neo
first load, as text, msThe median first load: a fresh process each round, the file in the operating system's cache, the engine's own start-up done before the clock.23.825210.6×
loads after the first, as text, msThe median of the loads after the first, in the same process.11.491.88.08×

01.8 · public data · a file this project did not make

load NYC taxi · 1M

neo 52.9 ms · DuckDB 661 ms · 12.5× neo. Against every engine →

neo

DuckDB

0 runs

0 runs

What this measures

Read NYC taxi · 1M from its path to a table in memory: 1,000,000 rows by 51 columns, 425 MB of trips. Every engine in a fresh process each round, the file in the OS cache, each engine's start-up done before the clock; warm is the median of the loads after the first. neo's table is text, with no types, no nulls and no UTF-8 check, so the rows as text are the like-for-like ones.

How neo does it

The same parser as the synthetic load, on a file this project did not make, checked column by column and row by row against DuckDB's reading of it before any time is taken.

How DuckDB does it

read_csv with all_varchar into a new table, so every column is text. One query is run first, untimed, so that the binding's own set-up is not counted as loading. The reader is the one shown under the first public file.

neo's side: the call and the contract

the parser's contract · neon-cpp/core/csv.h:55
Table parse(std::string_view raw);  // parallel above 1 MB, serial below; identical results

Every number taken for it

measurementneoDuckDB× neo
first load, as text, msThe median first load: a fresh process each round, the file in the operating system's cache, the engine's own start-up done before the clock.72.982711.3×
loads after the first, as text, msThe median of the loads after the first, in the same process.52.966112.5×

01.9 · public data · a file this project did not make

load NYC taxi · 10M

neo 418 ms · DuckDB 2,539 ms · 6.07× neo. Against every engine →

neo

DuckDB

0 runs

0 runs

What this measures

Read NYC taxi · 10M from its path to a table in memory: 10,000,000 rows by 51 columns, 4.3 GB of trips. Every engine in a fresh process each round, the file in the OS cache, each engine's start-up done before the clock; warm is the median of the loads after the first. neo's table is text, with no types, no nulls and no UTF-8 check, so the rows as text are the like-for-like ones.

How neo does it

The same parser as the synthetic load, on a file this project did not make, checked column by column and row by row against DuckDB's reading of it before any time is taken.

How DuckDB does it

read_csv with all_varchar into a new table, so every column is text. One query is run first, untimed, so that the binding's own set-up is not counted as loading. The reader is the one shown under the first public file.

neo's side: the call and the contract

the parser's contract · neon-cpp/core/csv.h:55
Table parse(std::string_view raw);  // parallel above 1 MB, serial below; identical results

Every number taken for it

measurementneoDuckDB× neo
first load, as text, msThe median first load: a fresh process each round, the file in the operating system's cache, the engine's own start-up done before the clock.4482,7526.14×
loads after the first, as text, msThe median of the loads after the first, in the same process.4182,5396.07×

01.10 · public data · a file this project did not make

load ClickBench · 1M

neo 96.0 ms · DuckDB 1,438 ms · 15.0× neo. Against every engine →

neo

DuckDB

0 runs

0 runs

What this measures

Read ClickBench · 1M from its path to a table in memory: 1,000,000 rows by 105 columns, 842 MB of web hits. Every engine in a fresh process each round, the file in the OS cache, each engine's start-up done before the clock; warm is the median of the loads after the first. neo's table is text, with no types, no nulls and no UTF-8 check, so the rows as text are the like-for-like ones.

How neo does it

The same parser as the synthetic load, on a file this project did not make, checked column by column and row by row against DuckDB's reading of it before any time is taken.

How DuckDB does it

read_csv with all_varchar into a new table, so every column is text. One query is run first, untimed, so that the binding's own set-up is not counted as loading. The reader is the one shown under the first public file.

neo's side: the call and the contract

the parser's contract · neon-cpp/core/csv.h:55
Table parse(std::string_view raw);  // parallel above 1 MB, serial below; identical results

Every number taken for it

measurementneoDuckDB× neo
first load, as text, msThe median first load: a fresh process each round, the file in the operating system's cache, the engine's own start-up done before the clock.1261,64713.0×
loads after the first, as text, msThe median of the loads after the first, in the same process.96.01,43815.0×

01.11 · public data · a file this project did not make

load ClickBench · 10M

neo 1,056 ms · DuckDB 4,682 ms · 4.43× neo. Against every engine →

neo

DuckDB

0 runs

0 runs

What this measures

Read ClickBench · 10M from its path to a table in memory: 10,000,000 rows by 105 columns, 8.1 GB of web hits. Every engine in a fresh process each round, the file in the OS cache, each engine's start-up done before the clock; warm is the median of the loads after the first. neo's table is text, with no types, no nulls and no UTF-8 check, so the rows as text are the like-for-like ones.

How neo does it

The same parser as the synthetic load, on a file this project did not make, checked column by column and row by row against DuckDB's reading of it before any time is taken.

How DuckDB does it

read_csv with all_varchar into a new table, so every column is text. One query is run first, untimed, so that the binding's own set-up is not counted as loading. The reader is the one shown under the first public file.

neo's side: the call and the contract

the parser's contract · neon-cpp/core/csv.h:55
Table parse(std::string_view raw);  // parallel above 1 MB, serial below; identical results

Every number taken for it

measurementneoDuckDB× neo
first load, as text, msThe median first load: a fresh process each round, the file in the operating system's cache, the engine's own start-up done before the clock.1,0534,6264.39×
loads after the first, as text, msThe median of the loads after the first, in the same process.1,0564,6824.43×

02 · Every number

Against the libraries people reach for first

1,000,000 rows · ms, lower is better · 2026-10-11 21:23

kernelneoDuckDB× neo
load the CSV (parse + type)2.7751.818.7×
filter amount > 500, count0.0180.60233.5×
filter into a new table0.38040.3106×
sort into a new table2.7223.48.62×
sum(amount) by region0.0202.24112×
filter, count: one run from rest0.7821.321.69×
sum by region: one run from rest0.7585.687.49×

The same CSV and the same six kernels in neo's engine and in the libraries people reach for first, each written the way its users write it (Polars 2.0.0, DuckDB 1.5.6, pandas 3.0.6, Spark 4.2.0). Milliseconds, best of three; the chip is how many times neo's time a library took. The last three rows are one run from rest: the call made once after 50 ms of nothing, mean of ten, neo's pool told to rest after it as the editor tells it. That is what a click finds; the rows above are runs in a row, every thread awake and the cores at speed.

The same run, in energy

1,000,000 rows · mJ, lower is better · 2026-10-11 21:23

kernelneoDuckDB× neo
load the CSV (parse + type)1721,4218.26×
filter amount > 500, count1.0814.813.7×
filter into a new table21.656826.3×
sort into a new table1449006.26×
sum(amount) by region1.5572.346.7×
filter, count: one run from rest8.729.401.08×
sum by region: one run from rest9.1138.04.17×

The same CSV and the same six kernels as the times, in the same run (Polars 2.0.0, DuckDB 1.5.6, pandas 3.0.6, Spark 4.2.0). Millijoules a run. What macOS billed each engine's process, and for Spark its JVM, for the processor cores it ran on: not the memory, the disk or the screen, and not the machine at rest. A kernel's figure is the energy over its timed runs, and as many more as fill 200 ms, divided by the runs, less what the process draws at rest for as long: a mean, where the time is a best. neo's is of the variant whose time is the number on the Speed side. Each benchmark reads the counter itself; scripts/bench_libraries.sh also reads it from outside the process (neon_energy) and RESULTS.md has both totals. The chip is how many times neo's energy a library used. The last three rows are one run from rest: the call made once after 50 ms of nothing, mean of ten, neo's pool told to rest after it as the editor tells it. That is what a click finds; the rows above are runs in a row, every thread awake and the cores at speed. From rest a run costs less energy than in a row where the work is large (the cores are not at their top voltage) and more where it is tiny (the threads have to be woken for it).

Public data, from a path to a table

loads after the first, as text · ms, lower is better · 2026-10-11 16:43

kernelneoDuckDB× neo
worldcitiespop · first load, as text23.825210.6×
worldcitiespop · loads after the first, as text11.491.88.08×
NYC taxi · 1M · first load, as text72.982711.3×
NYC taxi · 1M · loads after the first, as text52.966112.5×
NYC taxi · 10M · first load, as text4482,7526.14×
NYC taxi · 10M · loads after the first, as text4182,5396.07×
ClickBench · 1M · first load, as text1261,64713.0×
ClickBench · 1M · loads after the first, as text96.01,43815.0×
ClickBench · 10M · first load, as text1,0534,6264.39×
ClickBench · 10M · loads after the first, as text1,0564,6824.43×

Public CSVs, not ours, from a path to a table in memory, in neo and in engines this project did not write (Arrow 25.0.1, Polars 2.0.0, DuckDB 1.5.6, pandas 3.0.6). Milliseconds. worldcitiespop.csv is the file as xsv, qsv, xan and zsv benchmark it; the taxi and ClickBench files are the first records of the files those benchmarks publish, byte for byte, under a header line made from the benchmark's own schema (scripts/fetch_standards.sh; every source and SHA-256 is in bench/results/standards/manifest.jsonl). Every engine runs in a fresh process each round, in an order shuffled each round: the first row is the median first load, the second the median of the loads after it. The file is in the operating system's cache throughout, and each engine's own start-up is done before its first load, so these are the readers and nothing else. neo goes through the loader the app uses. Its table is text and only text: no types, no nulls (an empty field is an empty string), no check that the bytes are UTF-8, every field trimmed and a line end inside quotes made \n, as the app it replaces did; the others asked for text check UTF-8 and keep nulls. So the first two rows are the like-for-like ones; in the last two the others also infer types, which neo's loader does not do, and neo's numbers there are its text load again. Before any time is taken neo's table is compared with DuckDB's reading of the file, and each card says what came of it. pandas is run with its pyarrow engine. Threads are each engine's default: 18 here for the others; neo 16, and 18 from 256 MB of input, counts that were chosen on this machine. A dash is an engine that did not read the file.

03 · The code that ran

DuckDB's side, in full

DuckDB is an in-process analytical SQL database: columnar, vectorised, run from inside Python with no server to start. Its kernels are written the way its users write them. Each kernel's lines are beside its race above; this is the whole block they come from, with the checks it prints.

duckdb, as its users write it: the six kernels · neon-cpp/bench/lang/python/yardstick.py:237
elif args.lib == "duckdb":
    t0 = now()
    import duckdb
    res["version"] = duckdb.__version__
    con = duckdb.connect()
    con.execute(f"PRAGMA threads={args.threads}")
    res["startup_ms"] = (now() - t0) * 1000
    res["load_first_ms"], res["load_ms"] = load_twice(lambda: con.execute("CREATE OR REPLACE TABLE t AS SELECT * FROM read_csv(?, header=true)", [args.csv]))
    res["rows"] = con.execute("SELECT count(*) FROM t").fetchone()[0]
    timed("filter", lambda: con.execute("SELECT count(*) FROM t WHERE amount > 500").fetchone())
    timed("filter_mat", lambda: con.execute("CREATE OR REPLACE TABLE f AS SELECT * FROM t WHERE amount > 500"))
    res["sort_perm_ms"] = None  # SQL has no permutation; the sorted table is the only form
    timed("sort_mat", lambda: con.execute("CREATE OR REPLACE TABLE s AS SELECT * FROM t ORDER BY amount"))
    timed("agg", lambda: con.execute("SELECT region, sum(amount) FROM t GROUP BY region").fetchall())
    checks["filter_count"] = con.execute("SELECT count(*) FROM t WHERE amount > 500").fetchone()[0]
    checks["first_sorted"] = [float(r[0]) for r in con.execute("SELECT amount FROM t ORDER BY amount LIMIT 3").fetchall()]
    checks["sums"] = {r[0]: float(r[1]) for r in con.execute("SELECT region, sum(amount) FROM t GROUP BY region").fetchall()}

neo's side: the calls and the contracts

neo's kernels are the product, so their bodies stay in the engine. What is published is what a reader needs to judge the comparison: the harness calling the engine, and each kernel's declaration with the guarantees it is held to, down to the order a floating-point sum is taken in. Each contract is beside its race above; the harness lines together, and whatever no race showed, are here. The binaries run on request, and the scripts named below reproduce every number on this page.

the harness, calling the engine · neon-cpp/bench/bench_engine_options.cpp
 194  l.t = csv::parse(raw);
 337  const double f5 = timeIt([&] { Bitmap b = compareDoubles(amt.data(), n, Cmp::Gt, 500, &pool); sink = b.words[0]; }, 7);
 372  const double s4 = timeIt([&] { sortIndexByDouble(amt.data(), n, perm, &pool); sink = perm[0]; }, 3);
 454  const double a5 = G <= 16 ? timeIt([&] { groupSumAnyOrder(amtP, idP, n, G, &pool, pairTotal); sink = static_cast<size_t>(pairTotal[0]); }, 9) : 0;
how energy is read · neon-cpp/bench/energy.h:1
// energy.h — what a process has used, as the operating system's own ledger has it: the energy it was billed,
// its processor time, the wake-ups it caused. The one reader of that ledger, for two questions:
//   what a kernel costs — the Evals page's Energy view (bench_engine_options.cpp reads it around each kernel;
//     tools/energy/energy.cpp checks the counter and reads it for a command it runs;
//     bench/lang/python/yardstick.py reads the same field; PHD.md section 33)
//   what a process costs at rest — the idle studies (scripts/idle_cost.sh and scripts/idle_server.py take
//     `neon_energy --pid <pid>` twice and subtract; PHD.md section 31.1)
//
// macOS on Apple silicon: proc_pid_rusage(RUSAGE_INFO_V6). ri_energy_nj — nanojoules billed to the process's
// threads for the processor cores they ran on, since it started. Not the memory, the disk or the screen, and
// not the machine at rest: what running this code added. (Not ri_billed_energy: against half a second of a
// busy loop that field read 0.08 mJ where ri_energy_nj read 1,887.) Beside it, processor time in nanoseconds
// — `ps` has hundredths of a second, more than a resting window uses in a minute — and the wake-ups. No
// privilege is needed, and any process of the same user can be read, so a tree of processes can be summed.
// Linux: /proc/<pid>/stat and /proc/<pid>/status — clock ticks and context switches. No energy: RAPL is one
// figure for the whole machine and root's to read.
// Elsewhere: nothing is read (ok stays false).
//
// A reading is cumulative; a measurement is the difference of two. The counter moves in steps (a thread's
// share is settled when it leaves a core or enters the kernel), so a window should be a quarter of a second
// or more: `neon_energy --check` prints how far apart the steps are on the machine it runs on.

04 · Diligence

What both sides were held to

Scripts: scripts/bench_libraries.sh · scripts/bench_standards.sh · measured 2026-10-11 · the numbers on this page come from the same ledger as the app's own Evals page, written by neon-cpp/scripts/results_md.py.

More comparisons