Skip to content
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

[Perf improvement] Implementing memoization for sanitizeNamespace and replaceGenericParameters funcs. #273

Merged
merged 3 commits into from
Nov 25, 2023

Conversation

rafael-assis
Copy link

Hi @nalexn. Picking up were we left off on #263, we have identified another opportunity for performance improvements in ViewInspector. @bachand and I discussed it and we'd love to get your thoughts on it.
Thank you so much for your time! 🙏

Context: performance improvements under larger SwiftUI View hierarchies

We have profiled and traced some of our slowest tests in Instruments showed that the 2 funcs defined in the String extension were the top offenders in terms of running time.

  • String.replacingGenericParameters(_:)
  • String.sanitizingNamespace()

The combined time of all calls to both of these functions accounted for almost 50% of the entire test running time:

Weight(s) Weight(%) Self Weight Symbol Name
28.26 s 100.0% 0 s UnitTestHost (16171)
8.08 s 28.5% 8.08 s String.replacingGenericParameters(_:)
5.80 s 20.5% 5.80 s String.sanitizingNamespace()

Instruments_trace

It was already known that these 2 functions are called thousands of times for a single test case in our codebase.
This fact motivated an investigation on how many times these 2 funcs are called for the exact same input in order to inform a potential decision to memoize the calls.

A histogram of the calls to sanitizingNamespace for each input was built from a single feature integration test run.
We found that for some types, the func sanitizingNamespace thousands of times for them. For example:

    - key : "SwiftUI.Environment<Swift.Optional<Swift.AnyHashable>>"
    - value : 3640

    - key : "SwiftUICoreUI.ScrollEventDispatch"
    - value : 144817

    - key : "Swift.Bool"
    - value : 3906

    - key : "SwiftUI.Environment<PrimitiveTypesCoreUI.AdaptiveTraitCollection>"
    - value : 19052
 
    - key : "(PrimitiveTypesCoreUI.InteractivityState) -> SwiftUI.ModifiedContent<SwiftUI._ViewModifier_Content<PrimitiveTypesCoreUI.Interactive<PrimitiveTypesCoreUI.TextStyleViewModifier>>, PrimitiveTypesCoreUI.TextStyleViewModifier>"
    - value : 17912

Changes

It was then clear that caching the output of these expensive string manipulation operations would be worth it.
This is exactly what theses changes do.
It's a simple memoization which stores the computed value to be returned directly instead of recalculating them repeatedly.

Testing

  • Ran tests at Airbnb that use ViewInspector

Local runs of the the test used to profile ViewInspector's execution time showed a substantial improvement of running time. During the 2 Unit Tests CI runs of the changes in this PR, we noticed an improvement of about 86% (19 seconds). That particular test took ~22 seconds to run before the optimizations and ~3 seconds after the optimizations:

Although the unit tests in ViewInspector are more lightweight compared to the tests we run at Airbnb, we could also see a good improvement of the overall running time of all tests of the library:

Before After
ViewInspector_perf_before ViewInspector_perf_after

Please review:

@nalexn
@michael-bachand

