Hi Ole,
Thanks for GNU parallel, we use it a lot! While debugging a slow startup we
ran into a performance issue and put together a patch for it. Details below,
and the patch is attached.
If you pass a big command template to parallel and it has lots of "{" in it
but nothing that actually matches a replacement string, building the job
takes
time proportional to the square of the template length. It all happens in
the
parent, single threaded, before the first job even starts, so --jobs doesn't
help at all. A realistic way to hit this is a big chunk of inlined bash on
the
command line (say a script with lots of functions and ${...} expansions),
where
all those braces look like possible replacements but none of them match.
You can see it with a simple synthetic example:
for n in 750 1500 3000; do
tmpl="true $(yes 'eval "${x:0:5}...."' | head -n "$n" | tr '\n' ' ')"
printf '%5d tokens: ' "$n"
{ /usr/bin/time -f '%es' parallel --dry-run "$tmpl" ::: 1 >/dev/null; }
2>&1
done
Here (GNU parallel 20260822, perl 5.40.1, one core), before and after the
patch:
tokens size stock patched
750 15 KB 1.30 s 0.15 s
1500 30 KB 4.82 s 0.17 s
3000 60 KB 18.55 s 0.16 s
Stock roughly quadruples for each doubling of the template; with the patch
it
stays flat. The ${x:0:5} bits are just an easy way to get lots of "{" with
no
match. In real life it shows up when you put a serialized bash payload
(typeset -p) on the command line.
What's going on: in replace_rpl_def(), the `if($rpl =~ /^\{/)` branch runs
two
loops like
while($s =~ s{ ( (?: ^|\177> ) (?: [^\177]*|[\177][^<>] )*? ) \{ ... \}
}...)
for every rpl. That leading "unchanged" capture, (?: [^\177]*|[\177][^<>]
)*?,
is a lazy quantifier whose first branch [^\177]* is unbounded. When the
{...}
part matches nothing (the usual case for {} on a template full of ${...}),
the
engine keeps retrying that split from every "{", and that's where the square
comes from.
The fix leaves those loops alone. When the rpl has no group regexp it does a
cheap pre-check first for the same body but without that expensive leading
capture: if that can't match anywhere, the loops can't either, so we skip
them.
The pre-check uses only the \Q-quoted literal prefix/postfix and the
existing
position/format syntax (no user sub-expression), so it is always safe.
If the rpl does have a group regexp we don't pre-check at all and just run
the
original loops. The loops wrap the group in an extra capture and precede it
with
the position and unchanged-prefix captures, so re-embedding it in the
pre-check
would change the meaning of things like a backreference, a (?(1)...)
conditional,
(?1) recursion or a scoped (?-s) modifier. Skipping the pre-check for
grouped
rpls removes the observed slowdown while leaving their handling exactly as
before.
One honest note: A lot of the digging and this patch itself were done with
an
AI assistant. We checked it pretty hard on our side: differential
testing against stock parallel over a bunch of hand written cases, 900
random templates
and 2500 nasty custom --rpl regexps, all identical in stdout, stderr and
exit
code, plus the benchmarks above and perl -c. We're happy enough with it to
send
it.
That said, this still needs a careful review. The tests give us some
confidence, but we can't rule out a regression in an obscure corner case.
We'd appreciate your assessment of the approach itself - there may be a
better way to address this.
Thanks for taking a look.
Regards,
Igor
diff --git a/src/parallel b/src/parallel
index ea5c289..3611688 100755
--- a/src/parallel
+++ b/src/parallel
@@ -14794,6 +14794,29 @@ sub new($) {
# Look for: { position prefix group postfix format }
# Look for: { 1.1 / (.*) '' :% 5d }
# --match '\.(.*)' echo '{1.1/3/6:% 5d}' ::: fooa.123
+ # The two while-loops below carry a leading unchanged-capture
+ # (?:[^\177]*|\177[^<>])*? whose nested quantifier backtracks
+ # O(n^2) when the {..} body finds no match. When the rpl has NO
+ # group regexp, do a cheap pre-check of the same body without
+ # that capture: if it cannot match anywhere, the loops cannot
+ # either, so skip them. The pre-check uses only the \Q-quoted
+ # literal prefix/postfix and the existing position/format
+ # syntax (no user sub-expression), so it is always safe.
+ #
+ # We deliberately restrict the pre-check to the group-less case.
+ # The loops wrap $grp_regexp in an extra group and precede it
+ # with the position and unchanged-prefix captures; embedding it
+ # here in a different capture context would change the meaning
+ # of backreferences (\1, \g{-1}), conditionals (?(1)...),
+ # recursion (?1), scoped modifiers (?-s), #-comments under /x,
+ # etc. Applying the pre-check only to group-less rpls removes
+ # the observed slowdown while leaving grouped rpls unchanged.
+ my $run_scan = 1;
+ if(not defined $grp_regexp or $grp_regexp eq '') {
+ $run_scan = ($s =~
+ /\{\s*(?:-?\d+(?:\.\d+)?\s*)?\Q$prefix\E\Q$postfix\E(?:$format_regexp)?\}/sx);
+ }
+ if($run_scan) {
while($s =~
s{( (?: ^|\177> ) (?: [^\177]*|[\177][^<>] )*?)
\{
@@ -14821,6 +14844,7 @@ sub new($) {
{
replacer_rpl($rpl, $1, $2, $grp_regexp, $3);
}gsex){}
+ }
}
} else {
my ($prefix,$grp_regexp,$postfix) =