Skip to content

Commit 26becd6

Browse files
authored
perf: improves RingBuffer by using native PHP arrays (#2159)
1 parent 2c9a001 commit 26becd6

2 files changed

Lines changed: 82 additions & 24 deletions

File tree

src/Util/RingBuffer.php

Lines changed: 31 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -6,24 +6,21 @@
66

77
/**
88
* Ring buffer implementation with a fixed size that will overwrite the oldest elements when at capacity.
9-
* Backed by `SplFixedArray`, which means that it will always have a constant memory footprint while
10-
* also avoiding dynamically resizing.
11-
* This is NOT a copy-on-write data structure. Extra cloning is necessary to achieve this.
9+
* Backed by a regular PHP array that will dynamically grow in size until capacity is reached.
1210
*
1311
* `push` and `peek` operations are O(1).
1412
*
15-
* `toArray` and `drain` are O(n) where n is the count of the buffer.
16-
*
17-
* This implementation will never duplicate arrays unless `toArray` or `drain` is called.
13+
* `toArray` and `drain` are O(n) where n is the count of the buffer. As long as no element was
14+
* overwritten or shifted, both are O(1) because the backing array can be returned as-is
1815
*
1916
* @template T
2017
*/
2118
class RingBuffer implements \Countable
2219
{
2320
/**
24-
* @var \SplFixedArray<T|null>
21+
* @var array<int, T|null>
2522
*/
26-
private $buffer;
23+
private $buffer = [];
2724

2825
/**
2926
* @var int
@@ -60,7 +57,6 @@ public function __construct(int $capacity)
6057
throw new \RuntimeException('RingBuffer capacity must be greater than 0');
6158
}
6259
$this->capacity = $capacity;
63-
$this->buffer = new \SplFixedArray($capacity);
6460
}
6561

6662
/**
@@ -110,7 +106,7 @@ public function push($value): void
110106

111107
$this->tail = ($this->tail + 1) % $this->capacity;
112108

113-
if ($this->isFull()) {
109+
if ($this->count === $this->capacity) {
114110
$this->head = ($this->head + 1) % $this->capacity;
115111
} else {
116112
++$this->count;
@@ -125,7 +121,7 @@ public function push($value): void
125121
*/
126122
public function shift()
127123
{
128-
if ($this->isEmpty()) {
124+
if ($this->count === 0) {
129125
return null;
130126
}
131127
$value = $this->buffer[$this->head];
@@ -146,12 +142,11 @@ public function shift()
146142
*/
147143
public function peekBack()
148144
{
149-
if ($this->isEmpty()) {
145+
if ($this->count === 0) {
150146
return null;
151147
}
152-
$idx = ($this->tail - 1 + $this->capacity) % $this->capacity;
153148

154-
return $this->buffer[$idx];
149+
return $this->buffer[($this->tail - 1 + $this->capacity) % $this->capacity];
155150
}
156151

157152
/**
@@ -162,7 +157,7 @@ public function peekBack()
162157
*/
163158
public function peekFront()
164159
{
165-
if ($this->isEmpty()) {
160+
if ($this->count === 0) {
166161
return null;
167162
}
168163

@@ -174,9 +169,7 @@ public function peekFront()
174169
*/
175170
public function clear(): void
176171
{
177-
for ($i = 0; $i < $this->count; ++$i) {
178-
$this->buffer[($this->head + $i) % $this->capacity] = null;
179-
}
172+
$this->buffer = [];
180173
$this->count = 0;
181174
$this->head = 0;
182175
$this->tail = 0;
@@ -190,14 +183,28 @@ public function clear(): void
190183
*/
191184
public function toArray(): array
192185
{
193-
$result = [];
194-
for ($i = 0; $i < $this->count; ++$i) {
195-
$value = $this->buffer[($this->head + $i) % $this->capacity];
196-
/** @var T $value */
197-
$result[] = $value;
186+
if ($this->count === 0) {
187+
return [];
198188
}
199189

200-
return $result;
190+
// When we have never overwritten an element, or we did a full revolution, we can
191+
// just return the buffer as-is because it has the correct order
192+
if ($this->head === 0 && $this->count === \count($this->buffer)) {
193+
/** @var array<T> */
194+
return $this->buffer;
195+
}
196+
197+
// If the data doesn't wrap around, we can just return the slice from head to count
198+
if ($this->head + $this->count <= $this->capacity) {
199+
/** @var array<T> */
200+
return \array_slice($this->buffer, $this->head, $this->count);
201+
}
202+
203+
/** @var array<T> */
204+
return array_merge(
205+
\array_slice($this->buffer, $this->head),
206+
\array_slice($this->buffer, 0, $this->tail)
207+
);
201208
}
202209

203210
/**

tests/Util/RingBufferTest.php

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,57 @@ public function testFixedCapacity(): void
6767
$this->assertEquals(['bar', 'baz'], $buffer->toArray());
6868
}
6969

70+
public function testToArraySnapshotIsNotAffectedByLaterPushes(): void
71+
{
72+
$buffer = new RingBuffer(3);
73+
$buffer->push('foo');
74+
$buffer->push('bar');
75+
$buffer->push('baz');
76+
77+
$snapshot = $buffer->toArray();
78+
$buffer->push('qux');
79+
80+
$this->assertEquals(['foo', 'bar', 'baz'], $snapshot);
81+
$this->assertEquals(['bar', 'baz', 'qux'], $buffer->toArray());
82+
}
83+
84+
public function testToArrayAfterShift(): void
85+
{
86+
$buffer = new RingBuffer(5);
87+
$buffer->push('foo');
88+
$buffer->push('bar');
89+
$buffer->push('baz');
90+
91+
$buffer->shift();
92+
93+
$this->assertEquals(['bar', 'baz'], $buffer->toArray());
94+
}
95+
96+
public function testToArrayWrapped(): void
97+
{
98+
$buffer = new RingBuffer(3);
99+
for ($i = 1; $i <= 7; ++$i) {
100+
$buffer->push($i);
101+
}
102+
103+
$this->assertSame(3, $buffer->count());
104+
$this->assertEquals([5, 6, 7], $buffer->toArray());
105+
}
106+
107+
public function testPushAfterDrain(): void
108+
{
109+
$buffer = new RingBuffer(3);
110+
$buffer->push('foo');
111+
$buffer->push('bar');
112+
113+
$this->assertEquals(['foo', 'bar'], $buffer->drain());
114+
115+
$buffer->push('baz');
116+
117+
$this->assertCount(1, $buffer);
118+
$this->assertEquals(['baz'], $buffer->toArray());
119+
}
120+
70121
public function testClear(): void
71122
{
72123
$buffer = new RingBuffer(5);

0 commit comments

Comments
 (0)