static func sanitizeNamespace(ofTypeName typeName: String) -> String {
var str = typeName

if let sanitized = sanitizedNamespacesCache[typeName] {
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I moved the exact implementation from the String extension to here and added the memoization calls here and in line 26.

if balance == 0 {
return String(typeName[..<start]) +
replacement +
replaceGenericParameters(inTypeName: String(typeName[current...]),
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We still call into the memoized func to potentially leverage from memoized subtypes.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Smart!

private static var sanitizedNamespacesCache: [String: String] = [:]

// swiftlint:disable force_try
private static let sanitizeNamespaceRegex = try! NSRegularExpression(pattern: "\\.\\(unknown context at ..........\\)")
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We thought it'd be better to keep this regex as a constant instead of recreating it every time sanitizeNamespace is called.

static func guardType(value: Any, namespacedPrefixes: [String], inspectionCall: String) throws {

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@nalexn do you have a preference regarding the style of tabs / spaces / empty lines?
I can revert those to the original state if you prefer. Let me know.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nah, I like this more. I've googled how to tweak the Xcode settings to not insert spaces

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good! I like this better too!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@nalexn in case it's helpful, the Airbnb style guide has a script that you can run that enables various editor settings like this one that we find helpful in our large project.

@nalexn
Copy link
Owner

nalexn commented Nov 14, 2023

That is a substantial performance improvement, good job! Before merging this in, I suggest an additional slight refactor of sanitizeNamespace in this fasion.
The part where it removes "(extension in ...)" isn't yet confirmed to be helpful, but it'd be best to make sure your final version supports adding more patterns for prefix removal

@rafael-assis
Copy link
Author

That is a substantial performance improvement, good job! Before merging this in, I suggest an additional slight refactor of sanitizeNamespace in this fasion. The part where it removes "(extension in ...)" isn't yet confirmed to be helpful, but it'd be best to make sure your final version supports adding more patterns for prefix removal

Thank you for the super quick turnaround, @nalexn! 🙏
I have updated sanitizeNamespace to support multiple patterns as you suggested.

I left the new pattern disabled for now with a comment. Let me know what you think of the approach.


private static let sanitizeNamespaceRegex = {
// swiftlint:disable force_try
try! NSRegularExpression(pattern: sanitizeNamespacePatterns.joined(separator: "|"))
Copy link
Author

@rafael-assis rafael-assis Nov 14, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe having multiple patterns joined by an OR operator (|) might be faster than running the stringByReplacingMatches for each of them. Since we're not looking to replace different patterns with different strings, this seemed like the most performant approach.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This makes sense, though I wonder if we want to wrap patterns in ( and ) before OR'ing them together to ensure that each pattern is considered atomically?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe the fact that we add an | already treats both sides of it as individual patterns to be matched. But I don't think it's an issue adding them, and it makes it more clear too, so I added them. 👍

// This pattern may be helpful to solve issue #268.
// It will remain disabled until it is confirmed.
// https://github.com/nalexn/ViewInspector/issues/268
//"\\(extension in [a-zA-Z0-9]*\\)\\:",
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@nalexn I left this disabled since #268 is not yet resolved with a conclusion that this additional pattern helps.
I also left a comment for context.

static func guardType(value: Any, namespacedPrefixes: [String], inspectionCall: String) throws {

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good! I like this better too!

Copy link
Contributor

@bachand bachand left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great work @rafael-assis and thanks for the quick review @nalexn ! 🙏

Comment on lines +95 to +96
private static var replacedGenericParametersCache: [String : [String : String]] = [:]
private static var sanitizedNamespacesCache: [String: String] = [:]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This strategy for caching is not thread-safe. We think that's OK (i.e. thread safety isn't necessary) but I wanted to flag it to @nalexn out of an abundance of caution 👍

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for mentioning that @bachand!
I missed this comment and did want to flag it to @nalexn as well! 👍

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, no need to solve a problem that may not even exist. I'm 99% sure it should be fine without synchronization, we can add it later if needed

Comment on lines 8 to 9
/// - Parameter typeName: The raw type name. (e.g. ``SomeTypeName.(unknown context at $138b3290c).SomePropertyName``)
/// - Returns: The sanitized type name. (e.g. ``SomeTypeName.SomePropertyName``)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/// - Parameter typeName: The raw type name. (e.g. ``SomeTypeName.(unknown context at $138b3290c).SomePropertyName``)
/// - Returns: The sanitized type name. (e.g. ``SomeTypeName.SomePropertyName``)
/// - Parameter typeName: The raw type name. (e.g. `SomeTypeName.(unknown context at $138b3290c).SomePropertyName`)
/// - Returns: The sanitized type name. (e.g. `SomeTypeName.SomePropertyName`)

Nit: My understanding is that the double backticks are used to create links to other types when documentation comments are displayed in Xcode. Since these are example types, I think single backticks are more appropriate.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense! I'll update to single backticks! 👍

Comment on lines 35 to 37
/// - typeName: The original type name. (e.g. ``SomeTypeName<SomeGenericTypeParameter>``)
/// - replacement: The string to replace the generic parameters with. (e.g. "<EmptyView>")
/// - Returns: The type name with its generic parameters replaced. (e.g. ``SomeTypeName<EmptyView>``)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/// - typeName: The original type name. (e.g. ``SomeTypeName<SomeGenericTypeParameter>``)
/// - replacement: The string to replace the generic parameters with. (e.g. "<EmptyView>")
/// - Returns: The type name with its generic parameters replaced. (e.g. ``SomeTypeName<EmptyView>``)
/// - typeName: The original type name. (e.g. `SomeTypeName<SomeGenericTypeParameter>`)
/// - replacement: The string to replace the generic parameters with. (e.g. "<EmptyView>")
/// - Returns: The type name with its generic parameters replaced. (e.g. `SomeTypeName<EmptyView>`)

Nit: See similar comment above for rationale.

if balance == 0 {
return String(typeName[..<start]) +
replacement +
replaceGenericParameters(inTypeName: String(typeName[current...]),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Smart!


private static let sanitizeNamespaceRegex = {
// swiftlint:disable force_try
try! NSRegularExpression(pattern: sanitizeNamespacePatterns.joined(separator: "|"))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This makes sense, though I wonder if we want to wrap patterns in ( and ) before OR'ing them together to ensure that each pattern is considered atomically?

static func guardType(value: Any, namespacedPrefixes: [String], inspectionCall: String) throws {

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@nalexn in case it's helpful, the Airbnb style guide has a script that you can run that enables various editor settings like this one that we find helpful in our large project.

@rafael-assis
Copy link
Author

That is a substantial performance improvement, good job! Before merging this in, I suggest an additional slight refactor of sanitizeNamespace in #268 (comment).
The part where it removes "(extension in ...)" isn't yet confirmed to be helpful, but it'd be best to make sure your final version supports adding more patterns for prefix removal

Hi @nalexn. Do you think the changes I made fully addressed your suggestions?
Let me know if you need me to make any further adjustment before you merge this PR.

Thank you!

@nalexn nalexn merged commit 1be47b7 into nalexn:0.9.9 Nov 25, 2023
@nalexn
Copy link
Owner

nalexn commented Nov 25, 2023

Merged. Thank you @rafael-assis and @bachand !

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.

3 participants