When setting an environment variable gives you a 40x speedup

Today, we’d like to share some of our recent work on Sherlock that allowed a pretty significant speedup when listing files in directories with a lot of entries.

Unlike our usual announcements, this post is more of a behind-the-scenes account of things we do on a regular basis on Sherlock, to keep it up and running in the best possible conditions for our users. We hope to have more of this in the future.

Listing many files takes time

It all started from a support question, from a user reporting a usability problem with ls taking several minutes to list the contents of a 15,000+ entries directory on $SCRATCH.

Having thousands of files in a single directory is usually not very file system-friendly, and definitely not recommended. The user knew this already and admitted that wasn’t great, but when he mentioned his laptop was 1,000x faster than Sherlock to list this directory’s contents, of course, it stung. So we looked deeper.

Because ls is nice

We looked at what ls actually does to list the contents of a directory, and why it was taking so long to list files. On most modern distributions, ls is aliased to ls --color=auto by default. Which is nice, because everybody likes 🌈.

But those pretty colors come at a price: for each and every file it displays, ls needs to get information about a file’s type, its permissions, flags, extended attributes and the like, to choose the appropriate color to display.

One easy solution to our problem would have been to disable colored output in ls altogether, but imagine the uproar. There is no way we could have taken 🌈 away from users, we’re not monsters.

So we looked deeper. ls does coloring through the LS_COLORS environment variable, which is set by dircolors(1), based on a dir_colors(5) configuration file. Yes, that’s right: an executable reads a config file to produce an environment variable that is in turn used by ls.[1]

🤯

Let’s dive in

To be able to determine which of those specific coloring schemes were responsible for the slowdowns, we created an experimental environment:

$ mkdir $SCRATCH/dont
$ touch $SCRATCH/dont/{1..10000} # don't try this at home!
$ time ls --color=always $SCRATCH/dont | wc -l
10000

real 0m12.758s
user 0m0.104s
sys 0m0.699s

12.7s for 10,000 files, not great. 🐌

BTW, we need the --color=always flag, because, although it’s aliased to ls --color=auto, ls detects when it’s not attached to a terminal (like when piped to something or with its output redirected), and then turns off coloring when set to auto. Smart guy.

So, what’s taking so much time? Equipped with our strace-fu, we looked:

$ strace -c ls --color=always $SCRATCH/dont | wc -l
10000
% time seconds usecs/call calls errors syscall
------ ----------- ----------- --------- --------- ----------------
44.21 0.186617 19 10000 lstat
42.60 0.179807 18 10000 10000 getxattr
12.19 0.051438 5 10000 capget
0.71 0.003002 38 80 getdents
0.07 0.000305 10 30 mmap
0.05 0.000217 12 18 mprotect
0.03 0.000135 14 10 read
0.03 0.000123 11 11 open
0.02 0.000082 6 14 close
[...]

Wow: 10,000 calls to lstat(), 10,000 calls to getxattr() (which all fail by the way, because the attributes that ls is looking for don’t exist in our environment), 10,000 calls to capget().

Can do better for sure.

File capabilities? Nah

Following advice from a 10+ year-old bug, we tried file disabling capability checking:

$ eval $(dircolors -b | sed s/ca=[^:]*:/ca=:/)
$ time strace -c ls --color=always $SCRATCH/dont | wc -l
10000
% time seconds usecs/call calls errors syscall
------ ----------- ----------- --------- --------- ----------------
98.95 0.423443 42 10000 lstat
0.78 0.003353 42 80 getdents
0.04 0.000188 10 18 mprotect
0.04 0.000181 6 30 mmap
0.02 0.000085 9 10 read
0.02 0.000084 28 3 mremap
0.02 0.000077 7 11 open
0.02 0.000066 5 14 close
[...]
------ ----------- ----------- --------- --------- ----------------
100.00 0.427920 10221 6 total

real 0m8.160s
user 0m0.115s
sys 0m0.961s

Woohoo, down to 8s! We got rid of all those expensive getxattr() calls, and capget() calls are gone too, 👍.

We still have all those pesky lstat(), though…

How many colors is too many colors?

So we took a look at LS_COLORS in more details.

The first attempt was to simply unset that variable:

