further to this issue on free tagging I've been doing some performance testing. My site runs fine with a free tagging category coupled to glossary, Google loves it and my users too, only after cache flush actions (add term) pages are noticably slow.

I've taken a recent copy of my production site, enabled devel module for page generation timing and selected a worst case test scenario: On a large node with many glossary hits, "all matches" set, matching case insensitive and filter cache switched off, I'm getting consistent page generation times of 47 sec. When switching to case sensitive matching, the same page generates in 3.8 sec, a factor 12 faster. Switching filter cache on results in page times of 1.2 sec on cached pages.

I believe this could be a reasonable testcase to look for a faster way to do case insensitive matching, only I'm not very proficient on php programming. Any tips I might try?

Comments

jvlagsma’s picture

Additional info, I'm using full word matching.
Maybe this page on Performance: drupal_substr is useful

nancydru’s picture

That's pretty old and with a 5.x tag on it, going no where. I suggested changing it to 7.x and back linked it to this issue.

I thought I had some heavy pages, but I don't see anywhere near a 47 second load. How fast is your CPU and how much memory do you have? Do you see any query improvements that could be applied?

I'm certain that all the changes that were applied for handling non-English, and especially non-Latin/multi-byte, characters sets is not helping performance. I'd hate to think that we need parallel versions with and without this capability.

I suspect that using substring matching will make this problem even worse.

My first suggestion would have to be: don't use free-tagging. Perhaps you can have some method to have users suggest a category that only gets added by an admin once a day, or by a cron job at some less frequent interval. There may be room for improvement in the cache clearing and I'd be willing to entertain patches in that area.

nancydru’s picture

Component: Code » Performance
Status: Active » Postponed (maintainer needs more info)

I just added a "Performance" component for this module.

jvlagsma’s picture

Word matching has 2 calls to drupal_substr; "match any string" does not have calls to drupal_substr; My page times however do not significantly differ between the two settings, difference is <2%, so drupal_substr probably is not the main contributer to the problem here after all.

I added a counter for the number of matches, my test node has 823 matches in the node body (has no comments) and 44 unique matches. "All matches" takes 47 sec, "unique matches" takes 20 sec. A page with 0 matches generates in 2 sec. This looks like the number of matches, typically as a result of the size of the vocabulary and size of the node, has a big impact. This is consistent with users experience on the production site that large nodes and/or long threads tend to be particularly slow.

My test PC is a WinXP with a recent Xampp, PHP 5.2.5, a P4 2.8GHz with 2Gb mem. Note I've deliberately switched off all caching to make sure I'm seeing the bare effects of the code and I've deliberatly chosen a huge page to test with to exaggerate the problem.

On production I've limited the cache flush moments by moderating new terms using the unitag module. I moderate new tags once or twice a day. Still I'd like to keep the page timing spikes as low as possible. Only way to do this I think is to make Glossary scale as well as possible. This benefits anyone using Glossary obviously.

As for language aspects: I have a Dutch site and am using locale for translation, characters are plain latin, no difference really with English. Database is fully utf8-general-ci, pretty standard.

I know I'm using Glossary a little different than it was intended to but functionally it works great! It makes the site very dynamic with very little effort and users understand the concept as soon as they see it. It gives users often a much quicker and focussed access from the forum to the knowledge pages, much easier than using the search engine. Would probably also work nicely on drupal.org ;-)

I'm going to fiddle around a bit more, I'm slowly starting to understand how things work in glossary and drupal for that matter.

nancydru’s picture

I'm slowly starting to understand how things work in glossary and drupal for that matter.

Oh, good, that means you can help with the documentation too... ;-)

I know the taxonomy module does some caching of terms (static, so can't be disabled), but I'm wondering if some pre-fetching of terms might help. I'm not sure how to do this though.

Apparently, "first match" would help you considerably.

kbahey’s picture

@jvlagsma

Did you rule out database queries as the source of this slowness?

The devel module can list for you time spend in the database vs. time spend in Drupal's code.

Make sure that the 3.8 sec vs. 47 sec is all code and not due to queries first.

jscoble’s picture

What database engine are you using?

jvlagsma’s picture

@nancyw: yes, I'm using "first match" on production, makes the most sense from a functional point on my site. Note "first match" means first match per piece of text i.e. if 5 comments each use the same term twice, each comment gets the first one highlighted by Glossary, there'll still be 5 links created by Glossary in the full thread (and this feels sensible as a user).

@kbahey: I'm using the exact same page and changing only glossary settings between measurements. Nr of queries (563) and time spent on queries (500 ms) is the same in both cases but total page generation times differ hugely.

@jscoble: I'm using MySQL 5.0.51a (most recent Xampp as a quick test setup)

I realised there's a complication. Case sensitive matching will naturally result in fewer matches, that's fine but exercises 2 aspects of the problem at the same time. So I need a table of 4 cases where I'm using first match / all matches as the volume aspect against case sensitive/insensitive:

my node/13355    | Case insensitive   | Case sensitive    |
Only first match | 20 sec / 44 hits   | 3.7 sec / 32 hits |
All matches      | 47 sec / 136 hits* | 3.8 sec / 91 hits |

*) note I've lost the 823 hits, I may need to repeat the measurement on a fresh restore, may have changed something testing yesterday, I've had the site collapse on a CPU limit of 120 sec when tripeling the node content.. ;-)

