Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
4f4fbfe
Keep AsyncGetFromDisk calling size to be under SectorSize unless spec…
TedHartMS May 23, 2026
e9ef3b0
Tweak for GetAndPopulateReadBuffer read length
TedHartMS May 23, 2026
2b5b635
Safekeeping checkin, mostly test conversion to support 4k sectors whi…
TedHartMS May 24, 2026
161c95a
Finish conversion of tests to use 4k pages, which will work with any …
TedHartMS May 26, 2026
80b521a
Fixes so either 512b or 4kb sector sizes pass the tests
TedHartMS May 26, 2026
76b1c0d
Merge remote-tracking branch 'origin/main' into tedhar/io-4k
TedHartMS May 26, 2026
b95908a
Fix perf regression for new multilevel InitialIOSize spec
TedHartMS Jun 2, 2026
d034f35
Return GetDeviceSectorSize to use GetDiskFreeSpace and return MinDevi…
TedHartMS Jun 4, 2026
21c48e2
Merge 'main'
TedHartMS Jun 4, 2026
36f34cc
format
TedHartMS Jun 4, 2026
edf6b80
Fix comment for default for Garnet InitialIORecordSize option
TedHartMS Jun 4, 2026
fe33014
Address Copilot comments; restore previous version of GetDeviceSector…
TedHartMS Jun 4, 2026
3494532
Make WaitForFrameLoad rethrow OperationCanceledException
TedHartMS Jun 4, 2026
51bbca1
temporarily disable test that needs updating
TedHartMS Jun 4, 2026
ecf7902
Fix ObjectIterationPushLockTest
TedHartMS Jun 8, 2026
ce2cbfa
Merge remote-tracking branch 'origin/main' into tedhar/io-4k
TedHartMS Jun 8, 2026
b97d421
Fix ObjectIterationPushLockTests
TedHartMS Jun 8, 2026
a7f77b4
Merge branch 'main' into tedhar/io-4k
TedHartMS Jun 8, 2026
e9a6bd6
Fix MultiDatabaseSaveInProgressTest
TedHartMS Jun 10, 2026
8eae606
Merge branch 'tedhar/io-4k' of https://github.com/microsoft/Garnet in…
TedHartMS Jun 10, 2026
260fab0
merge 'main'
TedHartMS Jun 10, 2026
88b3510
Add WaitUntilNextSecond() to handle LASTSAVE returning only second-le…
TedHartMS Jun 10, 2026
9147319
format
TedHartMS Jun 11, 2026
14e6aeb
merge 'main'
TedHartMS Jun 11, 2026
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
5 changes: 5 additions & 0 deletions libs/host/Configuration/Options.cs
Original file line number Diff line number Diff line change
Expand Up @@ -581,6 +581,10 @@ internal sealed class Options : ICloneable
[Option("value-overflow-threshold", Required = false, HelpText = "Max size of a value stored inline in the main-log page (larger values overflow to the heap). Accepts a memory size (e.g. 4k, 1m). Minimum 64 bytes; must be less than PageSize.")]
public string ValueOverflowThreshold { get; set; }

[MemorySizeValidation(isRequired: false)]
[Option("initial-io-record-size", Required = false, HelpText = "Initial IO read size for records on disk. Accepts a memory size (e.g. 4k, 8k). Default is 128 bytes.")]
public string InitialIORecordSize { get; set; }

