Problem/Motivation

_search_find_match_with_simplify() is inefficient, to the point of timeouts with some real-life input. It needs to be made more efficient.

This function is used during the creating of highlighted keywords in the search results, and it finds matches between "simplified" keywords and "simplified" source text, where "simplified" means that the text has been passed through the search_simplify() function. The problem is that calling search_simplify() is expensive, and it potentially gets called a lot during the processing of each excerpt. This can cause site timeouts.

As a note, search_simplify() does the following:
- Decode HTML entities
- Lower case
- Remove accents
- Invoke hook_search_preprocess(), which can do things like linguistic stemming
- Optionally, do simple CJK word splitting (Chinese, Japanese, and Korean text splitting into "words" formed form overlapping character strings)
- Some processing for numbers and punctuation
- Split into words on generalized word boundaries
- Truncate all words to 50 characters or less

Proposed resolution

The function can be made more efficient by (a) improving the efficiency of search_simplify() and (b) calling it less. Some specific ideas:

1. Eliminate repetitive parts of search_simplify().
Details: This function calls search_simplify() repeatedly on shortened versions of $text. Parts of search_simplify() could be done once on the full $text and then wouldn't need to be done again, such as:

  $text = Html::decodeEntities($text);
  $text = Unicode::strtolower($text);
  $text = \Drupal::service('transliteration')->removeDiacritics($text);

So we could add another argument to search_simplify() to skip these parts, if we did them elsewhere for efficiency.

