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
4 changes: 2 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@ x86/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
[Ll]ogs/
/[Ll]og/
/[Ll]ogs/

# Visual Studio 2015/2017 cache/options directory
.vs/
Expand Down
191 changes: 191 additions & 0 deletions src/LogExpert.Benchmarks/BufferIndexBenchmarks.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
using BenchmarkDotNet.Attributes;

using ColumnizerLib;

using LogExpert.Benchmarks.Support;
using LogExpert.Core.Classes.Log.Buffers;

namespace LogExpert.Benchmarks;

[MemoryDiagnoser]
[RankColumn]
public class BufferIndexBenchmarks : IDisposable
{
private BufferIndex _index = null!;
private int _totalLines;

private bool _disposed;

[Params(100, 1_000, 10_000)]
public int BufferCount { get; set; }

private const int LINES_PER_BUFFER = 500;

[GlobalSetup]
public void Setup ()
{
_index = new BufferIndex(BufferCount, LINES_PER_BUFFER);
_totalLines = BufferCount * LINES_PER_BUFFER;

var fakeFileInfo = new FakeLogFileInfo();

using (var writeLock = _index.AcquireWriteLock())
{
for (int i = 0; i < BufferCount; i++)
{
var buffer = new LogBuffer(fakeFileInfo, LINES_PER_BUFFER)
{
StartLine = i * LINES_PER_BUFFER
};

for (int j = 0; j < LINES_PER_BUFFER; j++)
{
buffer.AddLine(new LogLine($"line {i * LINES_PER_BUFFER + j}".AsMemory(), i * LINES_PER_BUFFER + j), 0);
}

_index.Add(buffer);
}
}

// Validate setup
var snapshot = _index.CreateSnapshot();
if (snapshot.BufferCount != BufferCount)
{
throw new InvalidOperationException($"Setup failed: expected {BufferCount} buffers, got {snapshot.BufferCount}");
}
}

[GlobalCleanup]
public void Cleanup () => _index.Dispose();

/// <summary>
/// Simulates tail-follow: reading the last 1000 lines sequentially.
/// Should hit Layer 0 (thread-local cache) ~99% of the time.
/// </summary>
[Benchmark(Baseline = true)]
public LogBuffer? SequentialAccess ()
{
using var readlock = _index.AcquireReadLock();
LogBuffer? last = null;
var start = Math.Max(0, _totalLines - 1000);
for (int i = start; i < _totalLines; i++)
{
var logBufferEntry = _index.TryFindBuffer(i);
if (logBufferEntry.Found)
{
last = logBufferEntry.Buffer;
}
}

return last;
}

/// <summary>
/// Simulates search/goto: deterministic stride across the full file.
/// Co-prime stride visits buffers in non-sequential, non-repeating order.
/// Exercises Layers 2 and 3 heavily.
/// </summary>
[Benchmark]
public LogBuffer? StrideAccess ()
{
using var readLock = _index.AcquireReadLock();
LogBuffer? last = null;
var stride = _totalLines / 3 + 1;
var lineNum = 0;
for (int i = 0; i < 1000; i++)
{
var logBufferEntry = _index.TryFindBuffer(lineNum);
if (logBufferEntry.Found)
{
last = logBufferEntry.Buffer;
}

lineNum = (lineNum + stride) % _totalLines;
}

return last;
}

/// <summary>
/// Worst case for Layer 0: always crossing buffer boundaries.
/// Exercises Layer 1 (adjacent prediction).
/// </summary>
[Benchmark]
public LogBuffer? BoundaryAccess ()
{
using var readLock = _index.AcquireReadLock();
LogBuffer? last = null;

for (int i = 0; i < 1000; i++)
{
int lineNum = i * (_totalLines / 1000);
var logBufferEntry = _index.TryFindBuffer(lineNum);
if (logBufferEntry.Found)
{
last = logBufferEntry.Buffer;
}
}

return last;
}

/// <summary>
/// Simulates UI scrolling: page-sized jumps forward through the file.
/// 50-line pages with 3x page jumps (fast scroll drag).
/// Exercises Layer 0 within pages and Layers 1-2 on transitions.
/// </summary>
[Benchmark]
public LogBuffer? ScrollAccess ()
{
using var readLock = _index.AcquireReadLock();
LogBuffer? last = null;
const int pageSize = 50;
const int pageJump = pageSize * 3;
var pageStart = 0;

for (int page = 0; page < 20 && pageStart < _totalLines; page++)
{
var pageEnd = Math.Min(pageStart + pageSize, _totalLines);
for (int line = pageStart; line < pageEnd; line++)
{
var logBufferEntry = _index.TryFindBuffer(line);
if (logBufferEntry.Found)
{
last = logBufferEntry.Buffer;
}
}

pageStart += pageJump;
}

return last;
}

/// <summary>
/// Measures LRU eviction cost at current scale.
/// </summary>
[Benchmark]
public void EvictAndRepopulate ()
{
_index.EvictLeastRecentlyUsed();
}

public void Dispose ()
{
Dispose(true);
GC.SuppressFinalize(this);
}

protected virtual void Dispose (bool disposing)
{
if (!_disposed)
{
if (disposing)
{
_index?.Dispose();
}

_disposed = true;
}
}
}
170 changes: 170 additions & 0 deletions src/LogExpert.Benchmarks/BufferIndexContentionBenchmarks.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Diagnosers;

