Skip to content

Commit

Permalink
Drafted README
Browse files Browse the repository at this point in the history
  • Loading branch information
fryderykhuang committed Jan 19, 2024
1 parent de2bde9 commit 9ac1813
Show file tree
Hide file tree
Showing 32 changed files with 350 additions and 289 deletions.
74 changes: 73 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,75 @@
# NullGC

High performance unmanaged memory allocator / collection types / LINQ provider for .NET Core
High performance unmanaged memory allocator / collection types / LINQ provider for .NET Core.
Most suitable for game development since there will be no latency jitter caused by .NET garbage collection activities.

## Motivation
This project was born mostly out of my curiosity on how far can it go to entirely eliminate garbage collection. Although .NET background GC is already good at hiding GC stops, still there are some. Also for throughput focused scenarios, there may be huge throughput difference when GC is completely out of the equation.


## Usage
Currently this project contains 3 components:
1. Unmanaged memory allocator
2. Value type only collections
3. Linq operators

Two types of memory allocation strategy are supported.
### Arena
```csharp
using (AllocatorContext.BeginAllocationScope())
{
var list = new ValueList<T>();
var dict = new ValueDictionary<T>();
...
} // all Value* collections are automatically disposed as they go out of scope.
```
### Explicit lifetime
```csharp
// You can construct a value type collection anywhere(including inside of arena scope)
// using this overload:
var list = new ValueList<T>(AllocatorTypes.DefaultUnscoped);
...
// Anywhere after sometime:
list.Dispose();
```
To avoid double-free situations, when these collections are passed by value, Borrow() should be used. After this, all copies of the original collections can be safely disposed without double-free.
```csharp
var list = new ValueList<T>(AllocatorTypes.DefaultUnscoped);
...
// ref passing is not affected.
SomeListRefConsumingMethod(in list);
SomeListRefConsumingMethod(ref list);
// value passing should call Borrow()
SomeListConsumingMethod(list.Borrow())
```
### Custom collection types
* ValueArray&lt;T&gt;
* ValueList&lt;T&gt;
* ValueDictionary&lt;TKey, TValue&gt;
* ValueStack&lt;T&gt;
* ValueLinkedList&lt;T&gt;
* ValueFixedSizeDeque&lt;T&gt; (Circular buffer)
* SlidingWindow&lt;T&gt;
* SlidingTimeWindow&lt;T&gt;

### Linq
**The fastest LINQ provider as of today** (2024.1). <Benchmark here> (Compared with LinqGen/RefLinq/HyperLinq)

Proper usage is with the built-in value typed collections, but good old IEnumerable&lt;T&gt; is also supported. You can still get some benefit on LINQ operators that need to buffer data such as OrderBy.
The LINQ interface has 3 variations:
```csharp
SomeCollection.LinqValue()... // All types of IEnumerable<T> are supported
SomeCollection.LinqRef()... // Besides built-in value typed collections, only Enumerators that exposes 'ref T Current' are supported (e.g. normal array types)
SomeCollection.LinqPtr()... // Only built-in value typed collections are supported.
```

## Things to do
1. More examples.
2. Larger test coverage.
3. More collection types.
4. More LINQ providers and support range.

## Thanks
Many thanks to Emma Maassen from <https://github.com/Enichan/Arenas> and Angouri from <https://github.com/asc-community/HonkPerf.NET> on inspiring me to this project.

