From e25c38b473b4900ac001c935b0964a492b196ac3 Mon Sep 17 00:00:00 2001 From: Zane Sterling Date: Thu, 26 Feb 2026 16:25:05 +0100 Subject: [PATCH 1/4] Rework sort() to sort in place in a Float64Array. This is ~5x faster than the existing sort() when run with ~65k points. The main here is that it makes only one copy of the input ids array, and then sorts this in place. The existing implementation allocates new copies of the sublists at each recursion layer. In my case of 65k points, the deepest branch of the tree has 15 layers, so that's up to 15 copies of the list, or 15 * 65k * 8B ~= 7.8MB. Dodging the reallocations means less GC thrashing, which in my experiments took up about half of the runtime of cluster(). I've chosen here to split sort() into three calls to partition(), first partitioning on x, and then on y. Partition can then be very simple. It grows the list of los from 0-index upward and the list of his from the last index downward. 0 ids.len +--------+-----------------+--------+ | los ->| ....unsorted... |<- his | +--------+-----------------+--------+ It begins by picking up the first element in the list. Then, as long as the unsorted section is not empty, it checks if the held item should go in los or his, and then swaps it with the unsorted item in the next spot next to los or his. At the end the unsorted section is empty and it still has one item in its hand, so if that item is a lo it goes in index 0, and otherwise it swaps with the highest-index lo, which then goes in index 0. In this implementation of partition each element in the array is loaded exactly once from memory and written exactly once to memory. I tried five different implementations of partition, including a couple that broke the sort-in-place rule, and this was the quickest out of the pack. There might yet be something quicker -- this implementation definitely is not super friendly to SIMD, for instance -- but this is the best I can find in the couple hours I spent on it today and yesterday. --- quad.js | 61 +++++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 46 insertions(+), 15 deletions(-) diff --git a/quad.js b/quad.js index c5fe18f..85319c8 100644 --- a/quad.js +++ b/quad.js @@ -93,10 +93,11 @@ module.exports = function cluster (srcPoints, options) { return ids - - - // FIXME: it is possible to create one typed array heap and reuse that to avoid memory blow function sort (x, y, diam, ids, level, group) { + return sortInner(x, y, diam, new Float64Array(ids), level, group) + } + + function sortInner(x, y, diam, ids, level, group) { if (!ids.length) return null // save first point as level representative @@ -132,22 +133,17 @@ module.exports = function cluster (srcPoints, options) { let cx = x + d2, cy = y + d2 // distribute points by 4 buckets - let lolo = [], lohi = [], hilo = [], hihi = [] - - for (let i = 1, l = ids.length; i < l; i++) { - let idx = ids[i], - x = points[idx * 2], - y = points[idx * 2 + 1] - x < cx ? (y < cy ? lolo.push(idx) : lohi.push(idx)) : (y < cy ? hilo.push(idx) : hihi.push(idx)) - } + const [lo, hi] = partition(ids.subarray(1), points, 0, cx) // partition by x + const [lolo, lohi] = partition(lo, points, 1, cy) // then partition los and his by y + const [hilo, hihi] = partition(hi, points, 1, cy) group <<= 2 sublevel.push( - sort(x, y, d2, lolo, level, group), - sort(x, cy, d2, lohi, level, group + 1), - sort(cx, y, d2, hilo, level, group + 2), - sort(cx, cy, d2, hihi, level, group + 3) + sortInner(x, y, d2, lolo, level, group), + sortInner(x, cy, d2, lohi, level, group + 1), + sortInner(cx, y, d2, hilo, level, group + 2), + sortInner(cx, cy, d2, hihi, level, group + 3) ) return offset @@ -284,3 +280,38 @@ function normalize (pts, bounds) { return result } + +// Partitions ids in place into two subarrays, +// one where the ids reference points which have values below the pivot, +// and one where those values are >= the pivot. +function partition(ids, points, offset, pivot) { + if (ids.length === 0) { + return [ids.subarray(0, 0), ids.subarray(0, 0)] + } + + let next = ids[0] + let nextLo = 1 + let nextHi = ids.length - 1 + while (nextLo < nextHi) { + if (points[next * 2 + offset] < pivot) { + let x = ids[nextLo] + ids[nextLo++] = next + next = x + } else { + let x = ids[nextLo] + ids[nextHi--] = next + next = x + } + } + + if (next < cx) { + ids[0] = next; + } else if (nextLo > 0) { + ids[0] = ids[nextLo - 1]; + ids[nextHi] = next; + } + + let los = ids.subarray(0, nextLo) + let his = ids.subarray(nextHi + 1, ids.length) + return [los, his] +} From af762d516833e65c45c47af8ce988c30143a0452 Mon Sep 17 00:00:00 2001 From: Zane Sterling Date: Thu, 26 Feb 2026 19:39:14 +0100 Subject: [PATCH 2/4] typo fix: s/cx/pivot --- quad.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/quad.js b/quad.js index 85319c8..c25b92f 100644 --- a/quad.js +++ b/quad.js @@ -304,7 +304,7 @@ function partition(ids, points, offset, pivot) { } } - if (next < cx) { + if (next < pivot) { ids[0] = next; } else if (nextLo > 0) { ids[0] = ids[nextLo - 1]; From d6332437c0c86758c561123db5b722bddb9dd967 Mon Sep 17 00:00:00 2001 From: Zane Sterling Date: Thu, 26 Feb 2026 20:03:24 +0100 Subject: [PATCH 3/4] fix small bugs in partition --- quad.js | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/quad.js b/quad.js index c25b92f..76e7420 100644 --- a/quad.js +++ b/quad.js @@ -292,13 +292,13 @@ function partition(ids, points, offset, pivot) { let next = ids[0] let nextLo = 1 let nextHi = ids.length - 1 - while (nextLo < nextHi) { + while (nextLo <= nextHi) { if (points[next * 2 + offset] < pivot) { let x = ids[nextLo] ids[nextLo++] = next next = x } else { - let x = ids[nextLo] + let x = ids[nextHi] ids[nextHi--] = next next = x } @@ -307,11 +307,13 @@ function partition(ids, points, offset, pivot) { if (next < pivot) { ids[0] = next; } else if (nextLo > 0) { - ids[0] = ids[nextLo - 1]; + ids[0] = ids[nextHi]; ids[nextHi] = next; + nextHi--; + nextLo--; } let los = ids.subarray(0, nextLo) - let his = ids.subarray(nextHi + 1, ids.length) + let his = ids.subarray(nextLo, ids.length) return [los, his] } From e5d9dd59e5bf16fca7b9e19592a6d19e595e4541 Mon Sep 17 00:00:00 2001 From: Zane Sterling Date: Thu, 26 Feb 2026 20:23:54 +0100 Subject: [PATCH 4/4] fix tests to match partition's behavior. Because the new sort does not maintain the original order of the indexes, it may select different points to be the representative point at different internal nodes in the tree. This will produce a different ordering of the returned indexes, but is still an equally valid quadtree. The tests need to be updated to expect this new order. --- test.js | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/test.js b/test.js index a495c6a..1057d2d 100644 --- a/test.js +++ b/test.js @@ -8,7 +8,7 @@ t('quad: offsets case', t => { let points = [.15,.8, .2,.15, .6,.6, .6,.45, .8,.1, .9,.6, .91,.61] let index = cluster(points, {bounds: [0,0,1,1]}) - t.deepEqual(index.slice(), [0, 1,3,2, 4,5, 6]) + t.deepEqual(index.slice(), [0, 1,4,6, 3,2,5]) t.end() }) @@ -18,7 +18,7 @@ t('quad: output container', t => { let arr = [] let index = cluster(points, {bounds: [0,0,1,1], output: arr}) - t.deepEqual(arr.slice(), [0, 1,3,2, 4,5, 6]) + t.deepEqual(arr.slice(), [0, 1,4,6, 3,2,5]) t.equal(index, arr) t.end() @@ -42,13 +42,13 @@ t('quad: selection', t => { let index = cluster(points) - t.deepEqual(index.range(), [0, 1, 2, 3, 4, 5, 6, 7]) - t.deepEqual(index.range(1,1,6,6), [1, 2, 3, 4, 5, 6]) - t.deepEqual(index.range(2,1,5,6), [2, 3, 4, 5]) - t.deepEqual(index.range(1,2,6,5), [2, 3, 4, 5]) - t.deepEqual(index.range(1,3,5,6), [3, 4, 5]) + t.deepEqual(new Set(index.range()), new Set([0, 1, 2, 3, 4, 5, 6, 7])) + t.deepEqual(new Set(index.range(1,1,6,6)), new Set([1, 2, 3, 4, 5, 6])) + t.deepEqual(new Set(index.range(2,1,5,6)), new Set([2, 3, 4, 5])) + t.deepEqual(new Set(index.range(1,2,6,5)), new Set([2, 3, 4, 5])) + t.deepEqual(new Set(index.range(1,3,5,6)), new Set([3, 4, 5])) - t.deepEqual(index.range(5,6,1,3), [3, 4, 5]) + t.deepEqual(new Set(index.range(5,6,1,3)), new Set([3, 4, 5])) t.end() }) @@ -287,4 +287,3 @@ t('performance', t => { t.end() }) -