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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user