As a baseline a node without hits and very little content:

my node/10795    | Case insensitive  | Case sensitive    |
Only first match |  1.8 sec / 0 hits |  1.7 sec / 0 hits |
All matches      |  1.8 sec / 0 hits |  1.7 sec / 0 hits |

Filter out just case sensitive vs insensitive effects

Normalised       | Case insensitive    | Case sensitive    |
Only first match | 18.2 sec / 44 hits  | 2 sec / 32 hits   |
All matches      | 45.2 sec / 136 hits | 2.1 sec / 91 hits |

Normalised per hit  | Case insensitive | Case sensitive | Case sensitive vs insensitive
Only first match    | 0.41 sec/hit     | 0.06 sec/hit   | 15%
All matches         | 0.33 sec/hit     | 0.02 sec/hit   | 7%
nancydru’s picture

I had another thought that might have an impact with this test: Do you have MySql set for query caching?

I'm still trying to figure out everything here (BTW, very helpful information). One thing I see is that taxonomy's term caching is having an effect ("normalised" first vs all) but not as much as I'd like to see.

And clearly some work has to be done because many people cannot raise their CPU limit to 120 seconds on shared hosting accounts.

@Johan: If I can get the time to make some changes to the code, are you able to test them?

jvlagsma’s picture

Just checked, query cache is off on my test system. I tried to do all timings 3 or 4 times to get a feel for consistency and haven't seen 'cache effects' like the first attempt slower than the others. Now I'm reasonably sure we looking at just code effects. Thanks all for your comments :-)

About the 47 sec/823 hits, that was "match any substring", "case insensitive" and "all matches". Since match type "word", as used in all the measurements above, is more realistic and a more processing intensive case I'll forget about the 47 sec/823 case from now on. Still, good to understand where it went and saves me a database restore.

@Nancy, absolutely I'll test anything that might help :-)

This bit seems to me a good candidate as I feel it could be scaling consistent with the timings seen, would you agree?

  // Find match candidates.
  foreach ($terms as $i => $term) {
    foreach($term['synonyms'] as $synonym) {
      $offset=0;
      $first_match_found = false;
      while(($offset = $findfunc($text, $synonym, $offset)) !== false) {
        $match = drupal_substr($text, $offset, drupal_strlen($synonym));
        //only longer matches override shorter ones
        if (!isset($events[$offset]) || drupal_strlen($events[$offset]['match']) < drupal_strlen($match)) {
          // get synonym with case as in text
          $events[$offset] = array('type' => 'match', 'which' => $i, 'match' => $match);
          if (!$replaceall) {
            $first_match_found = true;
            break;
          }
        }
        $offset += drupal_strlen($synonym);
      }
      //TODO: remove this if we want different synonyms of the same term to be matched independently as 'first matches'
      if ($first_match_found && !$replaceall) {
        break;
      }
    }
  }

My vocabulary for Glossary holds 593 terms+aliasses so that's the outer 2 loops. The string being scanned (node text) is 51235 bytes long. I think there are really only a few functions that might be sensitive to differences in typecase and that are in the innermost loop: drupal_substr, $findfunc (expands to mb_stripos) and drupal_strlen.

[edit] counted nr of terms [/edit]

jscoble’s picture

It looks like your moving away from the DB as the source of the problem, but I will focus on the DB part just in case there is something that you can do there to help anyways.

By DB engine, I wasn't asking which DBMS, which was helpful, but what engine you selected for MySQL, e.g. MyISAM, InnoDB, etc. For MySQL, the DB engine being used can make a big difference on performance, data integrity, etc.

To see what role the DB has in this problem, try doing an explain on your various test SQL to see what is going on the approximate cost associated with each. Explains are a standard way of checking SQL statements and how the DB is executing them for performance analysis.

One of your earlier posts about results/run time made me think that part of the difference may be that a table scan is occurring in the longer running one and the DB is using an index on the quicker one, which would be consistent with the results.