[Note: not possible I think, so this one wasn't implemented]

2. Use static to reuse _search_index_truncate() results.
Details: At the end of search_simplify(), it does

  $words = explode(' ', $text);
  array_walk($words, '_search_index_truncate');
  $text = implode(' ', $words);

_search_index_truncate() is truncating words to 50 characters. It could use a static to save the output and make this faster.

3. Fix word splitting in _search_find_match_with_simplify().
Details: Back in _search_find_match_with_simplify(), it is splitting $text up into words like this:

  $words = preg_split('/' . $boundary . '/iu', $text, NULL, PREG_SPLIT_OFFSET_CAPTURE);
 

This $boundary reg exp is currently matching *each* word-separating character, and for some reason it is in practice also returning "words" that consist entirely of word boundary characters. This needs to be fixed... probably it should have a + on it so it matches multiple characters together as one word boundary. This would make the array $words smaller. Need to figure out why the space characters are appearing in $words too.

4. Use binary search in _search_find_match_with_simplify().
Details: The loop just below (3) is essentially doing a linear search to find the minimal start/end position. It could be doing a binary search instead.

5. Early return in _search_find_match_with_simplify() for caps/diacriticals
Details: Often it's the case that if you just lower case and remove diacriticals on both $key and $text (two of the first three lines in search_simplify()), that might be enough to make a match. If that works, this should not change the character count of either string, and we could return the upcased/diacriticaled word at the same position as the match. So that would mean the 90% case was much faster.

Remaining tasks

Implement one or more of the ideas from Proposed Resolution to make this function faster, and verify the performance improvements.

The latest patch (#14) has:
- Additional test cases with longer strings in order to verify performance changes. One of the cases is looking for a capitalization/diacritical change, and the other is looking for a change coming from hook_search_preprocess().
- Suggestion 2 implemented (Use static to reuse _search_index_truncate() results). This resulted in a small bit of improvement, from about 100 seconds in the test in place then (with just the diacriticals/caps test case) to about 95 seconds.
- Suggestion 5 implemented (Early return in _search_find_match_with_simplify() for caps/diacriticals). This cut the test time in the test in place then from 160 seconds down to 68 seconds.
- Suggestion 3 implemented (Fix word splitting in _search_find_match_with_simplify()). This cut the test time in the same test as Suggestion 5 from 68 seconds down to 31 seconds.
- Then added a new test with a lot of whitespace in it. Without the suggestion 2/3/5 patches, this test took too long to run and I aborted it; with these patches, it took 33 seconds to run. So that is the new benchmark for the rest of the improvements.
- Suggestion 4 implemented (Use binary search in _search_find_match_with_simplify()). This cut the test time in the new test down to 2.5 seconds.

So that's it!

There is also a new function added to the core component Unicode class, Unicode::strpos(), which it didn't previously have. Also some tests are included, and I updated some comments in the substr() function on that class that confused me when I was trying to make sure strpos() was compatible with what strlen() and substr() are doing in the case of the MB extension for PHP not being present.

User interface changes

None, except search excerpts shouldn't cause site timeouts.

API changes

None.

Data model changes

None.

Beta phase evaluation

Reference: https://www.drupal.org/core/beta-changes
Issue category Bug because the slow speed of this function can cause search results pages to time out in some cases
Issue priority Major because the search results page is unusable on some sites.
Prioritized changes The main goal of this issue is performance and it is a bug fix
Disruption Not disruptive. No APIs are changing.

Comments

jhodgdon created an issue. See original summary.

dawehner’s picture

Priority: Normal » Major
Issue tags: +Performance

Given that this easily causes totally broken sites I'd argue that this is at least major.

jhodgdon’s picture

Assigned: Unassigned » jhodgdon

Yes.

One other thought: I wonder if it may be possible to have an early return in this function. Often it's the case that if you just lower case, and remove diacriticals on both $key and $text (two of the first three lines in search_simplify()), that might be enough to make a match. If that works, this should not change the character count of either string, and we could return the upcased/diacriticaled word at the same position as the match. So that would mean the 90% case was much faster.

I suppose I should assign this to myself, since I'm probably the person who understands this function the best... might be a week or so before I'm able to make a patch though (unless I find time today, which seems unlikely). If someone else wants to take a stab first, that would also be fine.

I think for testing performance, the thing to do would be to (a) add a couple more cases to SearchExcerptTest to exercise the slow parts of the current function and (b) add a timer to it so that if you run it interactively, at the end it has a pass("message") that tells you how much time was spent in the excerpt calculations.

jhodgdon’s picture

I'm actually going to work on a patch for this now, starting with establishing a performance benchmark.

jhodgdon’s picture

Status: Active » Needs review
StatusFileSize
new3.21 KB

OK, here's step 1: I added a test to SearchExcerptTest that looks for an excerpt with accents and caps in a longer piece of text. When I used str_repeat($lorem2, 50) in this test, the test ran forever (or at least for quite a while and I gave up). With str_repeat($lorem2, 20) (as in the attached patch), it took about 101-103 seconds to run on my current local machine (which isn't exactly fast). Nicely illustrating the problem. Let's see if we can get it down.

jhodgdon’s picture

Issue summary: View changes
StatusFileSize
new3.09 KB

Updating summary with ideas from #3.

So here's a patch for idea 2: Use static to reuse _search_index_truncate() results.

I thought first that it had reduced the test time from 100-103 seconds down to 95-99 seconds, but then it turned out I had made the change in a different git repo from where I was running the tests (doh!). So I would say the range of times on the unpatched code is 95-103 seconds on my test machine.

With this patch for the static in the truncate function, I got 92 seconds on two different runs. This may be within the noise, but it seems to be a small improvement, so I think we should keep this change.

jhodgdon’s picture

Issue summary: View changes
StatusFileSize
new14.6 KB

For the next try, I added some to the test so that the search_simplify() part that invokes the hook comes into play in a longer text, and I implemented idea #5, which should fix the "look for caps and accents" problem, but not this new test. I also cleaned up the code in the test a bit. Probably out of scope. Sorry. [Someone added a doSearchExcerpt() method to the test class but didn't bother to move all the preg_replace() calls in there. Moving them in makes the test code quite a bit easier to read, plus centralized and easier to maintain.]

Without the code patch added here, the new test takes 164 seconds in one run, 155 in second run.

With the code patch, the new test takes just 68 seconds, so this is a good thing.

I think that's all for today...

Status: Needs review » Needs work

The last submitted patch, 7: 2553351-early-return-simplify-match.patch, failed testing.

jhodgdon’s picture

Yeah, I realized this morning that this last patch wouldn't work right. Needs to use Unicode strlen/strpos functions. Good thing we have very comprehensive tests. ;) I'll get back to this later today.

jhodgdon’s picture

Issue summary: View changes
Status: Needs work » Needs review
StatusFileSize
new1.78 KB
new15.72 KB

OK, here's a fixed patch, using Unicode functions properly. Also, I thought I should start doing interdiffs.

Note: I had to add a new Unicode::strpos() function, which was missing from the Unicode class. Other than that, this is pretty much the same as the last patch but using Unicode functions, and takes the same time to run the Excerpt test, but actually works (at least, I tested it on the failing test from the last one).

Updating summary as to which parts have made it into the patch, and adding some notes about what search_simplify() actually does.

jhodgdon’s picture

Issue summary: View changes
StatusFileSize
new19.66 KB
new4.39 KB

OK, one more update for today. This addresses item 3. (Fix word splitting in _search_find_match_with_simplify()). It turned out that the reg exps we were using in search_excerpt() and _search_find_match_with_simplify() were kind of wrong, so I fixed them up.

Assuming the patch passes tests (I ran SearchExcerptTest and SearchNodeDiacriticsTest, but who knows about the rest)... the time to run on Excerpt test is down to 31 seconds (as of last patch, it was about 68 seconds). So that's good!

dawehner’s picture

Did some manual testing and this indeed fixes the issue for me.

Previous state: execution time of > 300seconds, now: it actually runs.
Thank you @jhodgdon

Honestly I cannot really say anything about the fixes itself, search module is kinda like a black box for me :)
So here is just some feedback for the tests

  1. +++ b/core/lib/Drupal/Component/Utility/Unicode.php
    @@ -697,4 +697,29 @@ public static function validateUtf8($text) {
    +  /**
    +   * Finds the position of the first occurrence of a string in another string.
    +   *
    +   * @param string $haystack
    +   *   The string to search in.
    +   * @param string $needle
    +   *   The string to find in $haystack.
    +   * @param int $offset
    +   *   If specified, start the search at this number of characters from the
    +   *   beginning (default 0).
    +   *
    +   * @return int|false
    +   *   The position where $needle occurs in $haystack, always relative to the
    +   *   beginning (independent of $offset), or FALSE if not found. Note that
    +   *   a return value of 0 is not the same as FALSE.
    +   */
    +  public static function strpos($haystack, $needle, $offset = 0) {
    +    if (static::getStatus() == static::STATUS_MULTIBYTE) {
    +      return mb_strpos($haystack, $needle, $offset);
    +    }
    +    else {
    +      return strpos($haystack, $needle, $offset);
    +    }
    +  }
    

    Should we add some really quick unit test coverage for that method?

  2. +++ b/core/modules/search/src/Tests/SearchExcerptTest.php
    @@ -127,37 +127,51 @@ function testSearchExcerptSimplified() {
    +    // Test with accents and caps in a longer piece of text with the target
    +    // near the end.
    +    $text = str_repeat($lorem2, 20) . ' ' . $lorem1;
    +    $result = $this->doSearchExcerpt('Lìbêró', $text);
    +    $this->assertTrue(strpos($result, '<strong>libero</strong>') !== FALSE, 'Search excerpt works with caps and accents in longer text');
    +
    +    // Test with an acronym provided by the hook, with the target text in the
    +    // middle of a long string.
    +    $text = str_repeat($lorem2, 10) . ' DIC ' . str_repeat($lorem2, 10);
    +    $result = $this->doSearchExcerpt('Dependency', $text, 'ex');
    +    $this->assertTrue(strpos($result, '<strong>DIC</strong>') !== FALSE, 'Search excerpt works with acronym in longer text');
    +
    +    $this->verbose('Elapsed time: ' . (microtime(TRUE) - $start_time));
    

    I think we should have an example which uses a LOT of whitespace, basically a HTML document without all the tags.

jhodgdon’s picture

StatusFileSize
new24.66 KB
new4.98 KB

Thanks for testing! Glad this fixes your real-world site.

So I talked to @dawehner about the Unicode function and we need to make it compatible (in the case of no MB extension) with what Unicode::strlen() and Unicode::substr() are already doing, which is to cut out characters
0x80-0xBF. I thought first that substr() was doing something different, due to its somewhat misleading code comments. So this patch changes the new Unicode function, fixes up those comments in substr(), and adds a Unicode test.

jhodgdon’s picture

Issue summary: View changes
StatusFileSize
new25.05 KB
new922 bytes

I added a new test case with lots of whitespace. Confirmed it takes forever to run without the code patches (I gave up and aborted the test after a while, so not sure exactly how long). With code patches, the time is 33 seconds, so that is the new time to beat.

To do still: Suggestions 1 and 4 from the issue summary, if possible. I think (1) will just make a small improvement, and (4) will be the big win for the remaining slow case (when the hook is involved, see summary).

jhodgdon’s picture

Issue summary: View changes

Updating summary about unicode additions and adding beta eval.

jhodgdon’s picture

Issue summary: View changes
StatusFileSize
new29.07 KB
new4.27 KB

Looking at the way _search_find_match_with_simplify() works, I don't think that suggestion #1 from the issue summary will actually work out -- that's the idea of not doing the entity decode, lower-casing, and diacritical removing each time search_simplify() is called. The reason is that we're passing in a substring from the original $text each time, at a known position, and entity decoding will change the string positions.

So. That leaves #4, to improve the search algorithm.

So, here it is! This code improved the time from 33 seconds down to ***2.5 seconds*** on my local test machine. Woot! I think this is it!

Code actually needs review now.....

dawehner’s picture

So, here it is! This code improved the time from 33 seconds down to ***2.5 seconds*** on my local test machine. Woot! I think this is it!

Ha, this is not bad.

  1. +++ b/core/modules/search/search.module
    @@ -855,40 +855,61 @@
    +  // Using a binary search, find the earliest possible ending position in
    +  // $text where it will still match the keyword after applying
    

    nice, I would have not imagined that we use actual algorithms in Drupal

  2. +++ b/core/modules/search/search.module
    @@ -855,40 +855,61 @@
    +    $proposed_end_index = floor(($max_end_index + $min_end_index) * 0.5);
    ...
    +    $proposed_start_index = ceil(($max_start_index + $min_start_index) * 0.5);
    

    Any reason for using * 0.5 and not / 2, which makes it maybe a bit clearer what is going on?

  3. +++ b/core/modules/search/search.module
    @@ -855,40 +855,61 @@
    +      // The proposed endpoint is too early, so the earliest possible
    +      // OK ending would be the next word.
    +      $min_end_index = $proposed_end_index + 1;
    

    I'm a bit confused why the searched word can't be the current one ... Maybe we could explain why the ending word cannot be the current one?

  4. +++ b/core/modules/search/search.module
    @@ -855,40 +855,61 @@
    +  // Return the matching text.
    +  return trim(substr($text, $words[$start_index][1], $words[$end_index][1] - $words[$start_index][1]));
    

    If I read the code correctly $text is still the proper text (without HTML tags), but could contain unicode chars ...

jhodgdon’s picture

StatusFileSize
new29.29 KB
new2.43 KB

RE #17 - thanks for the review!

item 2 - Hah! That is left over from my Fortran programming days. ;) Back then, dividing was inefficient and computers were slow so you always wanted to multiply by 0.5 instead of dividing by 2. Not an issue in this century. Updated code in two places.

item 3/4 - added comments to hopefully clarify what this is doing. There was a comment about (4) in an earlier iteration but it got lost when I redid the code for the binary searches. We do have tests that include Unicode characters in $text, by the way, and they are working correctly; definitely needed a comment to clarify this though.

dawehner’s picture

Status: Needs review » Reviewed & tested by the community

item 2 - Hah! That is left over from my Fortran programming days. ;) Back then, dividing was inefficient and computers were slow so you always wanted to multiply by 0.5 instead of dividing by 2. Not an issue in this century. Updated code in two places.

