diff --git a/bench/merge.bench.js b/bench/merge.bench.js index 2c650ae6..2bb3a7b8 100644 --- a/bench/merge.bench.js +++ b/bench/merge.bench.js @@ -2,7 +2,18 @@ const fs = require('node:fs') const path = require('node:path') -const { merge } = require('../src/lib/cidr') +const assert = require('node:assert/strict') +const { parseArgs } = require('node:util') + +const { values } = parseArgs({ options: { + baseline: { type: 'string' }, + candidate: { type: 'string' }, + extended: { type: 'boolean', default: false }, + json: { type: 'string' }, + filter: { type: 'string' } +} }) +const baseline = values.baseline ? require(path.resolve(values.baseline)).merge : null +const { merge } = require(values.candidate ? path.resolve(values.candidate) : '../src/lib/cidr') const DATA = path.join(__dirname, '..', 'data') const readLines = (file) => fs.readFileSync(file, 'utf8').trim().split('\n').filter(Boolean) @@ -17,22 +28,43 @@ function loadCases () { const cncity = fs.readdirSync(path.join(DATA, 'cncity')).map((f) => readLines(path.join(DATA, 'cncity', f))) - return [ + const cases = [ { name: 'CN.txt', inputs: [readLines(path.join(DATA, 'country', 'CN.txt'))] }, { name: 'non-china (all countries except CN)', inputs: [nonChina] }, { name: `cncity/*.txt (${cncity.length} files)`, inputs: cncity }, { name: `every data/**/*.txt separately (${files.length} files)`, inputs: files.map(readLines) }, { name: 'all data/**/*.txt concatenated', inputs: [everything] } ] + if (values.extended) { + const ascending = merge(everything, true) + const shuffled = ascending.slice() + let seed = 42 + for (let i = shuffled.length - 1; i > 0; i--) { + seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0 + const j = seed % (i + 1) + ;[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]] + } + const v6 = Array.from({ length: 10000 }, (_, i) => `2001:db8:${i.toString(16)}::1/128`) + const v6Networks = Array.from({ length: 10000 }, (_, i) => `2001:db8:${i.toString(16)}::/64`) + cases.push( + { name: 'IPv4 ascending', inputs: [ascending] }, + { name: 'IPv4 descending', inputs: [ascending.slice().reverse()] }, + { name: 'IPv4 shuffled (seed 42)', inputs: [shuffled] }, + { name: 'IPv6 synthetic 10000 /128', inputs: [v6] }, + { name: 'IPv6 synthetic 10000 /128 sort=false', inputs: [v6], sort: false }, + { name: 'IPv6 synthetic 10000 /64', inputs: [v6Networks] }, + { name: 'mixed synthetic sort=false', inputs: [ascending.slice(0, 10000).concat(v6)], sort: false } + ) + } + return cases.filter((c) => !values.filter || c.name.includes(values.filter)) } -function bench (fn, inputs, minMs = 1500) { - for (let i = 0; i < 3; i++) for (const input of inputs) fn(input, true) // warm-up +function bench (fn, inputs, sort, minMs) { let iters = 0 const t0 = process.hrtime.bigint() let elapsed = 0 while (elapsed < minMs || iters < 5) { - for (const input of inputs) fn(input, true) + for (const input of inputs) fn(input, sort) iters++ elapsed = Number(process.hrtime.bigint() - t0) / 1e6 } @@ -40,17 +72,42 @@ function bench (fn, inputs, minMs = 1500) { } const fmt = (ms) => (ms < 1 ? `${(ms * 1000).toFixed(1)} µs` : `${ms.toFixed(2)} ms`) +const median = (samples) => samples.slice().sort((a, b) => a - b)[Math.floor(samples.length / 2)] console.log(`node ${process.version}\n`) const rows = [] +const results = [] for (const c of loadCases()) { const size = c.inputs.reduce((n, a) => n + a.length, 0) - const b = bench(merge, c.inputs) + const sort = c.sort !== false + for (const input of c.inputs) { + if (baseline) assert.deepEqual(merge(input, sort), baseline(input, sort), c.name) + } + const before = [] + const after = [] + for (let i = 0; i < 3; i++) { + for (const input of c.inputs) { + if (baseline) baseline(input, sort) + merge(input, sort) + } + } + // Alternate execution order to reduce drift; report the median of five batches. + for (let round = 0; round < (baseline ? 5 : 1); round++) { + const runBefore = () => before.push(bench(baseline, c.inputs, sort, 300)) + const runAfter = () => after.push(bench(merge, c.inputs, sort, baseline ? 300 : 1500)) + if (baseline && round % 2 === 0) runBefore() + runAfter() + if (baseline && round % 2 !== 0) runBefore() + } + const b = median(after) + results.push({ name: c.name, size, sort, before, after, beforeMs: baseline ? median(before) : null, afterMs: b }) rows.push({ case: c.name, 'input CIDRs': size, + ...(baseline ? { baseline: fmt(median(before)), speedup: `${(median(before) / b).toFixed(2)}x` } : {}), 'src/lib/cidr': fmt(b), 'CIDRs / second': Math.round(size / (b / 1000)).toLocaleString('en-US') }) } console.table(rows) +if (values.json) fs.writeFileSync(values.json, JSON.stringify({ node: process.version, results }, null, 2) + '\n') diff --git a/src/lib/cidr.js b/src/lib/cidr.js index 3da1bd7d..270516d6 100644 --- a/src/lib/cidr.js +++ b/src/lib/cidr.js @@ -46,7 +46,7 @@ function parseV4Packed (s) { if (++digits > 3) return bad(s) } else if (c === 46) { // '.' if (digits === 0 || octet > 255 || ++dots > 3) return bad(s) - ip = ip * 256 + octet + ip = (ip << 8) | octet octet = 0 digits = 0 } else if (c === 47) { // '/' @@ -58,7 +58,7 @@ function parseV4Packed (s) { } } if (digits === 0 || octet > 255 || dots !== 3) return bad(s) - ip = ip * 256 + octet + ip = (ip << 8) | octet let prefix = 32 if (i < len) { @@ -74,7 +74,9 @@ function parseV4Packed (s) { if (prefix > 32) return bad(s) } const hb = 32 - prefix - const start = ip - (ip % POW2[hb]) + const ip32 = ip >>> 0 + // JS masks shift counts to five bits, so /0 needs an explicit case. + const start = hb === 32 ? 0 : ((ip32 >>> hb) << hb) >>> 0 return start * 64 + hb } @@ -95,20 +97,21 @@ function emitV4 (s, e, out, starts) { } } -function mergeV4 (keys, count, out, starts) { +function mergeV4 (keys, count, out, starts, ordered) { if (count === 0) return const sorted = count === keys.length ? keys : keys.subarray(0, count) - sorted.sort() + // Project data files are already ordered by normalized packed key. + if (!ordered) sorted.sort() let k = sorted[0] - let hb = k % 64 - let curS = (k - hb) / 64 + let curS = Math.floor(k / 64) + let hb = k - curS * 64 let curE = curS + POW2[hb] - 1 for (let i = 1; i < count; i++) { k = sorted[i] - hb = k % 64 - const s = (k - hb) / 64 + const s = Math.floor(k / 64) + hb = k - s * 64 if (s > curE + 1) { emitV4(curS, curE, out, starts) curS = s @@ -221,7 +224,7 @@ function emitV6 (s, e, out, starts) { const hbAlign = s === 0n ? 128 : bitLength(s & -s) - 1 const hb = hbLen < hbAlign ? hbLen : hbAlign out.push(v6ToString(s, 128 - hb)) - starts.push(s) + if (starts !== null) starts.push(s) s += 1n << BigInt(hb) } } @@ -255,6 +258,8 @@ function merge (cidrs, sort = false) { const len = cidrs.length const keys = new Float64Array(len) let count = 0 + let ordered = true + let previous = -1 let v6 = null for (let i = 0; i < len; i++) { @@ -264,20 +269,24 @@ function merge (cidrs, sort = false) { if (v6 === null) v6 = [] v6.push(parseV6(c)) } else { + if (k < previous) ordered = false + previous = k keys[count++] = k } } const out4 = [] if (v6 === null) { - mergeV4(keys, count, out4, null) + mergeV4(keys, count, out4, null, ordered) return out4 } - const starts4 = [] + // Start arrays are only needed to interleave the two address families. + const trackStarts = sort && count !== 0 + const starts4 = trackStarts ? [] : null const out6 = [] - const starts6 = [] - mergeV4(keys, count, out4, starts4) + const starts6 = trackStarts ? [] : null + mergeV4(keys, count, out4, starts4, ordered) mergeV6(v6, out6, starts6) if (!sort || out4.length === 0 || out6.length === 0) return out4.concat(out6) diff --git a/test/cidr.test.js b/test/cidr.test.js index 1987932f..054682c4 100644 --- a/test/cidr.test.js +++ b/test/cidr.test.js @@ -79,6 +79,28 @@ test('edge cases IPv4', () => { check([]) }) +test('IPv4 bit masks cover every prefix and unsigned boundary', () => { + for (let prefix = 0; prefix <= 32; prefix++) { + for (const ip of ['0.0.0.0', '0.0.0.1', '127.255.255.255', '128.0.0.0', '255.255.255.255']) { + check([`${ip}/${prefix}`]) + } + } +}) + +test('IPv4 ordering detection uses normalized packed keys across IPv6 entries', () => { + const inputs = [ + ['10.0.0.255/24', '10.0.0.1/32', '10.0.0.1/32', '10.0.2.0/24'], + ['10.0.0.1/32', '10.0.0.255/24', '10.0.2.0/24'], + ['255.255.255.255', '128.0.0.0/1', '0.0.0.0/1'], + ['0.0.0.1', '::1', '0.0.0.2', '::2'], + ['0.0.0.2', '::1', '0.0.0.1', '::2'] + ] + for (const input of inputs) { + check(input, true) + check(input, false) + } +}) + test('edge cases IPv6 and mixed', () => { check(['::/0']) check(['::1', '::'])