Makefiles: Group parallel build output via GNU Make --output-sync

A parallel `cmake --build` with a Makefiles generator interleaved the
output of concurrent recipes.  When CMake is the one passing the
parallel flag and a build-time probe finds GNU Make 4.0 or newer,
append `-Otarget` so each recipe's output stays grouped.  A native
`-- -j` leaves the job count unset and opts out.

Honor `USES_TERMINAL` under grouping by prefixing such recipes with a
`$(CMAKE_USES_TERMINAL_PREFIX)` variable, empty by default and set to
`+` next to `-Otarget`, so interactive commands stay unbuffered.

Fixes: #27510
This commit is contained in:
15daksh-2003
2026-08-13 10:00:13 -04:00
committed by Brad King
parent 09967a7368
commit e135fd8e44
12 changed files with 426 additions and 1 deletions
+10
View File
@@ -869,6 +869,16 @@ following options:
Some native build tools always build in parallel. The use of ``<jobs>``
value of ``1`` can be used to limit to a single job.
.. versionadded:: 4.5
When CMake is the one passing the parallel flag to the build tool (through
this option or the :envvar:`CMAKE_BUILD_PARALLEL_LEVEL` environment
variable) and that tool is GNU Make 4.0 or newer, the output of each
recipe is grouped so that concurrent jobs do not interleave. Request
parallelism natively, e.g. ``cmake --build . -- -j``, to opt out and
stream output as it is produced. Since
:envvar:`CMAKE_BUILD_PARALLEL_LEVEL` likewise enables grouping, unset it
to opt out when it is set.
.. option:: -t <tgt>..., --target <tgt>...
Build ``<tgt>`` instead of the default target. Multiple targets may be
@@ -0,0 +1,9 @@
cmake-build-output-sync
-----------------------
* The :option:`cmake --build` command now groups the output of each recipe in
a parallel build so that concurrent jobs do not interleave, when CMake is
the one passing the parallel flag to the build tool (via :option:`--parallel
<cmake--build --parallel>` or :envvar:`CMAKE_BUILD_PARALLEL_LEVEL`) and that
tool is GNU Make 4.0 or newer. Request parallelism natively, e.g.
``cmake --build . -- -j``, to opt out and stream output as it is produced.
+47
View File
@@ -11,6 +11,9 @@
#include <cmext/algorithm>
#include <cmext/memory>
#include "cmsys/RegularExpression.hxx"
#include "cmDuration.h"
#include "cmGeneratedFileStream.h"
#include "cmGeneratorTarget.h"
#include "cmGlobalGenerator.h"
@@ -589,6 +592,21 @@ cmGlobalUnixMakefileGenerator3::GenerateBuildCommand(
} else {
makeCommand.Add(cmStrCat("-j", jobs));
}
// Group each recipe's output so parallel jobs do not interleave. Only
// done when CMake is the one passing the parallel flag to make; a native
// `-- -j` leaves jobs unset and thus opts out. Grouping requires GNU
// Make 4.0+ (its --output-sync); IsGNUMakeJobServerAware() excludes
// non-GNU makes such as JOM before the version probe runs. Emitted
// before the user's make options so a native `-- -O<mode>` overrides it.
if (this->IsGNUMakeJobServerAware() &&
this->MakeSupportsOutputSync(makeProgram)) {
makeCommand.Add("-Otarget");
// Set the (empty by default) USES_TERMINAL recipe prefix to "+" so
// GNU Make leaves those recipes unbuffered, keeping interactive
// commands able to reach the terminal under -Otarget.
makeCommand.Add("CMAKE_USES_TERMINAL_PREFIX=+");
}
}
makeCommand.Add(makeOptions.begin(), makeOptions.end());
@@ -604,6 +622,35 @@ cmGlobalUnixMakefileGenerator3::GenerateBuildCommand(
return { std::move(makeCommand) };
}
bool cmGlobalUnixMakefileGenerator3::MakeSupportsOutputSync(
std::string const& makeProgram)
{
if (this->OutputSyncSupportState == OutputSyncSupport::Unknown) {
// Any probe failure (spawn error, timeout, non-GNU or old make) leaves
// grouping off; it is a convenience and must never fail the build.
this->OutputSyncSupportState = OutputSyncSupport::No;
std::string version;
std::string error;
int retVal = 1;
std::vector<std::string> command{ this->SelectMakeProgram(makeProgram),
"--version" };
if (cmSystemTools::RunSingleCommand(command, &version, &error, &retVal,
nullptr, cmSystemTools::OUTPUT_NONE,
cmDuration(30)) &&
retVal == 0) {
// GNU Make's version line is not localized, so no LC_ALL=C is needed.
cmsys::RegularExpression versionRegex("GNU Make ([0-9]+(\\.[0-9]+)*)");
if (versionRegex.find(version) &&
!cmSystemTools::VersionCompare(cmSystemTools::OP_LESS,
versionRegex.match(1), "4.0")) {
this->OutputSyncSupportState = OutputSyncSupport::Yes;
}
}
}
return this->OutputSyncSupportState == OutputSyncSupport::Yes;
}
void cmGlobalUnixMakefileGenerator3::WriteConvenienceRules(
std::ostream& ruleFileStream, std::set<std::string>& emitted)
{
+12
View File
@@ -309,6 +309,18 @@ protected:
private:
char const* GetBuildIgnoreErrorsFlag() const override { return "-i"; }
// Probe the make tool (once, memoized) for whether it is GNU Make 4.0 or
// newer and thus supports grouped output via --output-sync.
bool MakeSupportsOutputSync(std::string const& makeProgram);
enum class OutputSyncSupport
{
Unknown,
Yes,
No
};
OutputSyncSupport OutputSyncSupportState = OutputSyncSupport::Unknown;
std::map<cmStateSnapshot, std::set<cmGeneratorTarget const*>,
cmStateSnapshot::StrictWeakOrder>
DirectoryTargetsMap;
+17
View File
@@ -674,6 +674,13 @@ void cmLocalUnixMakefileGenerator3::WriteMakeVariables(
"NULL=nul\n"
"!ENDIF\n";
}
if (gg->IsGNUMakeJobServerAware()) {
// Toggle for USES_TERMINAL recipes: empty here, set to "+" by
// "cmake --build" under --output-sync (see AppendCustomCommand).
makefileStream << "# Prefix for USES_TERMINAL recipes under output sync.\n"
"CMAKE_USES_TERMINAL_PREFIX =\n"
"\n";
}
if (this->IsWindowsShell()) {
makefileStream << "SHELL = cmd.exe\n"
"\n";
@@ -1135,6 +1142,16 @@ void cmLocalUnixMakefileGenerator3::AppendCustomCommand(
if (ccg.GetCC().GetJobserverAware() && gg->IsGNUMakeJobServerAware()) {
std::transform(commands1.begin(), commands1.end(), commands1.begin(),
[](std::string const& cmd) { return cmStrCat('+', cmd); });
} else if (ccg.GetCC().GetUsesTerminal() && gg->IsGNUMakeJobServerAware()) {
// Prefix USES_TERMINAL recipes with $(CMAKE_USES_TERMINAL_PREFIX): empty
// by default, but set to "+" by "cmake --build" under --output-sync so
// GNU Make leaves the recipe unbuffered and an interactive command keeps
// the terminal. Jobserver-aware commands already carry "+" from the
// branch above and are excluded here to avoid a doubled prefix.
std::transform(commands1.begin(), commands1.end(), commands1.begin(),
[](std::string const& cmd) {
return cmStrCat("$(CMAKE_USES_TERMINAL_PREFIX)", cmd);
});
}
// push back the custom commands
+2 -1
View File
@@ -201,7 +201,8 @@ if(NOT CMAKE_GENERATOR MATCHES "Visual Studio|Xcode")
endif()
add_executable(detect_jobserver detect_jobserver.c)
if(CMAKE_GENERATOR MATCHES "Make")
add_RunCMake_test(Make -DMAKE_IS_GNU=${MAKE_IS_GNU} -DDETECT_JOBSERVER=$<TARGET_FILE:detect_jobserver>)
add_executable(fake_make Make/fake_make.c)
add_RunCMake_test(Make -DMAKE_IS_GNU=${MAKE_IS_GNU} -DDETECT_JOBSERVER=$<TARGET_FILE:detect_jobserver> -DFAKE_MAKE=$<TARGET_FILE:fake_make>)
endif()
unset(ninja_test_with_qt_version)
unset(ninja_qt_args)
@@ -0,0 +1,77 @@
# Shared check for the OutputSync argv/probe cases. The calling test sets
# expectation variables (all optional) before each build command:
# expect_otarget 0/1 -- "-Otarget" must be absent/present
# expect_otarget_count N -- exact number of "-Otarget" occurrences
# expect_uses_terminal_flag 0/1 -- "CMAKE_USES_TERMINAL_PREFIX=+" absent/present
# expect_probe 0/1 -- the make tool must not/must be probed
# expect_probe_count N -- exact number of "--version" probes
# expect_order_onone 0/1 -- "-Otarget" must appear before "-Onone"
set(record "${RunCMake_TEST_BINARY_DIR}/fake_make_record.txt")
set(marker "${RunCMake_TEST_BINARY_DIR}/fake_make_probe.txt")
if(EXISTS "${record}")
file(READ "${record}" record_content)
else()
set(record_content "")
endif()
if(EXISTS "${marker}")
file(READ "${marker}" marker_content)
else()
set(marker_content "")
endif()
string(REGEX MATCHALL "-Otarget" _otarget_matches "${record_content}")
list(LENGTH _otarget_matches _otarget_count)
string(REGEX MATCHALL "probe" _probe_matches "${marker_content}")
list(LENGTH _probe_matches _probe_count)
if(DEFINED expect_otarget)
if(expect_otarget AND _otarget_count EQUAL 0)
string(APPEND RunCMake_TEST_FAILED
"Expected '-Otarget' in the build command but recorded:\n${record_content}\n")
elseif(NOT expect_otarget AND _otarget_count GREATER 0)
string(APPEND RunCMake_TEST_FAILED
"Did not expect '-Otarget' in the build command but recorded:\n${record_content}\n")
endif()
endif()
if(DEFINED expect_otarget_count AND NOT _otarget_count EQUAL expect_otarget_count)
string(APPEND RunCMake_TEST_FAILED
"Expected ${expect_otarget_count} '-Otarget' occurrence(s) but found ${_otarget_count}:\n${record_content}\n")
endif()
if(DEFINED expect_uses_terminal_flag)
string(FIND "${record_content}" "CMAKE_USES_TERMINAL_PREFIX=+" _ut_pos)
if(expect_uses_terminal_flag AND _ut_pos EQUAL -1)
string(APPEND RunCMake_TEST_FAILED
"Expected 'CMAKE_USES_TERMINAL_PREFIX=+' in the build command but recorded:\n${record_content}\n")
elseif(NOT expect_uses_terminal_flag AND NOT _ut_pos EQUAL -1)
string(APPEND RunCMake_TEST_FAILED
"Did not expect 'CMAKE_USES_TERMINAL_PREFIX=+' in the build command but recorded:\n${record_content}\n")
endif()
endif()
if(DEFINED expect_probe)
if(expect_probe AND _probe_count EQUAL 0)
string(APPEND RunCMake_TEST_FAILED
"Expected the make tool to be probed with '--version' but it was not.\n")
elseif(NOT expect_probe AND _probe_count GREATER 0)
string(APPEND RunCMake_TEST_FAILED
"Did not expect a '--version' probe but it ran ${_probe_count} time(s).\n")
endif()
endif()
if(DEFINED expect_probe_count AND NOT _probe_count EQUAL expect_probe_count)
string(APPEND RunCMake_TEST_FAILED
"Expected ${expect_probe_count} probe(s) but found ${_probe_count}.\n")
endif()
if(DEFINED expect_order_onone AND expect_order_onone)
if(NOT record_content MATCHES "-Otarget[^\n]*-Onone")
string(APPEND RunCMake_TEST_FAILED
"Expected '-Otarget' to appear before '-Onone' but recorded:\n${record_content}\n")
endif()
endif()
+1
View File
@@ -0,0 +1 @@
add_custom_target(drive ALL COMMAND $(CMAKE_COMMAND) -E true)
@@ -0,0 +1,37 @@
set(BUILD_DIR "${RunCMake_BINARY_DIR}/OutputSyncUsesTerminal-build")
function(check_has target regex)
file(STRINGS "${BUILD_DIR}/${target}" lines REGEX "${regex}")
list(LENGTH lines len)
if(len EQUAL 0)
set(RunCMake_TEST_FAILED
"${RunCMake_TEST_FAILED}Expected to find '${regex}' in ${target}\n"
PARENT_SCOPE)
endif()
endfunction()
function(check_missing target regex)
file(STRINGS "${BUILD_DIR}/${target}" lines REGEX "${regex}")
list(LENGTH lines len)
if(NOT len EQUAL 0)
set(RunCMake_TEST_FAILED
"${RunCMake_TEST_FAILED}Did not expect to find '${regex}' in ${target}: ${lines}\n"
PARENT_SCOPE)
endif()
endfunction()
# The USES_TERMINAL recipe is prefixed with the build-time toggle variable.
check_has("CMakeFiles/term.dir/build.make"
[[\$\(CMAKE_USES_TERMINAL_PREFIX\)\$\(CMAKE_COMMAND\) -E true]])
# A plain recipe references no prefix.
check_missing("CMakeFiles/plain.dir/build.make" [[\$\(CMAKE_USES_TERMINAL_PREFIX\)]])
# A jobserver-aware recipe keeps the literal '+' and is not given the toggle.
check_has("CMakeFiles/jsa.dir/build.make" [[\+\$\(CMAKE_COMMAND\) -E true]])
check_missing("CMakeFiles/jsa.dir/build.make" [[\$\(CMAKE_USES_TERMINAL_PREFIX\)]])
# A recipe that is both jobserver-aware and USES_TERMINAL uses only '+'
# (no doubled prefix).
check_has("CMakeFiles/both.dir/build.make" [[\+\$\(CMAKE_COMMAND\) -E true]])
check_missing("CMakeFiles/both.dir/build.make" [[\$\(CMAKE_USES_TERMINAL_PREFIX\)]])
@@ -0,0 +1,20 @@
# Recipe that wants interactive terminal access: prefixed with the toggle.
add_custom_target(term ALL
COMMAND $(CMAKE_COMMAND) -E true
USES_TERMINAL)
# Ordinary recipe: no prefix at all.
add_custom_target(plain ALL
COMMAND $(CMAKE_COMMAND) -E true)
# Jobserver-aware recipe: keeps the literal '+' prefix.
add_custom_target(jsa ALL
COMMAND $(CMAKE_COMMAND) -E true
JOB_SERVER_AWARE ON)
# Both jobserver-aware and interactive: the '+' branch wins, so the toggle
# prefix must not be added (no doubled prefix).
add_custom_target(both ALL
COMMAND $(CMAKE_COMMAND) -E true
USES_TERMINAL
JOB_SERVER_AWARE ON)
+127
View File
@@ -135,3 +135,130 @@ if(MAKE_IS_GNU)
# commands with the '+' operator.
run_cmake(GNUMakeJobServerAware)
endif()
# Output synchronization (-Otarget) is emitted only for the GNU Make family of
# generators. Drive these cases with a fake "make" so the behavior can be
# asserted independently of the host's real make tool.
if(FAKE_MAKE AND RunCMake_GENERATOR MATCHES "Unix Makefiles|MinGW Makefiles|MSYS Makefiles")
function(run_OutputSync)
# Use the fake make for the configure step so the build steps inherit it
# from the cache.
set(RunCMake_MAKE_PROGRAM "${FAKE_MAKE}")
set(RunCMake_TEST_BINARY_DIR ${RunCMake_BINARY_DIR}/OutputSync-build)
set(RunCMake_TEST_NO_CLEAN 1)
file(REMOVE_RECURSE "${RunCMake_TEST_BINARY_DIR}")
file(MAKE_DIRECTORY "${RunCMake_TEST_BINARY_DIR}")
run_cmake(OutputSync)
set(RunCMake-check-file OutputSync-check.cmake)
set(record "${RunCMake_TEST_BINARY_DIR}/fake_make_record.txt")
set(marker "${RunCMake_TEST_BINARY_DIR}/fake_make_probe.txt")
set(ENV{FAKE_MAKE_RECORD} "${record}")
set(ENV{FAKE_MAKE_PROBE_MARKER} "${marker}")
# CMake-driven parallel build with GNU Make >= 4.0: group the output.
file(REMOVE "${record}" "${marker}")
set(ENV{FAKE_MAKE_VERSION} "GNU Make 4.4.1")
set(expect_otarget 1)
set(expect_uses_terminal_flag 1)
set(expect_probe 1)
run_cmake_command(OutputSync-parallel ${CMAKE_COMMAND} --build . --parallel 2)
# Serial build: no grouping and no probe.
file(REMOVE "${record}" "${marker}")
set(expect_otarget 0)
set(expect_uses_terminal_flag 0)
set(expect_probe 0)
run_cmake_command(OutputSync-serial ${CMAKE_COMMAND} --build .)
# Native "-- -j" opt-out: CMake adds neither -j nor -Otarget, no probe.
file(REMOVE "${record}" "${marker}")
set(expect_otarget 0)
set(expect_uses_terminal_flag 0)
set(expect_probe 0)
run_cmake_command(OutputSync-native-j ${CMAKE_COMMAND} --build . -- -j)
# A user's native "-O" overrides CMake's: -Otarget appears before -Onone.
file(REMOVE "${record}" "${marker}")
set(expect_otarget 1)
set(expect_uses_terminal_flag 1)
set(expect_probe 1)
set(expect_order_onone 1)
run_cmake_command(OutputSync-override ${CMAKE_COMMAND} --build . --parallel 2 -- -Onone)
unset(expect_order_onone)
# GNU Make < 4.0 does not support --output-sync.
file(REMOVE "${record}" "${marker}")
set(ENV{FAKE_MAKE_VERSION} "GNU Make 3.81")
set(expect_otarget 0)
set(expect_uses_terminal_flag 0)
set(expect_probe 1)
run_cmake_command(OutputSync-old ${CMAKE_COMMAND} --build . --parallel 2)
# 4.0 is the first release to support --output-sync; verify the lower
# boundary and the rest of the 4.0-4.2 series are classified as supported.
foreach(v IN ITEMS 4.0 4.1 4.2)
file(REMOVE "${record}" "${marker}")
set(ENV{FAKE_MAKE_VERSION} "GNU Make ${v}")
set(expect_otarget 1)
set(expect_uses_terminal_flag 1)
set(expect_probe 1)
run_cmake_command(OutputSync-v${v} ${CMAKE_COMMAND} --build . --parallel 2)
endforeach()
# A non-GNU make is not grouped.
file(REMOVE "${record}" "${marker}")
set(ENV{FAKE_MAKE_VERSION} "bmake version 20200710")
set(expect_otarget 0)
set(expect_uses_terminal_flag 0)
set(expect_probe 1)
run_cmake_command(OutputSync-nongnu ${CMAKE_COMMAND} --build . --parallel 2)
# Unparsable "--version" output: probe fails gracefully, build still runs.
file(REMOVE "${record}" "${marker}")
set(ENV{FAKE_MAKE_VERSION} "garbage output")
set(expect_otarget 0)
set(expect_uses_terminal_flag 0)
set(expect_probe 1)
run_cmake_command(OutputSync-garbage ${CMAKE_COMMAND} --build . --parallel 2)
# Probe exits non-zero: treated as unsupported, build still runs.
file(REMOVE "${record}" "${marker}")
set(ENV{FAKE_MAKE_VERSION} "GNU Make 4.4.1")
set(ENV{FAKE_MAKE_VERSION_RESULT} "2")
set(expect_otarget 0)
set(expect_uses_terminal_flag 0)
set(expect_probe 1)
run_cmake_command(OutputSync-probe-fail ${CMAKE_COMMAND} --build . --parallel 2)
unset(ENV{FAKE_MAKE_VERSION_RESULT})
# --clean-first runs two build commands but probes only once (memoized).
file(REMOVE "${record}" "${marker}")
set(ENV{FAKE_MAKE_VERSION} "GNU Make 4.4.1")
set(expect_otarget 1)
set(expect_otarget_count 2)
set(expect_uses_terminal_flag 1)
set(expect_probe 1)
set(expect_probe_count 1)
run_cmake_command(OutputSync-clean-first ${CMAKE_COMMAND} --build . --parallel 2 --clean-first)
unset(expect_otarget_count)
unset(expect_probe_count)
unset(ENV{FAKE_MAKE_VERSION})
unset(ENV{FAKE_MAKE_RECORD})
unset(ENV{FAKE_MAKE_PROBE_MARKER})
endfunction()
run_OutputSync()
# USES_TERMINAL recipe prefixing is a generation-time change; inspect the
# generated build.make directly.
function(run_OutputSyncUsesTerminal)
set(RunCMake_MAKE_PROGRAM "${FAKE_MAKE}")
set(RunCMake_TEST_BINARY_DIR ${RunCMake_BINARY_DIR}/OutputSyncUsesTerminal-build)
set(RunCMake_TEST_NO_CLEAN 1)
file(REMOVE_RECURSE "${RunCMake_TEST_BINARY_DIR}")
file(MAKE_DIRECTORY "${RunCMake_TEST_BINARY_DIR}")
run_cmake(OutputSyncUsesTerminal)
endfunction()
run_OutputSyncUsesTerminal()
endif()
+67
View File
@@ -0,0 +1,67 @@
/* Distributed under the OSI-approved BSD 3-Clause License. See accompanying
file LICENSE.rst or https://cmake.org/licensing for details. */
/* Stand-in for "make" used by the RunCMake.Make output-sync tests so they can
assert what "cmake --build" passes to make without a real make tool.
On "--version" (the output-sync probe): touch $FAKE_MAKE_PROBE_MARKER, print
$FAKE_MAKE_VERSION (default a GNU Make 4.x banner), and exit with
$FAKE_MAKE_VERSION_RESULT (default 0).
Otherwise (the build): append the argument list to $FAKE_MAKE_RECORD and
exit 0. */
#ifndef _CRT_SECURE_NO_WARNINGS
# define _CRT_SECURE_NO_WARNINGS
#endif
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(int argc, char** argv)
{
int i;
int isVersion = 0;
for (i = 1; i < argc; ++i) {
if (strcmp(argv[i], "--version") == 0) {
isVersion = 1;
break;
}
}
if (isVersion) {
char const* marker = getenv("FAKE_MAKE_PROBE_MARKER");
char const* banner = getenv("FAKE_MAKE_VERSION");
char const* result = getenv("FAKE_MAKE_VERSION_RESULT");
if (marker) {
FILE* f = fopen(marker, "a");
if (f) {
fprintf(f, "probe\n");
fclose(f);
}
}
if (!banner) {
banner = "GNU Make 4.4.1";
}
if (banner[0] != '\0') {
printf("%s\n", banner);
}
return result ? atoi(result) : 0;
}
{
char const* record = getenv("FAKE_MAKE_RECORD");
if (record) {
FILE* f = fopen(record, "a");
if (f) {
for (i = 1; i < argc; ++i) {
fprintf(f, "%s%s", i > 1 ? " " : "", argv[i]);
}
fprintf(f, "\n");
fclose(f);
}
}
}
return 0;
}