Oh wow! Well, there seems to be still some truth in that, see https://stackoverflow.com/questions/226465/should-i-use-multiplication-o...
but yeah this level of performance optimization should not matter here at all and be destroyed by the various function calls / indirections PHP gives you.

You know, we have a really good test coverage for that functionality. This also solves an actual problem, and we can refactor it in the future, if we want to figure out better algorithms.

  1. +++ b/core/modules/search/search.module
    @@ -891,7 +891,7 @@
    +    $proposed_start_index = ceil(($max_start_index + $min_start_index)  / 2);
    

    Nitpick of the month: two spaces here

  2. +++ b/core/modules/search/search.module
    @@ -901,14 +901,17 @@
    +  // Return the matching text. We need to use substr() here and not the
    +  // Unicode::substr() function, because the indices in $words came from
    +  // preg_split(), so they are Unicode-safe byte positions, not character
    +  // positions.
    

    Ah its good that we have documented that now.

jhodgdon’s picture

StatusFileSize
new29.29 KB

Doh. Edited patch to take out one extra space and (oh horrors!) didn't make an interdiff file for the one space, and leaving RTBC since it was a direct patch edit so is the same patch file except one character. Hope that's OK.

jhodgdon’s picture

Assigned: jhodgdon » Unassigned

Unassigning to avoid confusion about committing.

alexpott’s picture

Status: Reviewed & tested by the community » Fixed

Committed 6a1b932 and pushed to 8.0.x. Thanks!

Thanks for adding the beta evaluation to the issue summary.

  • alexpott committed 6a1b932 on 8.0.x
    Issue #2553351 by jhodgdon, dawehner:  _search_find_match_with_simplify...

Status: Fixed » Closed (fixed)

Automatically closed - issue fixed for 2 weeks with no activity.