Skip to content

Commit e6b2b94

Browse files
badrishcCopilot
andcommitted
[Tsavorite] Scalable origin-return buffer pool + direct-VM native allocator
This change introduces two independent performance features to Tsavorite/Garnet for reducing GC pressure and improving buffer-pool scalability under many concurrent IO-completion threads. Both are opt-in/rollback-guarded and default to safe behavior. 1) Scalable origin-return SectorAlignedBufferPool (new default managed backend) - Replaces the single per-level ConcurrentQueue pool (which collapses under concurrent cross-thread Get/Return) with a per-thread magazine pool that routes each buffer back to its originating thread (mimalloc xthread_free style) instead of the freeing (IO-completion) thread. This avoids the memory bloat / zero-reuse asymmetry where completion threads accumulate every buffer while issuing threads keep allocating fresh. - Ownership keyed by (pool, thread, size-class) via a [ThreadStatic] recyclable-slot scheme (no ThreadLocal<T>, no monotonic-index leak). - Seal-aware retirement/teardown protocol for dead threads and pool close, per-buffer exactly-once permit release with a standalone BudgetState + finalizer reclamation, a hard per-pool byte budget (anti-bloat), a constrained linear-then-geometric size-class ladder with O(1) lookup, and per-pool immutable mode + frozen UnpinOnReturn. - Kill-switch: --use-legacy-buffer-pool (UseLegacyBufferPool) restores the legacy ConcurrentQueue pool. Selected once per pool at construction via SectorAlignedBufferPool.UseOriginReturn. - Measured (Release, 160-core): origin-return scales 22 -> 1260 Mops/s (1 -> 64 threads) vs legacy 44 -> 1.0 (collapses); 8M ops -> 528 allocations (8.2x working set); cross-thread p99 <= 2us. 2) Direct-VM native allocator (off | full) - Routes large memory regions (log pages / hash index / recovery frames) off the managed GC heap through direct OS virtual memory (mmap/VirtualAlloc). No native library is required. - New --native-allocator switch (NativeAllocator), NativeAllocatorSurfaces flags, NativeAllocatorInitializer/EnvironmentInitializer, DirectVirtualMemory, NativeMemoryTracker, and NativePageBlockRegistry. - New INFO field native_allocator_bytes surfaces off-heap committed bytes. - GC-sizing guidance documented in website/docs/getting-started/memory.md. Tests: SectorAlignedBufferPoolTests (CI correctness), NativeAllocator*Tests (hlog/recovery/server), config parsing tests for both switches, direct-VM Docker validation, plus [Explicit] scaling/reuse/latency/soak stress harnesses. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d0cba12e-76fa-44b7-8d5c-4c9d86c5c7b1
1 parent c504ce0 commit e6b2b94

40 files changed

