git/list[1] front-page[2] threads[3] people[4] search[5] about
 

Re: [PATCH 5/6] dir: replace exponential algorithm with a linear one

From
Derrick Stolee <stolee@gmail.com>
Date
Jan 30, 2020, 15:55 UTC
Message-ID
<59b5b766-29e2-a709-b407-56bf6ea60b47@gmail.com>
In-Reply-To
<40b378e7adbbff5ecfd95fd888465fd0f99791c8.1580335424.git.gitgitgadget@gmail.com>
I am very enticed by the subject!
On 1/29/2020 5:03 PM, Elijah Newren via GitGitGadget wrote:
> Unfortunately, commit df5bcdf83aeb ("dir: recurse into untracked dirs
> for ignored files", 2017-05-18), added exactly such a case to the code,

I was disappointed that the commit you mention did not add a test for the new behavior, but then found a test change in the following commit fb89888849 (dir: hide untracked contents of untracked dirs, 2017-05-18). This makes me feel better that your changes are less likely to un-do the intention of df5bcdf83aeb.

Show 8 quoted lines
> meaning we'd have two calls to read_directory_recursive() for an
> untracked directory.  So, if we had a file named
>    one/two/three/four/five/somefile.txt
> and nothing in one/ was tracked, then 'git status --ignored' would
> call read_directory_recursive() twice on the directory 'one/', and
> each of those would call read_directory_recursive() twice on the
> directory 'one/two/', and so on until read_directory_recursive() was
> called 2^5 times for 'one/two/three/four/five/'.

Wow! Good find. "Accidentally exponential" is a lot worse than "accidentally quadratic". At least the N here _usually_ does not grow too quickly, but the constant here (lstat-ing directories and files) is significant enough that 2^3 or 2^4 is enough to notice the difference.

Show 25 quoted lines
> Avoid calling read_directory_recursive() twice per level by moving a
> lot of the special logic into treat_directory().
> 
> Since dir.c is somewhat complex, extra cruft built up around this over
> time.  While trying to unravel it, I noticed several instances where the
> first call to read_directory_recursive() would return e.g.
> path_untracked for a some directory and a later one would return e.g.
> path_none, and the code relied on the side-effect of the first adding
> untracked entries to dir->entries in order to get the correct output
> despite the supposed override in return value by the later call.
>
> I am somewhat concerned that there are still bugs and maybe even
> testcases with the wrong expectation.  I have tried to carefully
> document treat_directory() since it becomes more complex after this
> change (though much of this complexity came from elsewhere that probably
> deserved better comments to begin with).  However, much of my work felt
> more like a game of whackamole while attempting to make the code match
> the existing regression tests than an attempt to create an
> implementation that matched some clear design.  That seems wrong to me,
> but the rules of existing behavior had so many special cases that I had
> a hard time coming up with some overarching rules about what correct
> behavior is for all cases, forcing me to hope that the regression tests
> are correct and sufficient.  (I'll note that this turmoil makes working
> with dir.c extremely unpleasant for me; I keep hoping it'll get better,
> but it never seems to.)

Keep fighting the good fight! It appears that some of our most-important code has these complicated cases and side-effects because it has grown so organically over time. It's unlikely that someone _could_ rewrite it to avoid that pain, as dir.c contains a lot of accumulated knowledge from the many special-cases Git handles. I suppose the only thing we can do is try to write as many detailed tests as possible.

Show 32 quoted lines
> However, on the positive side, it does make the code much faster.  For
> the following simple shell loop in an empty repository:
> 
>   for depth in $(seq 10 25)
>   do
>     dirs=$(for i in $(seq 1 $depth) ; do printf 'dir/' ; done)
>     rm -rf dir
>     mkdir -p $dirs
>     >$dirs/untracked-file
>     /usr/bin/time --format="$depth: %e" git status --ignored >/dev/null
>   done
> 
> I saw the following timings, in seconds (note that the numbers are a
> little noisy from run-to-run, but the trend is very clear with every
> run):
> 
>     10: 0.03
>     11: 0.05
>     12: 0.08
>     13: 0.19
>     14: 0.29
>     15: 0.50
>     16: 1.05
>     17: 2.11
>     18: 4.11
>     19: 8.60
>     20: 17.55
>     21: 33.87
>     22: 68.71
>     23: 140.05
>     24: 274.45
>     25: 551.15

Are these timings on Linux? I imagine that the timings will increase much more quickly on Windows.

> After this fix, those drop to:
> 
>     10: 0.00
...
>     25: 0.00

Nice. I wonder if presenting these 0.00 values as a table is worth the space? At least the effect is dramatic.

Show 7 quoted lines
> In fact, it isn't until a depth of 190 nested directories that it
> sometimes starts reporting a time of 0.01 seconds and doesn't
> consistently report 0.01 seconds until there are 240 nested directories.
> The previous code would have taken
>   17.55 * 2^220 / (60*60*24*365) = 9.4 * 10^59 YEARS
> to have completed the 240 nested directories case.  It's not often
> that you get to speed something up by a factor of 3*10^69.
Awesome.
> WARNING: This change breaks t7063.  I don't know whether that is to be expected
> (I now intentionally visit untracked directories differently so naturally the
> untracked cache should change), or if I've broken something.  I'm hoping to get
> an untracked cache expert to chime in...

I suppose that when the untracked cache is enabled, your expectation that we do not need to recurse into an untracked directory is incorrect: we actually want to explore that directory. Is there a mode we can check to see if we are REALLY REALLY collecting _all_ untracked paths? Perhaps we need to create one?

I'm CC'ing Kevin Willford because he is more familiar with the Git index than me, and perhaps the untracked cache in particular.

Show 36 quoted lines
> Signed-off-by: Elijah Newren <newren@gmail.com>
> ---
>  dir.c | 151 ++++++++++++++++++++++++++++++++++++++++------------------
>  1 file changed, 105 insertions(+), 46 deletions(-)
> 
> diff --git a/dir.c b/dir.c
> index ef3307718a..aaf038a9c4 100644
> --- a/dir.c
> +++ b/dir.c
> @@ -1659,7 +1659,13 @@ static enum path_treatment treat_directory(struct dir_struct *dir,
>  	const char *dirname, int len, int baselen, int excluded,
>  	const struct pathspec *pathspec)
>  {
> -	int nested_repo;
> +	/*
> +	 * WARNING: From this function, you can return path_recurse or you
> +	 *          can call read_directory_recursive() (or neither), but
> +	 *          you CAN'T DO BOTH.
> +	 */
> +	enum path_treatment state;
> +	int nested_repo, old_ignored_nr, stop_early;
>  
>  	/* The "len-1" is to strip the final '/' */
>  	switch (directory_exists_in_index(istate, dirname, len-1)) {
> @@ -1713,18 +1719,101 @@ static enum path_treatment treat_directory(struct dir_struct *dir,
>  
>  	/* This is the "show_other_directories" case */
>  
> -	if (!(dir->flags & DIR_HIDE_EMPTY_DIRECTORIES))
> +	/*
> +	 * We only need to recurse into untracked/ignored directories if
> +	 * either of the following bits is set:
> +	 *   - DIR_SHOW_IGNORED_TOO (because then we need to determine if
> +	 *                           there are ignored directories below)
> +	 *   - DIR_HIDE_EMPTY_DIRECTORIES (because we have to determine if
> +	 *                                 the directory is empty)

Perhaps here is where you could also have a DIR_LIST_ALL_UNTRACKED flag to ensure the untracked cache loads all untracked paths?

Show 21 quoted lines
> +	 */
> +	if (!(dir->flags & (DIR_SHOW_IGNORED_TOO | DIR_HIDE_EMPTY_DIRECTORIES)))
>  		return excluded ? path_excluded : path_untracked;
>  
> +	/*
> +	 * If we only want to determine if dirname is empty, then we can
> +	 * stop at the first file we find underneath that directory rather
> +	 * than continuing to recurse beyond it.  If DIR_SHOW_IGNORED_TOO
> +	 * is set, then we want MORE than just determining if dirname is
> +	 * empty.
> +	 */
> +	stop_early = ((dir->flags & DIR_HIDE_EMPTY_DIRECTORIES) &&
> +		      !(dir->flags & DIR_SHOW_IGNORED_TOO));
> +
> +	/*
> +	 * If /every/ file within an untracked directory is ignored, then
> +	 * we want to treat the directory as ignored (for e.g. status
> +	 * --porcelain), without listing the individual ignored files
> +	 * underneath.  To do so, we'll save the current ignored_nr, and
> +	 * pop all the ones added after it if it turns out the entire
> +	 * directory is ignored.

Here is a question for an untracked cache expert: Do we store ignored paths in the untracked cache?

Show 72 quoted lines
> +	 */
> +	old_ignored_nr = dir->ignored_nr;
> +
> +	/* Actually recurse into dirname now, we'll fixup the state later. */
>  	untracked = lookup_untracked(dir->untracked, untracked,
>  				     dirname + baselen, len - baselen);
> +	state = read_directory_recursive(dir, istate, dirname, len, untracked,
> +					 stop_early, stop_early, pathspec);
> +
> +	/* There are a variety of reasons we may need to fixup the state... */
> +	if (state == path_excluded) {
> +		int i;
> +
> +		/*
> +		 * When stop_early is set, read_directory_recursive() will
> +		 * never return path_untracked regardless of whether
> +		 * underlying paths were untracked or ignored (because
> +		 * returning early means it excluded some paths, or
> +		 * something like that -- see commit 5aaa7fd39aaf ("Improve
> +		 * performance of git status --ignored", 2017-09-18)).
> +		 * However, we're not really concerned with the status of
> +		 * files under the directory, we just wanted to know
> +		 * whether the directory was empty (state == path_none) or
> +		 * not (state == path_excluded), and if not, we'd return
> +		 * our original status based on whether the untracked
> +		 * directory matched an exclusion pattern.
> +		 */
> +		if (stop_early)
> +			state = excluded ? path_excluded : path_untracked;
> +
> +		else {
> +			/*
> +			 * When
> +			 *     !stop_early && state == path_excluded
> +			 * then all paths under dirname were ignored.  For
> +			 * this case, git status --porcelain wants to just
> +			 * list the directory itself as ignored and not
> +			 * list the individual paths underneath.  Remove
> +			 * the individual paths underneath.
> +			 */
> +			for (i = old_ignored_nr + 1; i<dir->ignored_nr; ++i)
> +				free(dir->ignored[i]);
> +			dir->ignored_nr = old_ignored_nr;
> +		}
> +	}
>  
>  	/*
> -	 * If this is an excluded directory, then we only need to check if
> -	 * the directory contains any files.
> +	 * If there is nothing under the current directory and we are not
> +	 * hiding empty directories, then we need to report on the
> +	 * untracked or ignored status of the directory itself.
>  	 */
> -	return read_directory_recursive(dir, istate, dirname, len,
> -					untracked, 1, excluded, pathspec);
> +	if (state == path_none && !(dir->flags & DIR_HIDE_EMPTY_DIRECTORIES))
> +		state = excluded ? path_excluded : path_untracked;
> +
> +	/*
> +	 * We can recurse into untracked directories that don't match any
> +	 * of the given pathspecs when some file underneath the directory
> +	 * might match one of the pathspecs.  If so, we should make sure
> +	 * to note that the directory itself did not match.
> +	 */
> +	if (pathspec &&
> +	    !match_pathspec(istate, pathspec, dirname, len,
> +			    0 /* prefix */, NULL,
> +			    0 /* do NOT special case dirs */))
> +		state = path_none;
> +
> +	return state;
>  }

This is certainly a substantial change, and I'm not able to read it carefully right now. I hope to return to it soon, but hopefully I've pointed out some places that may lead you to resolve your untracked cache issues.

Thanks, -Stolee

Previous: Elijah Newren via GitGitGadgetNext: Elijah Newren
Message 17 of 76 in “Avoid multiple recursive calls for same path in read_directory_recursive()”
  1. 0/6 Avoid multiple recursive calls for same path in read_directory_recursive()Elijah Newren via GitGitGadget, Jan 29, 2020
  2. 2/6 dir: fix broken commentElijah Newren via GitGitGadget, Jan 29, 2020
  3. 1/6 dir: consolidate treat_path() and treat_one_path()Elijah Newren via GitGitGadget, Jan 29, 2020
  4. 4/6 dir: move setting of nested_repo next to its actual usageElijah Newren via GitGitGadget, Jan 29, 2020
  5. Derrick StoleeJan 30, 2020
  6. Elijah NewrenJan 30, 2020
  7. Derrick StoleeJan 30, 2020
  8. Derrick StoleeJan 30, 2020
  9. Elijah NewrenJan 30, 2020
  10. Derrick StoleeJan 30, 2020
  11. 3/6 dir: fix confusion based on variable tenseElijah Newren via GitGitGadget, Jan 29, 2020
  12. Derrick StoleeJan 30, 2020
  13. SZEDER GáborJan 31, 2020
  14. Elijah NewrenJan 31, 2020
  15. 6/6 t7063: blindly accept diffsElijah Newren via GitGitGadget, Jan 29, 2020
  16. 5/6 dir: replace exponential algorithm with a linear oneElijah Newren via GitGitGadget, Jan 29, 2020
  17. Derrick StoleeJan 30, 2020
  18. Elijah NewrenJan 30, 2020
  19. Elijah NewrenJan 30, 2020
  20. SZEDER GáborJan 31, 2020
  21. Elijah NewrenJan 31, 2020
  22. 0/6 Avoid multiple recursive calls for same path in read_directory_recursive()Elijah Newren via GitGitGadget, Jan 31, 2020
  23. 1/6 dir: consolidate treat_path() and treat_one_path()Elijah Newren via GitGitGadget, Jan 31, 2020
  24. 2/6 dir: fix broken commentElijah Newren via GitGitGadget, Jan 31, 2020
  25. 3/6 dir: fix confusion based on variable tenseElijah Newren via GitGitGadget, Jan 31, 2020
  26. 6/6 t7063: blindly accept diffsElijah Newren via GitGitGadget, Jan 31, 2020
  27. 5/6 dir: replace exponential algorithm with a linear oneElijah Newren via GitGitGadget, Jan 31, 2020
  28. 4/6 dir: refactor treat_directory to clarify control flowDerrick Stolee via GitGitGadget, Jan 31, 2020
  29. 0/7 Avoid multiple recursive calls for same path in read_directory_recursive()Elijah Newren via GitGitGadget, Mar 25, 2020
  30. 2/7 dir: fix simple typo in commentElijah Newren via GitGitGadget, Mar 25, 2020
  31. 1/7 t7063: correct broken test expectationElijah Newren via GitGitGadget, Mar 25, 2020
  32. Derrick StoleeMar 26, 2020
  33. Elijah NewrenMar 26, 2020
  34. 4/7 dir: fix broken commentElijah Newren via GitGitGadget, Mar 25, 2020
  35. 5/7 dir: fix confusion based on variable tenseElijah Newren via GitGitGadget, Mar 25, 2020
  36. 6/7 dir: refactor treat_directory to clarify control flowDerrick Stolee via GitGitGadget, Mar 25, 2020
  37. 3/7 dir: consolidate treat_path() and treat_one_path()Elijah Newren via GitGitGadget, Mar 25, 2020
  38. 7/7 dir: replace exponential algorithm with a linear one, fix untracked cacheElijah Newren via GitGitGadget, Mar 25, 2020
  39. Derrick StoleeMar 26, 2020
  40. 0/7 Avoid multiple recursive calls for same path in read_directory_recursive()Elijah Newren via GitGitGadget, Mar 26, 2020
  41. 2/7 dir: fix simple typo in commentElijah Newren via GitGitGadget, Mar 26, 2020
  42. 1/7 t7063: more thorough status checkingElijah Newren via GitGitGadget, Mar 26, 2020
  43. Derrick StoleeMar 27, 2020
  44. Junio C HamanoMar 29, 2020
  45. Elijah NewrenMar 31, 2020
  46. 4/7 dir: fix broken commentElijah Newren via GitGitGadget, Mar 26, 2020
  47. 3/7 dir: consolidate treat_path() and treat_one_path()Elijah Newren via GitGitGadget, Mar 26, 2020
  48. 5/7 dir: fix confusion based on variable tenseElijah Newren via GitGitGadget, Mar 26, 2020
  49. 6/7 dir: refactor treat_directory to clarify control flowDerrick Stolee via GitGitGadget, Mar 26, 2020
  50. 7/7 dir: replace exponential algorithm with a linear oneElijah Newren via GitGitGadget, Mar 26, 2020
  51. Derrick StoleeMar 27, 2020
  52. Elijah NewrenMar 28, 2020
  53. Junio C HamanoMar 29, 2020
  54. 00/12 Avoid multiple recursive calls for same path in read_directory_recursive()Elijah Newren via GitGitGadget, Apr 1, 2020
  55. 03/12 dir: fix simple typo in commentElijah Newren via GitGitGadget, Apr 1, 2020
  56. 02/12 t3000: add more testcases testing a variety of ls-files issuesElijah Newren via GitGitGadget, Apr 1, 2020
  57. 01/12 t7063: more thorough status checkingElijah Newren via GitGitGadget, Apr 1, 2020
  58. 05/12 dir: fix broken commentElijah Newren via GitGitGadget, Apr 1, 2020
  59. 04/12 dir: consolidate treat_path() and treat_one_path()Elijah Newren via GitGitGadget, Apr 1, 2020
  60. 06/12 dir: fix confusion based on variable tenseElijah Newren via GitGitGadget, Apr 1, 2020
  61. 07/12 dir: refactor treat_directory to clarify control flowDerrick Stolee via GitGitGadget, Apr 1, 2020
  62. 09/12 dir: include DIR_KEEP_UNTRACKED_CONTENTS handling in treat_directory()Elijah Newren via GitGitGadget, Apr 1, 2020
  63. 10/12 dir: replace double pathspec matching with single in treat_directory()Elijah Newren via GitGitGadget, Apr 1, 2020
  64. 11/12 Fix error-prone fill_directory() API; make it only return matchesElijah Newren via GitGitGadget, Apr 1, 2020
  65. Andreas SchwabJul 19, 2020
  66. Martin ÅgrenJul 19, 2020
  67. Elijah NewrenJul 20, 2020
  68. dir: check pathspecs before returning `path_excluded`Martin Ågren, Jul 20, 2020
  69. Elijah NewrenJul 20, 2020
  70. Martin ÅgrenJul 20, 2020
  71. Junio C HamanoJul 20, 2020
  72. Junio C HamanoJul 20, 2020
  73. 08/12 dir: replace exponential algorithm with a linear oneElijah Newren via GitGitGadget, Apr 1, 2020
  74. Derrick StoleeApr 1, 2020
  75. Elijah NewrenApr 1, 2020
  76. 12/12 completion: fix 'git add' on paths under an untracked directoryElijah Newren via GitGitGadget, Apr 1, 2020

Read the whole thread, see it on lore, or plain text.

$ cat FOOTERMessages come from the public archive at lore.kernel.org/git, fetched every hour. The front page is chosen and written each morning by an AI editor and can be wrong; the threads themselves are the record. About and API. For agents: an MCP server at https://gitlist.dev/mcp, and any thread, story or person page as Markdown by adding .md to its URL (or sending Accept: text/markdown). Details in /llms.txt.