Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

(dev/core#635) Implement local array-cache for use with Redis/Memcache #13496

Merged
merged 5 commits into from
Jan 30, 2019

Conversation

totten
Copy link
Member

@totten totten commented Jan 23, 2019

Overview

Consumers of Redis or Memcached currently trigger I/O anytime they read a value from the cache -- even if the value has been read before. This is OK if the caller is organized to do one read. It can be even good in some edge-cases of inter-process communication. However, if the cache is read frequently, this can lead to a lot of redundant reads; and there are certainly use-cases where we have redundant reads.

This PR allows one to define a cache-hierarchy -- e.g. combining a thread-local array with an external cache (like Redis, Memcache, SQL, or a on-disk file).

Context

This is an off-shoot/subset of dev/core#635 and #13489. For dev/core#635, we want to minimize unnecessary SQL writes (e.g. by directing all caches to a non-SQL cache-service). The examples I've got all use CRM_Core_BAO_Cache, which is hard-coded with 2-tier cache-hierarchy (thread-local array-cache plus SQL-cache).

Concurrently, there've been critiques that some of the existing Redis/Memcache consumers should be using 2-tier cache-hierarchy.

In subsequent PRs, we'll want to create a "cache-hierarchy". With these utilities, it can be done with only 1-2 lines of extra code, and it will be PSR-16 compliant.

This depends on #13500.

Before

  • CRM_Utils_Cache::create(...type => *memory*...) returns an instance for direct-access to the underlying cache.
  • If you want extra caching of the results, you have to write your own.

After

  • CRM_Utils_Cache::create() accepts an option withArray => FALSE|TRUE|fast, which allows you to request a Redis/Memcache instance which uses a thread-local array.
  • The utility class CRM_Utils_Cache_ArrayDecorator implements a PSR-16 compliant wrapper. It takes any cache and puts a local-array in front of it.
  • The utility class CRM_Utils_Cache_FastArrayDecorator does the same thing, but it's not PSR-16 compliant. It sacrifices some correctness in order to improve performance.
  • The utility class CRM_Utils_Cache_Tiered allows construction of arbitrary N-tier cache hierarchies. Whereas ArrayDecorator allows two specific tiers (e.g. array => $delegate), Tiered can be used with stacks (like array => redis => sql or redis => sql => file).

Technical Details / Comments

I originally drafted the implementation of Tiered because it was a more flexible design. Then I ran a naive benchmark to compare the 2-tier hierarchy (array=>redis) against direct redis and found... it was about the same, and sometimes slower!

There were two basic reasons for this:

  1. Usage patterns: Tiering isn't a universal good; it depends on usage patterns. If you just write a record, read that record one time, and then repeat with different records... then tiers suck. There's no gain from avoiding reads, and writes are more expensive. But if you re-read data several times, then it helps a lot. I needed to change the benchmark to compare performance in that usage pattern.

  2. PSR-16 Compliance: PSR-16 standardizes certain edge-cases, esp: (a) TTL/expiration, which leads to an extra cost for enforcing consistent expiration times and (b) mutability of objects, which leads to an extra cost for copying in-memory objects ($copy=deserialize(serialize($original))).

The revised benchmark script lets us specify the #read operations. The ArrayDecorator and FastArrayDecorator were attempts to squeeze out more performance. And the benchmarks improved. In the figures below, we measure the write-time (writing 1000 items) and read-time (reading 200 items). Note how it checks performance with different readPerItem values (i.e. each item is read once; or read 10 times; or read 150 times).

[[ trialCount=3 writeItems=1000 readItems=200 numTimesToReadEachItem=1 ]]

Mean values across all trials:

direct          write=0.1050s   read=0.0092s   readPerItem=0.00922100s
tiered          write=0.1270s   read=0.0141s   readPerItem=0.01405501s
arrdec          write=0.1122s   read=0.0108s   readPerItem=0.01083700s
fastdec         write=0.1049s   read=0.0101s   readPerItem=0.01009822s

[[ trialCount=3 writeItems=1000 readItems=200 numTimesToReadEachItem=10 ]]

Mean values across all trials:

direct          write=0.1075s   read=0.0903s   readPerItem=0.00902683s
tiered          write=0.1233s   read=0.0363s   readPerItem=0.00362550s
arrdec          write=0.1133s   read=0.0179s   readPerItem=0.00179152s
fastdec         write=0.1036s   read=0.0129s   readPerItem=0.00128693s

[[ trialCount=3 writeItems=1000 readItems=200 numTimesToReadEachItem=150 ]]

Mean values across all trials:

direct          write=0.1012s   read=1.3051s   readPerItem=0.00870067s
tiered          write=0.1202s   read=0.3467s   readPerItem=0.00231102s
arrdec          write=0.1059s   read=0.1356s   readPerItem=0.00090372s
fastdec         write=0.1028s   read=0.0611s   readPerItem=0.00040723s

If there's only 1 read (no re-reading), then direct-access is best; all others have overhead. The overhead diminishes as you go from Tiered to ArrayDecorator to FastArrayDecorator.

Why include all three -- why not just do one? Well, (1) they're already written, and the parent unit-test goes a long way to showing the correctness of each, so it's not much difference cost-wise. (2) The best one actually depends on the situation -- why you're using a cache, how big the data-records are, how frequently the records are read. That means the decision about which mechanism to use (in which use-cases) should not be in this PR (which just provides the utility). The decision should be made when we use the cache-hierarchy (in subsequent PRs).

Reviewer Tips

Some things that might help in evaluating this:

  • Each of the new classes has a corresponding test (like E2E_Cache_TieredTest), and the test classes are derived from E2E_Cache_CacheTestCase and \Cache\IntegrationTests\LegacySimpleCacheTest. That class originates with the php-cache project and provides pretty good test-coverage for functions like get($key, $default=NULL), set($key,$value,$ttl=NULL), and has($key).
  • You can play with caches manually via cv cli. You may not have Redis or Memcache locally, but you can pick any cache-driver (CRM_Utils_Cache_SqlGroup, CRM_Utils_Cache_ArrayCache).
    • Step 1: Create some underlying caches ($a, $b, ...) and an overarching cache ($z), like one of these:
      • $a=new CRM_Utils_Cache_ArrayCache([]); $z=new CRM_Utils_Cache_ArrayDecorator($a);
      • $a=new CRM_Utils_Cache_ArrayCache([]); $b=new CRM_Utils_Cache_ArrayCache([]); $z = new CRM_Utils_Cache_Tiered([$a, $b]);
      • $a=CRM_Utils_Cache::create(['name'=>'foo-mem', 'type'=>['*memory*']]); $b=CRM_Utils_Cache::create(['name'=>'foo-sql', 'type'=>['SqlGroup']]); $z=new CRM_Utils_Cache_Tiered([$a,$b])
    • Step 2: Interact with these cache objects, e.g.
      • $z->set('foo',123);
      • $z->get('foo')
      • $a->get('foo')
      • print_r($z);
      • print_r(['a'=>$a, 'b'=>$b, 'z'=>$z])

@civibot
Copy link

civibot bot commented Jan 23, 2019

(Standard links)

@civibot civibot bot added the master label Jan 23, 2019
@totten totten changed the title (dev/core#635; Performance) Implement thread-local array-cache as a decorator (dev/core#635; Performance) Implement local array-cache (for use with Redis/Memcache) Jan 23, 2019
@totten totten changed the title (dev/core#635; Performance) Implement local array-cache (for use with Redis/Memcache) (dev/core#635) Implement local array-cache for use with Redis/Memcache Jan 23, 2019
@totten totten force-pushed the master-cache-tier branch from 3455831 to e0d7aa4 Compare January 23, 2019 21:38
@totten
Copy link
Member Author

totten commented Jan 23, 2019

Note: I've taken some bits that were originally copied by each driver (for handling "nack"s). Those have now been moved to a separate PR, #13500. #13496 is rebased and depends on #13500.

Before
------
* No way to daisy-chain caches to form a cache hierarchy
* ArrayCache::reobjectify() would fail to reobjectify objects in an array, which seems against the spirit of PSR-16

After
-----
* You can create a cache hierarchy with `new CRM_Utils_Cache_Tiered([$fastCache, $mediumCache, $slowCache])`
* ArrayCache::reobjectify() will reobjectify if it detects an object directly in an array

Note: To ensure that TTL data is respected consistently regardless of how
the tiers behave and the order in which they are used, the TTL/expiration
must be stored extra times.
This allows you to put a static array in front of another cache.  It is the
same basic idea as CRM_Utils_Cache_Tiered, but it's optimized for a typical case
where you only want one front-cache.

Based on some naive benchmarking (performing several trials with a few
thousand duplicate reads over the same cached data), this basically cut the
read-time in half.  The following is pretty representative of the results:

```
Redis-only cache               write=0.1044s   read=1.3266s
2-Tier (ArrayCache+Redis)      write=0.1189s   read=0.3765s
Decorated-Redis cache          write=0.1105s   read=0.1505s
```

See also: https://gist.github.com/totten/6d6524be115c193e0704ff3cf250336d

Note: To ensure that TTL data is respected consistently regardless of how
the tiers behave and the order in which they are used, the TTL/expiration
must be stored extra times.
This adds and documents a new config option which can be passed into the cache factory.

The option, `withArray`, indicates that we prefer to have a thread-local
array acting as an extra cache-tier.
@totten totten force-pushed the master-cache-tier branch from e0d7aa4 to 84413ec Compare January 30, 2019 00:51
@eileenmcnaughton
Copy link
Contributor

eileenmcnaughton commented Jan 30, 2019

I've done some testing on this.

Using Redis with or without this PR a 'normal' page load on a site with Shoreditch installed hits the Redis cache 26 times to get the key for the extension container - in this case
/017185808f04d7829d5f9aaf191b7ee5/full

With this PR I was, however, able to make the below one-liner to get it to use the ArrayDecorator and as expected it only hit Redis once, getting the remaining 25 instances from memory.

In previous testing switching to Redis didn't give a speed performance until I addressed a bunch of places which didn't have array caching & were falling back on Redis all the time (but had been using array caching in conjunction with mysql per Tim's comments above) . At the time I fixed through Civi::statics - however, this makes it much more possible to replace that with something more correct.

I got similar results with the fast array caching but didn't dig into the tiered caching as it's not currently in use & felt a bit obscure

diff --git a/CRM/Extension/System.php b/CRM/Extension/System.php
index 20d171d131..102ca29867 100644
--- a/CRM/Extension/System.php
+++ b/CRM/Extension/System.php
@@ -260,6 +260,7 @@ class CRM_Extension_System {
         'name' => $cacheGroup,
         'type' => array('*memory*', 'SqlGroup', 'ArrayCache'),
         'prefetch' => TRUE,
+        'withArray' => TRUE,
       ));
     }
     return $this->cache;

@eileenmcnaughton eileenmcnaughton merged commit b810682 into civicrm:master Jan 30, 2019
@eileenmcnaughton
Copy link
Contributor

Note that I think we should follow this up with a change to caching on the Extension System cache!

@totten totten deleted the master-cache-tier branch January 30, 2019 02:53
@totten
Copy link
Member Author

totten commented Jan 30, 2019

Agree! There's ~4 that I think we should change.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants