Skip to content

Commit 60cbcd1

Browse files
badrishcCopilot
andcommitted
[Storage] Root-cause single-thread perf: profiling harness + page-fault check
Adds [Explicit] profiling to explain the single-thread managed>mimalloc gap and verify the experiments are measuring what we think. Findings: - Page faults: over 5M rent/return ops, minflt/majflt are ~0 for BOTH managed and mimalloc (~0.0001 faults/op). No physical memory is faulted per-op; both reuse a hot committed block. So the ns/op numbers are pure allocator bookkeeping, not allocation cost. - SuppressGCTransition variants (mi_*alloc/mi_free): ~0ns benefit on .NET 10 — the function-pointer GC transition is already sub-ns. Kept only as profiling evidence. - Single-thread bookkeeping breakdown (ns/op): managed reuse ~30; raw mi_malloc ~17, mi_malloc_aligned ~24; via pool ~72 (+48 pool/interface/wrapper), tracker +15. Alignment slow-path ~5ns (plain mi_malloc(4096) is already 512/4096-aligned, but we keep mi_malloc_aligned for the guarantee). - Real-world dilution: writing the full 4KB buffer (like real IO) shrinks the single-thread gap from 3.0x to 1.7x; zeroing (clr=true) adds +39ns managed / +20ns mimalloc — confirming the clearOnReturn:false no-memset path matters. Conclusion: the single-thread deficit is bookkeeping-only and dilutes to near-noise once buffers are used; the real number needs the KV/Device/RESP e2e A/B vs the PR #2018 sharded pool. The ~15ns per-op tracker is a separately-fixable overhead (query mi_process_info on demand instead of per-op accounting). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a49f1db0-fd39-48c7-80dc-ce104c863b79
1 parent 8e6b561 commit 60cbcd1

2 files changed

Lines changed: 194 additions & 3 deletions

File tree

libs/storage/Tsavorite/cs/src/core/Native/Mimalloc.cs

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,18 @@ internal static unsafe class Mimalloc
2727
static delegate* unmanaged[Cdecl]<nint, nuint> p_usable_size;
2828
static delegate* unmanaged[Cdecl]<int, void> p_collect;
2929

30+
// SuppressGCTransition variants of the hot alloc/free path. mi_*alloc/mi_free do not block, call back
31+
// into the runtime, or throw, so skipping the cooperative->preemptive GC transition (~10-20ns/call)
32+
// is safe and roughly halves the per-op P/Invoke cost on the buffer-pool hot path. The only tradeoff
33+
// is the rare slow path (mimalloc obtaining/returning OS memory) briefly delaying a GC suspension.
34+
static delegate* unmanaged[Cdecl, SuppressGCTransition]<nuint, nuint, nint> p_malloc_aligned_fast;
35+
static delegate* unmanaged[Cdecl, SuppressGCTransition]<nuint, nuint, nint> p_zalloc_aligned_fast;
36+
static delegate* unmanaged[Cdecl, SuppressGCTransition]<nint, void> p_free_fast;
37+
38+
// Plain (unaligned) malloc, normal + fast — used only by profiling to isolate the alignment slow path.
39+
static delegate* unmanaged[Cdecl]<nuint, nint> p_malloc;
40+
static delegate* unmanaged[Cdecl, SuppressGCTransition]<nuint, nint> p_malloc_fast;
41+
3042
/// <summary>True if the mimalloc library loaded and all required exports resolved.</summary>
3143
internal static bool Available => available;
3244

@@ -47,11 +59,22 @@ internal static bool TryInitialize(ILogger logger = null)
4759
{
4860
if (TryLoad(out var handle))
4961
{
50-
p_malloc_aligned = (delegate* unmanaged[Cdecl]<nuint, nuint, nint>)NativeLibrary.GetExport(handle, "mi_malloc_aligned");
51-
p_zalloc_aligned = (delegate* unmanaged[Cdecl]<nuint, nuint, nint>)NativeLibrary.GetExport(handle, "mi_zalloc_aligned");
52-
p_free = (delegate* unmanaged[Cdecl]<nint, void>)NativeLibrary.GetExport(handle, "mi_free");
62+
var eMallocAligned = NativeLibrary.GetExport(handle, "mi_malloc_aligned");
63+
var eZallocAligned = NativeLibrary.GetExport(handle, "mi_zalloc_aligned");
64+
var eFree = NativeLibrary.GetExport(handle, "mi_free");
65+
var eMalloc = NativeLibrary.GetExport(handle, "mi_malloc");
66+
67+
p_malloc_aligned = (delegate* unmanaged[Cdecl]<nuint, nuint, nint>)eMallocAligned;
68+
p_zalloc_aligned = (delegate* unmanaged[Cdecl]<nuint, nuint, nint>)eZallocAligned;
69+
p_free = (delegate* unmanaged[Cdecl]<nint, void>)eFree;
5370
p_usable_size = (delegate* unmanaged[Cdecl]<nint, nuint>)NativeLibrary.GetExport(handle, "mi_usable_size");
5471
p_collect = (delegate* unmanaged[Cdecl]<int, void>)NativeLibrary.GetExport(handle, "mi_collect");
72+
73+
p_malloc_aligned_fast = (delegate* unmanaged[Cdecl, SuppressGCTransition]<nuint, nuint, nint>)eMallocAligned;
74+
p_zalloc_aligned_fast = (delegate* unmanaged[Cdecl, SuppressGCTransition]<nuint, nuint, nint>)eZallocAligned;
75+
p_free_fast = (delegate* unmanaged[Cdecl, SuppressGCTransition]<nint, void>)eFree;
76+
p_malloc = (delegate* unmanaged[Cdecl]<nuint, nint>)eMalloc;
77+
p_malloc_fast = (delegate* unmanaged[Cdecl, SuppressGCTransition]<nuint, nint>)eMalloc;
5578
available = true;
5679
}
5780
else
@@ -78,6 +101,25 @@ internal static bool TryInitialize(ILogger logger = null)
78101
[System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
79102
internal static void Free(nint ptr) => p_free(ptr);
80103

104+
// ---- SuppressGCTransition fast variants (hot path) ----
105+
106+
[System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
107+
internal static nint MallocAlignedFast(nuint size, nuint alignment) => p_malloc_aligned_fast(size, alignment);
108+
109+
[System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
110+
internal static nint ZallocAlignedFast(nuint size, nuint alignment) => p_zalloc_aligned_fast(size, alignment);
111+
112+
[System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
113+
internal static void FreeFast(nint ptr) => p_free_fast(ptr);
114+
115+
// ---- Plain (unaligned) malloc, profiling only ----
116+
117+
[System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
118+
internal static nint Malloc(nuint size) => p_malloc(size);
119+
120+
[System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
121+
internal static nint MallocFast(nuint size) => p_malloc_fast(size);
122+
81123
[System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
82124
internal static nuint UsableSize(nint ptr) => p_usable_size(ptr);
83125

libs/storage/Tsavorite/cs/test/NativeAllocatorProfileTests.cs

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// Licensed under the MIT license.
33

44
using System.Diagnostics;
5+
using System.Runtime.InteropServices;
56
using System.Threading;
67
using System.Threading.Tasks;
78
using NUnit.Framework;
@@ -110,5 +111,153 @@ void Report(string name, System.Func<int, double> run)
110111
Report("mimalloc, UNTRACKED", th => { SectorAlignedBufferPool.NativeAllocator = new UntrackedMimallocAllocator(); return RunShared(th); });
111112
SectorAlignedBufferPool.NativeAllocator = null;
112113
}
114+
115+
[System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)]
116+
static nint RunRaw(int variant, long n)
117+
{
118+
nint sink = 0;
119+
switch (variant)
120+
{
121+
case 0: for (long i = 0; i < n; i++) { var p = Mimalloc.Malloc((nuint)BufferSize); sink ^= p; Mimalloc.Free(p); } break;
122+
case 1: for (long i = 0; i < n; i++) { var p = Mimalloc.MallocFast((nuint)BufferSize); sink ^= p; Mimalloc.FreeFast(p); } break;
123+
case 2: for (long i = 0; i < n; i++) { var p = Mimalloc.MallocAligned((nuint)BufferSize, (nuint)SectorSize); sink ^= p; Mimalloc.Free(p); } break;
124+
case 3: for (long i = 0; i < n; i++) { var p = Mimalloc.MallocAlignedFast((nuint)BufferSize, (nuint)SectorSize); sink ^= p; Mimalloc.FreeFast(p); } break;
125+
}
126+
return sink;
127+
}
128+
129+
static double RawNs(int variant, long n)
130+
{
131+
_ = RunRaw(variant, n / 10); // warm up
132+
var sw = Stopwatch.StartNew();
133+
var sink = RunRaw(variant, n);
134+
sw.Stop();
135+
System.GC.KeepAlive(sink);
136+
return sw.Elapsed.TotalSeconds / n * 1e9;
137+
}
138+
139+
[Test]
140+
public void SingleThreadBreakdown()
141+
{
142+
if (!Mimalloc.TryInitialize())
143+
Assert.Ignore("mimalloc native library not available for this RID");
144+
145+
// Alignment probe: is plain mi_malloc(4096) already sector/page aligned (would let us skip the
146+
// aligned slow path)?
147+
const int nchk = 100_000;
148+
var ptrs = new nint[nchk];
149+
int a512 = 0, a4096 = 0;
150+
for (var i = 0; i < nchk; i++) { var p = Mimalloc.Malloc((nuint)BufferSize); ptrs[i] = p; if ((p & (SectorSize - 1)) == 0) a512++; if ((p & 4095) == 0) a4096++; }
151+
for (var i = 0; i < nchk; i++) Mimalloc.Free(ptrs[i]);
152+
153+
const long n = 20_000_000;
154+
double managedReuseNs = 1000.0 / RunShared(1); // Mops/s -> ns/op
155+
156+
double rawMalloc = RawNs(0, n);
157+
double rawMallocFast = RawNs(1, n);
158+
double rawAligned = RawNs(2, n);
159+
double rawAlignedFast = RawNs(3, n);
160+
161+
SectorAlignedBufferPool.NativeAllocator = new UntrackedMimallocAllocator();
162+
double poolUntrackedNs = 1000.0 / RunShared(1);
163+
SectorAlignedBufferPool.NativeAllocator = new MimallocPooledAllocator();
164+
double poolTrackedNs = 1000.0 / RunShared(1);
165+
SectorAlignedBufferPool.NativeAllocator = null;
166+
167+
TestContext.Progress.WriteLine($"plain mi_malloc(4096) alignment: 512-aligned {a512}/{nchk}, 4096-aligned {a4096}/{nchk}");
168+
TestContext.Progress.WriteLine("");
169+
TestContext.Progress.WriteLine($"{"single-thread cost (ns/op)",-40} | {"ns/op",8}");
170+
TestContext.Progress.WriteLine(new string('-', 52));
171+
TestContext.Progress.WriteLine($"{"managed pool reuse (floor)",-40} | {managedReuseNs,8:F1}");
172+
TestContext.Progress.WriteLine($"{"raw mi_malloc + mi_free (normal xition)",-40} | {rawMalloc,8:F1}");
173+
TestContext.Progress.WriteLine($"{"raw mi_malloc + mi_free (SuppressGC)",-40} | {rawMallocFast,8:F1}");
174+
TestContext.Progress.WriteLine($"{"raw mi_malloc_ALIGNED + free (normal)",-40} | {rawAligned,8:F1}");
175+
TestContext.Progress.WriteLine($"{"raw mi_malloc_ALIGNED + free (SuppressGC)",-40} | {rawAlignedFast,8:F1}");
176+
TestContext.Progress.WriteLine($"{"pool, mimalloc UNTRACKED (aligned,normal)",-40} | {poolUntrackedNs,8:F1}");
177+
TestContext.Progress.WriteLine($"{"pool, mimalloc tracked (current)",-40} | {poolTrackedNs,8:F1}");
178+
TestContext.Progress.WriteLine("");
179+
TestContext.Progress.WriteLine($"GC-transition cost (2 calls/op): normal-vs-fast plain = {rawMalloc - rawMallocFast,6:F1} ns/op");
180+
TestContext.Progress.WriteLine($"GC-transition cost (2 calls/op): normal-vs-fast aligned = {rawAligned - rawAlignedFast,6:F1} ns/op");
181+
TestContext.Progress.WriteLine($"alignment slow-path cost (normal): aligned - plain = {rawAligned - rawMalloc,6:F1} ns/op");
182+
TestContext.Progress.WriteLine($"alignment slow-path cost (fast): aligned - plain = {rawAlignedFast - rawMallocFast,6:F1} ns/op");
183+
TestContext.Progress.WriteLine($"pool + wrapper overhead: poolUntracked - rawAligned = {poolUntrackedNs - rawAligned,6:F1} ns/op");
184+
TestContext.Progress.WriteLine($"tracker overhead: poolTracked - poolUntracked = {poolTrackedNs - poolUntrackedNs,6:F1} ns/op");
185+
}
186+
187+
// ---- Physical-memory / page-fault verification ----
188+
189+
[DllImport("libc", SetLastError = true)]
190+
static extern int getrusage(int who, byte[] usage);
191+
192+
// struct rusage on Linux/x86-64: ru_minflt is the 9th long (offset 64), ru_majflt the 10th (offset 72).
193+
static (long minor, long major) Faults()
194+
{
195+
var b = new byte[144];
196+
if (getrusage(0, b) != 0)
197+
return (0, 0);
198+
return (System.BitConverter.ToInt64(b, 64), System.BitConverter.ToInt64(b, 72));
199+
}
200+
201+
static void PoolLoop(SectorAlignedBufferPool pool, long n, bool clearOnReturn, int touch)
202+
{
203+
for (long i = 0; i < n; i++)
204+
{
205+
var page = pool.Get(BufferSize, clearOnReturn);
206+
if (touch == 2)
207+
{
208+
page.aligned_pointer[0] = 1;
209+
page.aligned_pointer[BufferSize - 1] = 1;
210+
}
211+
else if (touch < 0)
212+
{
213+
new System.Span<byte>(page.aligned_pointer, BufferSize).Fill(1); // simulate real IO writing the whole buffer
214+
}
215+
page.Return();
216+
}
217+
}
218+
219+
[Test]
220+
public void PhysicalMemoryCheck()
221+
{
222+
if (!Mimalloc.TryInitialize())
223+
Assert.Ignore("mimalloc native library not available for this RID");
224+
225+
const long n = 5_000_000;
226+
227+
(double ns, long minflt, long majflt) Measure(INativePinnedAllocator alloc, bool clearOnReturn, int touch)
228+
{
229+
SectorAlignedBufferPool.NativeAllocator = alloc;
230+
var pool = new SectorAlignedBufferPool(1, SectorSize);
231+
PoolLoop(pool, n / 20, clearOnReturn, touch); // warm up (fault in the reused block)
232+
var (min0, maj0) = Faults();
233+
var sw = Stopwatch.StartNew();
234+
PoolLoop(pool, n, clearOnReturn, touch);
235+
sw.Stop();
236+
var (min1, maj1) = Faults();
237+
pool.Free();
238+
SectorAlignedBufferPool.NativeAllocator = null;
239+
return (sw.Elapsed.TotalSeconds / n * 1e9, min1 - min0, maj1 - maj0);
240+
}
241+
242+
INativePinnedAllocator Managed() => null;
243+
INativePinnedAllocator Native() => new MimallocPooledAllocator();
244+
245+
TestContext.Progress.WriteLine($"N = {n:N0} ops/scenario. minflt/majflt = page faults during the measured loop (NOT warmup).");
246+
TestContext.Progress.WriteLine($"{"scenario",-46} | {"ns/op",7} | {"minflt",8} | {"majflt",7} | flt/op");
247+
TestContext.Progress.WriteLine(new string('-', 90));
248+
249+
void Row(string name, INativePinnedAllocator alloc, bool clr, int touch)
250+
{
251+
var r = Measure(alloc, clr, touch);
252+
TestContext.Progress.WriteLine($"{name,-46} | {r.ns,7:F1} | {r.minflt,8:N0} | {r.majflt,7:N0} | {(double)r.minflt / n,6:F4}");
253+
}
254+
255+
Row("managed pool, clr=false, touch 2 bytes", Managed(), false, 2);
256+
Row("mimalloc pool, clr=false, touch 2 bytes", Native(), false, 2);
257+
Row("managed pool, clr=false, touch FULL 4KB", Managed(), false, -1);
258+
Row("mimalloc pool, clr=false, touch FULL 4KB", Native(), false, -1);
259+
Row("managed pool, clr=TRUE (zeroed), touch 2", Managed(), true, 2);
260+
Row("mimalloc pool, clr=TRUE (mi_zalloc), touch 2", Native(), true, 2);
261+
}
113262
}
114263
}

0 commit comments

Comments
 (0)