Skip to content

Commit 1b327ac

Browse files
committed
Support jobserver client mode automatically.
Detect that the environment variable MAKEFLAGS specifies a jobserver pool to use, and automatically use it to control build parallelism when this is the case. This is disabled is `--dry-run` or an explicit `-j<COUNT>` is passed on the command-line. Note that the `-l` option used to limit dispatch based on the overall load factor will still be in effect if used. + Use default member initialization for BuildConfig struct. + Add a new regression test suite that uses the misc/jobserver_pool.py script that was introduced in a previous commit, to verify that everything works properly.
1 parent 9ecae6d commit 1b327ac

File tree

8 files changed

+462
-13
lines changed

8 files changed

+462
-13
lines changed

.github/workflows/linux.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ jobs:
2727
run: |
2828
./ninja_test --gtest_color=yes
2929
../../misc/output_test.py
30+
../../misc/jobserver_test.py
3031
- name: Build release ninja
3132
run: CLICOLOR_FORCE=1 ninja -f build-Release.ninja
3233
working-directory: build
@@ -35,6 +36,7 @@ jobs:
3536
run: |
3637
./ninja_test --gtest_color=yes
3738
../../misc/output_test.py
39+
../../misc/jobserver_test.py
3840
3941
build:
4042
runs-on: [ubuntu-latest]
@@ -166,6 +168,7 @@ jobs:
166168
./ninja all
167169
python3 misc/ninja_syntax_test.py
168170
./misc/output_test.py
171+
./misc/jobserver_test.py
169172
170173
build-aarch64:
171174
name: Build Linux ARM64

doc/manual.asciidoc

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,10 +187,42 @@ Ninja defaults to running commands in parallel anyway, so typically
187187
you don't need to pass `-j`.)
188188
189189
190+
GNU Jobserver support
191+
~~~~~~~~~~~~~~~~~~~~~
192+
193+
Since version 1.13., Ninja builds can follow the
194+
https://https://www.gnu.org/software/make/manual/html_node/Job-Slots.html[GNU Make jobserver]
195+
client protocol. This is useful when Ninja is invoked as part of a larger
196+
build system controlled by a top-level GNU Make instance, or any other
197+
jobserver pool implementation, as it allows better coordination between
198+
concurrent build tasks.
199+
200+
This feature is automatically enabled under the following conditions:
201+
202+
- Dry-run (i.e. `-n` or `--dry-run`) is not enabled.
203+
204+
- No explicit job count (e.g. `-j<COUNT>`) is passed on the command
205+
line.
206+
207+
- The `MAKEFLAGS` environment variable is defined and describes a valid
208+
jobserver mode using `--jobserver-auth` or even `--jobserver-fds`.
209+
210+
In this case, Ninja will use the jobserver pool of job slots to control
211+
parallelism, instead of its default implementation of `-j<count>`.
212+
213+
Note that load-average limitations (i.e. when using `-l<count>`)
214+
are still being enforced in this mode.
215+
216+
On Posix, Ninja supports both the `pipe` and `fifo` client modes, based on
217+
the content of `MAKEFLAGS`.
218+
219+
IMPORTANT: A warning will be printed when `pipe` mode is detected, as this
220+
mode can be less reliable than `fifo`.
221+
190222
Environment variables
191223
~~~~~~~~~~~~~~~~~~~~~
192224
193-
Ninja supports one environment variable to control its behavior:
225+
Ninja supports a few environment variables to control its behavior:
194226
`NINJA_STATUS`, the progress status printed before the rule being run.
195227
196228
Several placeholders are available:
@@ -215,6 +247,10 @@ The default progress status is `"[%f/%t] "` (note the trailing space
215247
to separate from the build rule). Another example of possible progress status
216248
could be `"[%u/%r/%f] "`.
217249
250+
If `MAKEFLAGS` is defined in the environment, if may alter how
251+
Ninja dispatches parallel build commands. See the GNU Jobserver support
252+
section for details.
253+
218254
Extra tools
219255
~~~~~~~~~~~
220256

