Chart · milliseconds
sum by region.
neo 0.020 ms in the language study, 0.020 ms in the library run. Closest: Zig at 1.40×. Farthest: Spark at 1,948×.
Every engine's multiple of neo
- neo1×
- Zig1.40× · 0.028 vs 0.020
- Swift1.85× · 0.037 vs 0.020
- Rust2.20× · 0.044 vs 0.020
- Go4.45× · 0.089 vs 0.020
- Polars77.3× · 1.55 vs 0.020
- DuckDB112× · 2.24 vs 0.020
- pandas612× · 12.2 vs 0.020
- Spark1,948× · 39.0 vs 0.020
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 ms. The languages and the libraries were measured in separate runs, so neo has a number in each; every multiple is against neo in its own run.
What this measures
Total the amount column for each of five regions: a grouped sum, the kernel every dashboard runs.
Why neo is quick here
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.
The code that ran, this kernel only
Zig1.40× neo · neo vs Zig
fn aggMulti(pool: *Pool, amount: []const f64, ids: []const u32, ngroups: usize, partial: [][MAX_GROUPS]f64, out: []f64) void {
const nk = partial.len;
var j: AggJob = .{ .amount = amount, .ids = ids, .ngroups = ngroups, .partial = partial };
pool.runChunks(&j, AggJob.run, nk);
for (0..ngroups) |g| {
var s: f64 = 0;
for (0..nk) |k| s += partial[k][g]; // fixed chunk order -> deterministic sums
out[g] = s;
}
}Swift1.85× neo · neo vs Swift
func aggregateByRegion(_ ids: UnsafePointer<UInt32>, _ amt: UnsafePointer<Double>, _ n: Int, groups g: Int, pool: Pool?, parts: Int) -> [Double] {
let np = (pool == nil || parts <= 1) ? 1 : max(1, min(parts, n / 4096))
let chunk = (n + np - 1) / np
let acc = alloc(np * 8 * g, Double.self)
memset(acc, 0, np * 8 * g * 8)
let body: (Int) -> Void = { p in aggRange(ids, amt, p * chunk, min(n, (p + 1) * chunk), groups: g, acc + p * 8 * g) }
if let pool = pool, np > 1 { pool.run(np, body) } else { body(0) }
var sums = [Double](repeating: 0, count: g)
for p in 0..<np { for lane in 0..<8 { for k in 0..<g { sums[k] += acc[(p * 8 + lane) * g + k] } } }
free(acc)
return sums
}Rust2.20× neo · neo vs Rust
fn agg_mt(ids: &[u32], amt: &[f64], g: usize, pool: &Pool, parts: usize) -> Vec<f64> {
let ranges = chunk_ranges(ids.len(), parts);
let partials: Vec<Vec<f64>> = pool.map(parts, |p| {
let (a, b) = ranges[p];
agg_range(&ids[a..b], &amt[a..b], g)
});
let mut out = vec![0f64; g];
for p in &partials {
for j in 0..g {
out[j] += p[j];
}
}
out
}Go4.45× neo · neo vs Go
func aggregate(ids []uint32, amt []float64, groups int, p *pool) []float64 {
n := len(ids)
C := p.chunks()
partial := make([]float64, C*groups)
p.run(C, n, func(c, lo, hi int) {
acc := make([]float64, 4*groups)
a0, a1, a2, a3 := acc[:groups], acc[groups:2*groups], acc[2*groups:3*groups], acc[3*groups:]
i := lo
for ; i+4 <= hi; i += 4 {
a0[ids[i]] += amt[i]
a1[ids[i+1]] += amt[i+1]
a2[ids[i+2]] += amt[i+2]
a3[ids[i+3]] += amt[i+3]
}
for ; i < hi; i++ {
a0[ids[i]] += amt[i]
}
out := partial[c*groups : (c+1)*groups]
for g := 0; g < groups; g++ {
out[g] = a0[g] + a1[g] + a2[g] + a3[g]
}
})
sums := make([]float64, groups)
for c := 0; c < C; c++ {
for g := range sums {
sums[g] += partial[c*groups+g]
}
}
return sums
}Polars77.3× neo · neo vs Polars
timed("agg", lambda: df.group_by("region").agg(pl.col("amount").sum()))DuckDB112× neo · neo vs DuckDB
timed("agg", lambda: con.execute("SELECT region, sum(amount) FROM t GROUP BY region").fetchall())pandas612× neo · neo vs pandas
timed("agg", lambda: df.groupby("region")["amount"].sum())Spark1,948× neo · neo vs Spark
timed("agg", lambda: df.groupBy("region").agg(F.sum("amount")).collect())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;// 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);Measured on Apple M5 Max, 18 cores, 26.4.1, 2026-10-11 and 2026-10-12. The language study ran on a shared machine; its columns stand beside each other, but its times read high. Every number here is also on the comparison page of its engine, with the whole file it came from.