Merge "objectcache: optimize MemcachedPeclBagOStuff::*Multi() write methods"
authorjenkins-bot <jenkins-bot@gerrit.wikimedia.org>
Thu, 18 Jul 2019 15:28:13 +0000 (15:28 +0000)
committerGerrit Code Review <gerrit@wikimedia.org>
Thu, 18 Jul 2019 15:28:13 +0000 (15:28 +0000)
includes/libs/objectcache/BagOStuff.php
includes/libs/objectcache/MemcachedPeclBagOStuff.php
maintenance/mctest.php

index dce49c4..4819f0e 100644 (file)
@@ -97,14 +97,15 @@ abstract class BagOStuff implements IExpiringStore, IStoreKeyEncoder, LoggerAwar
        /** @var int[] Map of (ATTR_* class constant => QOS_* class constant) */
        protected $attrMap = [];
 
-       /** Bitfield constants for get()/getMulti() */
-       const READ_LATEST = 1; // use latest data for replicated stores
-       const READ_VERIFIED = 2; // promise that caller can tell when keys are stale
-       /** Bitfield constants for set()/merge() */
-       const WRITE_SYNC = 4; // synchronously write to all locations for replicated stores
-       const WRITE_CACHE_ONLY = 8; // Only change state of the in-memory cache
-       const WRITE_ALLOW_SEGMENTS = 16; // Allow partitioning of the value if it is large
-       const WRITE_PRUNE_SEGMENTS = 32; // Delete all partition segments of the value
+       /** Bitfield constants for get()/getMulti(); these are only advisory */
+       const READ_LATEST = 1; // if supported, avoid reading stale data due to replication
+       const READ_VERIFIED = 2; // promise that the caller handles detection of staleness
+       /** Bitfield constants for set()/merge(); these are only advisory */
+       const WRITE_SYNC = 4; // if supported, block until the write is fully replicated
+       const WRITE_CACHE_ONLY = 8; // only change state of the in-memory cache
+       const WRITE_ALLOW_SEGMENTS = 16; // allow partitioning of the value if it is large
+       const WRITE_PRUNE_SEGMENTS = 32; // delete all the segments if the value is partitioned
+       const WRITE_BACKGROUND = 64; // if supported,
 
        /** @var string Component to use for key construction of blob segment keys */
        const SEGMENT_COMPONENT = 'segment';
@@ -727,6 +728,8 @@ abstract class BagOStuff implements IExpiringStore, IStoreKeyEncoder, LoggerAwar
         *
         * This does not support WRITE_ALLOW_SEGMENTS to avoid excessive read I/O
         *
+        * WRITE_BACKGROUND can be used for bulk insertion where the response is not vital
+        *
         * @param mixed[] $data Map of (key => value)
         * @param int $exptime Either an interval in seconds or a unix timestamp for expiry
         * @param int $flags Bitfield of BagOStuff::WRITE_* constants (since 1.33)
@@ -759,6 +762,8 @@ abstract class BagOStuff implements IExpiringStore, IStoreKeyEncoder, LoggerAwar
         *
         * This does not support WRITE_ALLOW_SEGMENTS to avoid excessive read I/O
         *
+        * WRITE_BACKGROUND can be used for bulk deletion where the response is not vital
+        *
         * @param string[] $keys List of keys
         * @param int $flags Bitfield of BagOStuff::WRITE_* constants
         * @return bool Success