[OptionValidation]
[Option("fail-on-recovery-error", Required = false, HelpText = "Server bootup should fail if errors happen during bootup of AOF and checkpointing")]
public bool? FailOnRecoveryError { get; set; }
Expand Down Expand Up @@ -944,6 +948,7 @@ endpoint is IPEndPoint listenEp && clusterAnnounceEndpoint[0] is IPEndPoint anno
IndexResizeFrequencySecs = IndexResizeFrequencySecs,
IndexResizeThreshold = IndexResizeThreshold,
ValueOverflowThreshold = ValueOverflowThreshold,
InitialIORecordSize = InitialIORecordSize,
LoadModuleCS = LoadModuleCS,
FailOnRecoveryError = FailOnRecoveryError.GetValueOrDefault(),
LuaOptions = EnableLua.GetValueOrDefault() ? new LuaOptions(LuaMemoryManagementMode, LuaScriptMemoryLimit, LuaScriptTimeoutMs == 0 ? Timeout.InfiniteTimeSpan : TimeSpan.FromMilliseconds(LuaScriptTimeoutMs), LuaLoggingMode, LuaAllowedFunctions, logger) : null,
Expand Down
3 changes: 3 additions & 0 deletions libs/host/defaults.conf
Original file line number Diff line number Diff line change
Expand Up @@ -416,6 +416,9 @@
/* Max size of a value stored inline in the main-log page (larger values overflow to the heap). Accepts a memory size (e.g. "4k", "1m"). Minimum 64 bytes; must be less than PageSize. */
"ValueOverflowThreshold": "16k",

/* Initial IO read size for records on disk. Accepts a memory size (e.g. "4k", "8k"). null means use the default (128 bytes). */
"InitialIORecordSize": null,
Comment thread
TedHartMS marked this conversation as resolved.

/* List of module paths to be loaded at startup */
"LoadModuleCS": null,

Expand Down
30 changes: 30 additions & 0 deletions libs/server/Servers/GarnetServerOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,12 @@ public class GarnetServerOptions : ServerOptions
/// </summary>
public string ValueOverflowThreshold = "16k";

/// <summary>
/// Initial IO read size for records on disk. Accepts bytes or k/m/g suffixes (e.g. "4k", "8k").
/// null means use the default (<see cref="IStreamBuffer.DefaultInitialIORecordSize"/> bytes).
/// </summary>
public string InitialIORecordSize = null;

/// <summary>
/// Wait for AOF to commit before returning results to client.
/// Warning: will greatly increase operation latency.
Expand Down Expand Up @@ -629,6 +635,7 @@ public KVSettings GetSettings(ILoggerFactory loggerFactory, LightEpoch epoch, St
Epoch = epoch,
StateMachineDriver = stateMachineDriver,
MaxInlineValueSize = ValueOverflowThresholdBytes(),
InitialIORecordSize = GetInitialIORecordSizeBytes(),
loggerFactory = loggerFactory,
logger = loggerFactory?.CreateLogger("TsavoriteKV [main]")
};
Expand Down Expand Up @@ -872,6 +879,29 @@ public int ValueOverflowThresholdBytes()
return (int)sizeInBytes;
}

/// <summary>
/// Parse <see cref="InitialIORecordSize"/> as a byte count.
/// Returns <see cref="KVSettings.UseDefaultInitialIORecordSize"/> if the value is null or empty (use default).
/// </summary>
/// <returns>The byte value used for <c>KVSettings.InitialIORecordSize</c>, or <see cref="KVSettings.UseDefaultInitialIORecordSize"/> if unset.</returns>
/// <exception cref="Exception">Thrown when the value cannot be parsed.</exception>
public int GetInitialIORecordSizeBytes()
{
if (string.IsNullOrEmpty(InitialIORecordSize))
return KVSettings.UseDefaultInitialIORecordSize;

if (!TryParseSize(InitialIORecordSize, out var sizeInBytes))
throw new Exception($"Unable to parse {nameof(InitialIORecordSize)} value '{InitialIORecordSize}'. Expected a memory size string (e.g. '4k', '8k').");

if (sizeInBytes <= 0)
throw new Exception($"{nameof(InitialIORecordSize)} value '{InitialIORecordSize}' ({sizeInBytes} bytes) must be positive.");

if (sizeInBytes > 1L << PageSizeBits())
throw new Exception($"{nameof(InitialIORecordSize)} value '{InitialIORecordSize}' ({sizeInBytes} bytes) exceeds the page size ({1L << PageSizeBits()} bytes).");

return (int)sizeInBytes;
}
Comment thread
TedHartMS marked this conversation as resolved.

