Uh oh!
There was an error while loading. Please reload this page.
⚡ Sparse-array PriorityQueue for reducer - #1171
Conversation
commit: |
Merging this PR will improve performance by 14.55%
|
| Mode | Benchmark | BASE | HEAD | Efficiency | |
|---|---|---|---|---|---|
| ⚡ | Memory | effection-inline.recursion | 3.9 KB | 3.4 KB | +14.55% |
Tip
Curious why this is faster? Comment @codspeedbot explain why this is faster on this PR, or directly use the CodSpeed MCP with your agent.
Comparing sparse-priority-queue (13b6b2b) with v4 (d326678)
Footnotes
18 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩
cowboyd
commented
Jun 4, 2026
Worth noting that the |
fa97270 to
ee051deCompareOne of the things revealed by our benchmark suite was that enqueing and dequeing items on the priority queue was both taking time and allocating a lot of memory. In the new layout, there is a sparse outer array indexed by priority, where each cell is a FIFO queue that uses a sliding window (head pointer) to avoid memcpy on shit, and compacts only when too much dead memory accumulates. The outer array's min/max active priorities are tracked together so that pop() never walks the whole sparse array. The trade-off is that the there are lots of "dead" slots in both arrays that do not point to anything, but what you get is that there is no index math, and no copying of memory with each operation (except occasionaly when a tier compacts on pop). push(): O(1) looks up tier by priority and pops off the end of the tier pop(): O(1) scans min..max for the first non-empty tier, advancing min past emptied tiers so subsequent pops skip them. Within each tier, the storage is "grow only". a shift() to the tier does not call shift() on the underlying array, istead it tracks the current "first" index containing an element, and a pop reads from the front of that array and then increments the "head" element. This leaves a "dead slot" at the front of the array. Once the total number of dead slots is more than the maxDeadSlots parameter, the array is "compacted" by copying into a new array. PriorityQueue ┌─────────────────────────────────────────────────────────────┐ │ min: 2 │ │ max: 5 │ │ tiers: │ │ ┌────┬────┬────┬────┬────┬────┬────┬─ ─┬────┐ │ │ │ 0 │ 1 │ 2 │ 3 │ 4 │ 5 │ 6 │ ... │ N │ │ │ └────┴────┴─┬──┴────┴────┴─┬──┴────┴─ ─┴────┘ │ └───────────────│──────────────│──────────────────────────────┘ │ │ ▼ ▼ Tier (prio=2) Tier (prio=5) ┌──────────┐ ┌──────────┐ │ head: 0 │ │ head: 3 │ ← 3 dead slots │ items: ──┼──┐ │ items: ──┼──┐ └──────────┘ │ └──────────┘ │ ▼ ▼ ┌───┬───┬───┐ ┌───┬───┬───┬───┬───┬───┐ │ a │ b │ c │ │ _ │ _ │ _ │ x │ y │ z │ └───┴───┴───┘ └───┴───┴───┴───┴───┴───┘ 0 1 2 0 1 2 3 4 5 ↑ ↑ head head (next pop) (next pop) empty tiers at priorities 0, 1, 3, 4 — just absent slots in `tiers` (sparse: V8 stores them as holes, not undefined entries). ``` How operations traverse: ``` push(priority=3, item=q) ───────────────────────────────────────────── 1. tiers[3] is absent → create new Tier, store at tiers[3] 2. tier.push(q) → tier.items = [q], head=0 3. priority < min? 3 < 2? no → min stays 2 4. priority > max? 3 > 5? no → max stays 5 result: tiers: [·,·,T,T,·,T] ↑ ↑ ↑ 2 3 5 min=2, max=5 ``` ``` pop() ───────────────────────────────────────────── scan current = min..max: current=2: tiers[2] exists, length > 0 → shift it └─ value = items[head=0] = a items[0] = undefined (release ref for GC) head = 1 tier.length is now 2 (still has b, c) min = 2 (tier still non-empty, min stays here) return a tier state after pop: Tier (prio=2) ┌──────────┐ │ head: 1 │ │ items: ──┼──┐ └──────────┘ ▼ ┌───┬───┬───┐ │ _ │ b │ c │ ← slot [0] held `a`, now released └───┴───┴───┘ 0 1 2 ↑ head (next pop returns b) ``` ``` Compaction (per-tier, inside shift()) ───────────────────────────────────────────── fires when head > maxDeadSlots (default: 1024) before: items: [_,_,_, ... ,_, x, y, z] └─── 1025 ───┘ ↑ head=1025 this.items = this.items.slice(1025) this.head = 0 after: items: [x, y, z] ↑ head=0 cost: one allocation + memcpy of `live_items` references (NOT 1025 — only the live tail is copied) amortized O(1) per pop ``` ``` Tier-skip optimization (in pop's scan) ───────────────────────────────────────────── when shift drains a tier to empty, advance min so the next pop's scan doesn't re-walk known-empty slots before pop: min=2 tiers: [·,·,T,·,·,T] T at prio=2 has 1 item ↑ ↑ 2 5 pop: scan current=2: tier exists, length=1, shift it value = the one item items.length after shift === 0 min = current + 1 = 3 ← advance past now-empty return value after pop: min=3, max=5 tiers: [·,·,T,·,·,T] (tier at 2 still in `tiers`, but drained) ↑ next scan starts here if a future push(prio=2, ...) comes in, the push code lowers min back to 2 via `if (priority < this.min) this.min = priority`. ```
ee051de to
13b6b2bCompare
Motivation
One of the things revealed by our benchmark suite was that enqueing and dequeing items on the priority queue was both taking time and allocating a lot of memory.
Approach
Create a new layout for the priority queue in which there is a sparse outer array indexed by priority, where each cell is a FIFO queue that uses a sliding window (head pointer) to avoid memcpy on
shift(), and compacts only when too much dead memory accumulates. The outer array's min/max active priorities are tracked together so that pop() never walks the whole sparse array.The trade-off is that the there are lots of "dead" slots in both arrays that do not point to anything, but what you get is that there is no index math, and no copying of memory with each operation (except occasionaly when a tier compacts on pop).
push(): O(1)
looks up tier by priority and pops off the end of the tier
pop(): O(1)
scans min..max for the first non-empty tier, advancing min past emptied tiers so subsequent pops skip them.
Within each tier, the storage is "grow only". a shift() to the tier does not call shift() on the underlying array, istead it tracks the current "first" index containing an element, and a pop reads from the front of that array and then increments the "head" element. This leaves a "dead slot" at the front of the array. Once the total number of dead slots is more than the maxDeadSlots parameter, the array is "compacted" by copying into a new array.
How operations traverse: