Repository navigation
perf: reduce per-item allocation churn in Arius.Core #195
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
woutervanranst
wants to merge
46
commits into
master
Choose a base branch
from
memory-opt
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
46 commits
Select commit
Hold shift + click to select a range
67ae231
perf(benchmarks): add in-process allocation micro-benchmarks
woutervanranst eef3d24
perf(archive): pre-size the tar bundle buffer
woutervanranst 52fcbb7
perf(hashcache): pool the sparse-fingerprint capture buffers
woutervanranst 55b9e18
perf(hashes): halve the allocations in the hash codec
woutervanranst 0ae0798
perf(archive): stat each file once instead of four times
woutervanranst f0e7b69
perf(filetree): drop two per-node payload copies
woutervanranst 8fc47fe
perf(chunk-index): bind hash BLOBs from reusable buffers
woutervanranst a3e957a
perf(chunk-index): look up dedup batches with one query, not 256
woutervanranst 12659ab
perf(restore): restore the async read path on ChunkDownloadStream
woutervanranst 8f367c6
perf(streaming): coalesce ProgressStream reports to 500 ms
woutervanranst 63acf12
perf: three contained allocation fixes on hot paths
woutervanranst a9bdefa
perf(filetree): keep staging append handles open, and fix a boxing st…
woutervanranst 10947e7
perf(cli): stop the archive progress state growing with the run
woutervanranst 59d7fa7
perf(benchmarks): record the full-scale archive run for this branch
woutervanranst 6bb3aed
chore: update docstrings
woutervanranst 98d6f8d
Revert "perf(archive): stat each file once instead of four times"
woutervanranst 18b41a1
test(benchmarks): exercise large sampler captures
woutervanranst 6e2e6b4
docs(benchmarks): fix benchmark table notes column
woutervanranst 90c8845
fix(benchmarks): reject archive filters
woutervanranst 039cdee
test(streaming): make progress throttling deterministic
woutervanranst 3e7eae4
fix(streaming): ignore zero-length reads for EOF progress
woutervanranst 89f01ef
fix(chunk-index): bound batched SQLite lookups
woutervanranst 9f8048f
refactor(streaming): keep clock seam internal
woutervanranst c8c7989
fix(filetree): recover the staging stripe when retargeting its handle…
woutervanranst a4e1fcc
fix(hashcache): fail loudly when a disposed sampler is reused
woutervanranst ad9916d
revert(filetree): stop holding staging node handles open
woutervanranst b46fd6e
fix(archive): stop reserving a full tar bundle for tiny ones
woutervanranst 6436a6f
fix(chunk-index): do not ignore hex-to-digest conversion failures
woutervanranst 1477b3f
fix(cli): drop only the deduplicated file's progress row
woutervanranst d0f6e3e
fix(streaming): release the inner stream even if the final progress r…
woutervanranst 529f1a1
fix(chunk-index): bound the batched lookup in the store, drop the slo…
woutervanranst 9a6326c
fix(archive): size the tar buffer from observed framing, trim tail bu…
woutervanranst ac197a4
revert(streaming): drop the throwing-sink guard in ProgressStream.Dis…
woutervanranst 68f0dae
test(cli): cover deduplicating a copy of a file that is still uploading
woutervanranst cedb4b6
fix(cli): stop the content-hash reverse lookup growing with the run
woutervanranst cf24575
refactor(chunk-index): let the local store own lookup paging
woutervanranst 53c3bee
refactor: drop two guards for states that cannot occur
woutervanranst 1744009
refactor(streaming): trim ProgressStream's throttling machinery
woutervanranst b2de8ad
refactor(filesystem): leave control-character checks to PathSegment
woutervanranst 435b3ff
refactor: make single-use helpers local methods
woutervanranst 6090008
refactor(cli): drop always-true tar state checks
woutervanranst ca81615
refactor(benchmarks): hand micro runs to BenchmarkSwitcher
woutervanranst e6d14b7
docs: trim comments that retell the branch's history
woutervanranst 5a8d0fd
docs: fix stale descriptions of the batched lookup and streaming wrap…
woutervanranst b4e5a01
docs(filetree): restore the staging writer's invariant comments
woutervanranst b85a350
perf(benchmarks): record the full-scale archive run at the branch head
woutervanranst File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,269 @@ | ||
| using Arius.Core.Features.ArchiveCommand; | ||
| using Arius.Core.Shared.ChunkIndex; | ||
| using Arius.Core.Shared.Encryption; | ||
| using Arius.Core.Shared.FileSystem; | ||
| using Arius.Core.Shared.FileTree; | ||
| using Arius.Core.Shared.HashCache; | ||
| using Arius.Core.Shared.Hashes; | ||
| using Arius.Core.Shared.Storage; | ||
| using Arius.Tests.Shared; | ||
| using BenchmarkDotNet.Attributes; | ||
|
|
||
| namespace Arius.Benchmarks; | ||
|
|
||
| /// <summary> | ||
| /// In-process allocation benchmarks for selected Arius.Core components. | ||
| /// Run with <c>micro</c> (e.g. <c>micro --filter '*TarBuilder*'</c>); no Azurite or Docker is required. | ||
| /// </summary> | ||
| [MemoryDiagnoser] | ||
| public class AllocationBenchmarks | ||
| { | ||
| private const int EntryCount = 1_000; | ||
|
|
||
| /// <summary> | ||
| /// Digest containing hexadecimal letters, ensuring the lowercase conversion path is exercised. | ||
| /// </summary> | ||
| private readonly byte[] _digest = CreateHighNibbleDigest(); | ||
|
|
||
| private const string CanonicalHex = "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"; | ||
| private const string UppercaseHex = "00112233445566778899AABBCCDDEEFF00112233445566778899AABBCCDDEEFF"; | ||
|
|
||
| [Benchmark(Description = "HashCodec.ToLowerHex")] | ||
| public string HashCodec_ToLowerHex() => HashCodec.ToLowerHex(_digest); | ||
|
|
||
| /// <summary>Parses an already canonical lowercase value.</summary> | ||
| [Benchmark(Description = "HashCodec.NormalizeHex (already canonical)")] | ||
| public string HashCodec_NormalizeHex_Canonical() => HashCodec.NormalizeHex(CanonicalHex); | ||
|
|
||
| [Benchmark(Description = "HashCodec.NormalizeHex (uppercase input)")] | ||
| public string HashCodec_NormalizeHex_Uppercase() => HashCodec.NormalizeHex(UppercaseHex); | ||
|
|
||
| // ── Sparse fingerprint ─────────────────────────────────────────────────────── | ||
|
|
||
| private const long SmallFileSize = 200L * 1024; // one whole-file region | ||
| private const long LargeFileSize = 64L * 1024 * 1024 * 1024; // k = MaxBlocks = 64 regions | ||
|
|
||
| private byte[] _readBuffer = null!; | ||
|
|
||
| /// <summary>Exercises the single-region sampler path for a small file.</summary> | ||
| [Benchmark(Description = "SparseFingerprint.Sampler small file (200 KB)")] | ||
| public byte[] SparseFingerprint_Sampler_SmallFile() | ||
| { | ||
| using var sampler = new SparseFingerprint.Sampler(SmallFileSize); | ||
| var position = 0L; | ||
|
|
||
| while (position < SmallFileSize) | ||
| { | ||
| var length = (int)Math.Min(_readBuffer.Length, SmallFileSize - position); | ||
| sampler.Capture(position, _readBuffer.AsSpan(0, length)); | ||
| position += length; | ||
| } | ||
|
|
||
| return sampler.Finish(); | ||
| } | ||
|
|
||
| /// <summary>Exercises the maximum sampled-region buffer size.</summary> | ||
| [Benchmark(Description = "SparseFingerprint.Sampler large file (64 GB logical)")] | ||
| public byte[] SparseFingerprint_Sampler_LargeFile() | ||
| { | ||
| using var sampler = new SparseFingerprint.Sampler(LargeFileSize); | ||
|
|
||
| foreach (var (offset, length) in SparseFingerprint.Regions(LargeFileSize)) | ||
| sampler.Capture(offset, _readBuffer.AsSpan(0, length)); | ||
|
|
||
| return sampler.Finish(); | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| } | ||
|
|
||
| // ── Filetree serialization ─────────────────────────────────────────────────── | ||
|
|
||
| private IReadOnlyList<FileTreeEntry> _fileTreeEntries = null!; | ||
| private byte[] _fileTreeBytes = null!; | ||
|
|
||
| [Benchmark(Description = "FileTreeSerializer.Serialize (1000 entries)")] | ||
| public byte[] FileTreeSerializer_Serialize() => FileTreeSerializer.Serialize(_fileTreeEntries); | ||
|
|
||
| [Benchmark(Description = "FileTreeSerializer.Deserialize (1000 entries)")] | ||
| public IReadOnlyList<FileTreeEntry> FileTreeSerializer_Deserialize() => FileTreeSerializer.Deserialize(_fileTreeBytes); | ||
|
|
||
| // ── Tar builder ────────────────────────────────────────────────────────────── | ||
|
|
||
| private const long TarTargetSize = 64L * 1024 * 1024; | ||
| private const int TarEntrySize = 64 * 1024; | ||
|
|
||
| private byte[] _tarEntryPayload = null!; | ||
| private byte[] _smallEntryPayload = null!; | ||
| private IEncryptionService _encryption = null!; | ||
|
|
||
| /// <summary>Builds and seals one 64 MB TAR bundle.</summary> | ||
| [Benchmark(Description = "TarBuilder seal one 64 MB bundle")] | ||
| public async Task<int> TarBuilder_Seal_64MB() | ||
| { | ||
| await using var builder = new TarBuilder(TarTargetSize, _encryption); | ||
|
|
||
| var sealedCount = 0; | ||
| for (var i = 0; i < TarTargetSize / TarEntrySize; i++) | ||
| { | ||
| var source = new MemoryStream(_tarEntryPayload, writable: false); | ||
| if (await builder.AddAsync(CreateUpload(i, TarEntrySize), source, CancellationToken.None) is not null) | ||
| sealedCount++; | ||
| } | ||
|
|
||
| return sealedCount; | ||
| } | ||
|
|
||
| /// <summary>A handful of small files: the shape of a tiny repository or a small tail bundle.</summary> | ||
| [Benchmark(Description = "TarBuilder seal one small bundle (5 x 1 KB)")] | ||
| public async Task<int> TarBuilder_Seal_SmallBundle() | ||
| { | ||
| await using var builder = new TarBuilder(TarTargetSize, _encryption); | ||
|
|
||
| for (var i = 0; i < 5; i++) | ||
| { | ||
| var source = new MemoryStream(_smallEntryPayload, writable: false); | ||
| await builder.AddAsync(CreateUpload(i, _smallEntryPayload.Length), source, CancellationToken.None); | ||
| } | ||
|
|
||
| return (await builder.SealAsync(CancellationToken.None))!.Entries.Count; | ||
| } | ||
|
|
||
| // ── Chunk-index local store ────────────────────────────────────────────────── | ||
|
|
||
| private LocalDirectory _storeRoot = default; | ||
| private ChunkIndexLocalStore _store = null!; | ||
| private ShardEntry[] _shardEntries = null!; | ||
| private ContentHash[] _lookupHashes = null!; | ||
| private PathSegment _rangePrefix = default; | ||
|
|
||
| [Benchmark(Description = "ChunkIndexLocalStore.UpsertRemoteBacked (1000 rows)")] | ||
| public void ChunkIndexLocalStore_UpsertRemoteBacked() => _store.UpsertRemoteBacked(_shardEntries); | ||
|
|
||
| [Benchmark(Description = "ChunkIndexLocalStore.ReadRangeEntries (1000 rows)")] | ||
| public int ChunkIndexLocalStore_ReadRangeEntries() | ||
| { | ||
| var count = 0; | ||
| _store.ReadRangeEntries(_rangePrefix, _ => count++); | ||
| return count; | ||
| } | ||
|
|
||
| /// <summary>Measures the per-hash lookup shape for a 256-hash deduplication batch.</summary> | ||
| [Benchmark(Description = "ChunkIndexLocalStore.FindEntry x256 (one dedup batch)")] | ||
| public int ChunkIndexLocalStore_FindEntry_256() | ||
| { | ||
| var found = 0; | ||
| foreach (var hash in _lookupHashes) | ||
| if (_store.FindEntry(hash) is not null) | ||
| found++; | ||
|
|
||
| return found; | ||
| } | ||
|
|
||
| /// <summary>Measures the batched lookup for a 256-hash deduplication batch.</summary> | ||
| [Benchmark(Description = "ChunkIndexLocalStore.FindEntries x1 (one dedup batch)")] | ||
| public int ChunkIndexLocalStore_FindEntries_Batch() => _store.FindEntries(_lookupHashes).Count; | ||
|
|
||
| // ── Setup ──────────────────────────────────────────────────────────────────── | ||
|
|
||
| [GlobalSetup] | ||
| public void Setup() | ||
| { | ||
| _readBuffer = new byte[256 * 1024]; | ||
| _tarEntryPayload = new byte[TarEntrySize]; | ||
| _smallEntryPayload = new byte[1024]; | ||
| Random.Shared.NextBytes(_readBuffer); | ||
| Random.Shared.NextBytes(_tarEntryPayload); | ||
| Random.Shared.NextBytes(_smallEntryPayload); | ||
|
|
||
| _encryption = IEncryptionService.EncryptedInstance; | ||
|
|
||
| _fileTreeEntries = BuildFileTreeEntries(EntryCount); | ||
| _fileTreeBytes = FileTreeSerializer.Serialize(_fileTreeEntries); | ||
|
|
||
| _shardEntries = BuildShardEntries(EntryCount); | ||
| _lookupHashes = _shardEntries.Take(256).Select(e => e.ContentHash).ToArray(); | ||
| _rangePrefix = PathSegment.Parse("00"); | ||
|
|
||
| _storeRoot = TestTempRoots.CreateDirectory("benchmark-chunkindex"); | ||
| _store = new ChunkIndexLocalStore(_storeRoot); | ||
| _store.UpsertRemoteBacked(_shardEntries); | ||
| } | ||
|
|
||
| [GlobalCleanup] | ||
| public void Cleanup() | ||
| { | ||
| try | ||
| { | ||
| RelativeFileSystem.DeleteDirectory(_storeRoot, RelativePath.Root, recursive: true); | ||
| } | ||
| catch (IOException) | ||
| { | ||
| // Best effort: the SQLite connection pool may still hold the file. TestTempRoots sweeps stale dirs. | ||
| } | ||
| } | ||
|
|
||
| // ── Deterministic fixtures ─────────────────────────────────────────────────── | ||
|
|
||
| /// <summary> | ||
| /// Creates distinct digests under the <c>"00"</c> range prefix used by the range benchmark. | ||
| /// </summary> | ||
| private static byte[] CreateDigest(int seed) | ||
| { | ||
| var digest = new byte[32]; | ||
| BitConverter.TryWriteBytes(digest.AsSpan(1), seed); | ||
| return digest; | ||
| } | ||
|
|
||
| private static ContentHash CreateContentHash(int seed) => ContentHash.FromDigest(CreateDigest(seed)); | ||
|
|
||
| /// <summary>Creates a digest containing hexadecimal letters.</summary> | ||
| private static byte[] CreateHighNibbleDigest() | ||
| { | ||
| var digest = new byte[32]; | ||
| for (var i = 0; i < digest.Length; i++) | ||
| digest[i] = (byte)(0xA0 | (i & 0x0F)); | ||
|
|
||
| return digest; | ||
| } | ||
|
|
||
| private static IReadOnlyList<FileTreeEntry> BuildFileTreeEntries(int count) | ||
| { | ||
| var entries = new List<FileTreeEntry>(count); | ||
| var created = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); | ||
|
|
||
| for (var i = 0; i < count; i++) | ||
| { | ||
| entries.Add(new FileEntry | ||
| { | ||
| Name = PathSegment.Parse($"file-{i:D6}.bin"), | ||
| ContentHash = CreateContentHash(i), | ||
| Created = created.AddSeconds(i), | ||
| Modified = created.AddSeconds(i * 2), | ||
| }); | ||
| } | ||
|
|
||
| return entries; | ||
| } | ||
|
|
||
| private static ShardEntry[] BuildShardEntries(int count) | ||
| { | ||
| var entries = new ShardEntry[count]; | ||
| for (var i = 0; i < count; i++) | ||
| { | ||
| var contentHash = CreateContentHash(i); | ||
| entries[i] = new ShardEntry( | ||
| ContentHash: contentHash, | ||
| ChunkHash: ChunkHash.Parse(contentHash), // large chunk: chunk hash == content hash | ||
| OriginalSize: 4096 + i, | ||
| ChunkSize: 2048 + i, | ||
| StorageTierHint: BlobTier.Cool); | ||
| } | ||
|
|
||
| return entries; | ||
| } | ||
|
|
||
| private static FileToUpload CreateUpload(int seed, long size) | ||
| { | ||
| var filePair = new FilePair { RelativePath = RelativePath.Parse($"file-{seed:D6}.bin") }; | ||
| var hashed = new HashedFilePair(filePair, CreateContentHash(seed), DateTimeOffset.UnixEpoch, DateTimeOffset.UnixEpoch); | ||
| return new FileToUpload(hashed, size); | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🚀 Performance & Scalability | 🟡 Minor | ⚡ Quick win
Cache the large-file region list before the benchmark runs.
SparseFingerprint.Regions(LargeFileSize)creates a new tuple array on every benchmark invocation. This adds fixture allocation to the measuredSparseFingerprint.Samplerresult. Compute the regions inSetupand iterate the cached list by index.Proposed fix
📝 Committable suggestion
🤖 Prompt for AI Agents