Skip to content

Commit f7aedca

Browse files
committed
Fill a query result with one capacity check instead of one per element.
add cannot be inlined: its body calls grow() when the list is full, so an appending loop pays a call, a stack-trace push, and a global table lookup for every field it touches instead of a hoisted local. Measured on Lua, an append which cannot grow costs about a quarter of one which can - 36ns against 134ns per element - and the difference is the inlining, not the capacity check. A spatial query is told how many matches there are before it copies any of them out, so it can reserve once and then append without checking. reserve becomes public for that, and unsafeAdd is the append which assumes it. unsafeAdd is documented as needing the reserve, and why: the backing store is one array shared by every list, so writing past this list's capacity does not overflow into nothing, it overwrites another list's section.
1 parent ba73af1 commit f7aedca

4 files changed

Lines changed: 80 additions & 6 deletions

File tree

wurst/closures/SpatialIndexForDestructables.wurst

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,17 @@ public interface DestructableSpatialFilter
2020
function matches(destructable whichDestructable) returns boolean
2121

2222
function appendMatches(ArrayList<destructable> result, int matched, DestructableSpatialFilter filter)
23+
// One capacity check for the whole batch. The query already knows its upper bound, and
24+
// an append which cannot grow contains no call, so it inlines into this loop.
25+
result.reserve(result.size() + matched)
2326
if filter == null
2427
for i = 0 to matched - 1
25-
result.add(destructableSpatialIndexQuery(i))
28+
result.unsafeAdd(destructableSpatialIndexQuery(i))
2629
else
2730
for i = 0 to matched - 1
2831
let d = destructableSpatialIndexQuery(i)
2932
if filter.matches(d)
30-
result.add(d)
33+
result.unsafeAdd(d)
3134
destructableSpatialIndexEndQuery()
3235

3336
public function destructablesInRect(ArrayList<destructable> result, rect area,

wurst/closures/SpatialIndexForUnits.wurst

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,14 +27,17 @@ public interface UnitSpatialFilter
2727
function matches(unit whichUnit) returns boolean
2828

2929
function appendMatches(ArrayList<unit> result, int matched, UnitSpatialFilter filter)
30+
// One capacity check for the whole batch. The query already knows its upper bound, and
31+
// an append which cannot grow contains no call, so it inlines into this loop.
32+
result.reserve(result.size() + matched)
3033
if filter == null
3134
for i = 0 to matched - 1
32-
result.add(spatialIndexQueryUnit(i))
35+
result.unsafeAdd(spatialIndexQueryUnit(i))
3336
else
3437
for i = 0 to matched - 1
3538
let u = spatialIndexQueryUnit(i)
3639
if filter.matches(u)
37-
result.add(u)
40+
result.unsafeAdd(u)
3841
spatialIndexEndQuery()
3942

4043
/** Replaces result with units whose origins are within radius of center. */

wurst/data/ArrayList.wurst

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,24 @@ public class ArrayList<T:>
302302
store[startIndex + size] = elem
303303
size++
304304

305+
/** Appends without checking capacity. **Reserve first.**
306+
307+
`add` cannot be inlined, because its body calls `grow()` when the list is full, and an
308+
appending loop therefore pays a call, a stack-trace push and a global lookup for every
309+
field it touches. This one contains no call, so it inlines into the caller and reads its
310+
fields from hoisted locals - measured at roughly a quarter of the cost of `add` on Lua.
311+
312+
The precondition is not a formality. The backing store is one array shared by every list,
313+
each holding a section of it, so writing past this list's capacity does not overflow into
314+
nothing - it silently overwrites whatever another list is keeping there. Call
315+
`reserve(size + count)` first, and only append that many.
316+
317+
Intended for a caller which already knows how many elements it is about to add, such as a
318+
query copying out a result whose size it was told up front. Prefer `add` everywhere else. */
319+
function unsafeAdd(T elem)
320+
store[startIndex + size] = elem
321+
size++
322+
305323
/** Adds all elements from another list */
306324
function addAll(ArrayList<T> other)
307325
let otherSize = other.size
@@ -315,8 +333,11 @@ public class ArrayList<T:>
315333
size++
316334

317335
/** Ensures capacity for at least the given number of elements, moving the
318-
section at most once instead of once per doubling. */
319-
private function reserve(int needed)
336+
section at most once instead of once per doubling.
337+
338+
Public so a caller which knows its final size can pay one capacity check for a whole batch
339+
rather than one per element, which is also the precondition `unsafeAdd` needs. */
340+
function reserve(int needed)
320341
if needed <= capacity
321342
return
322343
var newCapacity = capacity

wurst/data/ArrayListTests.wurst

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -738,3 +738,50 @@ function testDetectRepeatedSlotsBug()
738738

739739
destroy seen
740740
destroy l
741+
742+
@Test
743+
function testReserveKeepsContentsAndGrowsCapacity()
744+
let list = new ArrayList<int>()
745+
list.add(1)
746+
list.add(2)
747+
list.reserve(64)
748+
// Reserving must not disturb what is already there, only where it can fit.
749+
list.size().assertEquals(2)
750+
list.get(0).assertEquals(1)
751+
list.get(1).assertEquals(2)
752+
// And it must be enough to append that many without growing again.
753+
for i = 0 to 61
754+
list.unsafeAdd(i)
755+
list.size().assertEquals(64)
756+
list.get(63).assertEquals(61)
757+
destroy list
758+
759+
@Test
760+
function testUnsafeAddAppendsLikeAdd()
761+
let reserved = new ArrayList<int>()
762+
let plain = new ArrayList<int>()
763+
reserved.reserve(16)
764+
for i = 0 to 15
765+
reserved.unsafeAdd(i)
766+
plain.add(i)
767+
reserved.size().assertEquals(plain.size())
768+
for i = 0 to 15
769+
reserved.get(i).assertEquals(plain.get(i))
770+
destroy reserved
771+
destroy plain
772+
773+
@Test
774+
function testUnsafeAddAfterResetRefillsInPlace()
775+
let list = new ArrayList<int>()
776+
list.reserve(8)
777+
for i = 0 to 7
778+
list.unsafeAdd(i)
779+
// reset keeps the capacity, which is what makes a scratch list reusable without reserving again
780+
list.reset()
781+
list.size().assertEquals(0)
782+
for i = 0 to 7
783+
list.unsafeAdd(100 + i)
784+
list.size().assertEquals(8)
785+
list.get(0).assertEquals(100)
786+
list.get(7).assertEquals(107)
787+
destroy list

0 commit comments

Comments
 (0)