Skip to content

Commit af6e670

Browse files
committed
Merge branch 'feature/parallel-plugin-build-171' into 'main'
Parallelise independent plugin builds during aro build Closes #171, #169, and #165 See merge request arolang/aro!222
2 parents b88ff55 + 1339267 commit af6e670

File tree

2 files changed

+251
-2
lines changed

2 files changed

+251
-2
lines changed

Sources/AROCLI/Commands/BuildCommand.swift

100644100755
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,7 @@ struct BuildCommand: AsyncParsableCommand {
214214
FileManager.default.fileExists(atPath: outputManagedPluginsDirEarly.path) {
215215
try FileManager.default.removeItem(at: outputManagedPluginsDirEarly)
216216
}
217-
try PluginLoader.shared.compileManagedPlugins(from: sourceManagedPluginsDirEarly, to: outputManagedPluginsDirEarly)
217+
try await PluginLoader.shared.compileManagedPluginsParallel(from: sourceManagedPluginsDirEarly, to: outputManagedPluginsDirEarly)
218218

219219
#if os(Windows)
220220
let libExt = "dll"
@@ -538,7 +538,7 @@ struct BuildCommand: AsyncParsableCommand {
538538
}
539539

540540
do {
541-
try PluginLoader.shared.compilePlugins(from: sourcePluginsDir, to: outputPluginsDir)
541+
try await PluginLoader.shared.compilePluginsParallel(from: sourcePluginsDir, to: outputPluginsDir)
542542
if verbose {
543543
// Count compiled plugins
544544
let pluginFiles = try? FileManager.default.contentsOfDirectory(at: outputPluginsDir, includingPropertiesForKeys: nil)

Sources/ARORuntime/Services/PluginLoader.swift

100644100755
Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -940,6 +940,255 @@ public final class PluginLoader: @unchecked Sendable {
940940
}
941941
}
942942

943+
// MARK: - Parallel Plugin Compilation
944+
945+
/// Compile legacy plugins concurrently using a TaskGroup.
946+
///
947+
/// Independent plugins share no build artefacts, so their `swiftc` / `swift build`
948+
/// invocations can run in parallel. Compilation warnings are collected and printed
949+
/// after all tasks complete; individual failures do not abort other compilations.
950+
public func compilePluginsParallel(from sourceDirectory: URL, to outputDirectory: URL) async throws {
951+
let sourcePluginsDir = sourceDirectory
952+
let outputPluginsDir = outputDirectory
953+
954+
#if os(Windows)
955+
let libraryExtension = "dll"
956+
#elseif os(Linux)
957+
let libraryExtension = "so"
958+
#else
959+
let libraryExtension = "dylib"
960+
#endif
961+
962+
guard FileManager.default.fileExists(atPath: sourcePluginsDir.path) else { return }
963+
try FileManager.default.createDirectory(at: outputPluginsDir, withIntermediateDirectories: true)
964+
965+
let contents = try FileManager.default.contentsOfDirectory(
966+
at: sourcePluginsDir,
967+
includingPropertiesForKeys: [.isDirectoryKey],
968+
options: [.skipsHiddenFiles]
969+
)
970+
971+
// Build a list of compilation work items
972+
struct CompileJob: Sendable {
973+
let name: String
974+
let source: URL
975+
let output: URL
976+
let isPackage: Bool
977+
}
978+
979+
var jobs: [CompileJob] = []
980+
981+
// Single-file Swift plugins
982+
for swiftFile in contents where swiftFile.pathExtension == "swift" {
983+
let name = swiftFile.deletingPathExtension().lastPathComponent
984+
let output = outputPluginsDir.appendingPathComponent("\(name).\(libraryExtension)")
985+
jobs.append(CompileJob(name: name, source: swiftFile, output: output, isPackage: false))
986+
}
987+
988+
// Swift package plugins
989+
for item in contents {
990+
var isDir: ObjCBool = false
991+
guard FileManager.default.fileExists(atPath: item.path, isDirectory: &isDir),
992+
isDir.boolValue else { continue }
993+
let packageSwift = item.appendingPathComponent("Package.swift")
994+
guard FileManager.default.fileExists(atPath: packageSwift.path) else { continue }
995+
let name = item.lastPathComponent
996+
let output = outputPluginsDir.appendingPathComponent("lib\(name).\(libraryExtension)")
997+
jobs.append(CompileJob(name: name, source: item, output: output, isPackage: true))
998+
}
999+
1000+
guard !jobs.isEmpty else { return }
1001+
1002+
// Run all compilations concurrently
1003+
await withTaskGroup(of: String?.self) { group in
1004+
for job in jobs {
1005+
group.addTask {
1006+
do {
1007+
if job.isPackage {
1008+
try self.compilePackagePlugin(source: job.source, output: job.output)
1009+
} else {
1010+
try self.compilePlugin(source: job.source, output: job.output)
1011+
}
1012+
return nil // success
1013+
} catch {
1014+
return "[PluginLoader] Warning: Failed to compile \(job.name): \(error)"
1015+
}
1016+
}
1017+
}
1018+
for await warning in group {
1019+
if let warning { print(warning) }
1020+
}
1021+
}
1022+
}
1023+
1024+
/// Compile managed plugins concurrently using a TaskGroup.
1025+
///
1026+
/// Each plugin in `Plugins/` is compiled independently, so all builds run in parallel.
1027+
/// Directory setup and file copies happen inside each task (they target separate output dirs).
1028+
public func compileManagedPluginsParallel(from sourceDirectory: URL, to outputDirectory: URL) async throws {
1029+
#if os(Windows)
1030+
let libraryExtension = "dll"
1031+
#elseif os(Linux)
1032+
let libraryExtension = "so"
1033+
#else
1034+
let libraryExtension = "dylib"
1035+
#endif
1036+
1037+
guard FileManager.default.fileExists(atPath: sourceDirectory.path) else { return }
1038+
1039+
let contents = try FileManager.default.contentsOfDirectory(
1040+
at: sourceDirectory,
1041+
includingPropertiesForKeys: [.isDirectoryKey],
1042+
options: [.skipsHiddenFiles]
1043+
)
1044+
1045+
// Filter to valid plugin directories (must contain plugin.yaml)
1046+
let pluginDirs = contents.filter { item -> Bool in
1047+
var isDir: ObjCBool = false
1048+
guard FileManager.default.fileExists(atPath: item.path, isDirectory: &isDir),
1049+
isDir.boolValue else { return false }
1050+
return FileManager.default.fileExists(
1051+
atPath: item.appendingPathComponent("plugin.yaml").path
1052+
)
1053+
}
1054+
1055+
guard !pluginDirs.isEmpty else { return }
1056+
1057+
// Run all plugin compilations concurrently
1058+
await withTaskGroup(of: String?.self) { group in
1059+
for item in pluginDirs {
1060+
let libExt = libraryExtension
1061+
let outDir = outputDirectory
1062+
group.addTask {
1063+
do {
1064+
try self.compileSingleManagedPlugin(
1065+
item: item,
1066+
outputDirectory: outDir,
1067+
libraryExtension: libExt
1068+
)
1069+
return nil
1070+
} catch {
1071+
return "[PluginLoader] Warning: Failed to compile managed plugin \(item.lastPathComponent): \(error)"
1072+
}
1073+
}
1074+
}
1075+
for await warning in group {
1076+
if let warning { print(warning) }
1077+
}
1078+
}
1079+
}
1080+
1081+
/// Compile a single managed plugin (extracted from the sequential loop body).
1082+
private func compileSingleManagedPlugin(
1083+
item: URL,
1084+
outputDirectory: URL,
1085+
libraryExtension: String
1086+
) throws {
1087+
let pluginName = item.lastPathComponent
1088+
let outputPluginDir = outputDirectory.appendingPathComponent(pluginName)
1089+
try FileManager.default.createDirectory(at: outputPluginDir, withIntermediateDirectories: true)
1090+
1091+
// Copy plugin.yaml
1092+
let manifestPath = item.appendingPathComponent("plugin.yaml")
1093+
let outputManifestPath = outputPluginDir.appendingPathComponent("plugin.yaml")
1094+
if !FileManager.default.fileExists(atPath: outputManifestPath.path) {
1095+
try FileManager.default.copyItem(at: manifestPath, to: outputManifestPath)
1096+
}
1097+
1098+
// Check for Package.swift (Swift Package)
1099+
let packageSwift = item.appendingPathComponent("Package.swift")
1100+
if FileManager.default.fileExists(atPath: packageSwift.path) {
1101+
let outputLibPath = outputPluginDir.appendingPathComponent("Sources")
1102+
.appendingPathComponent("lib\(pluginName).\(libraryExtension)")
1103+
try FileManager.default.createDirectory(
1104+
at: outputLibPath.deletingLastPathComponent(),
1105+
withIntermediateDirectories: true
1106+
)
1107+
try compilePackagePlugin(source: item, output: outputLibPath)
1108+
return
1109+
}
1110+
1111+
// Check for Sources/ directory with Swift files
1112+
let sourcesDir = item.appendingPathComponent("Sources")
1113+
if FileManager.default.fileExists(atPath: sourcesDir.path) {
1114+
let sourceContents = try? FileManager.default.contentsOfDirectory(
1115+
at: sourcesDir, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles]
1116+
)
1117+
let swiftFiles = sourceContents?.filter { $0.pathExtension == "swift" } ?? []
1118+
1119+
if !swiftFiles.isEmpty {
1120+
let outputSourcesDir = outputPluginDir.appendingPathComponent("Sources")
1121+
try FileManager.default.createDirectory(at: outputSourcesDir, withIntermediateDirectories: true)
1122+
let outputLibPath = outputSourcesDir.appendingPathComponent("\(pluginName).\(libraryExtension)")
1123+
if swiftFiles.count == 1 {
1124+
try compilePlugin(source: swiftFiles[0], output: outputLibPath)
1125+
} else {
1126+
try compileMultipleSwiftFiles(sources: swiftFiles, output: outputLibPath)
1127+
}
1128+
}
1129+
}
1130+
1131+
// Check for src/ directory with Rust or C files
1132+
let srcDir = item.appendingPathComponent("src")
1133+
if FileManager.default.fileExists(atPath: srcDir.path) {
1134+
let srcContents = try? FileManager.default.contentsOfDirectory(
1135+
at: srcDir, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles]
1136+
)
1137+
1138+
let cargoInSrc = srcDir.appendingPathComponent("Cargo.toml")
1139+
if FileManager.default.fileExists(atPath: cargoInSrc.path) {
1140+
let outputTargetDir = outputPluginDir.appendingPathComponent("target/release")
1141+
try compileRustPlugin(projectDir: srcDir, outputDir: outputTargetDir, pluginName: pluginName)
1142+
return
1143+
}
1144+
1145+
let cFiles = srcContents?.filter { $0.pathExtension == "c" } ?? []
1146+
if !cFiles.isEmpty {
1147+
let outputSrcDir = outputPluginDir.appendingPathComponent("src")
1148+
try FileManager.default.createDirectory(at: outputSrcDir, withIntermediateDirectories: true)
1149+
let outputLibPath = outputSrcDir.appendingPathComponent("\(pluginName).\(libraryExtension)")
1150+
try compileCPlugin(sources: cFiles, output: outputLibPath)
1151+
}
1152+
}
1153+
1154+
// Check for Cargo.toml in root
1155+
let rootCargoToml = item.appendingPathComponent("Cargo.toml")
1156+
if FileManager.default.fileExists(atPath: rootCargoToml.path) {
1157+
let outputTargetDir = outputPluginDir.appendingPathComponent("target/release")
1158+
try compileRustPlugin(projectDir: item, outputDir: outputTargetDir, pluginName: pluginName)
1159+
}
1160+
1161+
// Python plugins — copy only
1162+
if FileManager.default.fileExists(atPath: srcDir.path) {
1163+
let srcContents = try? FileManager.default.contentsOfDirectory(
1164+
at: srcDir, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles]
1165+
)
1166+
let pyFiles = srcContents?.filter { $0.pathExtension == "py" } ?? []
1167+
if !pyFiles.isEmpty {
1168+
let outputSrcDir = outputPluginDir.appendingPathComponent("src")
1169+
if !FileManager.default.fileExists(atPath: outputSrcDir.path) {
1170+
try FileManager.default.copyItem(at: srcDir, to: outputSrcDir)
1171+
}
1172+
let requirementsFile = item.appendingPathComponent("requirements.txt")
1173+
if FileManager.default.fileExists(atPath: requirementsFile.path) {
1174+
let outputReq = outputPluginDir.appendingPathComponent("requirements.txt")
1175+
if !FileManager.default.fileExists(atPath: outputReq.path) {
1176+
try FileManager.default.copyItem(at: requirementsFile, to: outputReq)
1177+
}
1178+
}
1179+
}
1180+
}
1181+
1182+
// Copy features/ directory if present
1183+
let featuresDir = item.appendingPathComponent("features")
1184+
if FileManager.default.fileExists(atPath: featuresDir.path) {
1185+
let outputFeaturesDir = outputPluginDir.appendingPathComponent("features")
1186+
if !FileManager.default.fileExists(atPath: outputFeaturesDir.path) {
1187+
try FileManager.default.copyItem(at: featuresDir, to: outputFeaturesDir)
1188+
}
1189+
}
1190+
}
1191+
9431192
/// Compile multiple Swift files to a dynamic library
9441193
private func compileMultipleSwiftFiles(sources: [URL], output: URL) throws {
9451194
guard let swiftc = findSwiftCompiler() else {

0 commit comments

Comments
 (0)