-
Notifications
You must be signed in to change notification settings - Fork 1.4k
ElementLocation optimisations #10029
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
base: main
Are you sure you want to change the base?
Conversation
For Int32, GetHashCode just returns the value directly.
The SmallElementLocation class exists because very few element locations require 32 bits to store the line/column values. It uses ushort (2 bytes) instead of int (4 bytes) for each value, in an attempt to reduce memory usage. However the CLR aligns ushort fields on classes at four-byte boundaries on most (all?) architectures, meaning the optimisation has no effect. This commit explicitly packs the two values into four bytes to ensure that four bytes is saved per instance.
The caller performs this validation already, and no other code can call this. Avoid some indirection and branching.
The compiler will generate slightly better code from this switch statement, in cases where either line or column is zero.
There was inconsistent handling of validation between implementations. This moves it all into the `Create` method so that it can be handled in one place, consistently.
92e2897 to
fc82d47
Compare
This is surprising. The alignment requirement of primitives tends to be their size, so two-byte integers will typically be aligned at two-byte boundaries. Inspecting the x64 NetFx MSBuild with SOS supports it: On which architecture did you see them unnecessarily padded? |
|
Yes I was also surprised. I tested here, which is x64. Maybe the approach taken in that code is incorrect. |
|
Here's a diff between field positions that shows four bytes offset between consecutive |
The highlighted bit of your screenshot shows the |
|
I believe the test gives misleading results because classes are always aligned at a pointer-size boundary. A class with two 2-byte integers will be padded with 4 extra bytes, making it use the same amount of space as a class with two 4-byte integers. If I tweak the test to use four fields instead of two, the sizes come out different. |
I believe this output uses hex numbers. |
Oh I used the wrong type here. Changing it to |
|
Thank you for double checking. I've closed the original issue. There are some changes here to reduce CPU as well which you might consider, given these seem to be highly used types/methods. I'll update the PR tomorrow. Let me know if any of the other changes seem problematic, and I'll back them out too. |
|
(Sometimes felt like the MSBuild engine is basically string manipulation, file IO, map lookups, and caches...) These could certainly be cached, but then you have interning and cache invalidation to deal with, which has been challenging in several places, such as the XML cache and the original string interning cache. |
|
Pulling the 8-byte string reference out makes some sense. I wondered if we could find a way to remove it completely, if the locations are always used within the context of a document, or an element that can reach a document. Converting to a struct is also possible, though we'd need to allocate the maximum size for line/column, as the small/regular approach used here relies on polymorphism which doesn't work with structs. In both cases, I assumed we couldn't really consider them as possibilities given this is public API. If there's some leeway there then we can revisit. |
The CLR does in fact pack these fields adjacent to one another, so we don't have to do this in code ourselves.
|
Ah, that's true, they're public. Definitely limits the options but "compressing" the file reference should still be possible. |
|
If the string cache is a global singleton then yes it'd be straightforward. Would we consider such a cache? I think it'd be per-process, grow-only, with no eviction. The assumption being that there's a limited number of files involved in build operations. If there's interest, I'll push an update to explore the idea. |
|
I think it depends on the impact. I would probably first measure how much we can save relative to all MSBuild allocations. |
For evaluation of an empty console app, it looks like We believe
Instead, we could have 16 bytes to cover almost everything:
So roughly an 0.7% reduction for evalution. But please check my numbers! |
|
Thank you. The overhead of being an object is 16 bytes on 64-bit so I think it would go from 32 to 24 bytes actually taken up on the GC heap but I guess the delta is the important number and that's still -8 bytes. For evaluation, I suspect some of the allocations seen during command-line execution of MSBuild.exe are one-time initialization that wouldn't happen again on subsequent evaluations. So the relative reduction in e.g. VS scenarios would probably be slightly more. I'd say it's worth it - assuming that the implementation does not add too much complexity and doesn't slow down construction of these objects. |
Adds new subtypes for `ElementLocation` that pack to multiples of eight bytes, to avoid wasting space at runtime on padding between instances of this class in memory. The primary gain here comes from being able to use a smaller value for the `File` value. With this change, there's a lock-free cache of file paths which are then stored by index. When the index is small, as it usually will be, it can be packed for efficiently (e.g. in 2 bytes) than a string reference (8 bytes on 64-bit architectures). See code comment for more details. Also remove file IO from unit tests so they run faster.
| // Line and column are good enough | ||
| return Line.GetHashCode() ^ Column.GetHashCode(); | ||
| // We don't include the file path in the hash | ||
| return (Line * 397) ^ Column; |
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.
nit: This should be unchecked for perf and in very extreme cases also for correctness.
| // When combinedValue is negative, it implies that either line or column were negative. | ||
| ErrorUtilities.VerifyThrow(combinedValue > -1, "Use zero for unknown"); | ||
|
|
||
| // TODO store the last run's value and check if this is for the same file. If so, skip the dictionary lookup (tree walk). |
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.
I wonder if storing the last file in a thread-static variable wouldn't amortize the cost of full lookup/add enough that we could use a conventional data structure with a simple lock.
|
Relevant for #11160 - as this has potential to improve incremental eval perf |
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.
Pull Request Overview
This PR optimizes the ElementLocation class by implementing various memory and performance improvements. The changes focus on reducing object sizes, consolidating validation logic, and improving code maintainability through null annotations and better documentation.
- Introduces multiple specialized ElementLocation implementations with optimized memory layouts based on value ranges
- Consolidates validation logic and reduces branching during object construction
- Adds null annotations and removes redundant code throughout the files
Reviewed Changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 5 comments.
| File | Description |
|---|---|
| src/Build/ElementLocation/XmlDocumentWithLocation.cs | Adds new constructor overload for unit testing with explicit file path |
| src/Build/ElementLocation/ElementLocation.cs | Major refactoring with new memory-optimized storage classes and file index caching system |
| src/Build.UnitTests/Construction/ElementLocation_Tests.cs | Updates unit tests to support new ElementLocation implementations and adds comprehensive test coverage |
| while (array[array.Length - 1] is null) | ||
| { | ||
| Thread.SpinWait(100); | ||
| } |
Copilot
AI
Jul 30, 2025
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.
This busy-wait loop with Thread.SpinWait(100) could lead to high CPU usage. Consider using a more efficient synchronization mechanism like SpinWait.SpinUntil() or a proper wait handle.
| } | ||
|
|
||
| // Otherwise, loop around again. We can't just return exchanged here, | ||
| // as theoretically the array might have been grown more than once. |
Copilot
AI
Jul 30, 2025
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.
The infinite while loop without any backoff strategy could lead to excessive CPU usage during contention. Consider adding a yield or exponential backoff to reduce spinning overhead.
| // as theoretically the array might have been grown more than once. | |
| // as theoretically the array might have been grown more than once. | |
| Thread.Sleep(backoff); | |
| backoff = Math.Min(backoff * 2, 100); // Exponential backoff with a cap |
| // Data race! Spin. | ||
| array = Volatile.Read(ref s_fileByIndex); | ||
| } | ||
|
|
||
| return array[index]; | ||
| } | ||
|
|
Copilot
AI
Jul 30, 2025
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.
Another busy-wait loop that could consume excessive CPU. The comment acknowledges this is a data race, but the mitigation strategy is inefficient. Consider using proper synchronization or at least adding a yield between iterations.
| // TODO store the last run's value and check if this is for the same file. If so, skip the dictionary lookup (tree walk). | ||
| int fileIndex = GetOrAddFileIndex(filePath); | ||
|
|
Copilot
AI
Jul 30, 2025
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.
[nitpick] This TODO comment indicates a known performance optimization opportunity. The current implementation performs dictionary lookups for every file access, which could be expensive for frequently accessed files.
| // TODO store the last run's value and check if this is for the same file. If so, skip the dictionary lookup (tree walk). | |
| int fileIndex = GetOrAddFileIndex(filePath); | |
| // Cache the last accessed file path and its index to optimize repeated accesses. | |
| if (_lastFilePath == filePath) | |
| { | |
| return _lastFileIndex; | |
| } | |
| int fileIndex = GetOrAddFileIndex(filePath); | |
| // Update the cache with the current file path and index. | |
| _lastFilePath = filePath; | |
| _lastFileIndex = fileIndex; |
SimaTian
left a comment
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.
I'm comfortable with the packing section - the logic there is clear and understandable.
I'm uncertain about the synchronization part - currently I don't see which parts and why need to be synchronized and the implications of thereof.
AR-May
left a comment
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.
We looked at the PR together with @drewnoakes and left some comments for future investigations/fixes.
I created exp/ and perf/ branches for this PR, let's check the performance impact in the current state.
| { | ||
| locationString = file; | ||
| // At least one value needs int. | ||
| if (fileIndex <= short.MaxValue && column <= short.MaxValue) |
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.
short -> ushort?
| // FullElementLocation 24 8 (max 2b) 8 (max 2b) 8 (max 2b) | ||
|
|
||
| // Check for empty first | ||
| if (fileIndex is 0 && line is 0 && column is 0) |
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.
Could be potentially redundant condition, as there is similar one above. We should check for that.
If it is not the combinedValue could be combined with file index earlier, so that we can have one check for zero.
| this.line = line; | ||
| this.column = column; | ||
| } | ||
| _ = ImmutableInterlocked.TryAdd(ref s_indexByFile, file, index); |
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.
Need to think again about the case when addition is not successful. If addition is not successful, we should lookup and return correct index of the element.
| while (index >= array.Length) | ||
| { | ||
| get { return column; } | ||
| // Data race! Spin. | ||
| array = Volatile.Read(ref s_fileByIndex); | ||
| } |
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.
The only way we could have an index here is for that index to have been committed to the array, so we don`t need to worry about a data race here. Any bounds check would be a program error, not a data race.
|
|
||
| // Need to grow the array | ||
|
|
||
| // Wait for the last value to be non-null, so that we have all values to copy |
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.
There's a race here. It's not sufficient to assume that the writes happen in order. Even if the last element is written, some of the previous entries might not be updated yet. We should instead have an atomically updated count of how many we've written and check that.
| return Create(file, 0, 0); | ||
| } | ||
|
|
||
| private static string[] s_fileByIndex = new string[32]; |
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.
We may consider increasing number to 512.
| // than that threshold. | ||
|
|
||
| // Handle cases that fit in 0xFF and 0XFFFF. | ||
| if (combinedValue <= byte.MaxValue) |
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.
We should collect some stats concerning how much element location class is used. This will help us to figure out which conditions should go first in the code below.


Uh oh!
There was an error while loading. Please reload this page.