The same input and the same kernels, in neo's C++ and in Spark 4.2.0. Across 14 measurements neo finished 83.2× faster in the middle and 2,049× at most; Spark 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, what neo is held to, how Spark does it and every number taken for it. In a race both ships run for the same stretch: Spark completes the work once, and neo completes it as many times as the measured ratio says, turning around at each end.
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.
What neo is held to
The same table the app loads, with the amount column typed and the region column encoded at load, so that no kernel after it touches text. Before a time is taken every engine prints its table as one number and its checks, and the line under the table says whether they agreed.
How Spark does it
spark.read.csv with inferSchema, cached, then a count to force the read into memory. inferSchema reads the file once more to find the types, and that is inside the time.
Spark, this kernel · neon-cpp/bench/lang/python/yardstick.py
def load():
global df
if df is not None:
df.unpersist(blocking=True)
df = spark.read.csv(args.csv, header=True, inferSchema=True).cache()
df.count() # force the read into memory; inferSchema has already read the file once more for types
res["load_first_ms"], res["load_ms"] = load_twice(load)
neo's side is confidential. What it is held to is above; why it is kept is under 03.
Every number taken for it
measurement
neo
Spark
× neo
time, runs in a row, msThe typed read, best of two, with the first read of a fresh process kept separately.
2.77
359
130×
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.
172
14,302
83.2×
Read it with this
Spark's JVM and session start-up are not counted here.
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.
What neo is held to
The count every other engine prints, under the JavaScript node's rule that NaN never matches. What comes out is a selection the next kernel reads from, not a copy of the rows.
How Spark does it
A filter and a count, which Spark plans and runs as a job.
Spark, this kernel · neon-cpp/bench/lang/python/yardstick.py
timed("filter", lambda: df.filter(pred).count())
neo's side is confidential. What it is held to is above; why it is kept is under 03.
Every number taken for it
measurement
neo
Spark
× neo
time, runs in a row, msBest of three, runs in a row: every thread awake and the cores at speed.
0.018
36.9
2,049×
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.08
1,413
1,313×
time, one run from rest, msThe call made once after 50 ms of nothing, mean of ten: what a click finds.
0.782
29.8
38.0×
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.72
315
36.1×
Read it with this
Spark's JVM and session start-up are not counted here.
neo's side is confidential. What it is held to is above; why it is kept is under 03.
Every number taken for it
measurement
neo
Spark
× neo
time, runs in a row, msBest of three, runs in a row: every thread awake and the cores at speed.
0.380
22.0
57.8×
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.6
1,402
65.0×
Read it with this
Spark's JVM and session start-up are not counted here.
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.
What neo is held to
A stable ascending order under the JavaScript rule that NaN sorts first and ties keep their place. The first sorted values are printed and compared with every other engine's.
How Spark does it
Spark, like SQL, has no ordering apart from the table it orders, so nothing is timed here and the cell is left blank.
Spark, this kernel · neon-cpp/bench/lang/python/yardstick.py
res["sort_perm_ms"] = None
neo's side is confidential. What it is held to is above; why it is kept is under 03.
Every number taken for it
measurement
neo
Spark
× 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
—
—
Read it with this
Spark's JVM and session start-up are not counted here.
neo's side is confidential. What it is held to is above; why it is kept is under 03.
Every number taken for it
measurement
neo
Spark
× neo
time, runs in a row, msBest of three, runs in a row: every thread awake and the cores at speed.
2.72
122
45.0×
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.
144
6,350
44.2×
Read it with this
Spark's JVM and session start-up are not counted here.
Total the amount column for each of five regions: a grouped sum, the kernel every dashboard runs.
What neo is held to
The JavaScript loop's result to the bit. A floating-point sum depends on the order it is taken in, and neo is held to the row order's answer whatever it does to get there.
How Spark does it
groupBy on region with a sum of amount, and the answer collected to the driver.
Spark, this kernel · neon-cpp/bench/lang/python/yardstick.py
neo's side is confidential. What it is held to is above; why it is kept is under 03.
Every number taken for it
measurement
neo
Spark
× neo
time, runs in a row, msBest of three, runs in a row: every thread awake and the cores at speed.
0.020
39.0
1,948×
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.55
2,352
1,518×
time, one run from rest, msThe call made once after 50 ms of nothing, mean of ten: what a click finds.
0.758
40.4
53.3×
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.11
787
86.4×
Read it with this
Spark's JVM and session start-up are not counted here.
02 · Every number
Against the libraries people reach for first
1,000,000 rows · ms, lower is better · 2026-10-11 21:23
kernel
neo
Spark
× neo
load the CSV (parse + type)
2.77
359
130×
filter amount > 500, count
0.018
36.9
2,049×
filter into a new table
0.380
22.0
57.8×
sort into a new table
2.72
122
45.0×
sum(amount) by region
0.020
39.0
1,948×
filter, count: one run from rest
0.782
29.8
38.0×
sum by region: one run from rest
0.758
40.4
53.3×
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
kernel
neo
Spark
× neo
load the CSV (parse + type)
172
14,302
83.2×
filter amount > 500, count
1.08
1,413
1,313×
filter into a new table
21.6
1,402
65.0×
sort into a new table
144
6,350
44.2×
sum(amount) by region
1.55
2,352
1,518×
filter, count: one run from rest
8.72
315
36.1×
sum by region: one run from rest
9.11
787
86.4×
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).
03 · Both sides
Spark's side, in full
Apache Spark is a distributed engine built for clusters, run here on one machine on Java 17. Its JVM and session start-up are listed separately and not counted against the kernels. 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.
spark, as its users write it: the six kernels · neon-cpp/bench/lang/python/yardstick.py:255
elif args.lib == "spark":
t0 = now()
from pyspark.sql import SparkSession, functions as F
import pyspark
res["version"] = pyspark.__version__
mem = os.environ.get("NEON_SPARK_MEMORY", "48g")
spark = (SparkSession.builder.master(f"local[{args.threads}]")
.config("spark.driver.memory", mem)
.config("spark.sql.shuffle.partitions", str(args.threads))
.config("spark.default.parallelism", str(args.threads))
.config("spark.ui.enabled", "false")
.config("spark.sql.adaptive.enabled", "true")
.getOrCreate())
spark.sparkContext.setLogLevel("ERROR")
res["startup_ms"] = (now() - t0) * 1000
PIDS = descendants() # the JVM is where Spark's work is done
res["java"] = spark.sparkContext._jvm.java.lang.System.getProperty("java.version")
df = None
def load():
global df
if df is not None:
df.unpersist(blocking=True)
df = spark.read.csv(args.csv, header=True, inferSchema=True).cache()
df.count() # force the read into memory; inferSchema has already read the file once more for types
res["load_first_ms"], res["load_ms"] = load_twice(load)
res["rows"] = df.count()
pred = F.col("amount") > 500
sink = lambda d: d.write.format("noop").mode("overwrite").save() # Spark's benchmarking sink: every row is produced, none is collected
timed("filter", lambda: df.filter(pred).count())
timed("filter_mat", lambda: sink(df.filter(pred)))
res["sort_perm_ms"] = None
timed("sort_mat", lambda: sink(df.orderBy("amount")))
timed("agg", lambda: df.groupBy("region").agg(F.sum("amount")).collect())
checks["filter_count"] = df.filter(pred).count()
checks["first_sorted"] = [float(r[0]) for r in df.orderBy("amount").select("amount").limit(3).collect()]
checks["sums"] = {r[0]: float(r[1]) for r in df.groupBy("region").agg(F.sum("amount")).collect()}
energy_summary() # while the JVM is still there to be read
spark.stop() # so the JVM's peak RSS is in RUSAGE_CHILDREN
neo's side: confidential
neo's kernels, the ports written in-house to beat them, and the techniques behind both stay with the engine. Nothing about how the numbers were made is withheld: the method, the checks every engine had to pass, every number and every place neo is behind are on this page. The engine itself we keep confidential, because we don't want it getting into the wrong hands. The binaries run on request and reproduce every number here.
04 · Diligence
What both sides were held to
Same input, same answer. Every engine reads the same file and prints the same checks line: the filter count, the first sorted values, the group sums. A number enters the ledger only when the checks agree with the C++.
Timed the same way. A kernel is the best of three in one process; a load is the engine's typed read, best of two, with the first read of a fresh process kept separately. Engines that need a warm-up get it before the clock.
Energy is what the machine billed. Millijoules a run, read from the hardware counters for each engine's whole process tree, with the draw at rest subtracted for the seconds of the window.
The machine is named, and so is its load. Apple M5 Max, 18 cores, 26.4.1. The load average before each run is in the ledger, and a study taken on a shared machine says so beside its numbers.
Where Spark wins, it is shown winning. None of the 14 measurements on this page, and that is also a measurement.
The caveat on this comparison. Spark is a cluster engine run on one laptop, which is not what it is for. Its JVM and session start are excluded from every kernel, its materialisations go to the noop sink so every row is produced and none collected, and it was given 48 GB. These are the numbers a Spark user gets on one machine, not what a cluster does.
What is kept back. neo's kernels, the in-house ports and the techniques behind them: we don't want them getting into the wrong hands. The binaries run on request.
Scripts: scripts/bench_libraries.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.