Skip to content

Port the BCL string algorithm for Path.GetRelativePath - #696

Merged
SimonCropp merged 1 commit into
mainfrom
perf/path-getrelativepath
Oct 12, 2026
Merged

SimonCropp merged 1 commit into
mainfrom
perf/path-getrelativepath

Conversation

@SimonCropp

Copy link
Copy Markdown
Owner

The Path.GetRelativePath polyfill (.NET Framework and netstandard2.0) was implemented through Uri.MakeRelativeUri, which costs two Uri instances plus escaping and unescaping on every call, and does not give the same answers as the BCL.

Change

Port the BCL's string algorithm: after Path.GetFullPath on both inputs, compare the roots, find the common prefix length (backed up to a segment boundary), emit one .. per remaining segment of the "from" path, then append the rest of the "to" path. When no .. segments are needed the result is a single Substring.

Roots are still taken from Path.GetPathRoot. The comparison is now ordinal-ignore-case on Windows and macOS and ordinal elsewhere, as in the BCL (the old code was hard-coded to ignore case, which is wrong for a netstandard2.0 library running on Linux).

Behaviour changes (all towards the BCL)

Of the 76 input pairs in the new test table, the old implementation disagreed with net10 on 19. They fall into three groups:

relativeTo path Before After (= BCL)
C:\a C:\a\b\ b b\
C:\a\b\c C:\a\x\y\ ..\..\x\y ..\..\x\y\
C:\base C:\base\a%2Db a-b a%2Db
C:\base C:\base\%41 A %41
C:\ba%2Dse\x C:\ba-se\y ..\y ..\..\ba-se\y
C:\É C:\é\x ..\é\x x
  • a trailing separator on path was dropped; it is now preserved
  • percent sequences that Uri does not need to keep escaped (%2D, %41, ...) were unescaped, so the result named a different file, and two different directories could be treated as the same one
  • non-ASCII characters differing only by case were treated as different segments

Measurements

net48, x64, Release, 500,000 calls each. Both versions start with the same two Path.GetFullPath calls, which cost roughly 600-850 ns of each figure and allocate nothing here.

Case Before After
sibling (..\..\Microsoft\Edge\Application) 4,593 ns / 5,576 B 1,414 ns / 320 B
child (src\Library\File.cs) 3,757 ns / 4,152 B 1,038 ns / 128 B
same directory (.) 780 ns / 128 B 909 ns / 64 B
different root 1,017 ns / 128 B 875 ns / 64 B

The last two rows never reached Uri before, so they are within noise of each other; the remaining 64 B is the two Path.GetPathRoot strings.

Tests

  • GetRelativePath_MatchesBcl: a table of 76 input pairs whose expected values were produced by Path.GetRelativePath on net10 on Windows: same path with and without trailing separators on either side, children, parents, siblings, partial segment matches (C:\Foodie / C:\Foobar), the drive root on either side, different drives, UNC shares (same share, different share, different server, UNC against a drive), case differences in ASCII and non-ASCII, percent sequences, #, &, +, spaces, non-ASCII names, forward slashes, . and .. segments, and relative inputs
  • GetRelativePath_ResultCombinesBackToPath: for every pair, combining the result with relativeTo leads back to path
  • GetRelativePath_RelativeInputsUseCurrentDirectory

The tests run on every target, so on netcoreapp3.1 and later they check the table against the real BCL as well.

@SimonCropp SimonCropp modified the milestones: 11.4.4, 11.4.5 Oct 12, 2026
@SimonCropp
SimonCropp merged commit 1d86751 into main Oct 12, 2026
7 checks passed
@SimonCropp
SimonCropp deleted the perf/path-getrelativepath branch October 12, 2026 12:08
This was referenced Oct 12, 2026
This was referenced Oct 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant