Skip to content

Commit

Permalink
parallel-checkout: auto-enable parallelism on NFS
Browse files Browse the repository at this point in the history
Parallel checkout is usually faster than the default sequential version
when writing to SSD or to NFS mounts. For NFS, this is true even when
the client and the server are both running on single-core machines, and
regardless of the NFS version.

Below are the mean runtimes and standard deviations for 5 cold-cache
executions of a local linux-v5.8 clone, on Linux, with increasing number
of checkout workers. The NFS client, where Git was invoked, is an Amazon
EC2 c5n.large instance, and the NFS server is a m6g.large instance. Both
have two vCPUs, but I disabled the additional core, to simulate
single-core machines.

    nfs 3.0              nfs 4.0              nfs 4.1
1:  183.708 s ± 3.290 s  205.851 s ± 0.844 s  217.317 s ± 3.047 s
2:  130.510 s ± 3.917 s  139.124 s ± 0.772 s  142.963 s ± 0.765 s
4:   89.611 s ± 1.032 s  102.701 s ± 1.665 s   94.728 s ± 1.014 s
8:   68.097 s ± 0.820 s  104.914 s ± 1.239 s   69.359 s ± 0.619 s
16:  63.999 s ± 0.820 s  104.808 s ± 2.279 s   64.843 s ± 0.587 s
32:  62.316 s ± 2.095 s  102.105 s ± 1.537 s   64.122 s ± 0.374 s
64:  63.699 s ± 0.841 s  103.103 s ± 1.319 s   63.532 s ± 0.734 s

The parallel version was also faster on small checkouts. Below are the
benchmark numbers for a local clone of the 'bat' repository, in the same
environment of the previous test but with 15 repetitions. The version of
'bat' cloned (v0.16.0) has 251 files, which correspond to about 3MB.
(The default threshold to enable parallelism is 100 files).

     nfs 3.0             nfs 4.0             nfs 4.1
1:   0.853 s ± 0.080 s   0.814 s ± 0.020 s   0.876 s ± 0.065 s
2:   0.671 s ± 0.020 s   0.702 s ± 0.030 s   0.705 s ± 0.030 s
4:   0.530 s ± 0.024 s   0.595 s ± 0.020 s   0.570 s ± 0.030 s
8:   0.470 s ± 0.033 s   0.609 s ± 0.025 s   0.510 s ± 0.031 s
16:  0.469 s ± 0.037 s   0.616 s ± 0.022 s   0.513 s ± 0.030 s
32:  0.487 s ± 0.030 s   0.639 s ± 0.018 s   0.527 s ± 0.028 s
64:  0.520 s ± 0.022 s   0.680 s ± 0.028 s   0.562 s ± 0.026 s

From these results, it seems that it is always advantageous to use
parallel-checkout on NFS, even on single-core machines and for
considerably small numbers of files. So, seeking to provide users with
the best performance by default, let's attempt to detect the file system
type and auto-enable parallelism on NFS (only when `checkout.workers` is
not already set). From the above numbers, 16 workers seem to be a good
default. If detection fails, we just fall back to the regular sequential
code.

Note: inferring the type of a file system is a platform-specific
problem. So the auto-detection is currently only implemented on Linux,
where we already verified that 16 workers usually brings the best
performance. Also, there are a number of ways to query the mounts
table on Linux, but we choose to manually read the /etc/mtab file. Other
alternatives that were discarded include:

  * statfs(): (non-POSIX) marked as deprecated.
  * getmntent(): (non-POSIX) discarded because that are implementation
    differences among the different libc's. For example, the glibc
    version unescapes the mount paths, whereas musl leaves them as
    they appear on the mount table.
  * statvfs(): it's in POSIX, but doesn't provide file system type.
  * libmount: adds a new dependency.

The function unscape_mtab_path() was copied from
misc/mntent_r.c:decode_name() in glibc commit
75a193b7611bade31a150dfcc528b973e3d46231, and adapted to use Git's
internal API and coding style. The original implementation is licensed
under LGPLv2.1. According to [1], it can be relicensed here to GPLv2.

Finally, two tests were added to make sure that the file system
detection is correctly working, and that the sequential code is used for
non-NFS checkouts.

[1]: https://www.gnu.org/licenses/gpl-faq.html#AllCompatibility

