perf: 优化 CIDR 合并并补充前后性能基准 (#37)

This commit is contained in:
METO authored and GitHub committed 2026-10-29 21:06:40 +08:00
1 parent b14976e21c
commit 7ca6281507
3 files changed
+108 -20

No files matched your search

+63 -6
View File
@@ -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')
+23 -14
View File
@@ -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)
+22
View File
@@ -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', '::'])