using ColumnizerLib;

using LogExpert.Benchmarks.Support;
using LogExpert.Core.Classes.Log.Buffers;

namespace LogExpert.Benchmarks;

/// <summary>
/// Measures ReaderWriterLockSlim contention under concurrent read load.
/// Compares single-threaded throughput against N concurrent readers
/// to determine if RWLS is a bottleneck worth optimizing.
/// </summary>
[MemoryDiagnoser]
[ThreadingDiagnoser] // Reports lock contention + thread pool stats
[RankColumn]
public class BufferIndexContentionBenchmarks : IDisposable
{
private BufferIndex _index = null!;
private int _totalLines;
private bool _disposed;

private const int BUFFERS = 10_000;
private const int LINES_PER_BUFFER = 500;
private const int READS_PER_TASK = 1_000;

[GlobalSetup]
public void Setup ()
{
_index = new BufferIndex(BUFFERS, LINES_PER_BUFFER);
_totalLines = BUFFERS * LINES_PER_BUFFER;

var fakeFileInfo = new FakeLogFileInfo();
using var writeLock = _index.AcquireWriteLock();
for (int i = 0; i < BUFFERS; i++)
{
var buffer = new LogBuffer(fakeFileInfo, LINES_PER_BUFFER)
{
StartLine = i * LINES_PER_BUFFER
};
for (int j = 0; j < LINES_PER_BUFFER; j++)
{
buffer.AddLine(
new LogLine($"line {i * LINES_PER_BUFFER + j}".AsMemory(),
i * LINES_PER_BUFFER + j), 0);
}
_index.Add(buffer);
}
}

/// <summary>
/// Single-threaded baseline: sequential reads under one read lock.
/// This is the ideal throughput ceiling.
/// </summary>
[Benchmark(Baseline = true)]
public int SingleThreadedReads ()
{
int found = 0;
using var readLock = _index.AcquireReadLock();
var start = Math.Max(0, _totalLines - READS_PER_TASK);
for (int i = start; i < _totalLines; i++)
{
if (_index.TryFindBuffer(i).Found)
{
found++;
}
}

return found;
}

/// <summary>
/// N concurrent readers each acquiring their own read lock.
/// If RWLS has no contention, throughput ≈ N × single-threaded.
/// </summary>
[Benchmark]
[Arguments(2)]
[Arguments(4)]
[Arguments(8)]
[Arguments(12)]
public int ConcurrentReads (int threadCount)
{
var total = 0;
_ = Parallel.For(0, threadCount, _ =>
{
int found = 0;
using var readLock = _index.AcquireReadLock();
var start = Math.Max(0, _totalLines - READS_PER_TASK);
for (int i = start; i < _totalLines; i++)
{
if (_index.TryFindBuffer(i).Found)
{
found++;
}
}
_ = Interlocked.Add(ref total, found);
});
return total;
}

/// <summary>
/// Simulates production: N readers + 1 writer (tail-follow append).
/// Writer acquires write lock briefly every ~1000 reads.
/// This is the realistic contention scenario.
/// </summary>
[Benchmark]
[Arguments(4)]
[Arguments(8)]
public int ConcurrentReadsWithWriter (int readerCount)
{
using var cts = new CancellationTokenSource();
var total = 0;

// Writer task: periodically takes write lock (simulates new buffer append)
var writerTask = Task.Run(() =>
{
while (!cts.Token.IsCancellationRequested)
{
using var writeLock = _index.AcquireWriteLock();
// Simulate brief write work (no actual mutation to keep state clean)
Thread.SpinWait(100);
}
});

// Reader tasks
_ = Parallel.For(0, readerCount, _ =>
{
int found = 0;
using var readLock = _index.AcquireReadLock();
var start = Math.Max(0, _totalLines - READS_PER_TASK);
for (int i = start; i < _totalLines; i++)
{
if (_index.TryFindBuffer(i).Found)
{
found++;
}
}

_ = Interlocked.Add(ref total, found);
});

cts.Cancel();
writerTask.Wait();
return total;
}

[GlobalCleanup]
public void Cleanup () => _index.Dispose();

public void Dispose ()
{
Dispose(true);
GC.SuppressFinalize(this);
}

protected virtual void Dispose (bool disposing)
{
if (!_disposed)
{
if (disposing)
{
_index?.Dispose();
}

_disposed = true;
}
}
}
1 change: 1 addition & 0 deletions src/LogExpert.Benchmarks/LogExpert.Benchmarks.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

<ItemGroup>
<ProjectReference Include="..\LogExpert.Core\LogExpert.Core.csproj" />
<ProjectReference Include="..\PluginRegistry\LogExpert.PluginRegistry.csproj" />
</ItemGroup>

<!-- Exclude the shared AssemblyInfo.cs that Directory.Build.props tries to add -->
Expand Down
Loading
Loading