Signed-off-by: Matheus Tavares <matheus.bernardino@usp.br>
  • Loading branch information
matheustavares committed Nov 15, 2020
1 parent d8c8ae5 commit 2e2c787
Show file tree
Hide file tree
Showing 9 changed files with 215 additions and 16 deletions.
22 changes: 12 additions & 10 deletions Documentation/config/checkout.txt
Original file line number Diff line number Diff line change
Expand Up @@ -19,17 +19,19 @@ commands or functionality in the future.

checkout.workers::
The number of parallel workers to use when updating the working tree.
The default is one, i.e. sequential execution. If set to a value less
than one, Git will use as many workers as the number of logical cores
available. This setting and `checkout.thresholdForParallelism` affect
all commands that perform checkout. E.g. checkout, clone, reset,
sparse-checkout, etc.
The default is one, i.e. sequential execution, unless the checkout path
is in an NFS mount (see more below). If set to a value less than one,
Git will use as many workers as the number of logical cores available.
This setting and `checkout.thresholdForParallelism` affect all commands
that perform checkout. E.g. checkout, clone, reset, sparse-checkout,
etc.
+
Note: parallel checkout usually delivers better performance for repositories
located on SSDs or over NFS. For repositories on spinning disks and/or machines
with a small number of cores, the default sequential checkout often performs
better. The size and compression level of a repository might also influence how
well the parallel version performs.
For repositories on spinning disks, the default sequential checkout often
performs better. But on SSDs and over NFS, the parallel version usually
delivers the best performance. On NFS, this is observed even with single-core
processors. For this reason, on Linux, git automatically enables parallelism
(16 workers) when it detects that the checkout path resides in an NFS mount.
Setting `checkout.workers` to any value disables the file system detection.

checkout.thresholdForParallelism::
When running parallel checkout with a small number of files, the cost
Expand Down
2 changes: 1 addition & 1 deletion builtin/checkout-index.c
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,7 @@ int cmd_checkout_index(int argc, const char **argv, const char *prefix)
}

if (!to_tempfile)
get_parallel_checkout_configs(&pc_workers, &pc_threshold);
get_parallel_checkout_configs(&state, &pc_workers, &pc_threshold);
else
pc_workers = 1;

Expand Down
2 changes: 1 addition & 1 deletion builtin/checkout.c
Original file line number Diff line number Diff line change
Expand Up @@ -368,7 +368,7 @@ static int checkout_worktree(const struct checkout_opts *opts,
state.istate = &the_index;

mem_pool_init(&ce_mem_pool, 0);
get_parallel_checkout_configs(&pc_workers, &pc_threshold);
get_parallel_checkout_configs(&state, &pc_workers, &pc_threshold);
init_checkout_metadata(&state.meta, info->refname,
info->commit ? &info->commit->object.oid : &info->oid,
NULL);
Expand Down
133 changes: 131 additions & 2 deletions parallel-checkout.c
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,136 @@ enum pc_status parallel_checkout_status(void)
return parallel_checkout.status;
}

#if defined(__linux__)

static int checkout_base_path(struct checkout *state, struct strbuf *path)
{
struct strbuf dirname = STRBUF_INIT;
int ret, sep = state->base_dir_len - 1;

while (sep >= 0 && !is_dir_sep(state->base_dir[sep]))
sep--;

if (sep < 0)
return strbuf_getcwd(path);

strbuf_add(&dirname, state->base_dir, sep + 1);
ret = !longest_realpath(path, dirname.buf, 0);
strbuf_release(&dirname);

return ret;
}


/*
* Undo the mtab escaping in-place.
*
* This function was copied from misc/mntent_r.c:decode_name() in glibc commit
* 75a193b7611bade31a150dfcc528b973e3d46231, and adapted to use Git's internal
* API and coding style. The original implementation is licensed under LGPLv2.1.
* Here it's relicensed to GPLv2.
*/
static void unescape_mnt_path(char *mnt_path)
{
const char *in = mnt_path;
char *out = mnt_path;

while (*in) {
if (skip_prefix(in, "\\134", &in) || skip_prefix(in, "\\\\", &in))
*out++ = '\\';
else if (skip_prefix(in, "\\040", &in))
*out++ = ' ';
else if (skip_prefix(in, "\\011", &in))
*out++ = '\t';
else if (skip_prefix(in, "\\012", &in))
*out++ = '\n';
else
*out++ = *in++;
}

*out = '\0';
}

