From 217a14714e27ad39496a01c02a25f8c11972f167 Mon Sep 17 00:00:00 2001 From: Gerard Smit Date: Wed, 4 Dec 2024 14:46:56 +0100 Subject: [PATCH 1/2] Reduce allocations in MemoryFileSystem and ZipArchiveFileSystem --- src/Zio.Tests/TestSearchPattern.cs | 2 +- src/Zio.Tests/TestUPath.cs | 44 +++++++++ src/Zio.Tests/Zio.Tests.csproj | 2 +- src/Zio/FileSystems/MemoryFileSystem.cs | 16 +-- src/Zio/FileSystems/ZipArchiveFileSystem.cs | 37 ++++--- src/Zio/SearchPattern.cs | 17 ++++ src/Zio/UPath.cs | 19 ++++ src/Zio/UPathComparer.cs | 41 ++++++++ src/Zio/UPathExtensions.cs | 104 ++++++++++++++++++++ src/Zio/Zio.csproj | 20 ++-- src/global.json | 2 +- 11 files changed, 275 insertions(+), 29 deletions(-) diff --git a/src/Zio.Tests/TestSearchPattern.cs b/src/Zio.Tests/TestSearchPattern.cs index f53ffc4..0ee2a57 100644 --- a/src/Zio.Tests/TestSearchPattern.cs +++ b/src/Zio.Tests/TestSearchPattern.cs @@ -91,7 +91,7 @@ public void TestExpectedExceptions() { var searchPattern = "*"; var search = SearchPattern.Parse(ref path, ref searchPattern); - Assert.Throws(() => search.Match(null)); + Assert.Throws(() => search.Match(((string)null)!)); } } diff --git a/src/Zio.Tests/TestUPath.cs b/src/Zio.Tests/TestUPath.cs index ec3a42b..82acfd8 100644 --- a/src/Zio.Tests/TestUPath.cs +++ b/src/Zio.Tests/TestUPath.cs @@ -267,6 +267,22 @@ public void TestGetDirectory(string path1, string expectedDir) Assert.Equal(expectedDir, result); } + [Theory] + [InlineData("", "")] + [InlineData("/", "")] + [InlineData("/a", "/")] + [InlineData("/a/b", "/a")] + [InlineData("/a/b/c.txt", "/a/b")] + [InlineData("a", "")] + [InlineData("../a", "..")] + [InlineData("../../a/b", "../../a")] + public void TestGetDirectoryAsSpan(string path1, string expectedDir) + { + var path = (UPath)path1; + var result = path.GetDirectoryAsSpan().ToString(); + Assert.Equal(expectedDir, result); + } + [Theory] [InlineData("", ".txt", "")] [InlineData("/", ".txt", "/.txt")] @@ -324,6 +340,34 @@ public void TestSplit() Assert.Equal(new List() { "a", "b", "c" }, ((UPath)"a/b/c").Split()); } + [Fact] + public void TestSplitSpan() + { + Assert.Equal(new List(), ToList((UPath)"")); + Assert.Equal(new List(), ToList((UPath)"/")); + Assert.Equal(new List() { "a" }, ToList((UPath)"/a")); + Assert.Equal(new List() {"a", "b", "c"}, ToList((UPath) "/a/b/c")); + Assert.Equal(new List() { "a" }, ToList((UPath)"a")); + Assert.Equal(new List() { "a", "b" }, ToList((UPath)"a/b")); + Assert.Equal(new List() { "a", "b", "c" }, ToList((UPath)"a/b/c")); + return; + + List ToList(UPath path) + { + var enumerator = path.SpanSplit(); + var list = new List(enumerator.Count); + + foreach (var span in enumerator) + { + list.Add(span.ToString()); + } + + Assert.Equal(enumerator.Count, list.Count); + + return list; + } + } + [Fact] public void TestExpectedException() diff --git a/src/Zio.Tests/Zio.Tests.csproj b/src/Zio.Tests/Zio.Tests.csproj index 896d525..e877007 100644 --- a/src/Zio.Tests/Zio.Tests.csproj +++ b/src/Zio.Tests/Zio.Tests.csproj @@ -1,7 +1,7 @@  - net8.0;net472 + net8.0;net9.0;net472 false 10 diff --git a/src/Zio/FileSystems/MemoryFileSystem.cs b/src/Zio/FileSystems/MemoryFileSystem.cs index 83e96cc..b535754 100644 --- a/src/Zio/FileSystems/MemoryFileSystem.cs +++ b/src/Zio/FileSystems/MemoryFileSystem.cs @@ -1304,7 +1304,7 @@ private NodeResult EnterFindNode(UPath path, FindNodeFlags flags, FileShare? sha var isRequiringExclusiveLockForParent = (flags & (FindNodeFlags.CreatePathIfNotExist | FindNodeFlags.KeepParentNodeExclusive)) != 0; var parentNode = _rootDirectory; - var names = path.Split(); + var names = path.SpanSplit(); // Walking down the nodes in locking order: // /a/b/c.txt @@ -1324,9 +1324,9 @@ private NodeResult EnterFindNode(UPath path, FindNodeFlags flags, FileShare? sha isParentLockTaken = true; } - for (var i = 0; i < names.Count && parentNode != null; i++) + for (var i = 0; names.MoveNext() && parentNode != null; i++) { - var name = names[i]; + ReadOnlySpan name = names.Current; bool isLast = i + 1 == names.Count; DirectoryNode? nextParent = null; @@ -1334,11 +1334,15 @@ private NodeResult EnterFindNode(UPath path, FindNodeFlags flags, FileShare? sha try { FileSystemNode? subNode; - if (!parentNode.Children.TryGetValue(name, out subNode)) +#if HAS_ALTERNATEEQUALITYCOMPARER + if (!parentNode.Children.GetAlternateLookup>().TryGetValue(name, out subNode)) +#else + if (!parentNode.Children.TryGetValue(name.ToString(), out subNode)) +#endif { if ((flags & FindNodeFlags.CreatePathIfNotExist) != 0) { - subNode = new DirectoryNode(this, parentNode, name); + subNode = new DirectoryNode(this, parentNode, name.ToString()); } } else @@ -1361,7 +1365,7 @@ private NodeResult EnterFindNode(UPath path, FindNodeFlags flags, FileShare? sha flags &= ~(FindNodeFlags.KeepParentNodeExclusive | FindNodeFlags.KeepParentNodeShared); } - result = new NodeResult(parentNode, subNode, name, flags); + result = new NodeResult(parentNode, subNode, name.ToString(), flags); // The last subnode may be null but we still want to return a valid parent // otherwise, lock the final node if necessary diff --git a/src/Zio/FileSystems/ZipArchiveFileSystem.cs b/src/Zio/FileSystems/ZipArchiveFileSystem.cs index f2477ed..ceebbc6 100644 --- a/src/Zio/FileSystems/ZipArchiveFileSystem.cs +++ b/src/Zio/FileSystems/ZipArchiveFileSystem.cs @@ -196,7 +196,7 @@ protected override void CopyFileImpl(UPath srcPath, UPath destPath, bool overwri if (srcEntry == null) { - if (!DirectoryExistsImpl(srcPath.GetDirectory())) + if (!DirectoryExistsImpl(srcPath.GetDirectoryAsSpan())) { throw new DirectoryNotFoundException(srcPath.GetDirectory().FullName); } @@ -204,10 +204,10 @@ protected override void CopyFileImpl(UPath srcPath, UPath destPath, bool overwri throw FileSystemExceptionHelper.NewFileNotFoundException(srcPath); } - var parentDirectory = destPath.GetDirectory(); + var parentDirectory = destPath.GetDirectoryAsSpan(); if (!DirectoryExistsImpl(parentDirectory)) { - throw FileSystemExceptionHelper.NewDirectoryNotFoundException(parentDirectory); + throw FileSystemExceptionHelper.NewDirectoryNotFoundException(parentDirectory.ToString()); } if (DirectoryExistsImpl(destPath)) @@ -261,12 +261,12 @@ protected override void CreateDirectoryImpl(UPath path) throw FileSystemExceptionHelper.NewDestinationDirectoryExistException(path); } - var parentPath = new UPath(GetParent(path.FullName)); - if (parentPath != "") + var parentPath = GetParent(path.AsSpan()); + if (!parentPath.IsEmpty) { if (!DirectoryExistsImpl(parentPath)) { - CreateDirectoryImpl(parentPath); + CreateDirectoryImpl(parentPath.ToString()); } } @@ -405,7 +405,12 @@ protected override void DeleteFileImpl(UPath path) /// protected override bool DirectoryExistsImpl(UPath path) { - if (path.FullName is "/" or "\\" or "") + return DirectoryExistsImpl(path.FullName.AsSpan()); + } + + private bool DirectoryExistsImpl(ReadOnlySpan path) + { + if (path is "/" or "\\" or "") { return true; } @@ -414,7 +419,11 @@ protected override bool DirectoryExistsImpl(UPath path) try { - return _entries.TryGetValue(path, out var entry) && entry.IsDirectory; +#if HAS_ALTERNATEEQUALITYCOMPARER + return _entries.GetAlternateLookup>().TryGetValue(path, out var entry) && entry.IsDirectory; +#else + return _entries.TryGetValue(path.ToString(), out var entry) && entry.IsDirectory; +#endif } finally { @@ -651,7 +660,7 @@ protected override void MoveFileImpl(UPath srcPath, UPath destPath) { var srcEntry = GetEntry(srcPath) ?? throw FileSystemExceptionHelper.NewFileNotFoundException(srcPath); - if (!DirectoryExistsImpl(destPath.GetDirectory())) + if (!DirectoryExistsImpl(destPath.GetDirectoryAsSpan())) { throw FileSystemExceptionHelper.NewDirectoryNotFoundException(destPath.GetDirectory()); } @@ -706,7 +715,7 @@ protected override Stream OpenFileImpl(UPath path, FileMode mode, FileAccess acc } else { - if (!DirectoryExistsImpl(path.GetDirectory())) + if (!DirectoryExistsImpl(path.GetDirectoryAsSpan())) { throw FileSystemExceptionHelper.NewDirectoryNotFoundException(path.GetDirectory()); } @@ -911,18 +920,18 @@ private ZipArchiveEntry CreateEntry(UPath path, bool isDirectory = false) private static readonly char[] s_slashChars = { '/', '\\' }; - private static string GetName(ZipArchiveEntry entry) + private static ReadOnlySpan GetName(ZipArchiveEntry entry) { var name = entry.FullName.TrimEnd(s_slashChars); var index = name.LastIndexOfAny(s_slashChars); - return name.Substring(index + 1); + return index == -1 ? name.AsSpan() : name.AsSpan(index + 1); } - private static string GetParent(string path) + private static ReadOnlySpan GetParent(ReadOnlySpan path) { path = path.TrimEnd(s_slashChars); var lastIndex = path.LastIndexOfAny(s_slashChars); - return lastIndex == -1 ? "" : path.Substring(0, lastIndex); + return lastIndex == -1 ? ReadOnlySpan.Empty : path.Slice(0, lastIndex); } private FileSystemEventDispatcher? TryGetDispatcher() diff --git a/src/Zio/SearchPattern.cs b/src/Zio/SearchPattern.cs index 33f9e39..9af2332 100644 --- a/src/Zio/SearchPattern.cs +++ b/src/Zio/SearchPattern.cs @@ -2,6 +2,7 @@ // This file is licensed under the BSD-Clause 2 license. // See the license.txt file in the project root for more information. +using System.Linq; using System.Text; using System.Text.RegularExpressions; @@ -43,6 +44,22 @@ public bool Match(string name) return _exactMatch != null ? _exactMatch == name : _regexMatch is null || _regexMatch.IsMatch(name); } + /// + /// Tries to match the specified path with this instance. + /// + /// The path to match. + /// true if the path was matched, false otherwise. + public bool Match(ReadOnlySpan name) + { +#if NET7_0_OR_GREATER + // if _execMatch is null and _regexMatch is null, we have a * match + return _exactMatch != null ? name.SequenceEqual(_exactMatch) : _regexMatch is null || _regexMatch.IsMatch(name); +#else + // Regex.Match(ReadOnlySpan) is only available starting from .NET + return Match(name.ToString()); +#endif + } + /// /// Parses and normalize the specified path and . /// diff --git a/src/Zio/UPath.cs b/src/Zio/UPath.cs index 9ae6859..cbc0a1a 100644 --- a/src/Zio/UPath.cs +++ b/src/Zio/UPath.cs @@ -116,6 +116,16 @@ public static explicit operator string(UPath path) return path.FullName; } + /// + /// Performs an explicit conversion from to . + /// + /// The path. + /// The result as a span of the conversion. + public static explicit operator ReadOnlySpan(UPath path) + { + return path.FullName.AsSpan(); + } + /// /// Combines two paths into a new path. /// @@ -322,6 +332,15 @@ public override string ToString() return FullName; } + /// + /// Creates a new readonly span from this path. + /// + /// A new readonly span from this path. + public ReadOnlySpan AsSpan() + { + return FullName.AsSpan(); + } + /// /// Tries to parse the specified string into a /// diff --git a/src/Zio/UPathComparer.cs b/src/Zio/UPathComparer.cs index 9bfdff3..89563d5 100644 --- a/src/Zio/UPathComparer.cs +++ b/src/Zio/UPathComparer.cs @@ -2,9 +2,14 @@ // This file is licensed under the BSD-Clause 2 license. // See the license.txt file in the project root for more information. +using System.Diagnostics; + namespace Zio; public class UPathComparer : IComparer, IEqualityComparer +#if HAS_ALTERNATEEQUALITYCOMPARER + , IAlternateEqualityComparer, UPath>, IAlternateEqualityComparer +#endif { public static readonly UPathComparer Ordinal = new(StringComparer.Ordinal); public static readonly UPathComparer OrdinalIgnoreCase = new(StringComparer.OrdinalIgnoreCase); @@ -16,6 +21,10 @@ public class UPathComparer : IComparer, IEqualityComparer private UPathComparer(StringComparer comparer) { _comparer = comparer; + +#if HAS_ALTERNATEEQUALITYCOMPARER + Debug.Assert(_comparer is IAlternateEqualityComparer, string>); +#endif } public int Compare(UPath x, UPath y) @@ -32,4 +41,36 @@ public int GetHashCode(UPath obj) { return _comparer.GetHashCode(obj.FullName); } + +#if HAS_ALTERNATEEQUALITYCOMPARER + bool IAlternateEqualityComparer, UPath>.Equals(ReadOnlySpan alternate, UPath other) + { + return ((IAlternateEqualityComparer, string>)_comparer).Equals(alternate, other.FullName); + } + + int IAlternateEqualityComparer, UPath>.GetHashCode(ReadOnlySpan alternate) + { + return ((IAlternateEqualityComparer, string>)_comparer).GetHashCode(alternate); + } + + UPath IAlternateEqualityComparer, UPath>.Create(ReadOnlySpan alternate) + { + return ((IAlternateEqualityComparer, string>)_comparer).Create(alternate); + } + + bool IAlternateEqualityComparer.Equals(string alternate, UPath other) + { + return _comparer.Equals(alternate, other.FullName); + } + + int IAlternateEqualityComparer.GetHashCode(string alternate) + { + return _comparer.GetHashCode(alternate); + } + + UPath IAlternateEqualityComparer.Create(string alternate) + { + return alternate; + } +#endif } diff --git a/src/Zio/UPathExtensions.cs b/src/Zio/UPathExtensions.cs index 5aa8c2b..ff6a4a0 100644 --- a/src/Zio/UPathExtensions.cs +++ b/src/Zio/UPathExtensions.cs @@ -2,6 +2,7 @@ // This file is licensed under the BSD-Clause 2 license. // See the license.txt file in the project root for more information. +using System.Collections; using System.IO; namespace Zio; @@ -72,6 +73,31 @@ public static UPath GetDirectory(this UPath path) return lastIndex == 0 ? UPath.Root : UPath.Empty; } + /// + /// Gets the directory of the specified path as a span. + /// + /// The path. + /// The directory of the path. + /// if path is + public static ReadOnlySpan GetDirectoryAsSpan(this UPath path) + { + path.AssertNotNull(); + + var fullname = path.FullName; + + if (fullname is "/") + { + return ReadOnlySpan.Empty; + } + + var lastIndex = fullname.LastIndexOf(UPath.DirectorySeparator); + if (lastIndex > 0) + { + return fullname.AsSpan(0, lastIndex); + } + return lastIndex == 0 ? UPath.Root.FullName.AsSpan() : ReadOnlySpan.Empty; + } + /// /// Gets the first directory of the specified path and return the remaining path (/a/b/c, first directory: /a, remaining: b/c) /// @@ -138,6 +164,84 @@ public static List Split(this UPath path) return paths; } + /// + /// Splits the specified path by directories using the directory separator character `/` + /// + /// The path. + /// A list of sub path for each directory entry in the path (/a/b/c returns [a,b,c], or a/b/c returns [a,b,c]. + public static SplitEnumerator SpanSplit(this UPath path) + { + path.AssertNotNull(); + + var span = path.IsAbsolute + ? path.FullName.AsSpan(1) + : path.FullName.AsSpan(); + + return new SplitEnumerator(span); + } + + /// + /// Enumerator for + /// + public ref struct SplitEnumerator + { + private ReadOnlySpan _remaining; + + public ReadOnlySpan Current { get; private set; } + + public int Count { get; } + + public SplitEnumerator(ReadOnlySpan remaining) + { + _remaining = remaining; + + if (remaining.IsEmpty) + { + Count = 0; + } + else + { +#if NET9_0_OR_GREATER + Count = remaining.Count(UPath.DirectorySeparator) + 1; +#else + Count++; + foreach (var t in _remaining) + { + if (t == UPath.DirectorySeparator) + { + Count++; + } + } +#endif + } + } + + public bool MoveNext() + { + if (_remaining.IsEmpty) + { + return false; + } + + var index = _remaining.IndexOf(UPath.DirectorySeparator); + + if (index < 0) + { + Current = _remaining; + _remaining = ReadOnlySpan.Empty; + } + else + { + Current = _remaining.Slice(0, index); + _remaining = _remaining.Slice(index + 1); + } + + return true; + } + + public SplitEnumerator GetEnumerator() => this; + } + /// /// Gets the file or last directory name and extension of the specified path. /// diff --git a/src/Zio/Zio.csproj b/src/Zio/Zio.csproj index a7d52a3..788daee 100644 --- a/src/Zio/Zio.csproj +++ b/src/Zio/Zio.csproj @@ -5,7 +5,7 @@ Zio en-US Alexandre Mutel - net462;netstandard2.0;netstandard2.1;net7.0 + net462;netstandard2.0;netstandard2.1;net7.0;net9.0 Zio Zio filesystem;vfs;VirtualFileSystem;virtual;abstract;directory;files;io;mock @@ -13,7 +13,7 @@ readme.md https://github.com/xoofx/zio BSD-2-Clause - 11 + 13 true true @@ -38,6 +38,9 @@ 4.5.0 + + 4.6.0 + @@ -47,6 +50,9 @@ 4.3.0 + + 4.6.0 + @@ -58,10 +64,6 @@ - - $(AdditionalConstants);NETSTANDARD;HAS_ZIPARCHIVE - - $(AdditionalConstants);NETSTANDARD;HAS_ZIPARCHIVE @@ -76,6 +78,12 @@ $(AdditionalConstants);NETSTANDARD;HAS_ZIPARCHIVE;HAS_NULLABLEANNOTATIONS + + true + true + $(AdditionalConstants);NETSTANDARD;HAS_ZIPARCHIVE;HAS_NULLABLEANNOTATIONS;HAS_ALTERNATEEQUALITYCOMPARER + + $(AdditionalConstants);HAS_ZIPARCHIVE diff --git a/src/global.json b/src/global.json index 60b4c02..4f85ebc 100644 --- a/src/global.json +++ b/src/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "8.0.100", + "version": "9.0.100", "rollForward": "latestMinor", "allowPrerelease": false } From acd06910de0c58cd5d92289df19646eab8985646 Mon Sep 17 00:00:00 2001 From: Gerard Smit Date: Wed, 4 Dec 2024 15:07:13 +0100 Subject: [PATCH 2/2] Update .NET version in CI --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aebedf0..f357bb3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,10 +24,10 @@ jobs: submodules: true fetch-depth: 0 - - name: Install .NET 8.0 + - name: Install .NET 9.0 uses: actions/setup-dotnet@v3 with: - dotnet-version: '8.0.x' + dotnet-version: '9.0.x' - name: Build, Test, Pack, Publish if: matrix.os == 'windows-latest'