$ echo $LS_COLORS
rs=0:di=01;34:ln=01;36:mh=00:pi=40;33:so=01;35:do=01;35:bd=40;33;01:cd=40;33;01:or=40;31;01:su=37;41:sg=30;43:ca=:tw=30;42:ow=34;42:st=37;44:ex=01;32:*.tar=01;31:*.tgz=01;31:*.arc=01;31:*.arj=01;31:*.taz=01;31:*.lha=01;31:*.lz4=01;31:*.lzh=01;31:*.lzma=01;31:*.tlz=01;31:*.txz=01;31:*.tzo=01;31:*.t7z=01;31:*.zip=01;31:*.z=01;31:*.Z=01;31:*.dz=01;31:*.gz=01;31:*.lrz=01;31:*.lz=01;31:*.lzo=01;31:*.xz=01;31:*.bz2=01;31:*.bz=01;31:*.tbz=01;31:*.tbz2=01;31:*.tz=01;31:*.deb=01;31:*.rpm=01;31:*.jar=01;31:*.war=01;31:*.ear=01;31:*.sar=01;31:*.rar=01;31:*.alz=01;31:*.ace=01;31:*.zoo=01;31:*.cpio=01;31:*.7z=01;31:*.rz=01;31:*.cab=01;31:*.jpg=01;35:*.jpeg=01;35:*.gif=01;35:*.bmp=01;35:*.pbm=01;35:*.pgm=01;35:*.ppm=01;35:*.tga=01;35:*.xbm=01;35:*.xpm=01;35:*.tif=01;35:*.tiff=01;35:*.png=01;35:*.svg=01;35:*.svgz=01;35:*.mng=01;35:*.pcx=01;35:*.mov=01;35:*.mpg=01;35:*.mpeg=01;35:*.m2v=01;35:*.mkv=01;35:*.webm=01;35:*.ogm=01;35:*.mp4=01;35:*.m4v=01;35:*.mp4v=01;35:*.vob=01;35:*.qt=01;35:*.nuv=01;35:*.wmv=01;35:*.asf=01;35:*.rm=01;35:*.rmvb=01;35:*.flc=01;35:*.avi=01;35:*.fli=01;35:*.flv=01;35:*.gl=01;35:*.dl=01;35:*.xcf=01;35:*.xwd=01;35:*.yuv=01;35:*.cgm=01;35:*.emf=01;35:*.axv=01;35:*.anx=01;35:*.ogv=01;35:*.ogx=01;35:*.aac=00;36:*.au=00;36:*.flac=00;36:*.mid=00;36:*.midi=00;36:*.mka=00;36:*.mp3=00;36:*.mpc=00;36:*.ogg=00;36:*.ra=00;36:*.wav=00;36:*.axa=00;36:*.oga=00;36:*.spx=00;36:*.xspf=00;36:
$ unset LS_COLORS
$ echo $LS_COLORS

$ time ls --color=always $SCRATCH/dont | wc -l
10000

real 0m13.037s
user 0m0.077s
sys 0m1.092s

Whaaaaat!?! Still 13s?

It turns out that when the LS_COLORS environment variable is not defined, or when just one of its <type>=color: elements is not there, it defaults to its embedded database and uses colors anyway. So if you want to disable coloring for a specific file type, you need to override it with <type>=:, or <type> 00 in the DIR_COLORS file.

After a lot of trial and error, we narrowed it down to this:

EXEC 00
SETUID 00
SETGID 00
CAPABILITY 00

which translates in

LS_COLORS='ex=00:su=00:sg=00:ca=00:'

In normal people speak, that means: don’t colorize files based on the their file capabilities, setuid/setgid bits nor executable flag.

Let ls fly

And if you don’t do any of those checks, then the lstat() calls disappear, and now, boom 🚀:

$ export LS_COLORS='ex=00:su=00:sg=00:ca=00:'
$ time strace -c ls --color=always $SCRATCH/dont | wc -l
10000
% time seconds usecs/call calls errors syscall
------ ----------- ----------- --------- --------- ----------------
63.02 0.002865 36 80 getdents
8.10 0.000368 12 30 mmap
5.72 0.000260 14 18 mprotect
3.72 0.000169 15 11 open
2.79 0.000127 13 10 read
[...]
------ ----------- ----------- --------- --------- ----------------
100.00 0.004546 221 6 total

real 0m0.337s
user 0m0.032s
sys 0m0.029s

0.3s to list 10,000 files, track record. 🏁

This is on Sherlock

From 13s with the default settings, to 0.3s with a small LS_COLORS tweak, that’s a 40x speedup right there, for the cheap price of not having setuid/setgid or executable files colorized differently.

Of course, this is now setup on Sherlock, for every user’s benefit.

But if you want all of your colors back, no worries, you can simply revert to the distribution defaults with:

$ unset LS_COLORS

But then, if you have directories with many many files, be sure to have some coffee handy while ls is doing its thing.

Also, if you didn’t know about doors, well, dir_colors got you covered no matter what. If you really wonder, the file type is do. ↩