Skip to content

Commit c652783

Browse files
author
Harlan
authored
Add parallel execution to test runner (#1)
1 parent 22dce17 commit c652783

10 files changed

+211
-54
lines changed

.swift-version

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
4.0

Package.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,12 @@ let package = Package(
1111
],
1212
dependencies: [
1313
.package(url: "https://github.com/onevcat/Rainbow.git", from: "3.0.0"),
14-
.package(url: "https://github.com/kareman/SwiftShell.git", from: "4.0.0"),
14+
.package(url: "https://github.com/harlanhaskins/ShellOut.git", .branch("on-your-mark-get-set-go")),
1515
],
1616
targets: [
1717
.target(
1818
name: "LiteSupport",
19-
dependencies: ["Rainbow", "SwiftShell"]),
19+
dependencies: ["Rainbow", "ShellOut"]),
2020

2121
// This needs to be named `lite-test` instead of `lite` because consumers
2222
// of `lite` should use the target name `lite`.

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,17 +24,18 @@ depends on the Lite support library, `LiteSupport`.
2424
### Making a `lite` Target
2525

2626
From that target's `main.swift`, make a call to
27-
`runLite(substitutions:pathExtensions:testDirPath:testLinePrefix:)`. This call
27+
`runLite(substitutions:pathExtensions:testDirPath:testLinePrefix:parallelismLevel:)`. This call
2828
is the main entry point to `lite`'s test running.
2929

30-
It takes 4 arguments:
30+
It takes 5 arguments:
3131

3232
| Argument | Description |
3333
|----------|-------------|
3434
| `substitutions` | The mapping of substitutions to make inside each run line. A substitution looks for a string beginning with `'%'` and replaces that whole string with the substituted value. |
3535
| `pathExtensions` | The set of path extensions that Lite should search for when discovering tests. |
3636
| `testDirPath` | The directory in which Lite should look for tests. Lite will perform a deep search through this directory for all files whose extension exists in `pathExtensions` and which have valid RUN lines. |
3737
| `testLinePrefix` | The prefix before `RUN:` in a file. This is almost always your specific langauge's line comment syntax. |
38+
| `parallelismLevel` | Specifies the amount of parallelism to apply to the test running process. Default value is `.none`, but you can provide `.automatic` to use the available machine cores, or `.explicit(n)` to specify an explicit number of parallel tests |
3839

3940
> Note: An example consumer of `Lite` exists in this repository as `lite-test`.
4041
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
/// ParallelExecutor.swift
2+
///
3+
/// Copyright 2017, The Silt Language Project.
4+
///
5+
/// This project is released under the MIT license, a copy of which is
6+
/// available in the repository.
7+
8+
import Foundation
9+
import Dispatch
10+
11+
/// A class that handles executing tasks in a round-robin fashion among
12+
/// a fixed number of workers. It uses GCD to split the work among a fixed
13+
/// set of queues and automatically manages balancing workloads between workers.
14+
final class ParallelExecutor<TaskResult> {
15+
/// The set of worker queues on which to add tasks.
16+
private let queues: [DispatchQueue]
17+
18+
/// The dispatch group on which to synchronize the workers.
19+
private let group = DispatchGroup()
20+
21+
/// The results from each task executed on the workers, in non-deterministic
22+
/// order.
23+
private var results = [TaskResult]()
24+
25+
/// The queue on which to protect the results array.
26+
private let resultQueue = DispatchQueue(label: "parallel-results")
27+
28+
/// The current number of tasks, used for round-robin dispatch.
29+
private var taskCount = 0
30+
31+
/// Creates an executor that splits tasks among the provided number of
32+
/// workers.
33+
/// - parameter numberOfWorkers: The number of workers to spawn. This number
34+
/// should be <= the number of hyperthreaded
35+
/// cores on your machine, to avoid excessive
36+
/// context switching.
37+
init(numberOfWorkers: Int) {
38+
self.queues = (0..<numberOfWorkers).map {
39+
DispatchQueue(label: "parallel-worker-\($0)")
40+
}
41+
}
42+
43+
/// Adds the provided result to the result array, synchronized on the result
44+
/// queue.
45+
private func addResult(_ result: TaskResult) {
46+
resultQueue.sync {
47+
results.append(result)
48+
}
49+
}
50+
51+
/// Synchronized on the result queue, gets a unique counter for the total
52+
/// next task to add to the queues.
53+
private var nextTask: Int {
54+
return resultQueue.sync {
55+
defer { taskCount += 1 }
56+
return taskCount
57+
}
58+
}
59+
60+
/// Adds a task to run asynchronously on the next worker. Workers are chosen
61+
/// in a round-robin fashion.
62+
func addTask(_ work: @escaping () -> TaskResult) {
63+
queues[nextTask % queues.count].async(group: group) {
64+
self.addResult(work())
65+
}
66+
}
67+
68+
/// Blocks until all workers have finished executing their tasks, then returns
69+
/// the set of results.
70+
func waitForResults() -> [TaskResult] {
71+
group.wait()
72+
return resultQueue.sync { results }
73+
}
74+
}

Sources/LiteSupport/Run.swift

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,15 +22,24 @@ import Foundation
2222
/// which have valid RUN lines.
2323
/// - testLinePrefix: The prefix before `RUN:` in a file. This is almost
2424
/// always your specific langauge's line comment syntax.
25+
/// - parallelismLevel: Specifies the amount of parallelism to apply to the
26+
/// test running process. Default value is `.none`, but
27+
/// you can provide `.automatic` to use the available
28+
/// machine cores, or `.explicit(n)` to specify an
29+
/// explicit number of parallel tests. This value should
30+
/// not exceed the number of hyperthreaded cores on your
31+
/// machine, to avoid excessive context switching.
2532
/// - Returns: `true` if all tests passed, `false` if any failed.
2633
/// - Throws: `LiteError` if there was any issue running tests.
2734
public func runLite(substitutions: [(String, String)],
2835
pathExtensions: Set<String>,
2936
testDirPath: String?,
30-
testLinePrefix: String) throws -> Bool {
37+
testLinePrefix: String,
38+
parallelismLevel: ParallelismLevel = .none) throws -> Bool {
3139
let testRunner = try TestRunner(testDirPath: testDirPath,
3240
substitutions: substitutions,
3341
pathExtensions: pathExtensions,
34-
testLinePrefix: testLinePrefix)
42+
testLinePrefix: testLinePrefix,
43+
parallelismLevel: parallelismLevel)
3544
return try testRunner.run()
3645
}

