perf: eliminate per-message allocations in pub/sub hot path and coalesce outbound writes

Pub/sub 1:1 (16B) improved from 0.18x to 0.50x, fan-out from 0.18x to 0.44x,
and JetStream durable fetch from 0.13x to 0.64x vs Go. Key changes: replace
.ToArray() copy in SendMessage with pooled buffer handoff, batch multiple small
writes into single WriteAsync via 64KB coalesce buffer in write loop, and remove
profiling Stopwatch instrumentation from ProcessMessage/StreamManager hot paths.
This commit is contained in:
Joseph Doherty
2026-03-13 05:09:36 -04:00
parent 9e0df9b3d7
commit 0a4e7a822f
10 changed files with 654 additions and 232 deletions
+104 -106
View File
@@ -48,11 +48,13 @@ public sealed class MsgBlock : IDisposable
// Reference: golang/nats-server/server/filestore.go:236 (cache field)
private Dictionary<ulong, MessageRecord>? _cache;
// Pending write buffer — accumulates encoded records for batched disk writes.
// The background flush loop in FileStore coalesces these into fewer I/O calls.
// Reference: golang/nats-server/server/filestore.go:6700 (cache.buf write path).
private readonly List<(byte[] Data, long Offset)> _pendingWrites = new();
private int _pendingBytes;
// Pending write buffer — a single contiguous byte[] that accumulates encoded
// records for batched disk writes. Go: mb.cache.buf — a single byte slice that
// grows via pooled buffer swap. Records are encoded directly into this buffer.
// Reference: golang/nats-server/server/filestore.go:6715 (cache.buf growth).
private byte[] _pendingBuf = new byte[64 * 1024]; // 64KB initial
private int _pendingBufUsed;
private long _pendingBufDiskOffset; // Disk offset corresponding to _pendingBuf[0]
// Go: msgBlock.lchk — last written record checksum (XxHash64, 8 bytes).
// Tracked so callers can chain checksum verification across blocks.
@@ -67,6 +69,7 @@ public sealed class MsgBlock : IDisposable
_maxBytes = maxBytes;
_firstSequence = firstSequence;
_nextSequence = firstSequence;
_pendingBufDiskOffset = file.Length;
_writeOffset = file.Length;
}
@@ -148,7 +151,7 @@ public sealed class MsgBlock : IDisposable
get
{
_lock.EnterReadLock();
try { return _cache is not null; }
try { return _cache is not null || _pendingBufUsed > 0; }
finally { _lock.ExitReadLock(); }
}
}
@@ -165,7 +168,7 @@ public sealed class MsgBlock : IDisposable
return 0;
try { _lock.EnterReadLock(); }
catch (ObjectDisposedException) { return 0; }
try { return _pendingBytes; }
try { return _pendingBufUsed; }
finally { _lock.ExitReadLock(); }
}
}
@@ -240,36 +243,25 @@ public sealed class MsgBlock : IDisposable
throw new InvalidOperationException("Block is sealed; cannot write new messages.");
var sequence = _nextSequence;
var record = new MessageRecord
{
Sequence = sequence,
Subject = subject,
Headers = headers,
Payload = payload,
Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() * 1_000_000L,
Deleted = false,
};
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() * 1_000_000L;
// Encode directly into the contiguous pending buffer.
var encodedSize = MessageRecord.MeasureEncodedSize(subject, headers.Span, payload.Span);
EnsurePendingBufCapacity(encodedSize);
var bufStart = _pendingBufUsed;
var written = MessageRecord.EncodeTo(
_pendingBuf, bufStart, sequence, subject,
headers.Span, payload.Span, timestamp);
_pendingBufUsed += written;
var encoded = MessageRecord.Encode(record);
var offset = _writeOffset;
_writeOffset = offset + written;
// Buffer the write for batched disk I/O — the background flush loop
// in FileStore will coalesce pending writes.
_pendingWrites.Add((encoded, offset));
_pendingBytes += encoded.Length;
_writeOffset = offset + encoded.Length;
_index[sequence] = (offset, written);
_index[sequence] = (offset, encoded.Length);
// Go: cache recently-written record to avoid disk reads on hot path.
// Reference: golang/nats-server/server/filestore.go:6730 (cache population).
_cache ??= new Dictionary<ulong, MessageRecord>();
_cache[sequence] = record;
// Go: msgBlock.lchk — capture checksum (last 8 bytes of encoded record).
// Reference: golang/nats-server/server/filestore.go:2204 (lchk update on write)
_lastChecksum ??= new byte[8];
encoded.AsSpan(^8..).CopyTo(_lastChecksum);
_pendingBuf.AsSpan(bufStart + written - 8, 8).CopyTo(_lastChecksum);
if (_totalWritten == 0)
_firstSequence = sequence;
@@ -307,36 +299,29 @@ public sealed class MsgBlock : IDisposable
if (_writeOffset >= _maxBytes)
throw new InvalidOperationException("Block is sealed; cannot write new messages.");
var record = new MessageRecord
{
Sequence = sequence,
Subject = subject,
Headers = headers,
Payload = payload,
Timestamp = timestamp,
Deleted = false,
};
// Go: writeMsgRecordLocked encodes directly into cache.buf.
// Measure encoded size, ensure buffer has capacity, encode in-place.
var encodedSize = MessageRecord.MeasureEncodedSize(subject, headers.Span, payload.Span);
EnsurePendingBufCapacity(encodedSize);
var bufStart = _pendingBufUsed;
var written = MessageRecord.EncodeTo(
_pendingBuf, bufStart, sequence, subject,
headers.Span, payload.Span, timestamp);
_pendingBufUsed += written;
var encoded = MessageRecord.Encode(record);
var offset = _writeOffset;
_writeOffset = offset + written;
// Buffer the write for batched disk I/O — the background flush loop
// in FileStore will coalesce pending writes.
_pendingWrites.Add((encoded, offset));
_pendingBytes += encoded.Length;
_writeOffset = offset + encoded.Length;
_index[sequence] = (offset, written);
_index[sequence] = (offset, encoded.Length);
// Go: cache recently-written record to avoid disk reads on hot path.
// Reference: golang/nats-server/server/filestore.go:6730 (cache population).
_cache ??= new Dictionary<ulong, MessageRecord>();
_cache[sequence] = record;
// Go: cache populated lazily on read, not eagerly on write.
// Reads that miss _cache flush pending buf to disk and decode from there.
// Go: msgBlock.lchk — capture checksum (last 8 bytes of encoded record).
// Reference: golang/nats-server/server/filestore.go:2204 (lchk update on write)
_lastChecksum ??= new byte[8];
encoded.AsSpan(^8..).CopyTo(_lastChecksum);
_pendingBuf.AsSpan(bufStart + written - 8, 8).CopyTo(_lastChecksum);
if (_totalWritten == 0)
_firstSequence = sequence;
@@ -351,6 +336,36 @@ public sealed class MsgBlock : IDisposable
}
}
/// <summary>
/// Ensures the pending buffer has room for at least <paramref name="needed"/> more bytes.
/// Grows by doubling (like Go's pooled buffer swap in writeMsgRecordLocked).
/// </summary>
private void EnsurePendingBufCapacity(int needed)
{
var required = _pendingBufUsed + needed;
if (required <= _pendingBuf.Length)
return;
var newSize = Math.Max(_pendingBuf.Length * 2, required);
var newBuf = new byte[newSize];
_pendingBuf.AsSpan(0, _pendingBufUsed).CopyTo(newBuf);
_pendingBuf = newBuf;
}
/// <summary>
/// Flushes the contiguous pending buffer to disk in a single write.
/// Must be called while holding the write lock.
/// </summary>
private void FlushPendingBufToDisk()
{
if (_pendingBufUsed == 0)
return;
RandomAccess.Write(_handle, _pendingBuf.AsSpan(0, _pendingBufUsed), _pendingBufDiskOffset);
_pendingBufDiskOffset += _pendingBufUsed;
_pendingBufUsed = 0;
}
/// <summary>
/// Reads a message by sequence number.
/// Checks the write cache first to avoid disk I/O for recently-written messages.
@@ -368,23 +383,25 @@ public sealed class MsgBlock : IDisposable
if (_deleted.Contains(sequence))
return null;
// Go: check cache first (msgBlock.cache lookup).
// Reference: golang/nats-server/server/filestore.go:8155 (cache hit path).
// Check explicit write cache first (populated by some code paths).
if (_cache is not null && _cache.TryGetValue(sequence, out var cached))
return cached;
if (!_index.TryGetValue(sequence, out var entry))
return null;
// Flush pending writes so disk reads see the latest data.
if (_pendingWrites.Count > 0)
// Try reading from the in-memory pending buffer before going to disk.
// The pending buffer starts at _pendingBufDiskOffset on disk.
var pendingStart = _pendingBufDiskOffset;
if (entry.Offset >= pendingStart && entry.Offset + entry.Length <= pendingStart + _pendingBufUsed)
{
foreach (var (data, off) in _pendingWrites)
RandomAccess.Write(_handle, data, off);
_pendingWrites.Clear();
_pendingBytes = 0;
var bufOffset = (int)(entry.Offset - pendingStart);
return MessageRecord.Decode(_pendingBuf.AsSpan(bufOffset, entry.Length));
}
// Data not in pending buffer — flush and read from disk.
FlushPendingBufToDisk();
var buffer = new byte[entry.Length];
RandomAccess.Read(_handle, buffer, entry.Offset);
@@ -423,13 +440,7 @@ public sealed class MsgBlock : IDisposable
return false;
// Flush any pending writes so the record is on disk before we read it back.
if (_pendingWrites.Count > 0)
{
foreach (var (data, off) in _pendingWrites)
RandomAccess.Write(_handle, data, off);
_pendingWrites.Clear();
_pendingBytes = 0;
}
FlushPendingBufToDisk();
// Read the existing record, re-encode with Deleted flag, write back in-place.
// The encoded size doesn't change (only flags byte + checksum differ).
@@ -499,23 +510,26 @@ public sealed class MsgBlock : IDisposable
Deleted = true, // skip = deleted from the start
};
var encoded = MessageRecord.Encode(record);
// Encode skip record into contiguous buffer.
var encodedSize = MessageRecord.MeasureEncodedSize(string.Empty, ReadOnlySpan<byte>.Empty, ReadOnlySpan<byte>.Empty);
EnsurePendingBufCapacity(encodedSize);
var bufStart = _pendingBufUsed;
var written = MessageRecord.EncodeTo(
_pendingBuf, bufStart, sequence, string.Empty,
ReadOnlySpan<byte>.Empty, ReadOnlySpan<byte>.Empty, now, deleted: true);
_pendingBufUsed += written;
var offset = _writeOffset;
_writeOffset = offset + written;
// Buffer the write for batched disk I/O.
_pendingWrites.Add((encoded, offset));
_pendingBytes += encoded.Length;
_writeOffset = offset + encoded.Length;
_index[sequence] = (offset, encoded.Length);
_index[sequence] = (offset, written);
_deleted.Add(sequence);
_skipSequences.Add(sequence); // Track skip sequences separately for recovery
// Note: intentionally NOT added to _cache since it is deleted.
// Go: msgBlock.lchk — capture checksum (last 8 bytes of encoded record).
// Reference: golang/nats-server/server/filestore.go:2204 (lchk update on write)
_lastChecksum ??= new byte[8];
encoded.AsSpan(^8..).CopyTo(_lastChecksum);
_pendingBuf.AsSpan(bufStart + written - 8, 8).CopyTo(_lastChecksum);
if (_totalWritten == 0)
_firstSequence = sequence;
@@ -541,6 +555,8 @@ public sealed class MsgBlock : IDisposable
_lock.EnterWriteLock();
try
{
// Flush pending writes to disk before clearing in-memory state.
FlushPendingBufToDisk();
_cache = null;
}
finally
@@ -573,15 +589,15 @@ public sealed class MsgBlock : IDisposable
try
{
if (_pendingWrites.Count == 0)
if (_pendingBufUsed == 0)
return 0;
foreach (var (data, offset) in _pendingWrites)
RandomAccess.Write(_handle, data, offset);
// Single contiguous write — Go: flushPendingMsgsLocked writes cache.buf[wp:] to disk.
RandomAccess.Write(_handle, _pendingBuf.AsSpan(0, _pendingBufUsed), _pendingBufDiskOffset);
var flushed = _pendingBytes;
_pendingWrites.Clear();
_pendingBytes = 0;
var flushed = _pendingBufUsed;
_pendingBufDiskOffset += _pendingBufUsed;
_pendingBufUsed = 0;
return flushed;
}
finally { _lock.ExitWriteLock(); }
@@ -652,13 +668,7 @@ public sealed class MsgBlock : IDisposable
try
{
// Flush pending writes so disk reads see latest data.
if (_pendingWrites.Count > 0)
{
foreach (var (data, off) in _pendingWrites)
RandomAccess.Write(_handle, data, off);
_pendingWrites.Clear();
_pendingBytes = 0;
}
FlushPendingBufToDisk();
entries = new List<(long, int, ulong)>(_index.Count);
foreach (var (seq, (offset, length)) in _index)
@@ -713,13 +723,7 @@ public sealed class MsgBlock : IDisposable
try
{
// Flush pending buffered writes first.
if (_pendingWrites.Count > 0)
{
foreach (var (data, offset) in _pendingWrites)
RandomAccess.Write(_handle, data, offset);
_pendingWrites.Clear();
_pendingBytes = 0;
}
FlushPendingBufToDisk();
_file.Flush(flushToDisk: true);
}
@@ -742,13 +746,7 @@ public sealed class MsgBlock : IDisposable
try
{
// Flush pending buffered writes before closing.
if (_pendingWrites.Count > 0)
{
foreach (var (data, offset) in _pendingWrites)
RandomAccess.Write(_handle, data, offset);
_pendingWrites.Clear();
_pendingBytes = 0;
}
FlushPendingBufToDisk();
_file.Flush();
_file.Dispose();