Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support for GNU Make jobserver #94

Open
wants to merge 11 commits into
base: master
Choose a base branch
from

Conversation

capezotte
Copy link
Contributor

@capezotte capezotte commented Oct 28, 2022

Quick and dirty implementation of #71, following the GNU Make 4.3 manual (Section 13.1.

TODO:

  • -l and -j are ignored when jobserver options are active, though CMake team's proposed patch for Ninja instead ignores jobserver options whenever -j is given in the command line. Should we follow them?
  • Aborts whenever given jobserver FDs are closed/invalid. Should it be downgraded to warning?
  • Make sure it works with older GNU Make (pre-4.2, with different option names).
  • The fifo: protocol mentioned on Support make jobserver protocol #71.
  • Tokens should be returned to GNU Make when the program terminates. This is probably the biggest issue with this PR, but I'd like feedback on the "core" of the changes before embarking on rewriting fatal and installing signal handlers.

@michaelforney
Copy link
Owner

This looks really good, thanks for working on it.

Some still-relevant distros ship with older GNU Make (pre-4.2, with different option names).

Can you elaborate? What happens if samu gets run with an old GNU make? Does it break, or just not work?

Tokens aren't returned to GNU Make when the program terminates.

Can we install an atexit function to clean them up?

@capezotte
Copy link
Contributor Author

Can you elaborate? What happens if samu gets run with an old GNU make? Does it break, or just not work?

Old GNU make uses --jobserver-fds= instead of --jobserver-auth= in the environment. The protocol isn't documented in the old version of manual, so I was unsure.

After some testing and a cursory reading of the source code, it seems that, other than that, it's the same.

Can we install an atexit function to clean them up?

Good idea, just implemented it and tokens are returned after fatal and after a normal exit.

@capezotte capezotte marked this pull request as ready for review October 31, 2022 16:19
build.c Outdated Show resolved Hide resolved
@thesamesam
Copy link

@michaelforney are you able to take a look? thank you!

@michaelforney
Copy link
Owner

@thesamesam There are several outstanding comments that still need to be resolved.

@trws
Copy link

trws commented Nov 28, 2023

What remains to be done here? Sorry for the drive-by comment, but I've been looking for a ninja alternative with this feature to use with our package manager, which allows doing a build of an entire tree of packages under a single jobserver. If I could help out a bit, I'd be happy to.

One note, this line: https://github.com/michaelforney/samurai/pull/94/files#diff-99b168f07bbb2d0e68bfe3cd8378543a6becd5989922c5283e499d1f69879b42R324
mentions children "stealing" tokens. If possible, it would be best to allow children to take tokens. This implementation allows them to with the gnu make 4.3 style interface, but not the old one. Reason being that things like cargo, spack, make, ld.gold and others can use that for their own parallelism instead of doing their own fanout and over-loading the system.

On a related note, would you like to have jobserver server support here as well?

@michaelforney
Copy link
Owner

michaelforney commented Feb 23, 2024

What remains to be done here? Sorry for the drive-by comment, but I've been looking for a ninja alternative with this feature to use with our package manager, which allows doing a build of an entire tree of packages under a single jobserver. If I could help out a bit, I'd be happy to.

See the unresolved comments. In particular, the one about polling. There should be a single poll in the event loop.

I pushed my current working tree to the jobserver branch. I don't remember the state, but if someone else wants to help out with this, that'd be a good place to start. Edit: on second thought, maybe not. It seems that my local changes don't even compile.

Copy link
Owner

@michaelforney michaelforney left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, turns out that the comments I thought I made were never submitted. My apologies.

build.c Outdated Show resolved Hide resolved
samu.c Outdated
arg = strtok(env, " ");
/* first word might contain dry run */
if (arg && strchr(arg, 'n'))
buildopts.dryrun = true;
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you explain this? As far as I can tell, the first word is supposed to be the single-character options, right? I see that by default it is just w. However, if I run make --no-print-directory, it is empty with just a blank space, so I think we might accidentally skip over the jobserver flags.

Another question: how is samu running in the first place if make is in dry run mode?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That does seem to be what the first word is for, though the protocol documentation only goes as far as saying "check the first word for the letter n". Thanks for catching the oversight.

As for the second question, GNU Make (version 4.4.1 my system) invokes jobserver clients even in dry run mode.