There are a lot of reasons why something may run slow. There are a many strategies that can be used for DB optimization, depending on what's appropriate for your data, environment, server, usage patterns, etc. The results of the explain may help you identify a place to start looking for those reasons.

jvlagsma’s picture

Sorry, you're right, the engine is MyISAM on all tables. Time spent in the database is 0.5 sec consistently on all test cases, I do not see how I can gain 15 odd seconds there. That said, there are 563 queries of which there are some 500 from _glossary_get_terms each at ~0.7 ms, totalling 350ms:

SELECT COUNT(n.nid) FROM term_node t JOIN node n USING (nid) WHERE t.tid=1018 AND n.status=0

It is "using index" says explain. No difference between case insensitive and case sensitive.

jvlagsma’s picture

The innermost loop in the code above is executed 530735 times in this particular case, 593 terms times 895 pieces of text to be scanned. Also, it will explode with either component increasing. The only way that I see to improve this, read make this more scalable, is to flatten the loops out, untangeling them. Maybe that's what Nancy called prefetching the terms? I can imagine that reading the terms into variables first and then doing the inner loop scanning text would help to the extent that it would reduce the text scan loop to ~1 sec (now 18.2 sec). I could do this in another programming language easily but I'm not up to php very well yet. I'm looking forward to what Nancy can come up with.
[edit] my point is, if the loop is this much more efficient I pretty much don't care anymore about case sensitivity ;-) [/edit]

jscoble’s picture

Ok, I just wanted to eliminate/verify the DB as the slowdown. Thanks.

Your information shows that it's the code, obviously. Ya, I know, call me master of the obvious.

Good old cartesian product. Code/logic that results in that should only be used for small data sets. You're showing us why this is the case. There should be another algorithm used for much greater efficiency in such situations.

nancydru’s picture

Please bear with me over the next week or so. My mother is expected to die within the next few days.

I was planning first to try Wesley Tanaka's utf8_substr variant. That's useful for two reasons: 1) Glossary, obviously, and 2) core in general (for 7.x). I think this would be a great test case to see if that core change is worthwhile.

I'm no longer sure how much value there would be in the prefetching idea, unless you're seeing a lot of time spent bouncing in and out of the taxonomy module (where there are known performance issues).

I will study the code you mentioned to see what I can do with it. I simply haven't paid much attention to synonyms because I don't know many people using them. That code was changed in the GHOP task, so perhaps I can locate the guy who did it and see if I can encourage him to revisit that code. Every time I try to understand what's going on, I get lost.

jvlagsma’s picture

@jscoble: no problem, I appreciate your help

@nancyw: that's sad news.. Drupal will just have to wait, take care

I'll have a go at that bit of code myself, good learning experience

nancydru’s picture

StatusFileSize
new53.83 KB

Please try this module file (need to remove the .txt suffix). I've replaced drupal_substr with utf8-substr.

I also see some places, such as tag scanning, that use multi-byte functions and may not really need to.

BTW, that's another possible timing test you might want to try: remove multi-byte support from your PHP and see what happens.

jvlagsma’s picture

With utf8_substr function

my node/13355    | Case insensitive    | Case sensitive    |
Only first match | 21.2 sec / 44 hits  | 4.9 sec / 32 hits |
All matches      | 49.9 sec / 136 hits | 5.3 sec / 91 hits |

Baseline:
my node/10795    | Case insensitive  | Case sensitive    |
Only first match |  2.1 sec / 0 hits |  1.9 sec / 0 hits |
All matches      |  2.1 sec / 0 hits |  1.9 sec / 0 hits |

Normalised       | Case insensitive    | Case sensitive    |
Only first match | 19.1 sec / 44 hits  | 3.0 sec / 32 hits |
All matches      | 47.8 sec / 136 hits | 3.4 sec / 91 hits |

So utf8_substr appears to be slightly slower than drupal_substr. Also the baseline is slightly slower as a result of utf8_substr; I put back the original module and retested the old cases and can still reproduce the old timings.

I will test removing multi-byte support from PHP tomorrow.

nancydru’s picture

Okay. Well that's one possibility down the drain. As Thomas Edison said "I have not failed. I simply have found 10,000 ways that did not work."

jscoble’s picture

The problem is with the code written. You wrote code that inner-outer loop code that results in an increase in the number of iterations directly proportional to the size of the datasets that you are working with, otherwise known as a cartesian product. Using jvslagsma's comment 13:

530735 times in this particular case, 593 terms times 895 pieces of text.