#ifdef _PATH_MOUNTED
# define MTAB_PATH _PATH_MOUNTED
#else
# define MTAB_PATH "/etc/mtab"
#endif

#define MTAB_DELIM "\t "

static int is_nfs_checkout(struct checkout *state)
{
int ret = 0, longest_mnt_prefix = 0;
struct strbuf checkout_path = STRBUF_INIT, mnt_type = STRBUF_INIT;
struct strbuf mtab_line = STRBUF_INIT;
char *env_mtab_path = getenv("GIT_TEST_MTAB_PATH");
FILE *mtab_file = fopen(env_mtab_path ? env_mtab_path : MTAB_PATH, "r");

if (!mtab_file)
return 0;

if (checkout_base_path(state, &checkout_path))
goto out;

while (strbuf_getline_lf(&mtab_line, mtab_file) != EOF) {
char *mnt_path, *first_field = strtok(mtab_line.buf, MTAB_DELIM);
int mnt_path_len;

if (!first_field || *first_field == '#')
continue;

mnt_path = strtok(NULL, MTAB_DELIM);
if (!mnt_path)
goto out; /* corrupted mtab? */

if (!strcmp(mnt_path, "none"))
continue;

unescape_mnt_path(mnt_path);
mnt_path_len = strlen(mnt_path);

if (mnt_path_len > longest_mnt_prefix &&
is_path_prefix(checkout_path.buf, mnt_path)) {

char *mnt_type_str = strtok(NULL, MTAB_DELIM);
if (!mnt_type_str)
goto out; /* corrupted mtab? */

strbuf_reset(&mnt_type);
strbuf_addstr(&mnt_type, mnt_type_str);

longest_mnt_prefix = mnt_path_len;
if (longest_mnt_prefix == checkout_path.len)
break;
}
}

if (starts_with(mnt_type.buf, "nfs"))
ret = 1;

out:
strbuf_release(&checkout_path);
strbuf_release(&mnt_type);
strbuf_release(&mtab_line);
fclose(mtab_file);
return ret;
}

#else /* !defined(__linux__) */

static int is_nfs_checkout(struct checkout *state)
{
return 0; /* unknown */
}

#endif

#define DEFAULT_THRESHOLD_FOR_PARALLELISM 100
#define DEFAULT_WORKERS_ON_NFS 16

void get_parallel_checkout_configs(int *num_workers, int *threshold)
void get_parallel_checkout_configs(struct checkout *state, int *num_workers,
int *threshold)
{
char *env_workers = getenv("GIT_TEST_CHECKOUT_WORKERS");

Expand All @@ -47,12 +174,14 @@ void get_parallel_checkout_configs(int *num_workers, int *threshold)
}

if (git_config_get_int("checkout.workers", num_workers))
*num_workers = 1;
*num_workers = is_nfs_checkout(state) ? DEFAULT_WORKERS_ON_NFS : 1;
else if (*num_workers < 1)
*num_workers = online_cpus();

if (git_config_get_int("checkout.thresholdForParallelism", threshold))
*threshold = DEFAULT_THRESHOLD_FOR_PARALLELISM;

trace2_data_intmax("parallel-checkout", NULL, "num_workers", *num_workers);
}

void init_parallel_checkout(void)
Expand Down
3 changes: 2 additions & 1 deletion parallel-checkout.h
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ enum pc_status {
};

enum pc_status parallel_checkout_status(void);
void get_parallel_checkout_configs(int *num_workers, int *threshold);
void get_parallel_checkout_configs(struct checkout *state, int *num_workers,
int *threshold);
void init_parallel_checkout(void);

