Chart · millijoules a run
load the CSV, in energy.
neo 172 mJ in the energy run. Closest: Polars at 2.87×. Farthest: Spark at 83.2×.
Every engine's multiple of neo
- neo1×
- Polars2.87× · 494 vs 172
- DuckDB8.26× · 1,421 vs 172
- pandas9.51× · 1,636 vs 172
- Spark83.2× · 14,302 vs 172
Bars to scale, from zero to the slowest engine. The gray pair is the engine's measurement and neo's in the same run, in mJ.
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. Energy is the same run in millijoules: what the machine billed each engine's whole process tree, with its draw at rest subtracted for the seconds of the window. neo's pool is told to rest after the kernel, as the editor tells it.
Why neo is quick here
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.
The code that ran, this kernel only
Polars2.87× neo · neo vs Polars
res["load_first_ms"], res["load_ms"] = load_twice(lambda: pl.read_csv(args.csv))DuckDB8.26× neo · neo vs DuckDB
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]))pandas9.51× neo · neo vs pandas
res["load_first_ms"], res["load_ms"] = load_twice(lambda: pd.read_csv(args.csv))
_, res["load_pyarrow_ms"] = load_twice(lambda: pd.read_csv(args.csv, engine="pyarrow"), "load_pyarrow_mj")Spark83.2× neo · neo vs Spark
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
The kernel's body stays in the engine. Its declaration and the guarantees it is held to are below, with the harness line that calls it.
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;Table parse(std::string_view raw); // parallel above 1 MB, serial below; identical results// 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.Measured on Apple M5 Max, 18 cores, 26.4.1, 2026-10-11. Every number here is also on the comparison page of its engine, with the whole file it came from.