index 221bc82..cc7ee2a 100644 (file)
  */
 class MemcachedPeclBagOStuff extends MemcachedBagOStuff {
        /** @var Memcached */
-       protected $client;
+       protected $syncClient;
+       /** @var Memcached|null */
+       protected $asyncClient;
+
+       /** @var bool Whether the non-buffering client is locked from use */
+       protected $syncClientIsBuffering = false;
+       /** @var bool Whether the non-buffering client should be flushed before use */
+       protected $hasUnflushedChanges = false;
+
+       /** @var array Memcached options */
+       private static $OPTS_SYNC_WRITES = [
+               Memcached::OPT_NO_BLOCK => false, // async I/O (using TCP buffers)
+               Memcached::OPT_BUFFER_WRITES => false // libmemcached buffers
+       ];
+       /** @var array Memcached options */
+       private static $OPTS_ASYNC_WRITES = [
+               Memcached::OPT_NO_BLOCK => true, // async I/O (using TCP buffers)
+               Memcached::OPT_BUFFER_WRITES => true // libmemcached buffers
+       ];
 
        /**
         * Available parameters are:
@@ -63,15 +81,22 @@ class MemcachedPeclBagOStuff extends MemcachedBagOStuff {
                        // The Memcached object is essentially shared for each pool ID.
                        // We can only reuse a pool ID if we keep the config consistent.
                        $connectionPoolId = md5( serialize( $params ) );
-                       $client = new Memcached( $connectionPoolId );
-                       $this->initializeClient( $client, $params );
+                       $syncClient = new Memcached( "$connectionPoolId-sync" );
+                       // Avoid clobbering the main thread-shared Memcached instance
+                       $asyncClient = new Memcached( "$connectionPoolId-async" );
                } else {
-                       $client = new Memcached;
-                       $this->initializeClient( $client, $params );
+                       $syncClient = new Memcached();
+                       $asyncClient = null;
                }
 
-               $this->client = $client;
+               $this->initializeClient( $syncClient, $params, self::$OPTS_SYNC_WRITES );
+               if ( $asyncClient ) {
+                       $this->initializeClient( $asyncClient, $params, self::$OPTS_ASYNC_WRITES );
+               }
 
+               // Set the main client and any dedicated one for buffered writes
+               $this->syncClient = $syncClient;
+               $this->asyncClient = $asyncClient;
                // The compression threshold is an undocumented php.ini option for some
                // reason. There's probably not much harm in setting it globally, for
                // compatibility with the settings for the PHP client.
@@ -84,9 +109,10 @@ class MemcachedPeclBagOStuff extends MemcachedBagOStuff {
         *
         * @param Memcached $client
         * @param array $params
+        * @param array $options Base options for Memcached::setOptions()
         * @throws RuntimeException
         */
-       private function initializeClient( Memcached $client, array $params ) {
+       private function initializeClient( Memcached $client, array $params, array $options ) {
                if ( $client->getServerList() ) {
                        $this->logger->debug( __METHOD__ . ": pre-initialized client instance." );
 
@@ -95,7 +121,9 @@ class MemcachedPeclBagOStuff extends MemcachedBagOStuff {
 
                $this->logger->debug( __METHOD__ . ": initializing new client instance." );
 
-               $options = [
+               $options += [
+                       Memcached::OPT_NO_BLOCK => false,
+                       Memcached::OPT_BUFFER_WRITES => false,
                        // Network protocol (ASCII or binary)
                        Memcached::OPT_BINARY_PROTOCOL => $params['use_binary_protocol'],
                        // Set various network timeouts
@@ -150,10 +178,12 @@ class MemcachedPeclBagOStuff extends MemcachedBagOStuff {
 
        protected function doGet( $key, $flags = 0, &$casToken = null ) {
                $this->debug( "get($key)" );
+
+               $client = $this->acquireSyncClient();
                if ( defined( Memcached::class . '::GET_EXTENDED' ) ) { // v3.0.0
                        /** @noinspection PhpUndefinedClassConstantInspection */
                        $flags = Memcached::GET_EXTENDED;
-                       $res = $this->client->get( $this->validateKeyEncoding( $key ), null, $flags );
+                       $res = $client->get( $this->validateKeyEncoding( $key ), null, $flags );
                        if ( is_array( $res ) ) {
                                $result = $res['value'];
                                $casToken = $res['cas'];
@@ -162,62 +192,77 @@ class MemcachedPeclBagOStuff extends MemcachedBagOStuff {
                                $casToken = null;
                        }
                } else {
-                       $result = $this->client->get( $this->validateKeyEncoding( $key ), null, $casToken );
+                       $result = $client->get( $this->validateKeyEncoding( $key ), null, $casToken );
                }
-               $result = $this->checkResult( $key, $result );
-               return $result;
+
+               return $this->checkResult( $key, $result );
        }
 
        protected function doSet( $key, $value, $exptime = 0, $flags = 0 ) {
                $this->debug( "set($key)" );
-               $result = $this->client->set(
+
+               $client = $this->acquireSyncClient();
+               $result = $client->set(
                        $this->validateKeyEncoding( $key ),
                        $value,
                        $this->fixExpiry( $exptime )
                );
-               if ( $result === false && $this->client->getResultCode() === Memcached::RES_NOTSTORED ) {
+
+               return ( $result === false && $client->getResultCode() === Memcached::RES_NOTSTORED )
                        // "Not stored" is always used as the mcrouter response with AllAsyncRoute
-                       return true;
-               }
-               return $this->checkResult( $key, $result );
+                       ? true
+                       : $this->checkResult( $key, $result );
        }
 
        protected function cas( $casToken, $key, $value, $exptime = 0, $flags = 0 ) {
                $this->debug( "cas($key)" );
-               $result = $this->client->cas( $casToken, $this->validateKeyEncoding( $key ),
-                       $value, $this->fixExpiry( $exptime ) );
+
+               $result = $this->acquireSyncClient()->cas(
+                       $casToken,
+                       $this->validateKeyEncoding( $key ),
+                       $value, $this->fixExpiry( $exptime )
+               );
+
                return $this->checkResult( $key, $result );
        }
 
        protected function doDelete( $key, $flags = 0 ) {
                $this->debug( "delete($key)" );
-               $result = $this->client->delete( $this->validateKeyEncoding( $key ) );
-               if ( $result === false && $this->client->getResultCode() === Memcached::RES_NOTFOUND ) {
+
+               $client = $this->acquireSyncClient();
+               $result = $client->delete( $this->validateKeyEncoding( $key ) );
+
+               return ( $result === false && $client->getResultCode() === Memcached::RES_NOTFOUND )
                        // "Not found" is counted as success in our interface
-                       return true;
-               }
-               return $this->checkResult( $key, $result );
+                       ? true
+                       : $this->checkResult( $key, $result );
        }
 
        public function add( $key, $value, $exptime = 0, $flags = 0 ) {
                $this->debug( "add($key)" );
-               $result = $this->client->add(
+
+               $result = $this->acquireSyncClient()->add(
                        $this->validateKeyEncoding( $key ),
                        $value,
                        $this->fixExpiry( $exptime )
                );
+
                return $this->checkResult( $key, $result );
        }
 
        public function incr( $key, $value = 1 ) {
                $this->debug( "incr($key)" );
-               $result = $this->client->increment( $key, $value );
+
+               $result = $this->acquireSyncClient()->increment( $key, $value );
+
                return $this->checkResult( $key, $result );
        }
 
        public function decr( $key, $value = 1 ) {
                $this->debug( "decr($key)" );
-               $result = $this->client->decrement( $key, $value );
+
+               $result = $this->acquireSyncClient()->decrement( $key, $value );
+
                return $this->checkResult( $key, $result );
        }
 
@@ -236,22 +281,25 @@ class MemcachedPeclBagOStuff extends MemcachedBagOStuff {
                if ( $result !== false ) {
                        return $result;
                }
-               switch ( $this->client->getResultCode() ) {
+
+               $client = $this->syncClient;
+               switch ( $client->getResultCode() ) {
                        case Memcached::RES_SUCCESS:
                                break;
                        case Memcached::RES_DATA_EXISTS:
                        case Memcached::RES_NOTSTORED:
                        case Memcached::RES_NOTFOUND:
-                               $this->debug( "result: " . $this->client->getResultMessage() );
+                               $this->debug( "result: " . $client->getResultMessage() );
                                break;
                        default:
-                               $msg = $this->client->getResultMessage();
+                               $msg = $client->getResultMessage();
                                $logCtx = [];
                                if ( $key !== false ) {
-                                       $server = $this->client->getServerByKey( $key );
+                                       $server = $client->getServerByKey( $key );
                                        $logCtx['memcached-server'] = "{$server['host']}:{$server['port']}";
                                        $logCtx['memcached-key'] = $key;
-                                       $msg = "Memcached error for key \"{memcached-key}\" on server \"{memcached-server}\": $msg";
+                                       $msg = "Memcached error for key \"{memcached-key}\" " .
+                                               "on server \"{memcached-server}\": $msg";
                                } else {
                                        $msg = "Memcached error: $msg";
                                }
@@ -263,41 +311,71 @@ class MemcachedPeclBagOStuff extends MemcachedBagOStuff {
 
        protected function doGetMulti( array $keys, $flags = 0 ) {
                $this->debug( 'getMulti(' . implode( ', ', $keys ) . ')' );
+
                foreach ( $keys as $key ) {
                        $this->validateKeyEncoding( $key );
                }
-               $result = $this->client->getMulti( $keys ) ?: [];
+
+               // The PECL implementation uses "gets" which works as well as a pipeline
+               $result = $this->acquireSyncClient()->getMulti( $keys ) ?: [];
+
                return $this->checkResult( false, $result );
        }
 
        protected function doSetMulti( array $data, $exptime = 0, $flags = 0 ) {
                $this->debug( 'setMulti(' . implode( ', ', array_keys( $data ) ) . ')' );
+
+               $exptime = $this->fixExpiry( $exptime );
                foreach ( array_keys( $data ) as $key ) {
                        $this->validateKeyEncoding( $key );
                }
-               $result = $this->client->setMulti( $data, $this->fixExpiry( $exptime ) );
+
+               // The PECL implementation is a naïve for-loop so use async I/O to pipeline;
+               // https://github.com/php-memcached-dev/php-memcached/blob/master/php_memcached.c#L1852
+               if ( ( $flags & self::WRITE_BACKGROUND ) == self::WRITE_BACKGROUND ) {
+                       $client = $this->acquireAsyncClient();
+                       $result = $client->setMulti( $data, $exptime );
+                       $this->releaseAsyncClient( $client );
+               } else {
+                       $result = $this->acquireSyncClient()->setMulti( $data, $exptime );
+               }
+
                return $this->checkResult( false, $result );
        }
 
        protected function doDeleteMulti( array $keys, $flags = 0 ) {
                $this->debug( 'deleteMulti(' . implode( ', ', $keys ) . ')' );
+
                foreach ( $keys as $key ) {
                        $this->validateKeyEncoding( $key );
                }
-               $result = $this->client->deleteMulti( $keys ) ?: [];
-               $ok = true;
-               foreach ( $result as $code ) {
+
+               // The PECL implementation is a naïve for-loop so use async I/O to pipeline;
+               // https://github.com/php-memcached-dev/php-memcached/blob/7443d16d02fb73cdba2e90ae282446f80969229c/php_memcached.c#L1852
+               if ( ( $flags & self::WRITE_BACKGROUND ) == self::WRITE_BACKGROUND ) {
+                       $client = $this->acquireAsyncClient();
+                       $resultArray = $client->deleteMulti( $keys ) ?: [];
+                       $this->releaseAsyncClient( $client );
+               } else {
+                       $resultArray = $this->acquireSyncClient()->deleteMulti( $keys ) ?: [];
+               }
+
+               $result = true;
+               foreach ( $resultArray as $code ) {
                        if ( !in_array( $code, [ true, Memcached::RES_NOTFOUND ], true ) ) {
                                // "Not found" is counted as success in our interface
-                               $ok = false;
+                               $result = false;
                        }
                }
-               return $this->checkResult( false, $ok );
+
+               return $this->checkResult( false, $result );
        }
 
        protected function doChangeTTL( $key, $exptime, $flags ) {
                $this->debug( "touch($key)" );
-               $result = $this->client->touch( $key, $exptime );
+
+               $result = $this->acquireSyncClient()->touch( $key, $this->fixExpiry( $exptime ) );
+
                return $this->checkResult( $key, $result );
        }
 
@@ -306,7 +384,7 @@ class MemcachedPeclBagOStuff extends MemcachedBagOStuff {
                        return $value;
                }
 
-               $serializer = $this->client->getOption( Memcached::OPT_SERIALIZER );
+               $serializer = $this->syncClient->getOption( Memcached::OPT_SERIALIZER );
                if ( $serializer === Memcached::SERIALIZER_PHP ) {
                        return serialize( $value );
                } elseif ( $serializer === Memcached::SERIALIZER_IGBINARY ) {
@@ -321,7 +399,7 @@ class MemcachedPeclBagOStuff extends MemcachedBagOStuff {
                        return (int)$value;
                }
 
-               $serializer = $this->client->getOption( Memcached::OPT_SERIALIZER );
+               $serializer = $this->syncClient->getOption( Memcached::OPT_SERIALIZER );
                if ( $serializer === Memcached::SERIALIZER_PHP ) {
                        return unserialize( $value );
                } elseif ( $serializer === Memcached::SERIALIZER_IGBINARY ) {
@@ -330,4 +408,52 @@ class MemcachedPeclBagOStuff extends MemcachedBagOStuff {
 
                throw new UnexpectedValueException( __METHOD__ . ": got serializer '$serializer'." );
        }
+
+       /**
+        * @return Memcached
+        */
+       private function acquireSyncClient() {
+               if ( $this->syncClientIsBuffering ) {
+                       throw new RuntimeException( "The main (unbuffered I/O) client is locked" );
+               }
+
+               if ( $this->hasUnflushedChanges ) {
+                       // Force a synchronous flush of async writes so that their changes are visible
+                       $this->syncClient->fetch();
+                       if ( $this->asyncClient ) {
+                               $this->asyncClient->fetch();
+                       }
+                       $this->hasUnflushedChanges = false;
+               }
+
+               return $this->syncClient;
+       }
+
+       /**
+        * @return Memcached
+        */
+       private function acquireAsyncClient() {
+               if ( $this->asyncClient ) {
+                       return $this->asyncClient; // dedicated buffering instance
+               }
+
+               // Modify the main instance to temporarily buffer writes
+               $this->syncClientIsBuffering = true;
+               $this->syncClient->setOptions( self::$OPTS_ASYNC_WRITES );
+
+               return $this->syncClient;
+       }
+
+       /**
+        * @param Memcached $client
+        */
+       private function releaseAsyncClient( $client ) {
+               $this->hasUnflushedChanges = true;
+
+               if ( !$this->asyncClient ) {
+                       // This is the main instance; make it stop buffering writes again
+                       $client->setOptions( self::$OPTS_SYNC_WRITES );
+                       $this->syncClientIsBuffering = false;
+               }
+       }
 }
index 513edf3..9548d6b 100644 (file)
@@ -37,6 +37,7 @@ class McTest extends Maintenance {
                        . " memcached server and shows a report" );
                $this->addOption( 'i', 'Number of iterations', false, true );
                $this->addOption( 'cache', 'Use servers from this $wgObjectCaches store', false, true );
+               $this->addOption( 'driver', 'Either "php" or "pecl"', false, true );
                $this->addArg( 'server[:port]', 'Memcached server to test, with optional port', false );
        }
 
@@ -66,41 +67,177 @@ class McTest extends Maintenance {
                # find out the longest server string to nicely align output later on
                $maxSrvLen = $servers ? max( array_map( 'strlen', $servers ) ) : 0;
 
+               $type = $this->getOption( 'driver', 'php' );
+               if ( $type === 'php' ) {
+                       $class = MemcachedPhpBagOStuff::class;
+               } elseif ( $type === 'pecl' ) {
+                       $class = MemcachedPeclBagOStuff::class;
+               } else {
+                       $this->fatalError( "Invalid driver type '$type'" );
+               }
+
                foreach ( $servers as $server ) {
-                       $this->output(
-                               str_pad( $server, $maxSrvLen ),
-                               $server # output channel
-                       );
+                       $this->output( str_pad( $server, $maxSrvLen ) . "\n" );
 
-                       $mcc = new MemcachedClient( [
-                               'persistant' => true,
+                       /** @var BagOStuff $mcc */
+                       $mcc = new $class( [
+                               'servers' => [ $server ],
+                               'persistent' => true,
                                'timeout' => $wgMemCachedTimeout
                        ] );
-                       $mcc->set_servers( [ $server ] );
-                       $set = 0;
-                       $incr = 0;
-                       $get = 0;
-                       $time_start = microtime( true );
-                       for ( $i = 1; $i <= $iterations; $i++ ) {
-                               if ( $mcc->set( "test$i", $i ) ) {
-                                       $set++;
-                               }
+
+                       $this->benchmarkSingleKeyOps( $mcc, $iterations );
+                       $this->benchmarkMultiKeyOpsImmediateBlocking( $mcc, $iterations );
+                       $this->benchmarkMultiKeyOpsDeferredBlocking( $mcc, $iterations );
+               }
+       }
+
+       /**
+        * @param BagOStuff $mcc
+        * @param int $iterations
+        */
+       private function benchmarkSingleKeyOps( $mcc, $iterations ) {
+               $add = 0;
+               $set = 0;
+               $incr = 0;
+               $get = 0;
+               $delete = 0;
+
+               $keys = [];
+               for ( $i = 1; $i <= $iterations; $i++ ) {
+                       $keys[] = "test$i";
+               }
+
+               // Clear out any old values
+               $mcc->deleteMulti( $keys );
+
+               $time_start = microtime( true );
+               foreach ( $keys as $key ) {
+                       if ( $mcc->add( $key, $i ) ) {
+                               $add++;
                        }
-                       for ( $i = 1; $i <= $iterations; $i++ ) {
-                               if ( !is_null( $mcc->incr( "test$i", $i ) ) ) {
-                                       $incr++;
-                               }
+               }
+               $addMs = intval( 1e3 * ( microtime( true ) - $time_start ) );
+
+               $time_start = microtime( true );
+               foreach ( $keys as $key ) {
+                       if ( $mcc->set( $key, $i ) ) {
+                               $set++;
+                       }
+               }
+               $setMs = intval( 1e3 * ( microtime( true ) - $time_start ) );
+
+               $time_start = microtime( true );
+               foreach ( $keys as $key ) {
+                       if ( !is_null( $mcc->incr( $key, $i ) ) ) {
+                               $incr++;
                        }
-                       for ( $i = 1; $i <= $iterations; $i++ ) {
-                               $value = $mcc->get( "test$i" );
-                               if ( $value == $i * 2 ) {
-                                       $get++;
-                               }
+               }
+               $incrMs = intval( 1e3 * ( microtime( true ) - $time_start ) );
+
+               $time_start = microtime( true );
+               foreach ( $keys as $key ) {
+                       $value = $mcc->get( $key );
+                       if ( $value == $i * 2 ) {
+                               $get++;
                        }
-                       $exectime = microtime( true ) - $time_start;
+               }
+               $getMs = intval( 1e3 * ( microtime( true ) - $time_start ) );
 
-                       $this->output( " set: $set   incr: $incr   get: $get time: $exectime", $server );
+               $time_start = microtime( true );
+               foreach ( $keys as $key ) {
+                       if ( $mcc->delete( $key ) ) {
+                               $delete++;
+                       }
                }
+               $delMs = intval( 1e3 * ( microtime( true ) - $time_start ) );
+
+               $this->output(
+                       " add: $add/$iterations {$addMs}ms   " .
+                       "set: $set/$iterations {$setMs}ms   " .
+                       "incr: $incr/$iterations {$incrMs}ms   " .
+                       "get: $get/$iterations ({$getMs}ms)   " .
+                       "delete: $delete/$iterations ({$delMs}ms)\n"
+               );
+       }
+
+       /**
+        * @param BagOStuff $mcc
+        * @param int $iterations
+        */
+       private function benchmarkMultiKeyOpsImmediateBlocking( $mcc, $iterations ) {
+               $keysByValue = [];
+               for ( $i = 1; $i <= $iterations; $i++ ) {
+                       $keysByValue["test$i"] = 'S' . str_pad( $i, 2048 );
+               }
+               $keyList = array_keys( $keysByValue );
+
+               $time_start = microtime( true );
+               $mSetOk = $mcc->setMulti( $keysByValue ) ? 'S' : 'F';
+               $mSetMs = intval( 1e3 * ( microtime( true ) - $time_start ) );
+
+               $time_start = microtime( true );
+               $found = $mcc->getMulti( $keyList );
+               $mGetMs = intval( 1e3 * ( microtime( true ) - $time_start ) );
+               $mGetOk = 0;
+               foreach ( $found as $key => $value ) {
+                       $mGetOk += ( $value === $keysByValue[$key] );
+               }
+
+               $time_start = microtime( true );
+               $mChangeTTLOk = $mcc->changeTTLMulti( $keyList, 3600 ) ? 'S' : 'F';
+               $mChangeTTTMs = intval( 1e3 * ( microtime( true ) - $time_start ) );
+
+               $time_start = microtime( true );
+               $mDelOk = $mcc->deleteMulti( $keyList ) ? 'S' : 'F';
+               $mDelMs = intval( 1e3 * ( microtime( true ) - $time_start ) );
+
+               $this->output(
+                       " setMulti (IB): $mSetOk {$mSetMs}ms   " .
+                       "getMulti (IB): $mGetOk/$iterations {$mGetMs}ms   " .
+                       "changeTTLMulti (IB): $mChangeTTLOk {$mChangeTTTMs}ms   " .
+                       "deleteMulti (IB): $mDelOk {$mDelMs}ms\n"
+               );
+       }
+
+       /**
+        * @param BagOStuff $mcc
+        * @param int $iterations
+        */
+       private function benchmarkMultiKeyOpsDeferredBlocking( $mcc, $iterations ) {
+               $flags = $mcc::WRITE_BACKGROUND;
+               $keysByValue = [];
+               for ( $i = 1; $i <= $iterations; $i++ ) {
+                       $keysByValue["test$i"] = 'A' . str_pad( $i, 2048 );
+               }
+               $keyList = array_keys( $keysByValue );
+
+               $time_start = microtime( true );
+               $mSetOk = $mcc->setMulti( $keysByValue, 0, $flags ) ? 'S' : 'F';
+               $mSetMs = intval( 1e3 * ( microtime( true ) - $time_start ) );
+
+               $time_start = microtime( true );
+               $found = $mcc->getMulti( $keyList );
+               $mGetMs = intval( 1e3 * ( microtime( true ) - $time_start ) );
+               $mGetOk = 0;
+               foreach ( $found as $key => $value ) {
+                       $mGetOk += ( $value === $keysByValue[$key] );
+               }
+
+               $time_start = microtime( true );
+               $mChangeTTLOk = $mcc->changeTTLMulti( $keyList, 3600, $flags ) ? 'S' : 'F';
+               $mChangeTTTMs = intval( 1e3 * ( microtime( true ) - $time_start ) );
+
+               $time_start = microtime( true );
+               $mDelOk = $mcc->deleteMulti( $keyList, $flags ) ? 'S' : 'F';
+               $mDelMs = intval( 1e3 * ( microtime( true ) - $time_start ) );
+
+               $this->output(
+                       " setMulti (DB): $mSetOk {$mSetMs}ms   " .
+                       "getMulti (DB): $mGetOk/$iterations {$mGetMs}ms   " .
+                       "changeTTLMulti (DB): $mChangeTTLOk {$mChangeTTTMs}ms   " .
+                       "deleteMulti (DB): $mDelOk {$mDelMs}ms\n"
+               );
        }
 }