Chart · milliseconds
filter amount > 500, count.
neo 0.017 ms in the language study, 0.018 ms in the library run. Closest: Zig at 1.88×. Farthest: Spark at 2,049×.
Every engine's multiple of neo
- neo1×
- Zig1.88× · 0.032 vs 0.017
- Rust2.24× · 0.038 vs 0.017
- Swift2.76× · 0.047 vs 0.017
- Go4.35× · 0.074 vs 0.017
- Polars9.94× · 0.179 vs 0.018
- pandas18.2× · 0.328 vs 0.018
- DuckDB33.5× · 0.602 vs 0.018
- Spark2,049× · 36.9 vs 0.018
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
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.
Why neo is quick here
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.
The code that ran, this kernel only
Zig1.88× neo · neo vs Zig
fn filterMulti(pool: *Pool, amount: []const f64, out: []u32, counts: []usize, bases: []usize) usize {
const nk = counts.len;
var j: FilterJob = .{ .amount = amount, .out = out, .counts = counts, .bases = bases };
pool.runChunks(&j, FilterJob.countJob, nk);
var sum: usize = 0;
for (0..nk) |k| {
bases[k] = sum;
sum += counts[k];
}
pool.runChunks(&j, FilterJob.writeJob, nk);
return sum;
}Rust2.24× neo · neo vs Rust
/// Two passes: per-chunk counts, prefix sum, then every chunk writes into its own slice.
fn filter_mt(amt: &[f64], pool: &Pool, parts: usize) -> Vec<u32> {
let n = amt.len();
let ranges = chunk_ranges(n, parts);
let counts: Vec<usize> = pool.map(parts, |p| {
let (a, b) = ranges[p];
amt[a..b].iter().map(|&x| (x > 500.0) as usize).sum()
});
let mut starts = vec![0usize; parts + 1];
for p in 0..parts {
starts[p + 1] = starts[p] + counts[p];
}
let total = starts[parts];
let mut out: Vec<u32> = Vec::with_capacity(total);
let op = SyncPtr(out.as_mut_ptr());
pool.run(parts, |p| unsafe {
let (a, b) = ranges[p];
let k = filter_range(amt, a, b, op.get().add(starts[p]), counts[p]);
debug_assert_eq!(k, counts[p]);
});
unsafe { out.set_len(total) };
out
}Swift2.76× neo · neo vs Swift
/// Fills `out` (capacity n + 1: the branchless loop writes one slot past the last hit) with the selected row indices; returns the count.
func filterGt500(_ amt: UnsafePointer<Double>, _ n: Int, _ out: UnsafeMutablePointer<UInt32>, pool: Pool?, parts: Int) -> Int {
guard let pool = pool, parts > 1 else { return filterRange(amt, 0, n, out) }
let np = max(1, min(parts, n / 4096))
let chunk = (n + np - 1) / np
let counts = alloc(np + 1, Int.self), ends = alloc(np, Int.self)
pool.run(np) { p in (counts[p], ends[p]) = countRange(amt, p * chunk, min(n, (p + 1) * chunk)) } // pass 1: count
var base = 0
for p in 0..<np { let c = counts[p]; counts[p] = base; base += c }
counts[np] = base
pool.run(np) { p in _ = filterRange(amt, p * chunk, ends[p], out + counts[p]) } // pass 2: write, stop after the last hit
free(counts); free(ends)
return base
}Go4.35× neo · neo vs Go
func filterGT(amt []float64, thr float64, p *pool) []uint32 {
n := len(amt)
if p == nil {
out := make([]uint32, n+1)
k := 0
for i, v := range amt {
out[k] = uint32(i)
if v > thr {
k++
}
}
return out[:k]
}
C := p.chunks()
bases := make([]int, C+1)
p.run(C, n, func(c, lo, hi int) {
cnt := 0
for _, v := range amt[lo:hi] {
if v > thr {
cnt++
}
}
bases[c+1] = cnt
})
for c := 0; c < C; c++ {
bases[c+1] += bases[c]
}
total := bases[C]
out := make([]uint32, total+C)
p.run(C, n, func(c, lo, hi int) {
k, end, dummy := bases[c], bases[c+1], total+c
for i := lo; i < hi; i++ {
pos := k
if pos >= end {
pos = dummy
}
out[pos] = uint32(i)
if amt[i] > thr {
k++
}
}
})
return out[:total]
}Polars9.94× neo · neo vs Polars
timed("filter", lambda: df.lazy().filter(pred).select(pl.len()).collect().item())pandas18.2× neo · neo vs pandas
timed("filter", lambda: int((amount > 500).sum()))DuckDB33.5× neo · neo vs DuckDB
timed("filter", lambda: con.execute("SELECT count(*) FROM t WHERE amount > 500").fetchone())Spark2,049× neo · neo vs Spark
timed("filter", lambda: df.filter(pred).count())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;// 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);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.