misc/jobserver_test.py

Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
#!/usr/bin/env python3
2+
# Copyright 2024 Google Inc. All Rights Reserved.
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
16+
from textwrap import dedent
17+
import os
18+
import platform
19+
import subprocess
20+
import tempfile
21+
import typing as T
22+
import sys
23+
import unittest
24+
25+
_SCRIPT_DIR = os.path.realpath(os.path.dirname(__file__))
26+
_JOBSERVER_POOL_SCRIPT = os.path.join(_SCRIPT_DIR, "jobserver_pool.py")
27+
_JOBSERVER_TEST_HELPER_SCRIPT = os.path.join(_SCRIPT_DIR, "jobserver_test_helper.py")
28+
29+
_PLATFORM_IS_WINDOWS = platform.system() == "Windows"
30+
31+
default_env = dict(os.environ)
32+
default_env.pop("NINJA_STATUS", None)
33+
default_env.pop("MAKEFLAGS", None)
34+
default_env["TERM"] = "dumb"
35+
NINJA_PATH = os.path.abspath("./ninja")
36+
37+
38+
class BuildDir:
39+
def __init__(self, build_ninja: str):
40+
self.build_ninja = dedent(build_ninja)
41+
self.d: T.Optional[tempfile.TemporaryDirectory] = None
42+
43+
def __enter__(self):
44+
self.d = tempfile.TemporaryDirectory()
45+
with open(os.path.join(self.d.name, "build.ninja"), "w") as f:
46+
f.write(self.build_ninja)
47+
return self
48+
49+
def __exit__(self, exc_type, exc_val, exc_tb):
50+
self.d.cleanup()
51+
52+
@property
53+
def path(self) -> str:
54+
assert self.d
55+
return self.d.name
56+
57+
def run(
58+
self,
59+
cmd_flags: T.Sequence[str] = [],
60+
env: T.Dict[str, str] = default_env,
61+
) -> None:
62+
"""Run a command, raise exception on error. Do not capture outputs."""
63+
ret = subprocess.run(cmd_flags, env=env)
64+
ret.check_returncode()
65+
66+
def ninja_run(
67+
self,
68+
ninja_args: T.List[str],
69+
prefix_args: T.List[str] = [],
70+
extra_env: T.Dict[str, str] = {},
71+
) -> "subprocess.CompletedProcess[str]":
72+
ret = self.ninja_spawn(
73+
ninja_args,
74+
prefix_args=prefix_args,
75+
extra_env=extra_env,
76+
capture_output=False,
77+
)
78+
ret.check_returncode()
79+
return ret
80+
81+
def ninja_clean(self) -> None:
82+
self.ninja_run(["-t", "clean"])
83+
84+
def ninja_spawn(
85+
self,
86+
ninja_args: T.List[str],
87+
prefix_args: T.List[str] = [],
88+
extra_env: T.Dict[str, str] = {},
89+
capture_output: bool = True,
90+
) -> "subprocess.CompletedProcess[str]":
91+
"""Run Ninja command and capture outputs."""
92+
return subprocess.run(
93+
prefix_args + [NINJA_PATH, "-C", self.path] + ninja_args,
94+
text=True,
95+
capture_output=capture_output,
96+
env={**default_env, **extra_env},
97+
)
98+
99+
100+
def span_output_file(span_n: int) -> str:
101+
return "out%02d" % span_n
102+
103+
104+
def generate_build_plan(command_count: int) -> str:
105+
"""Generate a Ninja build plan for |command_count| parallel tasks.
106+
107+
Each task calls the test helper script which waits for 50ms
108+
then writes its own start and end time to its output file.
109+
"""
110+
result = f"""
111+
rule span
112+
command = {sys.executable} -S {_JOBSERVER_TEST_HELPER_SCRIPT} $out
113+
114+
"""
115+
116+
for n in range(command_count):
117+
result += "build %s: span\n" % span_output_file(n)
118+
119+
result += "build all: phony %s\n" % " ".join(
120+
[span_output_file(n) for n in range(command_count)]
121+
)
122+
return result
123+
124+
125+
def compute_max_overlapped_spans(build_dir: str, command_count: int) -> int:
126+
"""Compute the maximum number of overlapped spanned tasks.
127+
128+
This reads the output files from |build_dir| and look at their start and end times
129+
to compute the maximum number of tasks that were run in parallel.
130+
"""
131+
# Read the output files.
132+
if command_count < 2:
133+
return 0
134+
135+
spans: T.List[T.Tuple[int, int]] = []
136+
for n in range(command_count):
137+
with open(os.path.join(build_dir, span_output_file(n)), "rb") as f:
138+
content = f.read().decode("utf-8")
139+
lines = content.splitlines()
140+
assert len(lines) == 2, f"Unexpected output file content: [{content}]"
141+
spans.append((int(lines[0]), int(lines[1])))
142+
143+
# Stupid but simple, for each span, count the number of other spans that overlap it.
144+
max_overlaps = 1
145+
for n in range(command_count):
146+
cur_start, cur_end = spans[n]
147+
cur_overlaps = 1
148+
for m in range(command_count):
149+
other_start, other_end = spans[m]
150+
if n != m and other_end > cur_start and other_start < cur_end:
151+
cur_overlaps += 1
152+
153+
if cur_overlaps > max_overlaps:
154+
max_overlaps = cur_overlaps
155+
156+
return max_overlaps
157+
158+
159+
class JobserverTest(unittest.TestCase):
160+
161+
def test_no_jobserver_client(self):
162+
task_count = 10
163+
build_plan = generate_build_plan(task_count)
164+
with BuildDir(build_plan) as b:
165+
output = b.run([NINJA_PATH, "-C", b.path, "-j0", "all"])
166+
167+
max_overlaps = compute_max_overlapped_spans(b.path, task_count)
168+
self.assertEqual(max_overlaps, task_count)
169+
170+
b.ninja_clean()
171+
output = b.run([NINJA_PATH, "-C", b.path, "-j1", "all"])
172+
173+
max_overlaps = compute_max_overlapped_spans(b.path, task_count)
174+
self.assertEqual(max_overlaps, 1)
175+
176+
def _run_client_test(self, jobserver_args: T.List[str]) -> None:
177+
task_count = 10
178+
build_plan = generate_build_plan(task_count)
179+
with BuildDir(build_plan) as b:
180+
# First, run the full 10 tasks with with 10 tokens, this should allow all
181+
# tasks to run in parallel.
182+
ret = b.ninja_run(
183+
ninja_args=["all"],
184+
prefix_args=jobserver_args + [f"--jobs={task_count}"],
185+
)
186+
max_overlaps = compute_max_overlapped_spans(b.path, task_count)
187+
self.assertEqual(max_overlaps, task_count)
188+
189+
# Second, use 4 tokens only, and verify that this was enforced by Ninja.
190+
b.ninja_clean()
191+
b.ninja_run(
192+
["all"],
193+
prefix_args=jobserver_args + ["--jobs=4"],
194+
)
195+
max_overlaps = compute_max_overlapped_spans(b.path, task_count)
196+
self.assertEqual(max_overlaps, 4)
197+
198+
# Third, verify that --jobs=1 serializes all tasks.
199+
b.ninja_clean()
200+
b.ninja_run(
201+
["all"],
202+
prefix_args=jobserver_args + ["--jobs=1"],
203+
)
204+
max_overlaps = compute_max_overlapped_spans(b.path, task_count)
205+
self.assertEqual(max_overlaps, 1)
206+
207+
# Finally, verify that -j1 overrides the pool.
208+
b.ninja_clean()
209+
b.ninja_run(
210+
["-j1", "all"],
211+
prefix_args=jobserver_args + [f"--jobs={task_count}"],
212+
)
213+
max_overlaps = compute_max_overlapped_spans(b.path, task_count)
214+
self.assertEqual(max_overlaps, 1)
215+
216+
@unittest.skipIf(_PLATFORM_IS_WINDOWS, "These test methods do not work on Windows")
217+
def test_jobserver_client_with_posix_pipe(self):
218+
self._run_client_test([sys.executable, "-S", _JOBSERVER_POOL_SCRIPT, "--pipe"])
219+
220+
@unittest.skipIf(_PLATFORM_IS_WINDOWS, "These test methods do not work on Windows")
221+
def test_jobserver_client_with_posix_fifo(self):
222+
self._run_client_test([sys.executable, "-S", _JOBSERVER_POOL_SCRIPT])
223+
224+
def _test_MAKEFLAGS_value(
225+
self, ninja_args: T.List[str] = [], prefix_args: T.List[str] = []
226+
):
227+
build_plan = r"""
228+
rule print
229+
command = echo MAKEFLAGS="[$$MAKEFLAGS]"
230+
231+
build all: print
232+
"""
233+
with BuildDir(build_plan) as b:
234+
ret = b.ninja_spawn(
235+
ninja_args + ["--quiet", "all"], prefix_args=prefix_args
236+
)
237+
self.assertEqual(ret.returncode, 0)
238+
output = ret.stdout.strip()
239+
pos = output.find("MAKEFLAGS=[")
240+
self.assertNotEqual(pos, -1, "Could not find MAKEFLAGS in output!")
241+
makeflags, sep, _ = output[pos + len("MAKEFLAGS=[") :].partition("]")
242+
self.assertEqual(sep, "]", "Missing ] in output!: " + output)
243+
self.assertTrue(
244+
"--jobserver-auth=" in makeflags,
245+
f"Missing --jobserver-auth from MAKEFLAGS [{makeflags}]\nSTDOUT [{ret.stdout}]\nSTDERR [{ret.stderr}]",
246+
)
247+
248+
def test_client_passes_MAKEFLAGS(self):
249+
self._test_MAKEFLAGS_value(
250+
prefix_args=[sys.executable, "-S", _JOBSERVER_POOL_SCRIPT]
251+
)
252+
253+
254+
if __name__ == "__main__":
255+
unittest.main()

misc/jobserver_test_helper.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
#!/usr/bin/env python3
2+
# Copyright 2024 Google Inc. All Rights Reserved.
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
16+
"""Simple utility used by the jobserver test. Wait for specific time, then write start/stop times to output file."""
17+
18+
import argparse
19+
import time
20+
import sys
21+
from pathlib import Path
22+
23+
24+
def main():
25+
parser = argparse.ArgumentParser(description=__doc__)
26+
parser.add_argument(
27+
"--duration-ms",
28+
default="50",
29+
help="sleep duration in milliseconds (default 50)",
30+
)
31+
parser.add_argument("output_file", type=Path, help="output file name.")
32+
args = parser.parse_args()
33+
34+
now_time_ns = time.time_ns()
35+
time.sleep(int(args.duration_ms) / 1000.0)
36+
args.output_file.write_text(f"{now_time_ns}\n{time.time_ns()}\n")
37+
38+
return 0
39+
40+
41+
if __name__ == "__main__":
42+
sys.exit(main())

0 commit comments

Comments
 (0)