/*
Expand Down
23 changes: 23 additions & 0 deletions path.c
Original file line number Diff line number Diff line change
Expand Up @@ -1277,6 +1277,29 @@ int ends_with_path_components(const char *path, const char *components)
return stripped_path_suffix_offset(path, components) != -1;
}

int is_path_prefix(const char *path, const char *prefix_candidate)
{
const char *trailing;
int prefix_len;

if (!*path || !*prefix_candidate)
BUG("is_path_prefix(): the empty string is not a valid path.");

if (!skip_prefix(path, prefix_candidate, &trailing))
return 0;

prefix_len = trailing - path;

/*
* OK, the candidate is a prefix, but is it a *path* prefix?
*
* - '/a' is only a path prefix of '/a/...' and '/a', not '/ab'
* - '/' is a path prefix of '/a' (same for 'C:/' and 'C:/a')
*/
return is_dir_sep(*trailing) || !*trailing ||
is_dir_sep(prefix_candidate[prefix_len - 1]);
}

/*
* If path ends with suffix (complete path components), returns the
* part before suffix (sans trailing directory separators).
Expand Down
3 changes: 3 additions & 0 deletions path.h
Original file line number Diff line number Diff line change
Expand Up @@ -197,4 +197,7 @@ const char *git_path_shallow(struct repository *r);

int ends_with_path_components(const char *path, const char *components);

/* Note: both `path` and `prefix_candidate` must be in canonical form. */
int is_path_prefix(const char *path, const char *prefix_candidate);

#endif /* PATH_H */
41 changes: 41 additions & 0 deletions t/t2080-parallel-checkout-basics.sh
Original file line number Diff line number Diff line change
Expand Up @@ -167,4 +167,45 @@ test_expect_success SYMLINKS,CASE_INSENSITIVE_FS 'symlink colliding with leading
)
'

if test "$(uname -s)" = Linux
then
test_set_prereq LINUX
fi

test_expect_success LINUX 'defaults to sequential version for non-NFS checkouts (on Linux)' '
echo "none / ext4 defaults 0 0" >mtab-non-nfs &&
GIT_TEST_MTAB_PATH="$PWD/mtab-non-nfs" GIT_TRACE2_EVENT="$PWD/trace-non-nfs" \
git clone various various-non-nfs &&
grep "\"category\":\"parallel-checkout\",\"key\":\"num_workers\",\"value\":\"1\"" trace-non-nfs
'

test_expect_success LINUX 'auto-parallelizes NFS checkouts (on Linux)' '
echo "none / nfs4 defaults 0 0" >mtab-nfs &&
GIT_TEST_MTAB_PATH="$PWD/mtab-nfs" GIT_TRACE2_EVENT="$PWD/trace-nfs" \
git clone various various-nfs &&
grep "\"category\":\"parallel-checkout\",\"key\":\"num_workers\",\"value\":\"16\"" trace-nfs
'

mtab_escape()
{
printf "%s" "$@" |
sed -e 's/\\/\\134/g' -e 's/ /\\040/g' -e 's/\t/\\011/g' -e 's/\n/\\012/g'
}

# Also test more unusual cases, with an escaped mount point and a checkout
# prefix containing: uncommon chars, symlinks, and nonexistent directories.
#
test_expect_success LINUX,SYMLINKS,FUNNYNAMES 'auto-parallelizes NFS checkout with funny prefix (on Linux)' '
nfs_dir="$PWD/dir\\ with\nfunny\tchars" &&
mkdir "$nfs_dir" &&
ln -s "$nfs_dir" nfs-link &&
echo "none / ext4 defaults 0 0" >mtab-nfs2 &&
printf "none %s nfs defaults 0 0" "$(mtab_escape "$nfs_dir")" >>mtab-nfs2 &&
GIT_TEST_MTAB_PATH="$PWD/mtab-nfs2" GIT_TRACE2_EVENT="$PWD/trace-nfs2" \
git -C various checkout-index --prefix=../nfs-link/sub/other/ --all &&
grep "\"category\":\"parallel-checkout\",\"key\":\"num_workers\",\"value\":\"16\"" trace-nfs2
'

test_done
2 changes: 1 addition & 1 deletion unpack-trees.c
Original file line number Diff line number Diff line change
Expand Up @@ -462,7 +462,7 @@ static int check_updates(struct unpack_trees_options *o,
oid_array_clear(&to_fetch);
}

get_parallel_checkout_configs(&pc_workers, &pc_threshold);
get_parallel_checkout_configs(&state, &pc_workers, &pc_threshold);

enable_delayed_checkout(&state);
if (pc_workers > 1)
Expand Down

0 comments on commit 2e2c787

Please sign in to comment.