How quickly do you expect the code to loop 530,735 times? If jvslagma had twice as many terms, 1186 for the same pieces of text, the loop would execute 1,061,470 times, and twice as slow, as expected. Or, conversely, it jvslagma only had 60 terms for those 895 pieces of text, it would perform 10x as fast.

This code structure may be necessary, but there are usually more efficient ways to do things,. I neither use this module nor have I had time to look at the code so I can't offer any suggestions at this point.

I just wanted to focus the 'troubleshooting' efforts on where they should be, restructuring the code to reduce the amount of times the loops are executed and to make the code more efficient.

nancydru’s picture

Thank you, Joel. Unfortunately, I didn't even write this code; it was part of what was done during the GHOP. I can't even say that I totally follow it. But I will try to get a handle on it so that I can improve it.

nancydru’s picture

StatusFileSize
new50.58 KB

Okay, Johan, let's try this code. The inner loop has been removed, not that it was being executed that many times (once for each term and once for each synonym). Instead of having an inner array containing the term name and all synonyms, I expanded the outer array to have an element for each potential name (including synonyms). However, this does at least totally remove the inner loop's logic, removes a few extra array element look ups, and removes at least one drupal_strlen call. In my testing everything looks the same as it did before yet seems to be a tad faster (although I only have 60 terms).

jvlagsma’s picture

Sorry to have to disappoint you Nancy but module #22 crashes on all the original testcases on the 120 sec CPU limit.. really. I've tried a smaller vocabulaty that produces just 1 match, with that vocab the module works properly and is about as fast as the original module.

I've noticed there's a new option "Abbr element" in the settings screen, we're not using the exact same version of the module, probably doesn't matter for this function. (For the record, mine is v 1.121.2.13, yours is v 1.121.2.14)

I'll now have a go at seeing the influence of disabeling mbstring in php (planned yesterday but I had some other unexpected obligations)

jvlagsma’s picture

With "mbstring" switched off, the site reverts to findfunc = "strpos"; and then strange things happen:

my node/13355    | Case insensitive  | Case sensitive  |
Only first match | 64 sec / 4 hits   | 69 sec / 2 hits |
All matches      | 65 sec / 2 hits   | 69 sec / 6 hits |

Not only are these slower, typecase has no real influence on speed and without mbstring most of the terms are not found anymore in the text. Also notice that it finds less hits on setting "all matches" that "first match" when case insensitive is set, strange.

Mbstring on again in php.ini, stop/start apache, check in admin/logs/status and the old timings and nr of matches can be reproduced again with the same glossary module.

jvlagsma’s picture

Ok, here's a thought. I'm running PHP 5; it uses stripos for case insensitive string searches and strpos for case sensitive; basically stripos is a strpos with both its arguments lowercased. This means we're lowercasing this long 50k textstring over and over again. By doing a strtolower once, would bring down case insensitive searches to case sensitive times and speed it up by a factor 5.

jvlagsma’s picture

This code runs equally fast for case insensitive as case sensitive and produces the right nr of matches

   // make sure we only do lowercase once, case sensitive searching is faster than case insensitive
   $typecase = variable_get("glossary_case_$format", '1');
   if ($typecase == 0) {
      $searchtext = strtolower($text);
      } else {
      $searchtext = $text;
      }

  // Find match candidates.
  foreach ($terms as $i => $term) {
    foreach($term['synonyms'] as $synonym) {
      $offset=0;
      if ($typecase == 0) {
         $searchsynonym = strtolower($synonym);
         } else {
         $searchsynonym = $synonym;
      }

      $first_match_found = false;
//      while(($offset = $findfunc($text, $synonym, $offset)) !== false) {
      while(($offset = $findtagfunc($searchtext, $searchsynonym, $offset)) !== false) { // always search case sensitive
        $match = drupal_substr($text, $offset, drupal_strlen($synonym));
        //only longer matches override shorter ones
        if (!isset($events[$offset]) || drupal_strlen($events[$offset]['match']) < drupal_strlen($match)) {
          // get synonym with case as in text
          $events[$offset] = array('type' => 'match', 'which' => $i, 'match' => $match);
          if (!$replaceall) {
            $first_match_found = true;
            break;
          }
        }
        $offset += drupal_strlen($synonym);
      }
      //TODO: remove this if we want different synonyms of the same term to be matched independently as 'first matches'
      if ($first_match_found && !$replaceall) {
        break;
      }
    }
  }
nancydru’s picture

Hmm, interesting. But we have to use "drupal_strtolower" which will be slower. I wonder if anyone is using case-sensitive for any reason other than speed.

