Skip to content

Commit e8873d1

Browse files
committed
Drastically Improve Speed and Memory Performance (#253)
Drastically improve xcbeautify's performance (time and memory). Testing with a large xcodebuild log, this change dropped xcbeautify's execution time by ~90% and memory footprint by ~99%. - Reduce unnecessary type instantiations, namely NSRegularExpression. - Skip parsing empty lines. - Introduce time-tracking logic and large xcodebuild log test to (1) find and fix the existing issue and (2) prevent future performance regressions. See testing results [here](#253).
1 parent b872edd commit e8873d1

File tree

7 files changed

+113072
-114
lines changed

7 files changed

+113072
-114
lines changed

Package.swift

+1
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ let package = Package(
4141
dependencies: ["XcbeautifyLib"],
4242
resources: [
4343
.copy("ParsingTests/clean_build_xcode_15_1.txt"),
44+
.copy("ParsingTests/large_xcodebuild_log.txt"),
4445
]
4546
),
4647
]

Sources/XcbeautifyLib/Parser.swift

+5
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,11 @@ public class Parser {
118118
}
119119

120120
public func parse(line: String) -> String? {
121+
if line.isEmpty {
122+
outputType = .undefined
123+
return nil
124+
}
125+
121126
// Find first parser that can parse specified string
122127
guard let idx = captureGroupTypes.firstIndex(where: { $0.regex.match(string: line) }) else {
123128
// Some uncommon cases, which have additional logic and don't follow default flow

Sources/XcbeautifyLib/Regex.swift

+19-2
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
import Foundation
22

3-
class Regex {
3+
final class Regex {
44
let pattern: String
55

6-
private lazy var regex: NSRegularExpression? = try? NSRegularExpression(pattern: "^" + pattern, options: [.caseInsensitive])
6+
private lazy var regex: NSRegularExpression? = {
7+
let regex = try? NSRegularExpression(pattern: "^" + pattern, options: [.caseInsensitive])
8+
assert(regex != nil)
9+
return regex
10+
}()
711

812
init(pattern: String) {
913
self.pattern = pattern
@@ -13,4 +17,17 @@ class Regex {
1317
let fullRange = NSRange(string.startIndex..., in: string)
1418
return regex?.rangeOfFirstMatch(in: string, range: fullRange).location != NSNotFound
1519
}
20+
21+
func captureGroups(for line: String) -> [String] {
22+
let matches = regex?.matches(in: line, range: NSRange(location: 0, length: line.utf16.count))
23+
guard let match = matches?.first else { return [] }
24+
25+
let lastRangeIndex = match.numberOfRanges - 1
26+
guard lastRangeIndex >= 1 else { return [] }
27+
28+
return (1...lastRangeIndex).compactMap { index in
29+
let capturedGroupIndex = match.range(at: index)
30+
return line.substring(with: capturedGroupIndex)
31+
}
32+
}
1633
}

Sources/XcbeautifyLib/String+CapturedGroups.swift

+90-112
Original file line numberDiff line numberDiff line change
@@ -1,119 +1,95 @@
11
import Foundation
22

33
extension String {
4-
private func captureGroup(with pattern: String) -> [String] {
5-
do {
6-
let regex = try NSRegularExpression(pattern: pattern, options: [.caseInsensitive])
4+
private static let captureGroups: [any CaptureGroup.Type] = [
5+
AnalyzeCaptureGroup.self,
6+
BuildTargetCaptureGroup.self,
7+
AggregateTargetCaptureGroup.self,
8+
AnalyzeTargetCaptureGroup.self,
9+
CheckDependenciesCaptureGroup.self,
10+
ShellCommandCaptureGroup.self,
11+
CleanRemoveCaptureGroup.self,
12+
CleanTargetCaptureGroup.self,
13+
CodesignCaptureGroup.self,
14+
CodesignFrameworkCaptureGroup.self,
15+
CompileCaptureGroup.self,
16+
CompileCommandCaptureGroup.self,
17+
CompileXibCaptureGroup.self,
18+
CompileStoryboardCaptureGroup.self,
19+
CopyHeaderCaptureGroup.self,
20+
CopyPlistCaptureGroup.self,
21+
CopyStringsCaptureGroup.self,
22+
CpresourceCaptureGroup.self,
23+
ExecutedWithoutSkippedCaptureGroup.self,
24+
ExecutedWithSkippedCaptureGroup.self,
25+
FailingTestCaptureGroup.self,
26+
UIFailingTestCaptureGroup.self,
27+
RestartingTestCaptureGroup.self,
28+
GenerateCoverageDataCaptureGroup.self,
29+
GeneratedCoverageReportCaptureGroup.self,
30+
GenerateDSYMCaptureGroup.self,
31+
LibtoolCaptureGroup.self,
32+
LinkingCaptureGroup.self,
33+
TestCasePassedCaptureGroup.self,
34+
TestCaseStartedCaptureGroup.self,
35+
TestCasePendingCaptureGroup.self,
36+
TestCaseMeasuredCaptureGroup.self,
37+
ParallelTestCasePassedCaptureGroup.self,
38+
ParallelTestCaseAppKitPassedCaptureGroup.self,
39+
ParallelTestCaseFailedCaptureGroup.self,
40+
ParallelTestingStartedCaptureGroup.self,
41+
ParallelTestingPassedCaptureGroup.self,
42+
ParallelTestingFailedCaptureGroup.self,
43+
ParallelTestSuiteStartedCaptureGroup.self,
44+
PhaseSuccessCaptureGroup.self,
45+
PhaseScriptExecutionCaptureGroup.self,
46+
ProcessPchCaptureGroup.self,
47+
ProcessPchCommandCaptureGroup.self,
48+
PreprocessCaptureGroup.self,
49+
PbxcpCaptureGroup.self,
50+
ProcessInfoPlistCaptureGroup.self,
51+
TestsRunCompletionCaptureGroup.self,
52+
TestSuiteStartedCaptureGroup.self,
53+
TestSuiteStartCaptureGroup.self,
54+
TestSuiteAllTestsPassedCaptureGroup.self,
55+
TestSuiteAllTestsFailedCaptureGroup.self,
56+
TIFFutilCaptureGroup.self,
57+
TouchCaptureGroup.self,
58+
WriteFileCaptureGroup.self,
59+
WriteAuxiliaryFilesCaptureGroup.self,
60+
CompileWarningCaptureGroup.self,
61+
LDWarningCaptureGroup.self,
62+
GenericWarningCaptureGroup.self,
63+
WillNotBeCodeSignedCaptureGroup.self,
64+
DuplicateLocalizedStringKeyCaptureGroup.self,
65+
ClangErrorCaptureGroup.self,
66+
CheckDependenciesErrorsCaptureGroup.self,
67+
ProvisioningProfileRequiredCaptureGroup.self,
68+
NoCertificateCaptureGroup.self,
69+
CompileErrorCaptureGroup.self,
70+
CursorCaptureGroup.self,
71+
FatalErrorCaptureGroup.self,
72+
FileMissingErrorCaptureGroup.self,
73+
LDErrorCaptureGroup.self,
74+
LinkerDuplicateSymbolsLocationCaptureGroup.self,
75+
LinkerDuplicateSymbolsCaptureGroup.self,
76+
LinkerUndefinedSymbolLocationCaptureGroup.self,
77+
LinkerUndefinedSymbolsCaptureGroup.self,
78+
PodsErrorCaptureGroup.self,
79+
SymbolReferencedFromCaptureGroup.self,
80+
ModuleIncludesErrorCaptureGroup.self,
81+
UndefinedSymbolLocationCaptureGroup.self,
82+
PackageFetchingCaptureGroup.self,
83+
PackageUpdatingCaptureGroup.self,
84+
PackageCheckingOutCaptureGroup.self,
85+
PackageGraphResolvingStartCaptureGroup.self,
86+
PackageGraphResolvingEndedCaptureGroup.self,
87+
PackageGraphResolvedItemCaptureGroup.self,
88+
XcodebuildErrorCaptureGroup.self,
89+
]
790

8-
let matches = regex.matches(in: self, range: NSRange(location: 0, length: utf16.count))
9-
guard let match = matches.first else { return [] }
10-
11-
let lastRangeIndex = match.numberOfRanges - 1
12-
guard lastRangeIndex >= 1 else { return [] }
13-
14-
return (1...lastRangeIndex).compactMap { index in
15-
let capturedGroupIndex = match.range(at: index)
16-
return substring(with: capturedGroupIndex)
17-
}
18-
} catch {
19-
assertionFailure(error.localizedDescription)
20-
return []
21-
}
22-
}
23-
}
24-
25-
extension String {
2691
func captureGroup(with pattern: String) -> CaptureGroup? {
27-
let results: [String] = captureGroup(with: pattern)
28-
29-
let captureGroups: [any CaptureGroup.Type] = [
30-
AnalyzeCaptureGroup.self,
31-
BuildTargetCaptureGroup.self,
32-
AggregateTargetCaptureGroup.self,
33-
AnalyzeTargetCaptureGroup.self,
34-
CheckDependenciesCaptureGroup.self,
35-
ShellCommandCaptureGroup.self,
36-
CleanRemoveCaptureGroup.self,
37-
CleanTargetCaptureGroup.self,
38-
CodesignCaptureGroup.self,
39-
CodesignFrameworkCaptureGroup.self,
40-
CompileCaptureGroup.self,
41-
CompileCommandCaptureGroup.self,
42-
CompileXibCaptureGroup.self,
43-
CompileStoryboardCaptureGroup.self,
44-
CopyHeaderCaptureGroup.self,
45-
CopyPlistCaptureGroup.self,
46-
CopyStringsCaptureGroup.self,
47-
CpresourceCaptureGroup.self,
48-
ExecutedWithoutSkippedCaptureGroup.self,
49-
ExecutedWithSkippedCaptureGroup.self,
50-
FailingTestCaptureGroup.self,
51-
UIFailingTestCaptureGroup.self,
52-
RestartingTestCaptureGroup.self,
53-
GenerateCoverageDataCaptureGroup.self,
54-
GeneratedCoverageReportCaptureGroup.self,
55-
GenerateDSYMCaptureGroup.self,
56-
LibtoolCaptureGroup.self,
57-
LinkingCaptureGroup.self,
58-
TestCasePassedCaptureGroup.self,
59-
TestCaseStartedCaptureGroup.self,
60-
TestCasePendingCaptureGroup.self,
61-
TestCaseMeasuredCaptureGroup.self,
62-
ParallelTestCasePassedCaptureGroup.self,
63-
ParallelTestCaseAppKitPassedCaptureGroup.self,
64-
ParallelTestCaseFailedCaptureGroup.self,
65-
ParallelTestingStartedCaptureGroup.self,
66-
ParallelTestingPassedCaptureGroup.self,
67-
ParallelTestingFailedCaptureGroup.self,
68-
ParallelTestSuiteStartedCaptureGroup.self,
69-
PhaseSuccessCaptureGroup.self,
70-
PhaseScriptExecutionCaptureGroup.self,
71-
ProcessPchCaptureGroup.self,
72-
ProcessPchCommandCaptureGroup.self,
73-
PreprocessCaptureGroup.self,
74-
PbxcpCaptureGroup.self,
75-
ProcessInfoPlistCaptureGroup.self,
76-
TestsRunCompletionCaptureGroup.self,
77-
TestSuiteStartedCaptureGroup.self,
78-
TestSuiteStartCaptureGroup.self,
79-
TestSuiteAllTestsPassedCaptureGroup.self,
80-
TestSuiteAllTestsFailedCaptureGroup.self,
81-
TIFFutilCaptureGroup.self,
82-
TouchCaptureGroup.self,
83-
WriteFileCaptureGroup.self,
84-
WriteAuxiliaryFilesCaptureGroup.self,
85-
CompileWarningCaptureGroup.self,
86-
LDWarningCaptureGroup.self,
87-
GenericWarningCaptureGroup.self,
88-
WillNotBeCodeSignedCaptureGroup.self,
89-
DuplicateLocalizedStringKeyCaptureGroup.self,
90-
ClangErrorCaptureGroup.self,
91-
CheckDependenciesErrorsCaptureGroup.self,
92-
ProvisioningProfileRequiredCaptureGroup.self,
93-
NoCertificateCaptureGroup.self,
94-
CompileErrorCaptureGroup.self,
95-
CursorCaptureGroup.self,
96-
FatalErrorCaptureGroup.self,
97-
FileMissingErrorCaptureGroup.self,
98-
LDErrorCaptureGroup.self,
99-
LinkerDuplicateSymbolsLocationCaptureGroup.self,
100-
LinkerDuplicateSymbolsCaptureGroup.self,
101-
LinkerUndefinedSymbolLocationCaptureGroup.self,
102-
LinkerUndefinedSymbolsCaptureGroup.self,
103-
PodsErrorCaptureGroup.self,
104-
SymbolReferencedFromCaptureGroup.self,
105-
ModuleIncludesErrorCaptureGroup.self,
106-
UndefinedSymbolLocationCaptureGroup.self,
107-
PackageFetchingCaptureGroup.self,
108-
PackageUpdatingCaptureGroup.self,
109-
PackageCheckingOutCaptureGroup.self,
110-
PackageGraphResolvingStartCaptureGroup.self,
111-
PackageGraphResolvingEndedCaptureGroup.self,
112-
PackageGraphResolvedItemCaptureGroup.self,
113-
XcodebuildErrorCaptureGroup.self,
114-
]
115-
116-
let captureGroupType: CaptureGroup.Type? = captureGroups.first { captureGroup in
92+
let captureGroupType: CaptureGroup.Type? = Self.captureGroups.first { captureGroup in
11793
captureGroup.pattern == pattern
11894
}
11995

@@ -122,7 +98,9 @@ extension String {
12298
return nil
12399
}
124100

125-
let captureGroup = captureGroupType.init(groups: results)
101+
let groups: [String] = captureGroupType.regex.captureGroups(for: self)
102+
103+
let captureGroup = captureGroupType.init(groups: groups)
126104
assert(captureGroup != nil)
127105
return captureGroup
128106
}

Sources/xcbeautify/Xcbeautify.swift

+10
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,15 @@ struct Xcbeautify: ParsableCommand {
3636
var junitReportFilename = "junit.xml"
3737

3838
func run() throws {
39+
#if DEBUG && os(macOS)
40+
let start = CFAbsoluteTimeGetCurrent()
41+
42+
defer {
43+
let diff = CFAbsoluteTimeGetCurrent() - start
44+
print("Took \(diff) seconds")
45+
}
46+
#endif
47+
3948
let output = OutputHandler(quiet: quiet, quieter: quieter, isCI: isCi) { print($0) }
4049
let junitReporter = JunitReporter()
4150

@@ -57,6 +66,7 @@ struct Xcbeautify: ParsableCommand {
5766
)
5867

5968
while let line = readLine() {
69+
guard !line.isEmpty else { continue }
6070
guard let formatted = parser.parse(line: line) else { continue }
6171
output.write(parser.outputType, formatted)
6272
}

Tests/XcbeautifyLibTests/ParsingTests/ParsingTests.swift

+37
Original file line numberDiff line numberDiff line change
@@ -38,4 +38,41 @@ final class ParsingTests: XCTestCase {
3838
// There's a regression whenever `uncapturedOutput` is greater than the current magic number.
3939
XCTAssertEqual(uncapturedOutput, 2218)
4040
}
41+
42+
func testLargeXcodebuildLog() throws {
43+
let url = Bundle.module.url(forResource: "large_xcodebuild_log", withExtension: "txt")!
44+
45+
var buildLog: [String] = try String(contentsOf: url)
46+
.components(separatedBy: .newlines)
47+
48+
let parser = Parser(
49+
colored: false,
50+
renderer: .terminal,
51+
preserveUnbeautifiedLines: false,
52+
additionalLines: {
53+
guard !buildLog.isEmpty else {
54+
XCTFail("The build log should never be empty when fetching additional lines.")
55+
return nil
56+
}
57+
return buildLog.removeFirst()
58+
}
59+
)
60+
61+
var uncapturedOutput = 0
62+
63+
while !buildLog.isEmpty {
64+
let line = buildLog.removeFirst()
65+
if !line.isEmpty, parser.parse(line: line) == nil {
66+
uncapturedOutput += 1
67+
}
68+
}
69+
70+
// The following is a magic number.
71+
// It represents the number of lines that aren't captured by the Parser.
72+
// Slowly, this value should decrease until it reaches 0.
73+
// It uses `XCTAssertEqual` instead of `XCTAssertLessThanOrEqual` as a reminder.
74+
// Update this magic number whenever `uncapturedOutput` is less than the current magic number.
75+
// There's a regression whenever `uncapturedOutput` is greater than the current magic number.
76+
XCTAssertEqual(uncapturedOutput, 77104)
77+
}
4178
}

0 commit comments

Comments
 (0)