= Recoll command execution performance :Author: Jean-François Dockès :Email: jfd@recoll.org :Date: 2015-05-22 == Abstract == Introduction The Recoll indexer, *recollindex*, is a big process which executes many others, mostly for extracting text from documents. Some of the executed processes are quite short-lived, and the time used by the process execution machinery can actually dominate the time used to translate data. This document explores possible approaches to improving performance without adding excessive complexity or damaging reliability. Studying fork/exec performance is not exactly a new venture, and there are many texts which address the subject. While researching, though, I found out that not so many were accurate and that a lot of questions were left as an exercise to the reader. == Issues with fork The traditional way for a Unix process to start another is the +fork()+/+exec()+ system call pair. +fork()+ duplicates the process address space and resources (open files etc.), then duplicates the thread of execution, ending up with 2 mostly identical processes. +exec()+ then replaces part of the newly executing process with an address space initialized from an executable file, inheriting some of the resources under various conditions. This was all fine with the small processes of the first Unix systems, but as time progressed, processes became bigger and the copy-before-discard operation was found to waste significant resources. It was optimized using two methods (at very different points in time): - The first approach was to supplement +fork()+ with the +vfork()+ call, which is similar but does not duplicate the address space: the new process thread executes in the old address space. The old thread is blocked until the new one calls +exec()+ and frees up access to the memory space. Any modification performed by the child thread persists when the old one resumes. - The more modern approach, which cohexists with +vfork()+, was to replace the full duplication of the memory space with duplication of the page descriptors only. The pages in the new process are marked copy-on-write so that the new process has write access to its memory without disturbing its parent. This approach was supposed to make +vfork()+ obsolete, but the operation can still be a significant resource consumer for big processes mapping a lot of memory, so that +vfork()+ is still around. Programs can have big memory spaces not only because they have huge data segments (rare), but just because they are linked to many shared libraries (more common). NOTE: Orders of magnitude: a *recollindex* process will easily grow into a few hundred of megabytes of virtual space. It executes the small and efficient *antiword* command to extract text from *ms-word* files. While indexing multiple such files, *recollindex* can spend '60% of its CPU time' doing `fork()`/`exec()` housekeeping instead of useful work (this is on Linux, where `fork()` uses copy-on-write). Apart from the performance cost, another issue with +fork()+ is that a big process can fail executing a small command because of the temporary need to allocate twice its address space. This is a much discussed subject which we will leave aside because it generally does not concern *recollindex*, which in typical conditions uses a small portion of the machine virtual memory, so that a temporary doubling is not an issue. The Recoll indexer is multithreaded, which may introduce other issues. Here is what happens to threads during the +fork()+/+exec()+ interval: - +fork()+: * The parent process threads all go on their merry way. * The child process is created with only one thread active, duplicated from the one which called +fork()+ - +vfork()+ * The parent process thread calling +vfork()+ is suspended, the others are unaffected. * The child is created with only one thread, as for +fork()+. This thread shares the memory space with the parent ones, without having any means to synchronize with them (pthread locks are not supposed to work across processes): caution needed ! NOTE: for a multithreaded program using the classical pipe method to communicate with children, the sequence between the `pipe()` call and the parent `close()` of the unused side is a candidate for a critical section: if several threads can interleave in there, children process may inherit descriptors which 'belong' to other `fork()`/`exec()` operations, which may in turn be a problem or not depending on how descriptor cleanup is performed in the child (if no cleanup is performed, pipes may remain open at both ends which will prevents seeing EOFs etc.). Thanks to StackExchange user Celada for explaining this to me. For multithreaded programs, both +fork()+ and +vfork()+ introduce possibilities of deadlock, because the resources held by a non-forking thread in the parent process can't be released in the child because the thread is not duplicated. This used to happen from time to time in *recollindex* because of an error logging call performed if the +exec()+ failed after the +fork()+ (e.g. command not found). With +vfork()+ it is also possible to trigger a deadlock in the parent by (inadvertently) modifying data in the child. This could happen just link:http://www.oracle.com/technetwork/server-storage/solaris10/subprocess-136439.html[because of dynamic linker operation] (which, seriously, should be considered a system bug). In general, the state of program data in the child process is a semi-random snapshot of what it was in the parent, and the official word about what you can do is that you can only call link:http://man7.org/linux/man-pages/man7/signal.7.html[async-safe library functions] between +fork()+ and +exec()+. These are functions which are safe to call from a signal handler because they are either reentrant or can't be interrupted by a signal. A notable missing entry in the list is `malloc()`. These are normally not issues for programs which only fork to execute another program (but the devil is in the details as demonstrated by the logging call issue...). One of the approaches often proposed for working around this mine-field is to use an auxiliary small process to execute any command needed by the main one. The small process can just use +fork()+/+exec()+ with no performance issues. This has the inconvenient of complicating communication a lot if data needs to be transferred one way or another. //// Passing descriptors around http://stackoverflow.com/questions/909064/portable-way-to-pass-file-descriptor-between-different-processes http://www.normalesup.org/~george/comp/libancillary/ http://stackoverflow.com/questions/28003921/sending-file-descriptor-by-linux-socket/ The process would then be: - Tell slave to fork/exec cmd (issue with cmd + args format) - Get fds - Tell slave to wait, recover status. //// == The posix_spawn() Linux non-event Given the performance issues of `fork()` and tricky behaviour of `vfork()`, a "simpler" method for starting a child process was introduced by Posix: `posix_spawn()`. The `posix_spawn()` function is a black box, externally equivalent to a `fork()`/`exec()` sequence, and has parameters to specify the usual house-keeping performed at this time (file descriptors and signals management etc.). Hiding the internals gives the system a chance to optimize the performance and avoid `vfork()` pitfalls like the `ld.so` lockup described in the Oracle article. The Linux posix_spawn() is implemented by a `fork()`/`exec()` pair by default. `vfork()` is used either if specified by an input flag or no signal/scheduler/process_group changes are requested. There must be a reason why signal handling changes would preclude `vfork()` usage, but I could not find it (signal handling data is stored in the kernel task_struct). The Linux glibc `posix_spawn()` currently does nothing that user code could not do. Still, using it would probably be a good future-proofing idea, but for a significant problem: there is no way to specify closing all open descriptors bigger than a specified value (closefrom() equivalent). This is available on Solaris and quite necessary in fact, because we have no way to be sure that all open descriptors have the CLOEXEC flag set. So, no `posix_spawn()` for us (support was implemented inside *recollindex*, but the code is normally not used). == The chosen solution The previous version of +recollindex+ used to use +vfork()+ if it was running a single thread, and +fork()+ if it ran multiple ones. After another careful look at the code, I could see few issues with using +vfork()+ in the multithreaded indexer, so this was committed. The only change necessary was to get rid of an implementation of the lacking Linux +closefrom()+ call (used to close all open descriptors above a given value). The previous Recoll implementation listed the +/proc/self/fd+ directory to look for open descriptors but this was unsafe because of of possible memory allocations in +opendir()+ etc. == Test results .Indexing 12500 small .doc files [options="header"] |=============================== |call |real |user |sys |fork |0m46.025s |0m26.574s |0m39.494s |vfork |0m18.223s |0m17.753s |0m1.736s |spawn/fork| 0m45.726s|0m27.082s| 0m40.575s |spawn/vfork|0m18.915s|0m18.681s|0m3.828s |recoll 1.18|1m47.589s|0m21.537s|0m29.458s |================================ No surprise here, given the implementation of +posix_spawn()+, it gets the same times as the +fork()+/+vfork()+ options. The tests were performed on an Intel Core i5 750 (4 cores, 4 threads). It would be painful to play it safe and discard the 60% reduction in execution time offered by using +vfork()+, so this was adopted for Recoll 1.21. To this day, no problems were discovered, but, still crossing fingers... The last line in the table is just for the fun: *recollindex* 1.18 (single-threaded) needed almost 6 times as long to process the same files... //// Objections to vfork: sigaction locks https://bugzilla.redhat.com/show_bug.cgi?id=193631 Is Linux vfork thread-safe ? Quoting interesting comments from Solaris implementation: No answer to the issues cited though. https://sourceware.org/bugzilla/show_bug.cgi?id=378 Aussi: http://blog.famzah.net/2009/11/20/fork-gets-slower-as-parent-process-use-more-memory/ http://blog.famzah.net/2009/11/20/a-much-faster-popen-and-system-implementation-for-linux/ Avec un workaround basé sur clone (donc linux-only). Tried it but crashes. ////