Details in [THIRD-PARTY-NOTICES.md](https://github.com/fryderykhuang/NullGC/blob/gha-test/THIRD-PARTY-NOTICES.md)
13 changes: 5 additions & 8 deletions THIRD-PARTY-NOTICES.md
Original file line number Diff line number Diff line change
@@ -1,20 +1,17 @@
### .NET

License: MIT
<https://github.com/dotnet/runtime/blob/main/LICENSE.TXT>
License: [MIT](https://github.com/dotnet/runtime/blob/main/LICENSE.TXT)

ValueList/ValueDictionary/ValueQueue is modified from corresponding .NET generic collections.

### Arenas
### [Arenas](https://github.com/Enichan/Arenas)

License: MIT
<https://github.com/Enichan/Arenas/blob/main/LICENSE>
License: [MIT](https://github.com/Enichan/Arenas/blob/main/LICENSE)

The ArenaAllocator class is based on code from this project.

### HonkPerf.NET
### [HonkPerf.NET](https://github.com/asc-community/HonkPerf.NET)

License: MIT
<https://github.com/asc-community/HonkPerf.NET/blob/main/LICENSE>
License: [MIT](https://github.com/asc-community/HonkPerf.NET/blob/main/LICENSE)

The NullGC.Linq project is inspired by this project.
2 changes: 1 addition & 1 deletion scripts/GenBenchmarkPage.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@
Push-Location $SolutionDir
if (!(Test-Path -PathType Container $BenchmarkResultPageDir)) { New-Item -ItemType Directory -Path $BenchmarkResultPageDir }
Copy-Item $BenchmarkArtifactsDir\results\*.html $BenchmarkResultPageDir
dotnet run --project .\BenchmarkResultPageGenerator\BenchmarkResultPageGenerator.csproj $BenchmarkArtifactsDir\results '' $BenchmarkResultPageDir\index.html
dotnet run --project .\BenchmarkResultPageGenerator\BenchmarkResultPageGenerator.csproj $BenchmarkArtifactsDir\results $BenchmarkResultPageDir\index.html
if (!$?) { throw }
Pop-Location
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
{
public class BenchmarkResultFile
{
public string Url { get; set; }
public string Title { get; set; }
public string Content { get; set; }
public required string Url { get; set; }
public required string Title { get; set; }
public required string Content { get; set; }
}
}
5 changes: 2 additions & 3 deletions src/BenchmarkResultPageGenerator/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,7 @@ internal class Program
private static async Task Main(string[] args)
{
var dir = args[0];
var urlBase = args[1];
var output = args[2];
var output = args[1];

var lst = new List<BenchmarkResultFile>();
foreach (var item in Directory.GetFiles(dir, "*.html"))
Expand All @@ -25,7 +24,7 @@ private static async Task Main(string[] args)
if (m.Success)
lst.Add(new BenchmarkResultFile
{
Title = urlBase + m.Groups["class"].Value, Url = args[1] + UrlEncoder.Default.Encode(fn),
Title = m.Groups["class"].Value, Url = args[1] + UrlEncoder.Default.Encode(fn),
Content = await File.ReadAllTextAsync(item)
});
}
Expand Down
4 changes: 2 additions & 2 deletions src/NullGC.Abstractions/Allocators/AllocatorContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ public static void ClearProvidersAndAllocations()
Impl.ClearProvidersAndAllocations();
}

public static IMemoryAllocator GetAllocator(int allocatorProviderId = (int) AllocatorProviderIds.Default)
public static IMemoryAllocator GetAllocator(int allocatorProviderId = (int) AllocatorTypes.Default)
{
GuardImpl();
return Impl.GetAllocator(allocatorProviderId);
Expand All @@ -81,7 +81,7 @@ public static IMemoryAllocator GetAllocator(int allocatorProviderId = (int) Allo
/// </summary>
/// <param name="allocatorProviderId"></param>
/// <returns></returns>
public static IDisposable BeginAllocationScope(int allocatorProviderId = (int) AllocatorProviderIds.Default)
public static IDisposable BeginAllocationScope(int allocatorProviderId = (int) AllocatorTypes.Default)
{
GuardImpl();
return Impl.BeginAllocationScope(allocatorProviderId);
Expand Down
11 changes: 0 additions & 11 deletions src/NullGC.Abstractions/Allocators/AllocatorProviderIds.cs

This file was deleted.

16 changes: 16 additions & 0 deletions src/NullGC.Abstractions/Allocators/AllocatorTypes.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
using System.ComponentModel;

namespace NullGC.Allocators;

public enum AllocatorTypes
{
[EditorBrowsable(EditorBrowsableState.Advanced)]
Invalid = 0,
Default = 1,
[EditorBrowsable(EditorBrowsableState.Advanced)]
ScopedUserMin = 16,
DefaultUnscoped = -1,
DefaultUncachedUnscoped = -2,
[EditorBrowsable(EditorBrowsableState.Advanced)]
UnscopedUserMax = -16,
}
4 changes: 2 additions & 2 deletions src/NullGC.Abstractions/Allocators/IAllocatorContextImpl.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ public interface IAllocatorContextImpl
void FreeAllocations(int allocatorProviderId);
void SetAllocatorProvider(IAllocatorProvider provider, int allocatorProviderId, bool scoped);
void ClearProvidersAndAllocations();
IMemoryAllocator GetAllocator(int allocatorProviderId = (int) AllocatorProviderIds.Default);
IDisposable BeginAllocationScope(int allocatorProviderId = (int) AllocatorProviderIds.Default);
IMemoryAllocator GetAllocator(int allocatorProviderId = (int) AllocatorTypes.Default);
IDisposable BeginAllocationScope(int allocatorProviderId = (int) AllocatorTypes.Default);
void FinalizeConfiguration();
}
8 changes: 4 additions & 4 deletions src/NullGC.Abstractions/Class.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ namespace NullGC;
public readonly unsafe T* Value;
public readonly int AllocatorProviderId;

public Class(int allocatorProviderId = (int) AllocatorProviderIds.Default)
public Class(int allocatorProviderId = (int) AllocatorTypes.Default)
{
AllocatorProviderId = allocatorProviderId;
unsafe
Expand All @@ -31,13 +31,13 @@ public Class(int allocatorProviderId = (int) AllocatorProviderIds.Default)
/// Allocate memory for <typeparamref name="T"/> from default scoped allocator, dispose is not mandatory.
/// </summary>
/// <returns></returns>
public static Class<T> CreateScoped() => new((int) AllocatorProviderIds.Default);
public static Class<T> CreateScoped() => new((int) AllocatorTypes.Default);

/// <summary>
/// Allocate memory for <typeparamref name="T"/> from default unscoped allocator, dispose is mandatory when lifetime ends.
/// </summary>
/// <returns></returns>
public static Class<T> CreateUnscoped() => new((int) AllocatorProviderIds.DefaultUnscoped);
public static Class<T> CreateUnscoped() => new((int) AllocatorTypes.DefaultUnscoped);

public ref T Ref
{
Expand All @@ -53,7 +53,7 @@ public ref T Ref

public void Dispose()
{
if (AllocatorProviderId == (int) AllocatorProviderIds.Invalid)
if (AllocatorProviderId == (int) AllocatorTypes.Invalid)
return;

unsafe
Expand Down
22 changes: 11 additions & 11 deletions src/NullGC.Allocators.Tests/AllocatorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,17 +23,17 @@ public void AllocatorContext_SetAllocatorProvider_WillThrowIfAllocatorProviderId
{
Assert.Throws<ArgumentException>(() =>
AllocatorContext.SetAllocatorProvider(new DefaultAlignedNativeMemoryAllocator(),
(int) AllocatorProviderIds.Invalid, true));
(int) AllocatorTypes.Invalid, true));
}

[Fact]
public void AllocatorContext_SetAllocatorProvider_WillThrowIfAllocatorProviderWithSameIdIsAlreadySet()
{
AllocatorContext.SetAllocatorProvider(new DefaultAlignedNativeMemoryAllocator(),
(int) AllocatorProviderIds.Default, true);
(int) AllocatorTypes.Default, true);
Assert.Throws<ArgumentException>(() =>
AllocatorContext.SetAllocatorProvider(new DefaultAlignedNativeMemoryAllocator(),
(int) AllocatorProviderIds.Default, true));
(int) AllocatorTypes.Default, true));
}

[Fact]
Expand All @@ -43,7 +43,7 @@ public void CanAllocateAndFreeOnStaticScopedProviderAndReturnedAllocatorIsTheSam
var nativeBuffer = new DefaultAllocationPooler(nativeAllocator, 1000);
AllocatorContext.SetAllocatorProvider(
new AllocatorPool<ArenaAllocator>(p => new ArenaAllocator(p, p, nativeBuffer)),
(int) AllocatorProviderIds.Default, true);
(int) AllocatorTypes.Default, true);
IMemoryAllocator allocator;
using (AllocatorContext.BeginAllocationScope())
{
Expand Down Expand Up @@ -76,7 +76,7 @@ public void CanAllocateAndFreeOnPooledScopedProviderAndPoolIsWorking()
var nativeAllocator = new DefaultAlignedNativeMemoryAllocator();
AllocatorContext.SetAllocatorProvider(
new AllocatorPool<ArenaAllocator>(p => new ArenaAllocator(p, p, nativeAllocator)),
(int) AllocatorProviderIds.Default, true);
(int) AllocatorTypes.Default, true);
IMemoryAllocator allocator;
using (AllocatorContext.BeginAllocationScope())
{
Expand Down Expand Up @@ -108,7 +108,7 @@ public void CanAllocateAndFreeOnPooledCachedScopedProviderAndPoolIsWorkingAndAll
{
var cache = new DefaultAllocationPooler(new DefaultAlignedNativeMemoryAllocator(), 1000);
var arenaAllocatorPool = new AllocatorPool<ArenaAllocator>(p => new ArenaAllocator(p, p, cache));
AllocatorContext.SetAllocatorProvider(arenaAllocatorPool, (int) AllocatorProviderIds.Default, true);
AllocatorContext.SetAllocatorProvider(arenaAllocatorPool, (int) AllocatorTypes.Default, true);

IMemoryAllocator allocator;
using (AllocatorContext.BeginAllocationScope())
Expand Down Expand Up @@ -148,7 +148,7 @@ public void AllocatorContextReturnsToPoolWhenThreadDies()
{
var cache = new DefaultAllocationPooler(new DefaultAlignedNativeMemoryAllocator(), 1000);
var arenaAllocatorPool = new AllocatorPool<ArenaAllocator>(p => new ArenaAllocator(p, p, cache));
AllocatorContext.SetAllocatorProvider(arenaAllocatorPool, (int) AllocatorProviderIds.Default, true);
AllocatorContext.SetAllocatorProvider(arenaAllocatorPool, (int) AllocatorTypes.Default, true);

Assert.Empty(((DefaultAllocatorContextImpl)AllocatorContext.Impl).ContextPool);
var mainThreadId = Thread.CurrentThread.ManagedThreadId;
Expand All @@ -162,13 +162,13 @@ void Worker()
// Assert.Null(ContextContainer.GetPerProviderContainer(0).ContextContainer.Value);
using (AllocatorContext.BeginAllocationScope())
{
Assert.NotNull(((DefaultAllocatorContextImpl)AllocatorContext.Impl).GetPerProviderContainer((int)AllocatorProviderIds.Default).Context!.Value);
Assert.NotNull(((DefaultAllocatorContextImpl)AllocatorContext.Impl).GetPerProviderContainer((int)AllocatorTypes.Default).Context!.Value);
Assert.Empty(((DefaultAllocatorContextImpl)AllocatorContext.Impl).ContextPool);
AllocatorContext.GetAllocator();
Assert.Empty(((DefaultAllocatorContextImpl)AllocatorContext.Impl).ContextPool);
}

Assert.NotNull(((DefaultAllocatorContextImpl)AllocatorContext.Impl).GetPerProviderContainer((int)AllocatorProviderIds.Default).Context!.Value);
Assert.NotNull(((DefaultAllocatorContextImpl)AllocatorContext.Impl).GetPerProviderContainer((int)AllocatorTypes.Default).Context!.Value);

Assert.Empty(((DefaultAllocatorContextImpl)AllocatorContext.Impl).ContextPool);
Assert.NotEqual(execCtx, Thread.CurrentThread.ExecutionContext);
Expand Down Expand Up @@ -256,7 +256,7 @@ public void NestedSameProviderTypeScope()
{
var allocPooler = new DefaultAllocationPooler(new DefaultAlignedNativeMemoryAllocator(), 1000);
var arenaAllocatorPool = new AllocatorPool<ArenaAllocator>(p => new ArenaAllocator(p, p, allocPooler));
AllocatorContext.SetAllocatorProvider(arenaAllocatorPool, (int) AllocatorProviderIds.Default, true);
AllocatorContext.SetAllocatorProvider(arenaAllocatorPool, (int) AllocatorTypes.Default, true);

using (AllocatorContext.BeginAllocationScope())
{
Expand Down Expand Up @@ -290,7 +290,7 @@ public void NestedDifferentProviderTypeScope()
{
var allocPooler = new DefaultAllocationPooler(new DefaultAlignedNativeMemoryAllocator(), 1000);
var arenaAllocatorPool = new AllocatorPool<ArenaAllocator>(p => new ArenaAllocator(p, p, allocPooler));
AllocatorContext.SetAllocatorProvider(arenaAllocatorPool, (int) AllocatorProviderIds.Default, true);
AllocatorContext.SetAllocatorProvider(arenaAllocatorPool, (int) AllocatorTypes.Default, true);
var arenaAllocatorPool2 = new AllocatorPool<ArenaAllocator>(p => new ArenaAllocator(p, p, allocPooler));
AllocatorContext.SetAllocatorProvider(arenaAllocatorPool2, 16, true);

Expand Down
4 changes: 2 additions & 2 deletions src/NullGC.Allocators/ArenaAllocator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,10 @@ public sealed class ArenaAllocator : IMemoryAllocator, IDisposable, IMemoryAlloc


private ValueDictionary<nuint, FreeList>
_freeList = new(Environment.ProcessorCount, (int) AllocatorProviderIds.DefaultUncachedUnscoped);
_freeList = new(Environment.ProcessorCount, (int) AllocatorTypes.DefaultUncachedUnscoped);

private ValueList<Page> _pages = new(Environment.ProcessorCount,
(int) AllocatorProviderIds.DefaultUncachedUnscoped);
(int) AllocatorTypes.DefaultUncachedUnscoped);

private ulong _totalAllocated;
private ulong _totalFreed;
Expand Down
8 changes: 4 additions & 4 deletions src/NullGC.Allocators/DefaultAllocationPooler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,9 @@ public class DefaultAllocationPooler : IMemoryAllocator, IMemoryAllocationTracka

private int _consecutivePrunes;
private ulong _currentPoolSize;
private ValueLinkedList<LruItem> _lruList = new(8, (int) AllocatorProviderIds.DefaultUncachedUnscoped);
private ValueLinkedList<LruItem> _lruList = new(8, (int) AllocatorTypes.DefaultUncachedUnscoped);

private ValueDictionary<nuint, AllocationPool> _pool = new(8, (int) AllocatorProviderIds.DefaultUncachedUnscoped);
private ValueDictionary<nuint, AllocationPool> _pool = new(8, (int) AllocatorTypes.DefaultUncachedUnscoped);
private ulong _totalAllocatedBytes;
private ulong _totalFreedBytes;

Expand Down Expand Up @@ -424,7 +424,7 @@ private struct AllocationPool
private readonly int _maxTtlAdaptStepMs;

// TODO Use Deque instead
public ValueList<PoolItem> Allocations = new(8, (int) AllocatorProviderIds.DefaultUncachedUnscoped);
public ValueList<PoolItem> Allocations = new(8, (int) AllocatorTypes.DefaultUncachedUnscoped);
private SlidingWindow<int> _cacheLostObserver;

public int HeadInLruList = -1;
Expand All @@ -450,7 +450,7 @@ public AllocationPool(int ttl, int cacheLostObserveWindowSize
_maxTtlAdaptStepMs = ttl * 2;
_ttlDecreaseStep = ttl / 10;
_cacheLostObserver = new SlidingWindow<int>(cacheLostObserveWindowSize,
(int) AllocatorProviderIds.DefaultUncachedUnscoped);
(int) AllocatorTypes.DefaultUncachedUnscoped);
}

public readonly int Count => Allocations.Count;
Expand Down
Loading

0 comments on commit 9ac1813

Please sign in to comment.