scripts/capacity/calculator.py (165 lines of code) (raw):
from __future__ import print_function
import argparse
from math import ceil, floor, log
import textwrap
# constants: units
K = 1000
M = K * 1000
KB = 1024
MB = 1024 * KB
GB = 1024 * MB
# constants: defaults
DEFAULT_QPS = 100 # (K)
DEFAULT_NKEY = 100 # (M)
DEFAULT_NCONN = 5 * K
DEFAULT_FAILURE_DOMAIN = 5.0 # 5% of the nodes may be lost at once
DEFAULT_SIZE = 64 # slimcache only
MAX_HOST_LIMIT = 10 # based on platform / job size constraint
# constants: pelikan related
CONN_OVERHEAD = 33 * KB # 2 16KiB buffers, one channel, and stream overhead
SAFETY_BUF = 128 # in MB
BASE_OVERHEAD = 10 # in MB
KQPS = 30 # much lower than single-instance max, picked to scale to 10 jobs/host
# segcache needs 8/7*8 byte per object for hash table, considering
# hash bucket overflow, give it 12, this should be sufficient for hash table load 1
HASH_OVERHEAD = {'twemcache': 8, 'slimcache': 0, 'segcache': 12}
ITEM_OVERHEAD = {'twemcache': 40 + 8, 'slimcache': 6 + 8, 'segcache': 5} # ITEM_HDR_SIZE + CAS
KEYVAL_ALIGNMENT = 8 # in bytes
NITEM_ALIGNMENT = 512 # so memory allocation is always 4K (page size) aligned
# constants: job related
CPU_PER_JOB = 2.0
DISK_PER_JOB = 3 # in GB
RAM_CANDIDATES = [4, 8] # in GB
RACK_TO_HOST_RATIO = 2.0 # a somewhat arbitrary ratio between rack/host-limit
FAILURE_DOMAIN_LOWER = 0.5
FAILURE_DOMAIN_UPPER = 20.0
WARNING_THRESHOLD = 1000 # alert when too many jobs are needed
def hash_parameters(nkey, runnable):
hash_power = int(ceil(log(nkey, 2)))
ram_hash = int(ceil(1.0 * HASH_OVERHEAD[runnable] * (2 ** hash_power) / MB))
return (hash_power, ram_hash)
def calculate(args):
"""calculate job configuration according to requirements.
For segcache, returns a dict with:
cpu, ram, disk,
hash_power, seg_mem,
instance, host_limit, rack_limit,
memory_bound
For twemcache, returns a dict with:
cpu, ram, disk,
hash_power, slab_mem,
instance, host_limit, rack_limit,
memory_bound
For slimcache, return a dict with:
cpu, ram, disk,
item_size, nitem,
instance, host_limit, rack_limit,
memory_bound
"""
if args.failure_domain < FAILURE_DOMAIN_LOWER or args.failure_domain > FAILURE_DOMAIN_UPPER:
print('ERROR: failure domain should be between {:.1f}% and {:.1f}'.format(
FAILURE_DOMAIN_LOWER, FAILURE_DOMAIN_UPPER))
# first calculate njob disrecarding memory, note both njob & bottleneck are not yet final
njob_qps = int(ceil(1.0 * args.qps / KQPS))
njob_fd = int(ceil(100.0 / args.failure_domain))
if njob_qps >= njob_fd:
bottleneck = 'qps'
njob = njob_qps
else:
bottleneck = 'failure domain'
njob = njob_fd
# then calculate njob (vector) assuming memory-bound
# all ram-related values in this function are in MB
# amount of ram needed to store dataset, factoring in overhead
item_size = int(KEYVAL_ALIGNMENT * ceil(1.0 * (ITEM_OVERHEAD[args.runnable] + args.size) /
KEYVAL_ALIGNMENT))
ram_data = 1.0 * item_size * args.nkey * M / MB
# per-job memory overhead, in MB
ram_conn = int(ceil(1.0 * CONN_OVERHEAD * args.nconn / MB))
ram_fixed = BASE_OVERHEAD + SAFETY_BUF
njob_mem = []
sorted_ram = sorted(args.ram)
for ram in sorted_ram:
ram = ram * GB / MB # change unit to MB
n_low = int(ceil(ram_data / ram)) # number of shards, lower bound
nkey_per_shard = 1.0 * args.nkey * M / n_low # number of keys per shard, upper bound
hash_power, ram_hash = hash_parameters(nkey_per_shard, args.runnable) # upper bound for both
n = int(ceil(ram_data / (ram - ram_fixed - ram_conn - ram_hash)))
njob_mem.append(n)
# get final njob count; prefer larger ram if it reduces njob, which means:
# if cluster needs higher job ram AND more instances due to memory, update njob
# if cluster is memory-bound with smaller job ram but qps-bound with larger ones, use higher ram
# otherwise, use smaller job ram and keep njob value unchanged
index = 0 # if qps bound, use smallest ram setting
for i, n in reversed(list(enumerate(njob_mem))[1:]):
if n > njob or njob_mem[i - 1] > njob:
bottleneck = 'memory'
index = i
njob = max(njob, n)
break
if njob > WARNING_THRESHOLD:
print('WARNING: more than {} instances needed, please verify input.'.format(WARNING_THRESHOLD))
# recalculate hash parameters with the final job count
nkey_per_shard = 1.0 * (sorted_ram[index] * GB - ram_fixed * MB - ram_conn * MB) / item_size
# used by twemcache and segcache
hash_power, ram_hash = hash_parameters(nkey_per_shard, args.runnable)
slab_mem = sorted_ram[index] * GB / MB - ram_fixed - ram_conn - ram_hash
# only used by slimcache
nitem = int(NITEM_ALIGNMENT * floor(nkey_per_shard / NITEM_ALIGNMENT))
rack_limit = int(floor(njob * args.failure_domain / 100)) # >= 1 given how we calculate njob
host_limit = int(floor(min(MAX_HOST_LIMIT, max(1, rack_limit / RACK_TO_HOST_RATIO))))
ret = {
'cpu': CPU_PER_JOB,
'ram': sorted_ram[index],
'disk': DISK_PER_JOB,
'instance': njob,
'rack_limit': rack_limit,
'host_limit': host_limit,
'bottleneck': bottleneck}
if args.runnable == 'twemcache':
ret['hash_power'] = hash_power
ret['slab_mem'] = slab_mem
elif args.runnable == 'segcache':
ret['hash_power'] = hash_power
ret['seg_mem'] = slab_mem
elif args.runnable == 'slimcache':
ret['item_size'] = item_size
ret['nitem'] = nitem
return ret
def format_input(args):
return textwrap.dedent('''
Requirement:
qps: {} K
key-val size: {}
number of key: {} M
data, computed: {:.1f} GB
number of conn: {} per server
failure domain: {:.1f} %
'''.format(args.qps, args.size, args.nkey,
1.0 * args.size * args.nkey * M / GB,
args.nconn, args.failure_domain))
def twemcache_format_output(config):
return textwrap.dedent('''
pelikan_twemcache config:
hash_power: {}
slab_mem: {} MB
job config:
cpu: {}
ram: {} GB
disk: {} GB
instances: {}
host limit: {}
rack limit: {}
'''.format(config['hash_power'], config['slab_mem'],
config['cpu'], config['ram'], config['disk'],
config['instance'], config['host_limit'], config['rack_limit']))
def slimcache_format_output(config):
return textwrap.dedent('''
pelikan_slimcache config:
item_size: {}
nitem: {}
job config:
cpu: {}
ram: {} GB
disk: {} GB
instances: {}
host limit: {}
rack limit: {}
'''.format(config['item_size'], config['nitem'],
config['cpu'], config['ram'], config['disk'],
config['instance'], config['host_limit'], config['rack_limit']))
# parser for calculator: to be included by the generator as a parent parser
parser = argparse.ArgumentParser(
formatter_class=argparse.RawDescriptionHelpFormatter,
description=textwrap.dedent("""
This script calculates resource requirement of a pelikan cluster (twemcache, segcache or slimcache)
based on input. It has to be run from the top level directory of source.\n
Optional arguments that probably should be overwritten:
qps, size, nkey, nconn
Optional arguments:
failure_domain (default to 5%, acceptable range {:.1f}% - {:.1f}%)
""".format(FAILURE_DOMAIN_LOWER, FAILURE_DOMAIN_UPPER)),
usage='%(prog)s [options]')
parser.add_argument('--qps', dest='qps', type=int, default=DEFAULT_QPS,
help='query per second in *thousands/K*, round up')
parser.add_argument('--size', dest='size', type=int, default=DEFAULT_SIZE,
help='key+value size in bytes, average for twemcache, max for slimcache')
parser.add_argument('--nkey', dest='nkey', type=int, default=DEFAULT_NKEY,
help='number of keys in *millions/M*, round up')
parser.add_argument('--nconn', dest='nconn', type=int, default=DEFAULT_NCONN,
help='number of connections to each server')
parser.add_argument('--failure_domain', dest='failure_domain', type=float,
default=DEFAULT_FAILURE_DOMAIN,
help='percentage of server/data that may be lost simultaneously')
parser.add_argument('--ram', nargs='+', type=int, default=RAM_CANDIDATES,
help='provide a (sorted) list of container ram sizes to consider')
# end of parser
if __name__ == "__main__":
# add runnable as a positional option instead of subparser (as in aurora.py) to avoid import
parser.add_argument('runnable',
choices=['twemcache', 'segcache', 'slimcache'],
help='flavor of backend')
format_output = {'twemcache': twemcache_format_output,
'segcache': twemcache_format_output,
'slimcache': slimcache_format_output}
args = parser.parse_args()
print(format_input(args))
config = calculate(args)
print(format_output[args.runnable](config))
print('Cluster sizing is primarily driven by {}.\n'.format(config['bottleneck']))