/// <summary>
/// Get AOF settings
/// </summary>
Expand Down
22 changes: 11 additions & 11 deletions libs/storage/Tsavorite/cs/src/core/Allocator/AllocatorBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1679,16 +1679,16 @@ internal void AsyncReadRecordToMemory(long fromLogicalAddress, int numBytes, Dev
[MethodImpl(MethodImplOptions.NoInlining)]
internal void AsyncReadBlittableRecordToMemory(long fromLogicalAddress, int numBytes, DeviceIOCompletionCallback callback, ref SimpleReadContext context)
{
context.record = GetAndPopulateReadBuffer(fromLogicalAddress, numBytes, out var alignedFileOffset, out var alignedReadLength);
device.ReadAsync(alignedFileOffset, (IntPtr)context.record.aligned_pointer, alignedReadLength, callback, context);
context.record = GetAndPopulateReadBuffer(fromLogicalAddress, numBytes, out var alignedReadStart, out var alignedReadLength);
device.ReadAsync(alignedReadStart, (IntPtr)context.record.aligned_pointer, alignedReadLength, callback, context);
}

private SectorAlignedMemory GetAndPopulateReadBuffer(long fromLogicalAddress, int numBytes, out ulong alignedFileOffset, out uint alignedReadLength)
private SectorAlignedMemory GetAndPopulateReadBuffer(long fromLogicalAddress, int numBytes, out ulong alignedReadStart, out uint alignedReadLength)
{
var fileOffset = (ulong)(AlignedPageSizeBytes * GetPage(fromLogicalAddress) + GetOffsetOnPage(fromLogicalAddress));
alignedFileOffset = (ulong)RoundDown((long)fileOffset, sectorSize);
alignedReadLength = (uint)((long)fileOffset + numBytes - (long)alignedFileOffset);
alignedReadLength = (uint)RoundUp(alignedReadLength, sectorSize);
var readStart = (ulong)(AlignedPageSizeBytes * GetPage(fromLogicalAddress) + GetOffsetOnPage(fromLogicalAddress));
alignedReadStart = (ulong)RoundDown((long)readStart, sectorSize);
var alignedReadEnd = (ulong)RoundUp((long)readStart + numBytes, sectorSize);
alignedReadLength = (uint)(alignedReadEnd - alignedReadStart);

// Records never span page boundaries (HandlePageOverflow guarantees this), but the
// sector-aligned read window above can over-extend past the page-end on records near
Expand All @@ -1701,16 +1701,16 @@ private SectorAlignedMemory GetAndPopulateReadBuffer(long fromLogicalAddress, in
// sector-aligned (PageSizeBits >= SectorSize), so the clamped length stays
// sector-aligned.
var pageEndInFile = (ulong)(AlignedPageSizeBytes * (GetPage(fromLogicalAddress) + 1));
if (alignedFileOffset + alignedReadLength > pageEndInFile)
alignedReadLength = (uint)(pageEndInFile - alignedFileOffset);
if (alignedReadStart + alignedReadLength > pageEndInFile)
alignedReadLength = (uint)(pageEndInFile - alignedReadStart);

// Rent the read-destination buffer with clearOnReturn=false: the device read
// will fully overwrite [aligned_pointer .. aligned_pointer + alignedReadLength)
// and downstream consumers bound their access by valid_offset / available_bytes,
// so the historical Array.Clear on Return is pure waste here (~4-8 KB of memory
// bandwidth per pending read).
var record = bufferPool.Get((int)alignedReadLength, clearOnReturn: false);
record.valid_offset = (int)(fileOffset - alignedFileOffset);
record.valid_offset = (int)(readStart - alignedReadStart);
record.available_bytes = (int)(alignedReadLength - record.valid_offset);
record.required_bytes = numBytes;
return record;
Expand Down Expand Up @@ -2104,7 +2104,7 @@ private protected virtual bool VerifyRecordFromDiskCallback(ref AsyncIOContext c
// TODO: Optimize for non-ReadAtAddress tombstoned records to not have to retrieve the full record or, if we have it, not deserialize objects.

// Initialize to "key is not present (data too small) or does not match so get previous record" length to read
prevLengthToRead = IStreamBuffer.InitialIOSize;
prevLengthToRead = IStreamBuffer.DefaultInitialIORecordSize;

// See if we have a complete record.
var currentLength = ctx.record.available_bytes;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ internal bool GetFromDiskAndPushToReader<TKey, TScanFunctions>(TKey key, ref lon
return false;

completionEvent.Prepare(key, logicalAddress, bufferPool);
AsyncGetFromDisk(logicalAddress, IStreamBuffer.InitialIOSize, completionEvent.request);
AsyncGetFromDisk(logicalAddress, IStreamBuffer.DefaultInitialIORecordSize, completionEvent.request);
completionEvent.Wait();

ref var request = ref completionEvent.request;
Expand Down
20 changes: 20 additions & 0 deletions libs/storage/Tsavorite/cs/src/core/Allocator/LogRecord.cs
Original file line number Diff line number Diff line change
Expand Up @@ -481,6 +481,26 @@ public static int GetAllocatedSize(long physicalAddress)

#endregion // ISourceLogRecord

/// <summary>
/// Computes the expected allocated record size from component lengths and optional-field expectations, without requiring
/// an actual record in memory. This uses the same layout math as <see cref="RecordSizeInfo.CalculateSizes"/>.
/// </summary>
/// <param name="keyDataLength">Length of the key data in bytes.</param>
/// <param name="valueDataLength">Length of the value data in bytes. For object values, use <see cref="ObjectIdMap.ObjectIdSize"/>.</param>
/// <param name="extendedNamespaceLength">Length of any extended namespace data preceding the key (0 if none).</param>
/// <param name="expectETag">Whether the record is expected to have an ETag optional field.</param>
/// <param name="expectExpiration">Whether the record is expected to have an Expiration optional field.</param>
/// <param name="expectObject">Whether the record is expected to have an object (key overflow, value overflow, or value object),
/// which requires an <see cref="ObjectLogPositionSize"/>-byte object-log position field.</param>
/// <returns>The expected allocated (record-aligned) size in bytes, including <see cref="RecordInfo"/> header.</returns>
public static int GetExpectedIORecordSize(int keyDataLength, int valueDataLength, int extendedNamespaceLength = 0,
bool expectETag = false, bool expectExpiration = false, bool expectObject = false)
{
var optionalSize = (expectETag ? ETagSize : 0) + (expectExpiration ? ExpirationSize : 0) + (expectObject ? ObjectLogPositionSize : 0);
var actualSize = Constants.FixedHeaderSize + extendedNamespaceLength + keyDataLength + valueDataLength + optionalSize;
return Utility.RoundUp(actualSize, Constants.kRecordAlignment);
}

/// <summary>Set the filler length on the RDH for a record. Used only by <see cref="DiskLogRecord.DirectCopyInlinePortionOfRecord"/>
/// when constructing a LogRecord over a transient output buffer (NOT a live log record), so no concurrent scanner exists.
/// <see cref="RecordDataHeader.SetFiller"/> performs its own atomic word update.</summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ private enum AllocationMode { None, Single, Bulk };
private AllocationMode allocationMode;
#endif

// This is used only for Flush and Restore, not random reads, so 512 is fine.
const int SectorSize = 512;

private int initialAllocation = 0;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -239,10 +239,19 @@ public bool GetNext()
try
{
// Lock to ensure no value tearing while copying to temp storage.
if (currentAddress >= headAddress && store is not null)
if (store is not null)
{
var logRecord = hlogBase._wrapper.CreateLogRecord(currentAddress, physicalAddress);
store.LockForScan(ref stackCtx, logRecord);

// LockForScan may spin via epoch.ProtectAndDrain(), which advances this thread's localCurrentEpoch and may synchronously fire a
// previously-queued OnPagesClosed action (from a concurrent ShiftHeadAddress). If HeadAddress now exceeds currentAddress, the page
// our physicalAddress points to has been freed and may have been reused; retry this address through the disk-buffering path.
if (currentAddress < hlogBase.HeadAddress)
{
nextAddress = currentAddress;
continue;
}
}

if (recordBuffer == null)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,16 @@ public interface IStreamBuffer : IDisposable
/// <summary>The size of the buffer used for writing data to and reading it from the disk. Must be a sector multiple.</summary>
internal const int BufferSize = 1 << LogSettings.kMinObjectLogSegmentSizeBits;

/// <summary>Initial IO size to read. Sized to comfortably cover a typical small record (header + small key + small value)
/// <summary>Default Initial IO size to read; may be overridden by operation-level, session-level, or store-level specification.
/// Sized to comfortably cover a typical small record (header + small key + small value)
/// in one device-sector IO. The previous default of one OS system page (4 KB on Linux x64) caused most reads of small
/// records to span 4 KB NAND-page boundaries on NVMe, doubling per-IO device latency (~0.92 ms vs ~0.67 ms for sector-aligned
/// 4 KB reads). With a 128-byte speculative read, the sector-aligned IO is typically 1 sector (and up to 2 sectors when the
/// record begins near the end of a sector), and usually captures a full small record with no re-read.
/// Records larger than what fits in the speculative read trigger a precise re-read via VerifyRecordFromDiskCallback with the
/// now-known recordLength, same as before — the cost is one extra IO per multi-sector record, which is a fair trade against
/// avoiding the NAND-crossing penalty on every small-record IO.</summary>
internal const int InitialIOSize = 128;
public const int DefaultInitialIORecordSize = 128;

/// <summary>
/// We use these buffers for only read or only write operations, never both at the same time.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ internal void OnBeginReadRecords(ObjectLogFilePositionInfo filePosition, ulong t
/// <item>If we have an Overflow or Object value, read and store it in the transient <see cref="ObjectIdMap"/> in <paramref name="logRecord"/>.</item>
/// </list>
/// </summary>
/// <param name="logRecord">The initial record read from disk from Pending IO, so it is of size <see cref="IStreamBuffer.InitialIOSize"/> or less.</param>
/// <param name="logRecord">The initial record read from disk from Pending IO, so it is of size <see cref="IStreamBuffer.DefaultInitialIORecordSize"/> or less.</param>
/// <param name="requestedKey">The requested key, if not ReadAtAddress; we will compare to see if it matches the record.</param>
/// <param name="segmentSizeBits">Number of bits in segment size</param>
/// <returns>False if requestedKey is set and we read an Overflow key and it did not match; otherwise true</returns>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -320,7 +320,15 @@ private bool WaitForFrameLoad(long currentAddress, long currentFrame)
loadedPages[currentFrame] = -1;
loadCTSs[currentFrame] = new CancellationTokenSource();
_ = Utility.MonotonicUpdate(ref nextAddress, GetLogicalAddressOfStartOfPage(1 + GetPageOfAddress(currentAddress, logPageSizeBits), logPageSizeBits), out _);
throw new TsavoriteException("Page read from storage failed, skipping page. Inner exception: " + e.ToString());

// Callers may be looking for an OCE so throw that if it's what we got.
if (e is OperationCanceledException)
{
logger?.LogWarning(e, "Wait for frame load was canceled, skipping page. CurrentAddress: {currentAddress}, currentFrame: {currentFrame}", AddressString(currentAddress), currentFrame);
throw;
}
else
throw new TsavoriteException("Page read from storage failed, skipping page. Inner exception: " + e.ToString());
}
finally
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -238,10 +238,19 @@ public bool GetNext()
try
{
// Lock to ensure no value tearing while copying to temp storage.
if (currentAddress >= headAddress && store is not null)
if (store is not null)
{
var logRecord = hlogBase._wrapper.CreateLogRecord(currentAddress, physicalAddress);
store.LockForScan(ref stackCtx, logRecord);

// LockForScan may spin via epoch.ProtectAndDrain(), which advances this thread's localCurrentEpoch and may synchronously fire a
// previously-queued OnPagesClosed action (from a concurrent ShiftHeadAddress). If HeadAddress now exceeds currentAddress, the page
// our physicalAddress points to has been freed and may have been reused; retry this address through the disk-buffering path.
if (currentAddress < hlogBase.HeadAddress)
{
nextAddress = currentAddress;
continue;
}
}

if (recordBuffer == null)
Expand Down
Loading
Loading