samu.c Outdated Show resolved Hide resolved
samu.c Outdated
/* assert valid fds */
check[0].fd = read_end;
check[1].fd = write_end;
if (poll(check, 2, 0) == -1 || check[0].revents & POLLNVAL || check[1].revents & POLLNVAL) {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we should do a validity check like this. If the fds are invalid that's the caller's problem, and I'd like to minimize the POSIX usage as much as possible.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The documentation recommends doing the check. As it's there mostly to address a PEBKAC (user forgot a + in the Makefile), I do understand if you want to drop it.

build.c Outdated Show resolved Hide resolved
@michaelforney
Copy link
Owner

One question I have, should a jobserver client request a token for every job it starts and release it when its done? Or is it fine if samurai takes as many jobs as it can use, and release them only when it has extra tokens that it can't use?

I don't think it would make a difference, except maybe collective ordering of the jobs run.

@eli-schwartz
Copy link

If it's going to immediately reuse those tokens then I think it's fine.

build.c Outdated
@@ -610,6 +672,13 @@ build(void)
next = i;
if (jobs[i].failed)
++numfail;
/* must return the GNU/tokens once a job is done */
if (buildopts.gmakepipe[1] > 0 && gmakelatest != gmaketokens) {
if (write(buildopts.gmakepipe[1], --gmakelatest, 1) < 0) {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure if it matters, but this might not be the same job token that we acquired to start this job. Maybe we should track the job token in the job struct?

Copy link
Contributor Author

@capezotte capezotte Apr 1, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this is necessary, it's not like make knows how exactly the job slots are used by the client. In fact, despite documentation saying "don’t assume that all tokens are the same character", in my testing they usually are.

samu.c Outdated Show resolved Hide resolved
samu.c Outdated
errno = EBADF;
} else if (strncmp(flag, "fifo:", 5) == 0) {
rfd = open(flag + 5, O_RDONLY);
wfd = open(flag + 5, O_WRONLY);
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should just open it once as O_RDWR and set both rfd and wfd to the same value.

Also, we should print a message if this happens.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Print a message whenever a FIFO is used?

samu.c Outdated Show resolved Hide resolved
build.c Outdated
@@ -319,6 +327,15 @@ jobstart(struct job *j, struct edge *e)
warn("posix_spawn_file_actions_addclose:");
goto err3;
}
if (isjobserverclient()) {
/* do not allow children to steal GNU/tokens */
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As @trws asked, do we need to prevent children from interacting with the job server?

If we do, I think it'd be better to set FD_CLOEXEC on the fds than closing them here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't remember why I thought of that in the first place (nowhere in the documentation is this recommended).

After testing for a bit, it seems GNU Make uses the same --jobserver-auth parameters for every client, so it's fine for samu to share the jobserver with its own clients.

build.c Outdated Show resolved Hide resolved
fix return type for reachedload
avoid needlessly changing errno
allow children to take tokens
use only one fd with 'fifo:' authentication type
fix |=/&= mixup
@bonzini
Copy link
Contributor

bonzini commented Apr 19, 2024

There should be a single poll in the event loop.

My implementation was completely different and I have now refreshed it. It used a separate thread to read from the GNU Make pipe, and a local pipe between the thread and build() to integrate with the event loop.

@bonzini
Copy link
Contributor

bonzini commented Apr 19, 2024

The main issue with this implementation is that if you have make -j26 and your build is

      a     b     c                large files that take a huge time
       `----+----'
            d
  ,----.----+--------.
 e     f    g  ....   z            small files that take a small time

then samu will take all 26 tokens and block other users of the jobserver pipe. Instead, tokens must be taken only when jobs are started.

@eli-schwartz
Copy link

@bonzini I haven't looked at the implementation at all. But, based on the previous review comment:

One question I have, should a jobserver client request a token for every job it starts and release it when its done? Or is it fine if samurai takes as many jobs as it can use, and release them only when it has extra tokens that it can't use?

I had been under the assumption that in the case you describe, samu would relinquish 23 tokens as soon as it detects it is blocked on a/b/c and can't start d.

@bonzini
Copy link
Contributor

bonzini commented Apr 19, 2024

The only write system call that I see in the jobserver branch is

+                       /* must return the GNU/tokens once a job is done */
+                       if (buildopts.gmakepipe[1] > 0 && gmakelatest != gmaketokens) {
+                               if (write(buildopts.gmakepipe[1], --gmakelatest, 1) < 0) {
+                                       warn("write to jobserver:");
+                               }
+                               --maxjobs;
+                       }

so I don't think it does, though of course I might be wrong.

}
}
if (fds[0].revents & POLLIN && isjobserverclient() && nstarted < ntotal && !reachedload()) {
gmakeread = read(buildopts.gmakepipe[0], gmakelatest, buildopts.maxjobs - numjobs);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a race condition here:

  • poll returns POLLIN
  • another process steals the token that poll detected
  • read blocks
  • one or more children of samurai finish
  • the whole build cannot reuse the token(s) currently owned by samurai

The solution used by GNU Make is complex and relies on trapping SIGCHLD and dup-ing the file descriptor. It's complicated and it's why I just went for reading the jobserver file descriptor in a separate thread instead.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not even sure GNU Make's solution is 100% robust without an extra (void)alarm(1);, or only starting a new process as an absolute last resort.

I really have to rethink this implementation.

@capezotte
Copy link
Contributor Author

I'm starting to think of an in-between approach of having the job spawn a helper program that waits for a token, feeds it back to samurai through a second pipe (so it's stored in the associated job struct, or the gmakelatest in this PR), and execs the actual job. Only samurai would have read access to this second pipe, so there is no way of POLLIN getting outdated before read.

Once I have more time I might rework this PR into this design, but I'd like for feedback.

@bonzini
Copy link
Contributor

bonzini commented May 3, 2024

That seems similar to what I am doing, just with a process instead of a thread. You could also use fork() to do the wait, instead of using a helper program.

However, there would probably be more complications in returning the tokens on a call to fatal(). samurai does not attempt to kill child processes in that case, instead leaving them to die because of a broken pipe if they write anything to stdout/stderr; but in this case the children would also survive until they get a token. This would be untidy. And also, to ensure that the token is put back if the parent is dead, you would have to catch SIGPIPE (or temporarily ignore it so that you get EPIPE).

As an aside, it' s ok not to return the tokens on SIGINT/SIGTERM. I tried using a Makefile like

test-parent:
	$(MAKE) -f parent-makefile test-child1 test-child2 test-child3

test-child%:

Then invoking make -j3 -Onone and killing the child make in another terminal. Both SIGKILL and SIGTERM have the same behavior and show:

make[1]: *** [Makefile:10: test-child1] Terminated
make[1]: *** [Makefile:10: test-child3] Terminated
make[1]: *** [Makefile:10: test-child2] Terminated
make: *** [Makefile:7: test-parent] Terminated
make: INTERNAL: Exiting with 1 jobserver tokens available; should be 3!

So it's not a huge deal. But if you want to do it, a better way is to enhance samurai so that it traps SIGTERM (#106), killing all the children in response. The basic idea would be as simple as

diff --git a/build.c b/build.c
index 65305d2..877d04c 100644
--- a/build.c
+++ b/build.c
@@ -494,6 +494,9 @@ jobwork(struct job *j)
 	size_t newcap;
 	ssize_t n;
 
+	if (killall)
+		goto kill;
+
 	if (j->buf.cap - j->buf.len < BUFSIZ / 2) {
 		newcap = j->buf.cap + BUFSIZ;
 		newdata = realloc(j->buf.data, newcap);
@@ -560,7 +563,7 @@ build(void)
 		if (buildopts.maxload)
 			maxjobs = queryload() > buildopts.maxload ? 1 : buildopts.maxjobs;
 		/* start ready edges */
-		while (work && numjobs < maxjobs && numfail < buildopts.maxfail) {
+		while (work && !killall && numjobs < maxjobs && numfail < buildopts.maxfail) {
 			e = work;
 			work = work->worknext;
 			if (e->rule != &phonyrule && buildopts.dryrun) {
@@ -616,10 +619,12 @@ build(void)
 		}
 		if (numjobs == 0)
 			break;
-		if (poll(fds, jobslen, 5000) < 0)
+		if (poll(fds, jobslen, 5000) < 0 && errno != EINTR)
 			fatal("poll:");
 		for (i = 0; i < jobslen; ++i) {
-			if (!fds[i].revents || jobwork(&jobs[i]))
+			if (!killall && !fds[i].revents)
+				continue;
+			if (jobwork(&jobs[i]))
 				continue;
 			--numjobs;
 			jobs[i].next = next;

where the killall variable is set by the signal handler. By terminating all jobs cleanly, the tokens would also be returned.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

7 participants