Lines changed: 4506 additions & 234 deletions
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
# Shared publish step for the native-binary build workflow (Build Native Device). Given the
2+
# freshly built artifacts for the component, it copies them over the checked-in prebuilts and
3+
# commits the result back to the dispatched branch (updating an open PR in place), or opens a PR
4+
# when dispatched on a protected branch.
5+
#
6+
# The caller MUST run actions/checkout FIRST, on the branch being published and with the
7+
# push token (secrets.NATIVE_BINARIES_PAT || github.token). checkout persists those
8+
# credentials in git config, so the git push below uses them; this action does not check
9+
# out or take a push token itself.
10+
name: Publish native prebuilts
11+
description: Copy freshly built native binaries over the checked-in prebuilts and commit them back to the branch (or open a PR on main/dev).
12+
inputs:
13+
branch:
14+
description: Branch to publish onto (github.ref_name); must already be checked out.
15+
required: true
16+
gh-token:
17+
description: Token for the gh CLI (opening a PR on protected branches).
18+
required: true
19+
artifact-pattern:
20+
description: download-artifact pattern selecting this component's artifacts (e.g. native-*).
21+
required: true
22+
artifact-prefix:
23+
description: Artifact-name prefix to strip to recover the RID (e.g. native-).
24+
required: true
25+
dest-base:
26+
description: Checked-in runtimes base directory to overwrite (…/Device/runtimes).
27+
required: true
28+
label:
29+
description: Component label for the commit / PR message (e.g. device).
30+
required: true
31+
run-id:
32+
description: github.run_id, recorded in the commit / PR message for provenance.
33+
required: true
34+
runs:
35+
using: composite
36+
steps:
37+
- name: Download ${{ inputs.label }} artifacts
38+
uses: actions/download-artifact@v4
39+
with:
40+
path: staging-publish
41+
pattern: ${{ inputs.artifact-pattern }}
42+
43+
- name: Copy artifacts over checked-in prebuilts
44+
shell: bash
45+
run: |
46+
set -euo pipefail
47+
base="${{ inputs.dest-base }}"
48+
prefix="${{ inputs.artifact-prefix }}"
49+
shopt -s nullglob
50+
dirs=("staging-publish/${prefix}"*)
51+
if [ ${#dirs[@]} -eq 0 ]; then
52+
echo "No artifacts matched '${prefix}*' - nothing was built to publish." >&2
53+
exit 1
54+
fi
55+
for d in "${dirs[@]}"; do
56+
rid="$(basename "$d")"; rid="${rid#"$prefix"}"
57+
echo "Updating $base/$rid/native/"
58+
mkdir -p "$base/$rid/native"
59+
cp -f "$d"/* "$base/$rid/native/"
60+
done
61+
# .so need the executable bit; Windows .dll/.pdb do not.
62+
find "$base" -name '*.so' -exec chmod 755 {} +
63+
git status --porcelain "$base"
64+
65+
- name: Commit and publish
66+
shell: bash
67+
env:
68+
GH_TOKEN: ${{ inputs.gh-token }}
69+
run: |
70+
set -euo pipefail
71+
base="${{ inputs.dest-base }}"
72+
branch="${{ inputs.branch }}"
73+
label="${{ inputs.label }}"
74+
run_id="${{ inputs.run-id }}"
75+
76+
if [ -z "$(git status --porcelain "$base")" ]; then
77+
echo "No changes to the checked-in $label prebuilts; nothing to do."
78+
exit 0
79+
fi
80+
81+
git config user.name "github-actions[bot]"
82+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
83+
git add "$base"
84+
git commit -m "[Tsavorite] Update prebuilt native $label binaries" \
85+
-m "Regenerated by the native build workflow (run $run_id) from the sources on '$branch'."
86+
87+
case "$branch" in
88+
main|dev)
89+
# Protected branches: never push directly; open a PR for review.
90+
pr_branch="bot/update-native-${label}-${branch}"
91+
git branch -M "$pr_branch"
92+
git push --force-with-lease --set-upstream origin "$pr_branch"
93+
if gh pr view "$pr_branch" --json number >/dev/null 2>&1; then
94+
echo "PR already open for $pr_branch; branch updated."
95+
else
96+
gh pr create --base "$branch" --head "$pr_branch" \
97+
--title "[Tsavorite] Update prebuilt native $label binaries" \
98+
--body "Automated refresh of the checked-in $label prebuilts under \`$base\`, produced by the native build workflow (run $run_id)."
99+
fi
100+
;;
101+
*)
102+
# Feature / PR branch: commit straight onto it so the open PR updates in place. Re-sync
103+
# with the remote tip first in case the branch moved during the (multi-minute) build.
104+
# Only $base files change here, which does not match either native workflow's source
105+
# triggers - so this push cannot re-trigger a native build.
106+
git pull --rebase origin "$branch"
107+
git push origin "HEAD:${branch}"
108+
echo "Pushed refreshed $label binaries onto '$branch'."
109+
echo "If ci.yml did not start automatically (GITHUB_TOKEN push), push another commit or re-run ci.yml to validate against the new binaries."
110+
;;
111+
esac

.github/workflows/native-build.yml

Lines changed: 12 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -190,10 +190,10 @@ jobs:
190190
$cfg = & $dumpbin /loadconfig $dll
191191
$cfg | Write-Host
192192
if (-not ($cfg | Select-String -Quiet -Pattern 'Guard')) {
193-
throw "Control Flow Guard (/guard:cf) not present in $dll security flags did not take effect"
193+
throw "Control Flow Guard (/guard:cf) not present in $dll - security flags did not take effect"
194194
}
195195
if (-not ($cfg | Select-String -Quiet -Pattern 'Security Cookie')) {
196-
throw "Stack Security Cookie (/GS,/sdl) not present in $dll security flags did not take effect"
196+
throw "Stack Security Cookie (/GS,/sdl) not present in $dll - security flags did not take effect"
197197
}
198198
Write-Host "Security mitigations verified: Control Flow Guard + Security Cookie present."
199199
@@ -206,6 +206,7 @@ jobs:
206206
# Publishes the freshly built binaries. When dispatched on a feature branch, it commits
207207
# them directly onto that branch so an open PR updates in place; on a protected branch
208208
# (main/dev) it opens a PR instead. Runs only for a manual dispatch with update_repo = true.
209+
# The download/copy/commit-back logic lives in the publish-native-prebuilts composite action.
209210
publish:
210211
name: Publish binaries to ${{ github.ref_name }}
211212
needs: [build-linux, build-windows]
@@ -225,71 +226,15 @@ jobs:
225226
fetch-depth: 0
226227
# If a maintainer configured a PAT / App token, use it so the resulting push
227228
# re-triggers ci.yml; otherwise fall back to GITHUB_TOKEN (push lands but does not
228-
# start new runs ci.yml then runs on the developer's next push or a manual re-run).
229+
# start new runs - ci.yml then runs on the developer's next push or a manual re-run).
229230
token: ${{ secrets.NATIVE_BINARIES_PAT || github.token }}
230231

231-
- name: Download all native artifacts
232-
uses: actions/download-artifact@v4
232+
- uses: ./.github/actions/publish-native-prebuilts
233233
with:
234-
path: staging
235-
pattern: native-*
236-
237-
- name: Copy artifacts over checked-in prebuilts
238-
run: |
239-
set -euo pipefail
240-
base="libs/storage/Tsavorite/cs/src/core/Device/runtimes"
241-
for d in staging/native-*; do
242-
rid="$(basename "$d" | sed 's/^native-//')"
243-
echo "Updating runtimes/$rid/native/"
244-
mkdir -p "$base/$rid/native"
245-
cp -f "$d"/* "$base/$rid/native/"
246-
done
247-
# .so need the executable bit; Windows .dll/.pdb do not.
248-
find "$base" -name '*.so' -exec chmod 755 {} +
249-
git status --porcelain "$base"
250-
251-
- name: Commit and publish
252-
env:
253-
GH_TOKEN: ${{ github.token }}
254-
run: |
255-
set -euo pipefail
256-
base="libs/storage/Tsavorite/cs/src/core/Device/runtimes"
257-
branch="${{ github.ref_name }}"
258-
259-
if [ -z "$(git status --porcelain "$base")" ]; then
260-
echo "No changes to the checked-in prebuilts; nothing to do."
261-
exit 0
262-
fi
263-
264-
git config user.name "github-actions[bot]"
265-
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
266-
git add "$base"
267-
git commit -m "[Tsavorite] Update prebuilt native device binaries
268-
269-
Regenerated by the Build Native Device workflow (run ${{ github.run_id }}) from the native sources on '$branch'."
270-
271-
case "$branch" in
272-
main|dev)
273-
# Protected branches: never push directly; open a PR for review.
274-
pr_branch="bot/update-native-binaries-${branch}"
275-
git branch -M "$pr_branch"
276-
git push --force-with-lease --set-upstream origin "$pr_branch"
277-
if gh pr view "$pr_branch" --json number >/dev/null 2>&1; then
278-
echo "PR already open for $pr_branch; branch updated."
279-
else
280-
gh pr create --base "$branch" --head "$pr_branch" \
281-
--title "[Tsavorite] Update prebuilt native device binaries" \
282-
--body "Automated refresh of the checked-in native prebuilts under \`$base\`, produced by the **Build Native Device** workflow (run ${{ github.run_id }})."
283-
fi
284-
;;
285-
*)
286-
# Feature / PR branch: commit the refreshed binaries straight onto it so the open
287-
# PR updates in place. Re-sync with the remote tip first in case the branch moved
288-
# during the (multi-minute) build, then push. Only the runtimes/ files change here,
289-
# and this workflow triggers on cc/** — so this push cannot re-trigger native-build.
290-
git pull --rebase origin "$branch"
291-
git push origin "HEAD:${branch}"
292-
echo "Pushed refreshed native binaries onto '$branch'."
293-
echo "If ci.yml did not start automatically (GITHUB_TOKEN push), push another commit or re-run ci.yml to validate against the new binaries."
294-
;;
295-
esac
234+
branch: ${{ github.ref_name }}
235+
gh-token: ${{ github.token }}
236+
artifact-pattern: native-*
237+
artifact-prefix: native-
238+
dest-base: libs/storage/Tsavorite/cs/src/core/Device/runtimes
239+
label: device
240+
run-id: ${{ github.run_id }}

libs/host/Configuration/Options.cs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,14 @@ internal sealed class Options : ICloneable
8787
[Option("index-max-size", Required = false, HelpText = "Max size of hash index in bytes (rounds down to power of 2)")]
8888
public string IndexMaxMemorySize { get; set; }
8989

90+
[Option("native-allocator", Required = false, HelpText = "Route large memory regions through a native (off-managed-heap) direct-VM allocator (mmap/VirtualAlloc). Values: off (default, fully managed), full (routes log pages / hash index / recovery frames off the GC heap). No native library is required. When enabled, this memory is outside the managed GC heap: size GCHeapHardLimit to leave headroom and monitor 'native_allocator_bytes' in INFO memory.")]
91+
[NativeAllocatorModeValidation(false)]
92+
public string NativeAllocator { get; set; }
93+
94+
[OptionValidation]
95+
[Option("use-legacy-buffer-pool", Required = false, HelpText = "Select the per-level ConcurrentQueue SectorAlignedBufferPool instead of the default origin-return (per-thread magazine) pool. The origin-return pool returns each buffer to the thread that allocated it, so it scales with concurrent IO-completion threads under a per-pool byte budget.")]
96+
public bool? UseLegacyBufferPool { get; set; }
97+
9098
[PercentageValidation(false)]
9199
[Option("mutable-percent", Required = false, HelpText = "Percentage of log memory that is kept mutable")]
92100
public int MutablePercent { get; set; }
@@ -737,6 +745,41 @@ public bool IsValid(out List<string> invalidOptions, ILogger logger = null)
737745
return isValid;
738746
}
739747

748+
/// <summary>
749+
/// Parse the <c>--native-allocator</c> mode string into its surface set. Returns <c>false</c> for an
750+
/// unrecognized value (empty/unset maps to <see cref="NativeAllocatorSurfaces.None"/>).
751+
/// </summary>
752+
internal static bool TryParseNativeAllocatorMode(string mode, out NativeAllocatorSurfaces surfaces)
753+
{
754+
if (string.IsNullOrWhiteSpace(mode))
755+
{
756+
surfaces = NativeAllocatorSurfaces.None;
757+
return true;
758+
}
759+
switch (mode.Trim().ToLowerInvariant())
760+
{
761+
case "off":
762+
case "none":
763+
case "managed":
764+
surfaces = NativeAllocatorSurfaces.None;
765+
return true;
766+
case "full":
767+
case "all":
768+
surfaces = NativeAllocatorSurfaces.Full;
769+
return true;
770+
default:
771+
surfaces = NativeAllocatorSurfaces.None;
772+
return false;
773+
}
774+
}
775+
776+
static NativeAllocatorSurfaces ParseNativeAllocatorMode(string mode)
777+
{
778+
if (!TryParseNativeAllocatorMode(mode, out var surfaces))
779+
throw new GarnetException($"Invalid --native-allocator value '{mode}'. Expected one of: off, full.");
780+
return surfaces;
781+
}
782+
740783
public GarnetServerOptions GetServerOptions(ILogger logger = null)
741784
{
742785
var enableStorageTier = EnableStorageTier.GetValueOrDefault();
@@ -865,6 +908,8 @@ endpoint is IPEndPoint listenEp && clusterAnnounceEndpoint[0] is IPEndPoint anno
865908
ObjectLogSegmentSize = ObjectLogSegmentSize,
866909
IndexMemorySize = IndexMemorySize,
867910
IndexMaxMemorySize = IndexMaxMemorySize,
911+
NativeAllocatorSurfaces = ParseNativeAllocatorMode(NativeAllocator),
912+
UseLegacyBufferPool = UseLegacyBufferPool.GetValueOrDefault(),
868913
MutablePercent = MutablePercent,
869914
EnableReadCache = EnableReadCache.GetValueOrDefault(),
870915
ReadCacheMemorySize = ReadCacheMemorySize,

libs/host/Configuration/OptionsValidators.cs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -412,6 +412,32 @@ protected override ValidationResult IsValid(object value, ValidationContext vali
412412
}
413413
}
414414

415+
/// <summary>
416+
/// Validation logic for the <c>--native-allocator</c> mode string (off | full).
417+
/// </summary>
418+
[AttributeUsage(AttributeTargets.Property)]
419+
internal sealed class NativeAllocatorModeValidationAttribute : OptionValidationAttribute
420+
{
421+
internal NativeAllocatorModeValidationAttribute(bool isRequired = true) : base(isRequired)
422+
{
423+
}
424+
425+
protected override ValidationResult IsValid(object value, ValidationContext validationContext)
426+
{
427+
if (TryInitialValidation<string>(value, validationContext, out var initValidationResult, out var mode))
428+
return initValidationResult;
429+
430+
if (!Options.TryParseNativeAllocatorMode(mode, out _))
431+
{
432+
var baseError = validationContext.MemberName != null ? base.FormatErrorMessage(validationContext.MemberName) : string.Empty;
433+
var errorMessage = $"{baseError} Expected one of: off, full. Actual value: {mode}";
434+
return new ValidationResult(errorMessage, [validationContext.MemberName]);
435+
}
436+
437+
return ValidationResult.Success;
438+
}
439+
}
440+
415441
/// <summary>
416442
/// Validation logic for an integer representing a percentage (range between 0 and 100)
417443
/// </summary>

libs/host/GarnetServer.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,16 @@ private void InitializeServer()
199199
string.Join(',', opts.EndPoints.Select(endpoint => endpoint.ToString())));
200200
logger?.LogInformation("Environment .NET {netVersion}; {osPlatform}; {processArch}", Environment.Version, Environment.OSVersion.Platform, RuntimeInformation.ProcessArchitecture);
201201

202+
// Resolve and install native (off-managed-heap) allocators before any store or buffer pool is created.
203+
// Always call Initialize (even for None) so the resolved CLI/config scope is authoritative and any
204+
// process-wide leftover (e.g. from the GARNET_NATIVE_ALLOCATOR env var) is reset to the managed path.
205+
NativeAllocatorInitializer.Initialize(opts.NativeAllocatorSurfaces, loggerFactory?.CreateLogger("NativeAllocator"));
206+
207+
// Select the managed SectorAlignedBufferPool backend before any pool is constructed (each pool captures
208+
// this static once at construction). Default is the origin-return per-thread magazine pool; the flag
209+
// selects the per-level ConcurrentQueue pool instead.
210+
SectorAlignedBufferPool.UseOriginReturn = !opts.UseLegacyBufferPool;
211+
202212
// Flush initialization logs from memory logger
203213
FlushMemoryLogger(this.initLogger, "ArgParser", this.loggerFactory);
204214

libs/host/defaults.conf

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,16 @@
4242
/* Max size of hash index in bytes (rounds down to power of 2) */
4343
"IndexMaxMemorySize": "",
4444

45+
/* Route large memory regions through a native (off-managed-heap) direct-VM allocator (mmap/VirtualAlloc).
46+
Values: "off" (default, fully managed), "full" (routes log pages / hash index / recovery frames off the
47+
GC heap). No native library is required. */
48+
"NativeAllocator": "off",
49+
50+
/* Use the legacy per-level ConcurrentQueue SectorAlignedBufferPool instead of the default origin-return
51+
(per-thread magazine) pool. The origin-return pool scales far better under many concurrent IO-completion
52+
threads; this flag is a rollback/kill-switch. */
53+
"UseLegacyBufferPool": false,
54+
4555
/* Percentage of log memory that is kept mutable */
4656
"MutablePercent" : 90,
4757

libs/server/Metrics/Info/GarnetInfoMetrics.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ private void PopulateMemoryInfo(StoreWrapper storeWrapper)
140140
new("gc_heap_bytes", gcMemoryInfo.HeapSizeBytes.ToString()),
141141
new("gc_managed_memory_bytes_excluding_heap", gcAvailableMemory.ToString()),
142142
new("gc_fragmented_bytes", gcMemoryInfo.FragmentedBytes.ToString()),
143+
new("native_allocator_bytes", Tsavorite.core.NativeMemoryTracker.Bytes.ToString()),
143144
new("store_index_size", store_index_size.ToString()),
144145
new("store_mainlog_memory_size", store_mainlog_memory_size.ToString()),
145146
new("store_readcache_memory_size", store_readcache_memory_size.ToString()),

libs/server/Servers/GarnetServerOptions.cs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,23 @@ public class GarnetServerOptions : ServerOptions
2121
/// </summary>
2222
public bool DisableObjects = false;
2323

24+
/// <summary>
25+
/// Which memory surfaces use a native (off-managed-heap) allocator. Resolved from the
26+
/// <c>--native-allocator</c> mode (off | full) and installed at startup via
27+
/// <see cref="NativeAllocatorInitializer"/>. The direct-VM surfaces (log pages / hash index / recovery
28+
/// frames) call the OS virtual-memory APIs directly and are always available; no native library is required.
29+
/// </summary>
30+
public NativeAllocatorSurfaces NativeAllocatorSurfaces = NativeAllocatorSurfaces.None;
31+
32+
/// <summary>
33+
/// Select the per-level <c>ConcurrentQueue</c> <see cref="SectorAlignedBufferPool"/> instead of the
34+
/// default origin-return (per-thread magazine) pool. The origin-return pool returns each buffer to the
35+
/// thread that allocated it, scaling with concurrent IO-completion threads under a per-pool byte budget.
36+
/// Installed at startup by toggling <see cref="SectorAlignedBufferPool.UseOriginReturn"/> before any pool
37+
/// is created.
38+
/// </summary>
39+
public bool UseLegacyBufferPool = false;
40+
2441
/// <summary>
2542
/// Enable cluster.
2643
/// </summary>

0 commit comments

Comments
 (0)