Sources/LiteSupport/Substitutor.swift

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
/// available in the repository.
77

88
import Foundation
9+
import Dispatch
910

1011
public extension String {
1112
public var quoted: String {
@@ -31,6 +32,7 @@ class Substitutor {
3132
let tmpFileRegex = try! NSRegularExpression(pattern: "%t")
3233
let tmpDirectoryRegex = try! NSRegularExpression(pattern: "%T")
3334

35+
private let tmpDirQueue = DispatchQueue(label: "tmp-dir-queue")
3436
private var tmpDirMap = [URL: URL]()
3537

3638
init(substitutions: [(String, String)]) throws {
@@ -47,10 +49,12 @@ class Substitutor {
4749
}
4850

4951
func tempFile(for file: URL) -> URL {
50-
if let tmpFile = tmpDirMap[file] { return tmpFile }
51-
let tmpFile = tempDir.appendingPathComponent(UUID().uuidString)
52-
tmpDirMap[file] = tmpFile
53-
return tmpFile
52+
return tmpDirQueue.sync {
53+
if let tmpFile = tmpDirMap[file] { return tmpFile }
54+
let tmpFile = tempDir.appendingPathComponent(UUID().uuidString)
55+
tmpDirMap[file] = tmpFile
56+
return tmpFile
57+
}
5458
}
5559

5660
func substitute(_ line: String, in file: URL) -> String {

Sources/LiteSupport/TestFile.swift

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,18 @@
66
/// available in the repository.
77

88
import Foundation
9-
import SwiftShell
109

1110
/// Represents a test that either passed or failed, and contains the run line
1211
/// that triggered the result.
1312
struct TestResult {
1413
/// The run line comprising this test.
1514
let line: RunLine
1615

17-
/// The output from running this test.
18-
let output: RunOutput
16+
/// The stdout output from running this test.
17+
let stdout: String
18+
19+
/// The stderr output from running this test.
20+
let stderr: String
1921

2022
/// The time it took to execute this test from start to finish.
2123
let executionTime: TimeInterval

0 commit comments

Comments
 (0)