Skip to content

Commit b0aef48

Browse files
authored
Add an upper bound on the number of test cases we run in parallel. (#1390)
This PR adds an upper bound, `NCORES * 2`, on the number of test cases we run in parallel. Depending on the exact nature of your tests, this can significantly reduce the maximum amount of dirty memory needed, but does not generally impact execution time. As an example, Swift Testing's own tests go from > 300MB max memory usage to around 60MB. You can configure a different upper bound by setting the `"SWT_EXPERIMENTAL_MAXIMUM_PARALLELIZATION_WIDTH"` environment variable (look, naming is hard okay?) ### Checklist: - [x] Code and documentation should follow the style of the [Style Guide](https://github.com/apple/swift-testing/blob/main/Documentation/StyleGuide.md). - [x] If public symbols are renamed or modified, DocC references should be updated.
1 parent 3139538 commit b0aef48

File tree

6 files changed

+218
-13
lines changed

6 files changed

+218
-13
lines changed

Sources/Testing/ABI/EntryPoints/EntryPoint.swift

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,9 @@ public struct __CommandLineArguments_v0: Sendable {
210210
/// The value of the `--parallel` or `--no-parallel` argument.
211211
public var parallel: Bool?
212212

213+
/// The maximum number of test tasks to run in parallel.
214+
public var experimentalMaximumParallelizationWidth: Int?
215+
213216
/// The value of the `--symbolicate-backtraces` argument.
214217
public var symbolicateBacktraces: String?
215218

@@ -336,6 +339,7 @@ extension __CommandLineArguments_v0: Codable {
336339
enum CodingKeys: String, CodingKey {
337340
case listTests
338341
case parallel
342+
case experimentalMaximumParallelizationWidth
339343
case symbolicateBacktraces
340344
case verbose
341345
case veryVerbose
@@ -485,6 +489,10 @@ func parseCommandLineArguments(from args: [String]) throws -> __CommandLineArgum
485489
if args.contains("--no-parallel") {
486490
result.parallel = false
487491
}
492+
if let maximumParallelizationWidth = args.argumentValue(forLabel: "--experimental-maximum-parallelization-width").flatMap(Int.init) {
493+
// TODO: decide if we want to repurpose --num-workers for this use case?
494+
result.experimentalMaximumParallelizationWidth = maximumParallelizationWidth
495+
}
488496

489497
// Whether or not to symbolicate backtraces in the event stream.
490498
if let symbolicateBacktraces = args.argumentValue(forLabel: "--symbolicate-backtraces") {
@@ -545,7 +553,22 @@ public func configurationForEntryPoint(from args: __CommandLineArguments_v0) thr
545553
var configuration = Configuration()
546554

547555
// Parallelization (on by default)
548-
configuration.isParallelizationEnabled = args.parallel ?? true
556+
if let parallel = args.parallel, !parallel {
557+
configuration.isParallelizationEnabled = parallel
558+
} else {
559+
var maximumParallelizationWidth = args.experimentalMaximumParallelizationWidth
560+
if maximumParallelizationWidth == nil && Test.current == nil {
561+
// Don't check the environment variable when a current test is set (which
562+
// presumably means we're running our own unit tests).
563+
maximumParallelizationWidth = Environment.variable(named: "SWT_EXPERIMENTAL_MAXIMUM_PARALLELIZATION_WIDTH").flatMap(Int.init)
564+
}
565+
if let maximumParallelizationWidth {
566+
if maximumParallelizationWidth < 1 {
567+
throw _EntryPointError.invalidArgument("--experimental-maximum-parallelization-width", value: String(describing: maximumParallelizationWidth))
568+
}
569+
configuration.maximumParallelizationWidth = maximumParallelizationWidth
570+
}
571+
}
549572

550573
// Whether or not to symbolicate backtraces in the event stream.
551574
if let symbolicateBacktraces = args.symbolicateBacktraces {

Sources/Testing/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ add_library(Testing
9090
Support/Graph.swift
9191
Support/JSON.swift
9292
Support/Locked.swift
93+
Support/Serializer.swift
9394
Support/VersionNumber.swift
9495
Support/Versions.swift
9596
Discovery+Macro.swift

Sources/Testing/Running/Configuration.swift

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
// See https://swift.org/CONTRIBUTORS.txt for Swift project authors
99
//
1010

11+
private import _TestingInternals
12+
1113
/// A type containing settings for preparing and running tests.
1214
@_spi(ForToolsIntegrationOnly)
1315
public struct Configuration: Sendable {
@@ -18,7 +20,33 @@ public struct Configuration: Sendable {
1820
// MARK: - Parallelization
1921

2022
/// Whether or not to parallelize the execution of tests and test cases.
21-
public var isParallelizationEnabled: Bool = true
23+
///
24+
/// - Note: Setting the value of this property implicitly sets the value of
25+
/// the experimental ``maximumParallelizationWidth`` property.
26+
public var isParallelizationEnabled: Bool {
27+
get {
28+
maximumParallelizationWidth > 1
29+
}
30+
set {
31+
maximumParallelizationWidth = newValue ? defaultParallelizationWidth : 1
32+
}
33+
}
34+
35+
/// The maximum width of parallelization.
36+
///
37+
/// The value of this property determines how many tests (or rather, test
38+
/// cases) will run in parallel.
39+
///
40+
/// @Comment {
41+
/// The default value of this property is equal to twice the number of CPU
42+
/// cores reported by the operating system, or `Int.max` if that value is
43+
/// not available.
44+
/// }
45+
///
46+
/// - Note: Setting the value of this property implicitly sets the value of
47+
/// the ``isParallelizationEnabled`` property.
48+
@_spi(Experimental)
49+
public var maximumParallelizationWidth: Int = defaultParallelizationWidth
2250

2351
/// How to symbolicate backtraces captured during a test run.
2452
///

Sources/Testing/Running/Runner.swift

Lines changed: 52 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,20 @@ extension Runner {
6666
.current ?? .init()
6767
}
6868

69+
/// Context to apply to a test run.
70+
///
71+
/// Instances of this type are passed directly to the various functions in
72+
/// this file and represent context for the run itself. As such, they are not
73+
/// task-local nor are they meant to change as the test run progresses.
74+
///
75+
/// This type is distinct from ``Configuration`` which _can_ change on a
76+
/// per-test basis. If you find yourself wanting to modify a property of this
77+
/// type at runtime, it may be better-suited for ``Configuration`` instead.
78+
private struct _Context: Sendable {
79+
/// A serializer used to reduce parallelism among test cases.
80+
var testCaseSerializer: Serializer?
81+
}
82+
6983
/// Apply the custom scope for any test scope providers of the traits
7084
/// associated with a specified test by calling their
7185
/// ``TestScoping/provideScope(for:testCase:performing:)`` function.
@@ -179,6 +193,7 @@ extension Runner {
179193
///
180194
/// - Parameters:
181195
/// - stepGraph: The subgraph whose root value, a step, is to be run.
196+
/// - context: Context for the test run.
182197
///
183198
/// - Throws: Whatever is thrown from the test body. Thrown errors are
184199
/// normally reported as test failures.
@@ -193,7 +208,7 @@ extension Runner {
193208
/// ## See Also
194209
///
195210
/// - ``Runner/run()``
196-
private static func _runStep(atRootOf stepGraph: Graph<String, Plan.Step?>) async throws {
211+
private static func _runStep(atRootOf stepGraph: Graph<String, Plan.Step?>, context: _Context) async throws {
197212
// Whether to send a `.testEnded` event at the end of running this step.
198213
// Some steps' actions may not require a final event to be sent — for
199214
// example, a skip event only sends `.testSkipped`.
@@ -250,18 +265,18 @@ extension Runner {
250265
try await _applyScopingTraits(for: step.test, testCase: nil) {
251266
// Run the test function at this step (if one is present.)
252267
if let testCases = step.test.testCases {
253-
await _runTestCases(testCases, within: step)
268+
await _runTestCases(testCases, within: step, context: context)
254269
}
255270

256271
// Run the children of this test (i.e. the tests in this suite.)
257-
try await _runChildren(of: stepGraph)
272+
try await _runChildren(of: stepGraph, context: context)
258273
}
259274
}
260275
}
261276
} else {
262277
// There is no test at this node in the graph, so just skip down to the
263278
// child nodes.
264-
try await _runChildren(of: stepGraph)
279+
try await _runChildren(of: stepGraph, context: context)
265280
}
266281
}
267282

@@ -286,10 +301,11 @@ extension Runner {
286301
/// - Parameters:
287302
/// - stepGraph: The subgraph whose root value, a step, will be used to
288303
/// find children to run.
304+
/// - context: Context for the test run.
289305
///
290306
/// - Throws: Whatever is thrown from the test body. Thrown errors are
291307
/// normally reported as test failures.
292-
private static func _runChildren(of stepGraph: Graph<String, Plan.Step?>) async throws {
308+
private static func _runChildren(of stepGraph: Graph<String, Plan.Step?>, context: _Context) async throws {
293309
let childGraphs = if _configuration.isParallelizationEnabled {
294310
// Explicitly shuffle the steps to help detect accidental dependencies
295311
// between tests due to their ordering.
@@ -331,7 +347,7 @@ extension Runner {
331347

332348
// Run the child nodes.
333349
try await _forEach(in: childGraphs.lazy.map(\.value), namingTasksWith: taskNamer) { childGraph in
334-
try await _runStep(atRootOf: childGraph)
350+
try await _runStep(atRootOf: childGraph, context: context)
335351
}
336352
}
337353

@@ -340,12 +356,15 @@ extension Runner {
340356
/// - Parameters:
341357
/// - testCases: The test cases to be run.
342358
/// - step: The runner plan step associated with this test case.
359+
/// - context: Context for the test run.
343360
///
344361
/// If parallelization is supported and enabled, the generated test cases will
345362
/// be run in parallel using a task group.
346-
private static func _runTestCases(_ testCases: some Sequence<Test.Case>, within step: Plan.Step) async {
363+
private static func _runTestCases(_ testCases: some Sequence<Test.Case>, within step: Plan.Step, context: _Context) async {
364+
let configuration = _configuration
365+
347366
// Apply the configuration's test case filter.
348-
let testCaseFilter = _configuration.testCaseFilter
367+
let testCaseFilter = configuration.testCaseFilter
349368
let testCases = testCases.lazy.filter { testCase in
350369
testCaseFilter(testCase, step.test)
351370
}
@@ -359,7 +378,13 @@ extension Runner {
359378
}
360379

361380
await _forEach(in: testCases.enumerated(), namingTasksWith: taskNamer) { _, testCase in
362-
await _runTestCase(testCase, within: step)
381+
if let testCaseSerializer = context.testCaseSerializer {
382+
// Note that if .serialized is applied to an inner scope, we still use
383+
// this serializer (if set) so that we don't overcommit.
384+
await testCaseSerializer.run { await _runTestCase(testCase, within: step, context: context) }
385+
} else {
386+
await _runTestCase(testCase, within: step, context: context)
387+
}
363388
}
364389
}
365390

@@ -368,10 +393,11 @@ extension Runner {
368393
/// - Parameters:
369394
/// - testCase: The test case to run.
370395
/// - step: The runner plan step associated with this test case.
396+
/// - context: Context for the test run.
371397
///
372398
/// This function sets ``Test/Case/current``, then invokes the test case's
373399
/// body closure.
374-
private static func _runTestCase(_ testCase: Test.Case, within step: Plan.Step) async {
400+
private static func _runTestCase(_ testCase: Test.Case, within step: Plan.Step, context: _Context) async {
375401
let configuration = _configuration
376402

377403
Event.post(.testCaseStarted, for: (step.test, testCase), configuration: configuration)
@@ -431,6 +457,21 @@ extension Runner {
431457
eventHandler(event, context)
432458
}
433459

460+
// Context to pass into the test run. We intentionally don't pass the Runner
461+
// itself (implicitly as `self` nor as an argument) because we don't want to
462+
// accidentally depend on e.g. the `configuration` property rather than the
463+
// current configuration.
464+
let context: _Context = {
465+
var context = _Context()
466+
467+
let maximumParallelizationWidth = runner.configuration.maximumParallelizationWidth
468+
if maximumParallelizationWidth > 1 && maximumParallelizationWidth < .max {
469+
context.testCaseSerializer = Serializer(maximumWidth: runner.configuration.maximumParallelizationWidth)
470+
}
471+
472+
return context
473+
}()
474+
434475
await Configuration.withCurrent(runner.configuration) {
435476
// Post an event for every test in the test plan being run. These events
436477
// are turned into JSON objects if JSON output is enabled.
@@ -457,7 +498,7 @@ extension Runner {
457498
taskAction = "running iteration #\(iterationIndex + 1)"
458499
}
459500
_ = taskGroup.addTaskUnlessCancelled(name: decorateTaskName("test run", withAction: taskAction)) {
460-
try? await _runStep(atRootOf: runner.plan.stepGraph)
501+
try? await _runStep(atRootOf: runner.plan.stepGraph, context: context)
461502
}
462503
await taskGroup.waitForAll()
463504
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
//
2+
// This source file is part of the Swift.org open source project
3+
//
4+
// Copyright (c) 2024–2025 Apple Inc. and the Swift project authors
5+
// Licensed under Apache License v2.0 with Runtime Library Exception
6+
//
7+
// See https://swift.org/LICENSE.txt for license information
8+
// See https://swift.org/CONTRIBUTORS.txt for Swift project authors
9+
//
10+
11+
private import _TestingInternals
12+
13+
/// The number of CPU cores on the current system, or `nil` if that
14+
/// information is not available.
15+
var cpuCoreCount: Int? {
16+
#if SWT_TARGET_OS_APPLE || os(Linux) || os(FreeBSD) || os(OpenBSD) || os(Android)
17+
return Int(sysconf(Int32(_SC_NPROCESSORS_CONF)))
18+
#elseif os(Windows)
19+
var siInfo = SYSTEM_INFO()
20+
GetSystemInfo(&siInfo)
21+
return Int(siInfo.dwNumberOfProcessors)
22+
#elseif os(WASI)
23+
return 1
24+
#else
25+
#warning("Platform-specific implementation missing: CPU core count unavailable")
26+
return nil
27+
#endif
28+
}
29+
30+
/// The default parallelization width when parallelized testing is enabled.
31+
var defaultParallelizationWidth: Int {
32+
// cpuCoreCount.map { max(1, $0) * 2 } ?? .max
33+
.max
34+
}
35+
36+
/// A type whose instances can run a series of work items in strict order.
37+
///
38+
/// When a work item is scheduled on an instance of this type, it runs after any
39+
/// previously-scheduled work items. If it suspends, subsequently-scheduled work
40+
/// items do not start running; they must wait until the suspended work item
41+
/// either returns or throws an error.
42+
///
43+
/// This type is not part of the public interface of the testing library.
44+
final actor Serializer {
45+
/// The maximum number of work items that may run concurrently.
46+
nonisolated let maximumWidth: Int
47+
48+
/// The number of scheduled work items, including any currently running.
49+
private var _currentWidth = 0
50+
51+
/// Continuations for any scheduled work items that haven't started yet.
52+
private var _continuations = [CheckedContinuation<Void, Never>]()
53+
54+
init(maximumWidth: Int = 1) {
55+
precondition(maximumWidth >= 1, "Invalid serializer width \(maximumWidth).")
56+
self.maximumWidth = maximumWidth
57+
}
58+
59+
/// Run a work item serially after any previously-scheduled work items.
60+
///
61+
/// - Parameters:
62+
/// - workItem: A closure to run.
63+
///
64+
/// - Returns: Whatever is returned from `workItem`.
65+
///
66+
/// - Throws: Whatever is thrown by `workItem`.
67+
func run<R>(_ workItem: @isolated(any) @Sendable () async throws -> R) async rethrows -> R where R: Sendable {
68+
_currentWidth += 1
69+
defer {
70+
// Resume the next scheduled closure.
71+
if !_continuations.isEmpty {
72+
let continuation = _continuations.removeFirst()
73+
continuation.resume()
74+
}
75+
76+
_currentWidth -= 1
77+
}
78+
79+
await withCheckedContinuation { continuation in
80+
if _currentWidth <= maximumWidth {
81+
// Nothing else was scheduled, so we can resume immediately.
82+
continuation.resume()
83+
} else {
84+
// Something was scheduled, so add the continuation to the
85+
// list. When it resumes, we can run.
86+
_continuations.append(continuation)
87+
}
88+
}
89+
90+
return try await workItem()
91+
}
92+
}
93+

Tests/TestingTests/SwiftPMTests.swift

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,25 @@ struct SwiftPMTests {
5959
#expect(!configuration.isParallelizationEnabled)
6060
}
6161

62+
@Test("--experimental-maximum-parallelization-width argument")
63+
func maximumParallelizationWidth() throws {
64+
var configuration = try configurationForEntryPoint(withArguments: ["PATH", "--experimental-maximum-parallelization-width", "12345"])
65+
#expect(configuration.isParallelizationEnabled)
66+
#expect(configuration.maximumParallelizationWidth == 12345)
67+
68+
configuration = try configurationForEntryPoint(withArguments: ["PATH", "--experimental-maximum-parallelization-width", "1"])
69+
#expect(!configuration.isParallelizationEnabled)
70+
#expect(configuration.maximumParallelizationWidth == 1)
71+
72+
configuration = try configurationForEntryPoint(withArguments: ["PATH", "--experimental-maximum-parallelization-width", "\(Int.max)"])
73+
#expect(configuration.isParallelizationEnabled)
74+
#expect(configuration.maximumParallelizationWidth == .max)
75+
76+
#expect(throws: (any Error).self) {
77+
_ = try configurationForEntryPoint(withArguments: ["PATH", "--experimental-maximum-parallelization-width", "0"])
78+
}
79+
}
80+
6281
@Test("--symbolicate-backtraces argument",
6382
arguments: [
6483
(String?.none, Backtrace.SymbolicationMode?.none),

0 commit comments

Comments
 (0)