Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 48 additions & 11 deletions galloc.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ type Allocator struct {
nodes []node
freeNodes []uint32
freeOffset uint32
allocCount uint32
}

// New creates an Allocator that manages a contiguous range of size units
Expand All @@ -91,18 +92,41 @@ func New(size, maxAllocs uint32) *Allocator {
size: size,
maxAllocs: maxAllocs,
}
a.nodes = make([]node, maxAllocs)
a.freeNodes = make([]uint32, maxAllocs)
// A free region cannot be adjacent to another free region (Free coalesces
// neighbors), so with K active allocations there are at most K+1 free
// regions. The +1 case is reachable only below the active-allocation cap;
// once maxAllocs is reached, no split can add another region. Thus the
// reachable region count is bounded by twice maxAllocs. Keep one node for
// the initial free region when maxAllocs is zero.
nodeCount := allocatorNodeCount(maxAllocs)
a.nodes = make([]node, nodeCount)
a.freeNodes = make([]uint32, nodeCount)
a.reset()
return a
}

// allocatorNodeCount returns the maximum number of reachable spatial regions
// while honoring maxAllocs. Keep the result representable by both int (for
// slice lengths) and uint32 (for node indices and the NoSpace sentinel).
func allocatorNodeCount(maxAllocs uint32) int {
count := uint64(maxAllocs) * 2
if count == 0 {
count = 1
}
maxInt := uint64(^uint(0) >> 1)
if count > uint64(NoSpace) || count > maxInt {
panic("galloc: maxAllocs is too large")
}
return int(count)
}