When my base version was made, these changes were made over yours:
#233688 by nancyw for chirale - added db_rewrite_sql for i18n.
#233752 by nancyw for Prodoc81 - added abbr element support.
#233771 by nancyw for Prodoc81 - added warning message and made vocabularies not required.
#228066 by nancyw - move hook_requirements and hook_enable to .install file.

The last one could do nothing but speed up the module loading. The third one was only to the settings page, so it is not really a suspect. Adding "abbr" can't possibly have caused that much overhead as it is before this loop. And I'd have to check again, but I don't think that the first one is really anywhere in this loop either.

nancydru’s picture

StatusFileSize
new50.53 KB

Okay, one more time... Here's a slightly modified version of what you came up with. It just fits a little better in the overall structure.

For some reason, Devel does not want to tell me anything even though I have it all enabled.

nancydru’s picture

Johan, around line 381 you'll see a comment "use these always/when Codefilter module is on?" - delete that and the comment lines below it completely. They are causing some strange stuff when I'm editing, I'm wondering about during execution.

jvlagsma’s picture

Nancy, case sensitive works but case insensitive crashes:
Fatal error: Maximum execution time of 120 seconds exceeded in .....\glossary.module on line 404
That's with the comments around 381 removed. It executes 504 of the 563 SQL queries needed for the page.
Maybe something is still wrong with the removed loop from update #22, that also crashed. I do have some 50 odd synonyms in the vocab, maybe that's it?

nancydru’s picture

I have been experiencing a similar problem with two of my test vocabularies - but not directly in Glossary. They are including it indirectly by using check_markup, which invokes filters. I'm having trouble pinning it down, but both have description that include anchor (<a>) tags, and one of them has those tags starting in the first position (i.e. offset=0). Do you know if any of yours have anchors in them?

nancydru’s picture

StatusFileSize
new50.83 KB

This one is back to the two loop solution but with your suggested change for case insensitive and the elimination of one extra drupal_strlen. I cannot reproduce the problems I was seeing with the other module.

jvlagsma’s picture

Hi Nancy,

have a look and enjoy, module #32 works wonderful:

my node/13355    | Case insensitive   | Case sensitive    |
Only first match | 3.9 sec / 44 hits  | 3.9 sec / 32 hits |
All matches      | 4.7 sec / 136 hits | 4.0 sec / 91 hits |

Baseline:
my node/10795    | Case insensitive  | Case sensitive    |
Only first match |  1.9 sec / 0 hits |  1.9 sec / 0 hits |
All matches      |  1.9 sec / 0 hits |  1.9 sec / 0 hits |

This means that in a cache flush situation, not only are pages with glossary matches considerably faster but all pages without glossary matches are slightly faster as well. Also there'll much less variation in speed between pages which helps website consistency and user trust. And ofcourse, eventhough there's still a dependency on nr of terms and volume of text, it's impact is far less than before, unlikely to become a problem again any time soon.

I'm delighted with this result! and I think we should call this problem busted ;-)
the problem in #31 is for some other time, hopefully under clearer conditions.
Thank you for your patience and your time

Johan

PS maybe the performance degradation after a cache flush is now so small that the warning message is no longer necessary.

nancydru’s picture

Wow! a 5-10 fold improvement? From a simple little change like that? Thanks for the suggestion, Johan.

Please don't throw away your test bed. I'd like to look around and see if there is any extra savings to be made.

Also, I'm beginning to wonder about performance of the overview page. Do you think you could benchmark that for some testing in that area soon?

I'll go ahead and port this change to 6.x and commit it.

jvlagsma’s picture

5-10 fold is in a worst case scenario but not bad at all, that's for sure. Funny thing is that once the problem is found it seems so obvious ;-)
I'll have the test system anyway for support of the prod site so it's no problem to test further. I suggest opening a new thread, If you forward link this one to it I'll pick it up. Are you after the /glossary page? any particular circumstances?

nancydru’s picture

I will open a new one because this one needs to close once I commit.

Some people seem to think that the overview page is too slow to show the whole thing unless it is specifically asked for. I have to admit that I think it should be rendering faster, but I also use Taxonomy Image to show pictures. I don't know if there's any "low-hanging" fruit there or not since I haven't really examined the code in a while. I do know that, in this case particularly, I think 6.x is a more telling release since I'm looking at splitting it out to avoid so much code loading and the theming system is quite different in 6.x.

Now, if you really want to get down to a benchmark, I really want to speed Taxonomy List up a lot. I sure wish Devel was working right on my system.

nancydru’s picture

Assigned: Unassigned » nancydru
Status: Postponed (maintainer needs more info) » Fixed

Committed to both branches.

Anonymous’s picture

Status: Fixed » Closed (fixed)

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