Skip to content

Commit d9b92ee

Browse files
Reduce allocations in file path normalization (#9326)
This change is a rewrite of tooling's `FilePathNormalizer` helper methods to avoid extra allocations associated with string manipulation. These started showing up in perf tests recently because the new `ProjectKey` type uses them to construct itself. These changes should reduce the allocations in those perf tests.
2 parents 5e25e7b + f78ac16 commit d9b92ee

File tree

31 files changed

+596
-65
lines changed

31 files changed

+596
-65
lines changed
Lines changed: 4 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,22 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

4-
#nullable disable
5-
64
using System;
75
using System.Runtime.InteropServices;
86

97
namespace Microsoft.CodeAnalysis.Razor;
108

119
internal static class FilePathComparer
1210
{
13-
private static StringComparer _instance;
11+
private static StringComparer? _instance;
1412

1513
public static StringComparer Instance
1614
{
1715
get
1816
{
19-
if (_instance == null && RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
20-
{
21-
_instance = StringComparer.Ordinal;
22-
}
23-
else if (_instance == null)
24-
{
25-
_instance = StringComparer.OrdinalIgnoreCase;
26-
}
27-
28-
return _instance;
17+
return _instance ??= RuntimeInformation.IsOSPlatform(OSPlatform.Linux)
18+
? StringComparer.Ordinal
19+
: StringComparer.OrdinalIgnoreCase;
2920
}
3021
}
3122
}
Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

4-
#nullable disable
5-
64
using System;
75
using System.Runtime.InteropServices;
86

@@ -16,16 +14,9 @@ public static StringComparison Instance
1614
{
1715
get
1816
{
19-
if (_instance == null && RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
20-
{
21-
_instance = StringComparison.Ordinal;
22-
}
23-
else if (_instance == null)
24-
{
25-
_instance = StringComparison.OrdinalIgnoreCase;
26-
}
27-
28-
return _instance.Value;
17+
return _instance ??= RuntimeInformation.IsOSPlatform(OSPlatform.Linux)
18+
? StringComparison.Ordinal
19+
: StringComparison.OrdinalIgnoreCase;
2920
}
3021
}
3122
}

src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/ProjectSystem/DefaultRazorProjectService.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ void AddDocumentToProject(IProjectSnapshot projectSnapshot, string textDocumentP
105105
}
106106

107107
var targetFilePath = textDocumentPath;
108-
var projectDirectory = FilePathNormalizer.GetDirectory(projectSnapshot.FilePath);
108+
var projectDirectory = FilePathNormalizer.GetNormalizedDirectoryName(projectSnapshot.FilePath);
109109
if (targetFilePath.StartsWith(projectDirectory, FilePathComparison.Instance))
110110
{
111111
// Make relative
@@ -324,7 +324,7 @@ private void UpdateProjectDocuments(IReadOnlyList<DocumentSnapshotHandle> docume
324324

325325
var project = (ProjectSnapshot)_projectSnapshotManagerAccessor.Instance.GetLoadedProject(projectKey);
326326
var currentHostProject = project.HostProject;
327-
var projectDirectory = FilePathNormalizer.GetDirectory(project.FilePath);
327+
var projectDirectory = FilePathNormalizer.GetNormalizedDirectoryName(project.FilePath);
328328
var documentMap = documents.ToDictionary(document => EnsureFullPath(document.FilePath, projectDirectory), FilePathComparer.Instance);
329329
var miscellaneousProject = (ProjectSnapshot)_snapshotResolver.GetMiscellaneousProject();
330330

src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/ProjectSystem/SnapshotResolver.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ public IEnumerable<IProjectSnapshot> FindPotentialProjects(string documentFilePa
5050
continue;
5151
}
5252

53-
var projectDirectory = FilePathNormalizer.GetDirectory(projectSnapshot.FilePath);
53+
var projectDirectory = FilePathNormalizer.GetNormalizedDirectoryName(projectSnapshot.FilePath);
5454
if (normalizedDocumentPath.StartsWith(projectDirectory, FilePathComparison.Instance))
5555
{
5656
yield return projectSnapshot;

src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/Utilities/FilePathNormalizer.cs

Lines changed: 153 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
// Licensed under the MIT license. See License.txt in the project root for license information.
33

44
using System;
5-
using System.Net;
5+
using System.Buffers;
66
using System.Runtime.InteropServices;
77
using Microsoft.CodeAnalysis.Razor;
88

@@ -12,60 +12,186 @@ internal static class FilePathNormalizer
1212
{
1313
public static string NormalizeDirectory(string? directoryFilePath)
1414
{
15-
var normalized = Normalize(directoryFilePath);
15+
if (directoryFilePath.IsNullOrEmpty())
16+
{
17+
return "/";
18+
}
19+
20+
var directoryFilePathSpan = directoryFilePath.AsSpan();
21+
22+
// Ensure that the array is at least 1 character larger, so that we can add
23+
// a trailing space after normalization if necessary.
24+
var arrayLength = directoryFilePathSpan.Length + 1;
25+
using var _ = ArrayPool<char>.Shared.GetPooledArray(arrayLength, out var array);
26+
var arraySpan = array.AsSpan(0, arrayLength);
27+
var (start, length) = NormalizeCore(directoryFilePathSpan, arraySpan);
28+
ReadOnlySpan<char> normalizedSpan = arraySpan.Slice(start, length);
29+
30+
// Add a trailing slash if the normalized span doesn't end in one.
31+
if (normalizedSpan[^1] != '/')
32+
{
33+
arraySpan[start + length] = '/';
34+
normalizedSpan = arraySpan.Slice(start, length + 1);
35+
}
1636

17-
if (!normalized.EndsWith("/", StringComparison.Ordinal))
37+
if (directoryFilePathSpan.Equals(normalizedSpan, StringComparison.Ordinal))
1838
{
19-
normalized += '/';
39+
return directoryFilePath;
2040
}
2141

22-
return normalized;
42+
return CreateString(normalizedSpan);
2343
}
2444

2545
public static string Normalize(string? filePath)
2646
{
27-
if (string.IsNullOrEmpty(filePath))
47+
if (filePath.IsNullOrEmpty())
2848
{
2949
return "/";
3050
}
3151

32-
var decodedPath = filePath.AssumeNotNull().Contains("%") ? WebUtility.UrlDecode(filePath) : filePath;
33-
var normalized = decodedPath.Replace('\\', '/');
52+
var filePathSpan = filePath.AsSpan();
3453

35-
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) &&
36-
normalized[0] == '/' &&
37-
!normalized.StartsWith("//", StringComparison.OrdinalIgnoreCase))
54+
// Rent a buffer for Normalize to write to.
55+
using var _ = ArrayPool<char>.Shared.GetPooledArray(filePathSpan.Length, out var array);
56+
var normalizedSpan = NormalizeCoreAndGetSpan(filePathSpan, array);
57+
58+
// If we didn't change anything, just return the original string.
59+
if (filePathSpan.Equals(normalizedSpan, StringComparison.Ordinal))
3860
{
39-
// We've been provided a path that probably looks something like /C:/path/to
40-
normalized = normalized[1..];
61+
return filePath;
4162
}
42-
else
63+
64+
// Otherwise, create a new string from our normalized char buffer.
65+
return CreateString(normalizedSpan);
66+
}
67+
68+
/// <summary>
69+
/// Returns the directory portion of the given file path in normalized form.
70+
/// </summary>
71+
public static string GetNormalizedDirectoryName(string? filePath)
72+
{
73+
if (filePath.IsNullOrEmpty())
4374
{
44-
// Already a valid path like C:/path or //path
75+
return "/";
76+
}
77+
78+
var filePathSpan = filePath.AsSpan();
79+
80+
using var _1 = ArrayPool<char>.Shared.GetPooledArray(filePathSpan.Length, out var array);
81+
var normalizedSpan = NormalizeCoreAndGetSpan(filePathSpan, array);
82+
83+
var lastSlashIndex = normalizedSpan.LastIndexOf('/');
84+
85+
var directoryNameSpan = lastSlashIndex >= 0
86+
? normalizedSpan[..(lastSlashIndex + 1)] // Include trailing slash
87+
: normalizedSpan;
88+
89+
if (filePathSpan.Equals(directoryNameSpan, StringComparison.Ordinal))
90+
{
91+
return filePath;
4592
}
4693

47-
return normalized;
94+
return CreateString(directoryNameSpan);
4895
}
4996

50-
public static string GetDirectory(string filePath)
97+
public static bool FilePathsEquivalent(string? filePath1, string? filePath2)
5198
{
52-
if (string.IsNullOrEmpty(filePath))
99+
var filePathSpan1 = filePath1.AsSpanOrDefault();
100+
var filePathSpan2 = filePath2.AsSpanOrDefault();
101+
102+
if (filePathSpan1.IsEmpty)
103+
{
104+
return filePathSpan2.IsEmpty;
105+
}
106+
else if (filePathSpan2.IsEmpty)
53107
{
54-
throw new InvalidOperationException(filePath);
108+
return false;
55109
}
56110

57-
var normalizedPath = Normalize(filePath);
58-
var lastSeparatorIndex = normalizedPath.LastIndexOf('/');
111+
using var _1 = ArrayPool<char>.Shared.GetPooledArray(filePathSpan1.Length, out var array1);
112+
var normalizedSpan1 = NormalizeCoreAndGetSpan(filePathSpan1, array1);
113+
114+
using var _2 = ArrayPool<char>.Shared.GetPooledArray(filePathSpan2.Length, out var array2);
115+
var normalizedSpan2 = NormalizeCoreAndGetSpan(filePathSpan2, array2);
59116

60-
var directory = normalizedPath[..(lastSeparatorIndex + 1)];
61-
return directory;
117+
return normalizedSpan1.Equals(normalizedSpan2, FilePathComparison.Instance);
62118
}
63119

64-
public static bool FilePathsEquivalent(string filePath1, string filePath2)
120+
private static ReadOnlySpan<char> NormalizeCoreAndGetSpan(ReadOnlySpan<char> source, Span<char> destination)
65121
{
66-
var normalizedFilePath1 = Normalize(filePath1);
67-
var normalizedFilePath2 = Normalize(filePath2);
122+
var (start, length) = NormalizeCore(source, destination);
123+
return destination.Slice(start, length);
124+
}
68125

69-
return FilePathComparer.Instance.Equals(normalizedFilePath1, normalizedFilePath2);
126+
/// <summary>
127+
/// Normalizes the given <paramref name="source"/> file path and writes the result in <paramref name="destination"/>.
128+
/// </summary>
129+
/// <param name="source">The span to normalize.</param>
130+
/// <param name="destination">The span to write to.</param>
131+
/// <returns>
132+
/// Returns a tuple containing the start index and length of the normalized path within <paramref name="destination"/>.
133+
/// </returns>
134+
private static (int start, int length) NormalizeCore(ReadOnlySpan<char> source, Span<char> destination)
135+
{
136+
if (source.IsEmpty)
137+
{
138+
if (destination.Length < 1)
139+
{
140+
throw new ArgumentException("Destination length must be at least 1 if the source is empty.", nameof(destination));
141+
}
142+
143+
destination[0] = '/';
144+
145+
return (start: 0, length: 1);
146+
}
147+
148+
if (destination.Length < source.Length)
149+
{
150+
throw new ArgumentException("Destination length must be greater or equal to the source length.", nameof(destination));
151+
}
152+
153+
int charsWritten;
154+
155+
// Note: We check for '%' characters before calling UrlDecoder.Decode to ensure that we *only*
156+
// decode when there are '%XX' entities. So, calling Normalize on a path and then calling Normalize
157+
// on the result will not call Decode twice.
158+
if (source.Contains("%".AsSpan(), StringComparison.Ordinal))
159+
{
160+
UrlDecoder.Decode(source, destination, out charsWritten);
161+
}
162+
else
163+
{
164+
source.CopyTo(destination);
165+
charsWritten = source.Length;
166+
}
167+
168+
// Replace slashes in our normalized span.
169+
destination[..charsWritten].Replace('\\', '/');
170+
171+
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) &&
172+
destination is ['/', not '/', ..])
173+
{
174+
// We've been provided a path that probably looks something like /C:/path/to.
175+
// So, we adjust the result to skip the leading '/'.
176+
return (start: 1, length: charsWritten - 1);
177+
}
178+
else
179+
{
180+
// Already a valid path like C:/path or //path
181+
return (start: 0, length: charsWritten);
182+
}
183+
}
184+
185+
private static unsafe string CreateString(ReadOnlySpan<char> source)
186+
{
187+
if (source.IsEmpty)
188+
{
189+
return string.Empty;
190+
}
191+
192+
fixed (char* ptr = source)
193+
{
194+
return new string(ptr, 0, source.Length);
195+
}
70196
}
71197
}

0 commit comments

Comments
 (0)