// reset reinitializes the allocator to its initial state: a single free
// region spanning the entire managed range.
func (a *Allocator) reset() {
a.freeStorage = 0
a.usedBinsTop = 0
a.freeOffset = a.maxAllocs - 1
a.allocCount = 0
a.freeOffset = uint32(len(a.freeNodes)) - 1

for i := range a.usedBins {
a.usedBins[i] = 0
Expand All @@ -117,8 +141,8 @@ func (a *Allocator) reset() {
}

// Freelist is a stack. Nodes in inverse order so that index 0 pops first.
for i := uint32(0); i < a.maxAllocs; i++ {
a.freeNodes[i] = a.maxAllocs - i - 1
for i := range a.freeNodes {
a.freeNodes[i] = uint32(len(a.freeNodes) - i - 1)
}

// Start state: whole storage as one big node.
Expand All @@ -141,8 +165,8 @@ func (a *Allocator) Reset() {
func (a *Allocator) Allocate(size uint32) Allocation {
fail := Allocation{Offset: NoSpace, Metadata: NoSpace}

// Out of node slots?
if a.freeOffset == 0 {
// Out of advertised allocation slots?
if a.allocCount >= a.maxAllocs {
return fail
}

Expand Down Expand Up @@ -174,11 +198,18 @@ func (a *Allocator) Allocate(size uint32) Allocation {
}

binIndex := (topBinIndex << topBinsIndexShift) | leafBinIndex

// Pop the top node of the bin. Bin top = node.next.
nodeIndex := a.binIndices[binIndex]
nd := &a.nodes[nodeIndex]
nodeTotalSize := nd.dataSize

// Splitting a free region needs an additional node for its remainder. A
// fragmented layout can exhaust the node pool before maxAllocs is reached;
// fail cleanly instead of indexing the freelist sentinel.
if nodeTotalSize > size && a.freeOffset == nodeUnused {
return fail
}

// Pop the top node of the bin. Bin top = node.next.
nd.dataSize = size
nd.used = true
a.binIndices[binIndex] = nd.binListNext
Expand Down Expand Up @@ -214,6 +245,7 @@ func (a *Allocator) Allocate(size uint32) Allocation {
a.nodes[newNodeIndex].neighborNext = nd.neighborNext
nd.neighborNext = newNodeIndex
}
a.allocCount++

return Allocation{
Offset: nd.dataOffset,
Expand All @@ -238,6 +270,9 @@ func (a *Allocator) AllocateAligned(size, alignment uint32) Allocation {
if alignment&(alignment-1) != 0 {
panic("galloc: alignment must be a power of two")
}
if size > NoSpace-(alignment-1) {
return Allocation{Offset: NoSpace, Metadata: NoSpace}
}
padded := size + alignment - 1
alloc := a.Allocate(padded)
if alloc.Failed() {
Expand All @@ -264,6 +299,7 @@ func (a *Allocator) Free(alloc Allocation) {
if !nd.used {
panic("galloc: double free detected")
}
a.allocCount--

offset := nd.dataOffset
size := nd.dataSize
Expand Down Expand Up @@ -325,8 +361,9 @@ func (a *Allocator) StorageReport() StorageReport {
var largestFreeRegion uint32
var freeStorage uint32

// Out of node slots? -> report zero free space.
if a.freeOffset > 0 {
// Out of node slots? -> report zero free space. freeOffset is a stack
// index, so zero still represents one available node.
if a.freeOffset != nodeUnused {
freeStorage = a.freeStorage
if a.usedBinsTop != 0 {
topBinIndex := 31 - uint32(bits.LeadingZeros32(a.usedBinsTop)) //nolint:gosec // LeadingZeros32 returns [0,32], safe for uint32
Expand Down
121 changes: 118 additions & 3 deletions galloc_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
package galloc

import (
"math"
"sync"
"testing"
)
Expand All @@ -25,6 +26,74 @@ func TestAllocateBasic(t *testing.T) {
a.Free(alloc)
}

func TestSingleAllocationSlotWithRemainder(t *testing.T) {
a := New(1024, 1)

alloc := a.Allocate(1)
if alloc.Failed() {
t.Fatal("Allocate(1) failed with one allocation slot")
}
if alloc.Offset != 0 {
t.Errorf("allocation offset = %d, want 0", alloc.Offset)
}
if got := a.AllocationSize(alloc); got != 1 {
t.Errorf("AllocationSize = %d, want 1", got)
}

// maxAllocs limits simultaneous allocations. Once the slot is occupied,
// another request must fail cleanly even though the remainder is free.
beforeFailure := a.StorageReport()
if next := a.Allocate(1); !next.Failed() {
t.Fatal("second allocation should fail when the only slot is occupied")
}
if afterFailure := a.StorageReport(); afterFailure != beforeFailure {
t.Errorf("failed allocation changed storage report: before=%#v after=%#v", beforeFailure, afterFailure)
}

a.Free(alloc)
reused := a.Allocate(1)
if reused.Failed() {
t.Fatal("allocation after freeing the only slot failed")
}
a.Free(reused)

report := a.StorageReport()
if report.TotalFreeSpace != 1024 {
t.Errorf("TotalFreeSpace after free/reuse = %d, want 1024", report.TotalFreeSpace)
}
}

func TestZeroAllocationSlots(t *testing.T) {
a := New(1024, 0)

if alloc := a.Allocate(1); !alloc.Failed() {
t.Fatalf("Allocate(1) with zero allocation slots = %#v, want failure", alloc)
}
}

func TestExcessiveAllocationCapacityPanics(t *testing.T) {
defer func() {
if r := recover(); r == nil {
t.Error("New with unrepresentable allocation capacity should panic")
}
}()

New(1, math.MaxUint32)
}

func TestAllocateSplitWithoutFreeNodeFails(t *testing.T) {
a := New(1024, 1)
a.freeOffset = nodeUnused // Simulate exhausted structural metadata.
before := a.StorageReport()

if alloc := a.Allocate(1); !alloc.Failed() {
t.Fatalf("Allocate(1) without a remainder node = %#v, want failure", alloc)
}
if after := a.StorageReport(); after != before {
t.Errorf("failed split changed storage report: before=%#v after=%#v", before, after)
}
}

func TestAllocateMultiple(t *testing.T) {
a := New(testSize256MB, testMaxAllocs)

Expand Down Expand Up @@ -274,9 +343,8 @@ func TestMaxAllocsExhaustion(t *testing.T) {
const maxAllocs = 8
a := New(1024, maxAllocs)

// Each allocation consumes a node. The initial free region also consumes
// one node, plus the remainder from each split. With maxAllocs=8, we can
// make fewer than 8 simultaneous allocations because of remainder nodes.
// The node pool includes enough structural slots for free regions and split
// remainders, while maxAllocs bounds simultaneous allocations.
var allocs []Allocation
for i := 0; i < 100; i++ {
alloc := a.Allocate(1)
Expand Down Expand Up @@ -313,6 +381,38 @@ func TestMaxAllocsExhaustion(t *testing.T) {
a.Free(recovered)
}

func TestFragmentedNodeCapacity(t *testing.T) {
// Keep several non-adjacent holes while five of the eight allocation slots
// remain active. The allocator still has ample contiguous storage, and the
// request must be able to split one of the holes rather than fail due to
// exhausted structural nodes.
a := New(4096, 8)
allocs := make([]Allocation, 8)
for i := range allocs {
allocs[i] = a.Allocate(100)
if allocs[i].Failed() {
t.Fatalf("initial allocation %d failed", i)
}
}

for _, i := range []int{1, 3, 5} {
a.Free(allocs[i])
}

split := a.Allocate(62)
if split.Failed() {
t.Fatal("allocation in fragmented free space failed before maxAllocs")
}
a.Free(split)

for _, i := range []int{0, 2, 4, 6, 7} {
a.Free(allocs[i])
}
if report := a.StorageReport(); report.TotalFreeSpace != 4096 {
t.Errorf("TotalFreeSpace after cleanup = %d, want 4096", report.TotalFreeSpace)
}
}

func TestStorageReport(t *testing.T) {
a := New(1024, 256)

Expand Down Expand Up @@ -611,6 +711,21 @@ func TestAllocateAlignedNonPowerOfTwoPanics(t *testing.T) {
a.AllocateAligned(100, 3)
}

func TestAllocateAlignedOverflowFails(t *testing.T) {
const alignment = 4
size := uint32(math.MaxUint32)
a := New(size, 2)
before := a.StorageReport()

alloc := a.AllocateAligned(size, alignment)
if !alloc.Failed() {
t.Fatalf("AllocateAligned(%d, %d) should fail on padded-size overflow: %#v", size, alignment, alloc)
}
if after := a.StorageReport(); after != before {
t.Errorf("overflow failure changed storage report: before=%#v after=%#v", before, after)
}
}

func TestAllocateAlignedExhaustion(t *testing.T) {
// Small allocator — aligned allocations waste padding, should exhaust faster.
a := New(1024, 256)
Expand Down