1#!/usr/bin/python
2#
3# Copyright (c) 2013 The Chromium OS Authors. All rights reserved.
4# Use of this source code is governed by a BSD-style license that can be
5# found in the LICENSE file.
6
7import logging, numpy, os, shutil, socket
8import struct, subprocess, tempfile, time
9
10from autotest_lib.client.bin import utils, test
11from autotest_lib.client.common_lib import error
12
13def selection_sequential(cur_index, length):
14    """
15    Iterates over processes sequentially. This should cause worst-case
16    behavior for an LRU swap policy.
17
18    @param cur_index: Index of current hog (if sequential)
19    @param length: Number of hog processes
20    """
21    return cur_index
22
23def selection_exp(cur_index, length):
24    """
25    Iterates over processes randomly according to an exponential distribution.
26    Simulates preference for a few long-lived tabs over others.
27
28    @param cur_index: Index of current hog (if sequential)
29    @param length: Number of hog processes
30    """
31
32    # Discard any values greater than the length of the array.
33    # Inelegant, but necessary. Otherwise, the distribution will be skewed.
34    exp_value = length
35    while exp_value >= length:
36        # Mean is index 4 (weights the first 4 indexes the most).
37        exp_value = numpy.random.geometric(0.25) - 1
38    return int(exp_value)
39
40def selection_uniform(cur_index, length):
41    """
42    Iterates over processes randomly according to a uniform distribution.
43
44    @param cur_index: Index of current hog (if sequential)
45    @param length: Number of hog processes
46    """
47    return numpy.random.randint(0, length)
48
49# The available selection functions to use.
50selection_funcs = {'sequential': selection_sequential,
51                   'exponential': selection_exp,
52                   'uniform': selection_uniform}
53
54def get_selection_funcs(selections):
55    """
56    Returns the selection functions listed by their names in 'selections'.
57
58    @param selections: List of strings, where each string is a key for a
59                       selection function
60    """
61    return {
62            k: selection_funcs[k]
63            for k in selection_funcs
64            if k in selections
65           }
66
67def reset_zram():
68    """
69    Resets zram, clearing all swap space.
70    """
71    swapoff_timeout = 60
72    zram_device = 'zram0'
73    zram_device_path = os.path.join('/dev', zram_device)
74    reset_path = os.path.join('/sys/block', zram_device, 'reset')
75    disksize_path = os.path.join('/sys/block', zram_device, 'disksize')
76
77    disksize = utils.read_one_line(disksize_path)
78
79    # swapoff is prone to hanging, especially after heavy swap usage, so
80    # time out swapoff if it takes too long.
81    ret = utils.system('swapoff ' + zram_device_path,
82                       timeout=swapoff_timeout, ignore_status=True)
83
84    if ret != 0:
85        raise error.TestFail('Could not reset zram - swapoff failed.')
86
87    # Sleep to avoid "device busy" errors.
88    time.sleep(1)
89    utils.write_one_line(reset_path, '1')
90    time.sleep(1)
91    utils.write_one_line(disksize_path, disksize)
92    utils.system('mkswap ' + zram_device_path)
93    utils.system('swapon ' + zram_device_path)
94
95swap_reset_funcs = {'zram': reset_zram}
96
97class platform_CompressedSwapPerf(test.test):
98    """Runs basic performance benchmarks on compressed swap.
99
100    Launches a number of "hog" processes that can be told to "balloon"
101    (allocating a specified amount of memory in 1 MiB chunks) and can
102    also be "poked", which reads from and writes to random places in memory
103    to force swapping in and out. Hog processes report back statistics on how
104    long a "poke" took (real and CPU time) and number of page faults.
105    """
106    version = 1
107    executable = 'hog'
108    swap_enable_file = '/home/chronos/.swap_enabled'
109    swap_disksize_file = '/sys/block/zram0/disksize'
110
111    CMD_POKE = 1
112    CMD_BALLOON = 2
113    CMD_EXIT = 3
114
115    CMD_FORMAT = "=L"
116    CMD_FORMAT_SIZE = struct.calcsize(CMD_FORMAT)
117
118    RESULT_FORMAT = "=QQQQ"
119    RESULT_FORMAT_SIZE = struct.calcsize(RESULT_FORMAT)
120
121    def setup(self):
122        """
123        Compiles the hog program.
124        """
125        os.chdir(self.srcdir)
126        utils.make(self.executable)
127
128    def report_stat(self, units, swap_target, selection, metric, stat, value):
129        """
130        Reports a single performance statistic. This function puts the supplied
131        args into an autotest-approved format.
132
133        @param units: String describing units of the statistic
134        @param swap_target: Current swap target, 0.0 <= swap_target < 1.0
135        @param selection: Name of selection function to report for
136        @param metric: Name of the metric that is being reported
137        @param stat: Name of the statistic (e.g. median, 99th percentile)
138        @param value: Actual floating-point value
139        """
140        swap_target_str = '%.2f' % swap_target
141        perfkey_name_list = [ units, 'swap', swap_target_str,
142                              selection, metric, stat ]
143
144        # Filter out any args that evaluate to false.
145        perfkey_name_list = filter(None, perfkey_name_list)
146        perf_key = '_'.join(perfkey_name_list)
147        self.write_perf_keyval({perf_key: value})
148
149    def report_stats(self, units, swap_target, selection, metric, values):
150        """
151        Reports interesting statistics from a list of recorded values.
152
153        @param units: String describing units of the statistic
154        @param swap_target: Current swap target
155        @param selection: Name of current selection function
156        @param metric: Name of the metric that is being reported
157        @param values: List of floating point measurements for this metric
158        """
159        if not values:
160            logging.info('Cannot report empty list!')
161            return
162
163        values = sorted(values)
164        mean = float(sum(values)) / len(values)
165        median = values[int(0.5*len(values))]
166        percentile_95 = values[int(0.95*len(values))]
167        percentile_99 = values[int(0.99*len(values))]
168
169        self.report_stat(units, swap_target, selection, metric, 'mean', mean)
170        self.report_stat(units, swap_target, selection,
171                         metric, 'median', median)
172        self.report_stat(units, swap_target, selection,
173                         metric, '95th_percentile', percentile_95)
174        self.report_stat(units, swap_target, selection,
175                         metric, '99th_percentile', percentile_99)
176
177    def sample_memory_state(self):
178        """
179        Samples memory info from /proc/meminfo and use that to calculate swap
180        usage and total memory usage, adjusted for double-counting swap space.
181        """
182        self.mem_total = utils.read_from_meminfo('MemTotal')
183        self.swap_total = utils.read_from_meminfo('SwapTotal')
184        self.mem_free = utils.read_from_meminfo('MemFree')
185        self.swap_free = utils.read_from_meminfo('SwapFree')
186        self.swap_used = self.swap_total - self.swap_free
187
188        used_phys_memory = self.mem_total - self.mem_free
189
190        # Get zram's actual compressed size and convert to KiB.
191        swap_phys_size = utils.read_one_line('/sys/block/zram0/compr_data_size')
192        swap_phys_size = int(swap_phys_size) / 1024
193
194        self.total_usage = used_phys_memory - swap_phys_size + self.swap_used
195        self.usage_ratio = float(self.swap_used) / self.swap_total
196
197    def send_poke(self, hog_sock):
198        """Pokes a hog process.
199        Poking a hog causes it to simulate activity and report back on
200        the same socket.
201
202        @param hog_sock: An open socket to the hog process
203        """
204        hog_sock.send(struct.pack(self.CMD_FORMAT, self.CMD_POKE))
205
206    def send_balloon(self, hog_sock, alloc_mb):
207        """Tells a hog process to allocate more memory.
208
209        @param hog_sock: An open socket to the hog process
210        @param alloc_mb: Amount of memory to allocate, in MiB
211        """
212        hog_sock.send(struct.pack(self.CMD_FORMAT, self.CMD_BALLOON))
213        hog_sock.send(struct.pack(self.CMD_FORMAT, alloc_mb))
214
215    def send_exit(self, hog_sock):
216        """Tells a hog process to exit and closes the socket.
217
218        @param hog_sock: An open socket to the hog process
219        """
220        hog_sock.send(struct.pack(self.CMD_FORMAT, self.CMD_EXIT))
221        hog_sock.shutdown(socket.SHUT_RDWR)
222        hog_sock.close()
223
224    def recv_poke_results(self, hog_sock):
225        """Returns the results from poking a hog as a tuple.
226
227        @param hog_sock: An open socket to the hog process
228        @return: A tuple (wall_time, user_time, sys_time, fault_count)
229        """
230        try:
231            result = hog_sock.recv(self.RESULT_FORMAT_SIZE)
232            if len(result) != self.RESULT_FORMAT_SIZE:
233                logging.info("incorrect result, len %d",
234                               len(result))
235            else:
236                result_unpacked = struct.unpack(self.RESULT_FORMAT, result)
237                wall_time = result_unpacked[0]
238                user_time = result_unpacked[1]
239                sys_time = result_unpacked[2]
240                fault_count = result_unpacked[3]
241
242                return (wall_time, user_time, sys_time, fault_count)
243        except socket.error:
244            logging.info('Hog died while touching memory')
245
246
247
248    def recv_balloon_results(self, hog_sock, alloc_mb):
249        """Receives a balloon response from a hog.
250        If a hog succeeds in allocating more memory, it will respond on its
251        socket with the original allocation size.
252
253        @param hog_sock: An open socket to the hog process
254        @param alloc_mb: Amount of memory to allocate, in MiB
255        @raise TestFail: Fails if hog could not allocate memory, or if
256                         there is a communication problem.
257        """
258        balloon_result = hog_sock.recv(self.CMD_FORMAT_SIZE)
259        if len(balloon_result) != self.CMD_FORMAT_SIZE:
260            return False
261
262        balloon_result_unpack = struct.unpack(self.CMD_FORMAT, balloon_result)
263
264        return balloon_result_unpack == alloc_mb
265
266    def run_single_test(self, compression_factor, num_procs, cycles,
267                        swap_target, switch_delay, temp_dir, selections):
268        """
269        Runs the benchmark for a single swap target usage.
270
271        @param compression_factor: Compression factor (int)
272                                   example: compression_factor=3 is 1:3 ratio
273        @param num_procs: Number of hog processes to use
274        @param cycles: Number of iterations over hogs list for a given swap lvl
275        @param swap_target: Floating point value of target swap usage
276        @param switch_delay: Number of seconds to wait between poking hogs
277        @param temp_dir: Path of the temporary directory to use
278        @param selections: List of selection function names
279        """
280        # Get initial memory state.
281        self.sample_memory_state()
282        swap_target_usage = swap_target * self.swap_total
283
284        # usage_target is our estimate on the amount of memory that needs to
285        # be allocated to reach our target swap usage.
286        swap_target_phys = swap_target_usage / compression_factor
287        usage_target = self.mem_free - swap_target_phys + swap_target_usage
288
289        hogs = []
290        paths = []
291        sockets = []
292        cmd = [ os.path.join(self.srcdir, self.executable) ]
293
294        # Launch hog processes.
295        while len(hogs) < num_procs:
296            socket_path = os.path.join(temp_dir, str(len(hogs)))
297            paths.append(socket_path)
298            launch_cmd = list(cmd)
299            launch_cmd.append(socket_path)
300            launch_cmd.append(str(compression_factor))
301            p = subprocess.Popen(launch_cmd)
302            utils.write_one_line('/proc/%d/oom_score_adj' % p.pid, '15')
303            hogs.append(p)
304
305        # Open sockets to hog processes, waiting for them to bind first.
306        time.sleep(5)
307        for socket_path in paths:
308            hog_sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
309            sockets.append(hog_sock)
310            hog_sock.connect(socket_path)
311
312        # Allocate conservatively until we reach our target.
313        while self.usage_ratio <= swap_target:
314            free_per_hog = (usage_target - self.total_usage) / len(hogs)
315            alloc_per_hog_mb = int(0.80 * free_per_hog) / 1024
316            if alloc_per_hog_mb <= 0:
317                alloc_per_hog_mb = 1
318
319            # Send balloon command.
320            for hog_sock in sockets:
321                self.send_balloon(hog_sock, alloc_per_hog_mb)
322
323            # Wait until all hogs report back.
324            for hog_sock in sockets:
325                self.recv_balloon_results(hog_sock, alloc_per_hog_mb)
326
327            # We need to sample memory and swap usage again.
328            self.sample_memory_state()
329
330        # Once memory is allocated, report how close we got to the swap target.
331        self.report_stat('percent', swap_target, None,
332                         'usage', 'value', self.usage_ratio)
333
334        # Run tests by sending "touch memory" command to hogs.
335        for f_name, f in get_selection_funcs(selections).iteritems():
336            result_list = []
337
338            for count in range(cycles):
339                for i in range(len(hogs)):
340                    selection = f(i, len(hogs))
341                    hog_sock = sockets[selection]
342                    retcode = hogs[selection].poll()
343
344                    # Ensure that the hog is not dead.
345                    if retcode is None:
346                        # Delay between switching "tabs".
347                        if switch_delay > 0.0:
348                            time.sleep(switch_delay)
349
350                        self.send_poke(hog_sock)
351
352                        result = self.recv_poke_results(hog_sock)
353                        if result:
354                            result_list.append(result)
355                    else:
356                        logging.info("Hog died unexpectedly; continuing")
357
358            # Convert from list of tuples (rtime, utime, stime, faults) to
359            # a list of rtimes, a list of utimes, etc.
360            results_unzipped = [list(x) for x in zip(*result_list)]
361            wall_times = results_unzipped[0]
362            user_times = results_unzipped[1]
363            sys_times = results_unzipped[2]
364            fault_counts = results_unzipped[3]
365
366            # Calculate average time to service a fault for each sample.
367            us_per_fault_list = []
368            for i in range(len(sys_times)):
369                if fault_counts[i] == 0.0:
370                    us_per_fault_list.append(0.0)
371                else:
372                    us_per_fault_list.append(sys_times[i] * 1000.0 /
373                                             fault_counts[i])
374
375            self.report_stats('ms', swap_target, f_name, 'rtime', wall_times)
376            self.report_stats('ms', swap_target, f_name, 'utime', user_times)
377            self.report_stats('ms', swap_target, f_name, 'stime', sys_times)
378            self.report_stats('faults', swap_target, f_name, 'faults',
379                              fault_counts)
380            self.report_stats('us_fault', swap_target, f_name, 'fault_time',
381                              us_per_fault_list)
382
383        # Send exit message to all hogs.
384        for hog_sock in sockets:
385            self.send_exit(hog_sock)
386
387        time.sleep(1)
388
389        # If hogs didn't exit normally, kill them.
390        for hog in hogs:
391            retcode = hog.poll()
392            if retcode is None:
393                logging.debug("killing all remaining hogs")
394                utils.system("killall -TERM hog")
395                # Wait to ensure hogs have died before continuing.
396                time.sleep(5)
397                break
398
399    def run_once(self, compression_factor=3, num_procs=50, cycles=20,
400                 selections=None, swap_targets=None, switch_delay=0.0):
401        if selections is None:
402            selections = ['sequential', 'uniform', 'exponential']
403        if swap_targets is None:
404            swap_targets = [0.00, 0.25, 0.50, 0.75, 0.95]
405
406        swaptotal = utils.read_from_meminfo('SwapTotal')
407
408        # Check for proper swap space configuration.
409        # If the swap enable file says "0", swap.conf does not create swap.
410        if os.path.exists(self.swap_enable_file):
411            enable_size = utils.read_one_line(self.swap_enable_file)
412        else:
413            enable_size = "nonexistent" # implies nonzero
414        if enable_size == "0":
415            if swaptotal != 0:
416                raise error.TestFail('The swap enable file said 0, but'
417                                     ' swap was still enabled for %d.' %
418                                     swaptotal)
419            logging.info('Swap enable (0), swap disabled.')
420        else:
421            # Rather than parsing swap.conf logic to calculate a size,
422            # use the value it writes to /sys/block/zram0/disksize.
423            if not os.path.exists(self.swap_disksize_file):
424                raise error.TestFail('The %s swap enable file should have'
425                                     ' caused zram to load, but %s was'
426                                     ' not found.' %
427                                     (enable_size, self.swap_disksize_file))
428            disksize = utils.read_one_line(self.swap_disksize_file)
429            swaprequested = int(disksize) / 1000
430            if (swaptotal < swaprequested * 0.9 or
431                swaptotal > swaprequested * 1.1):
432                raise error.TestFail('Our swap of %d K is not within 10%%'
433                                     ' of the %d K we requested.' %
434                                     (swaptotal, swaprequested))
435            logging.info('Swap enable (%s), requested %d, total %d',
436                         enable_size, swaprequested, swaptotal)
437
438        # We should try to autodetect this if we add other swap methods.
439        swap_method = 'zram'
440
441        for swap_target in swap_targets:
442            logging.info('swap_target is %f', swap_target)
443            temp_dir = tempfile.mkdtemp()
444            try:
445                # Reset swap space to make sure nothing leaks between runs.
446                swap_reset = swap_reset_funcs[swap_method]
447                swap_reset()
448                self.run_single_test(compression_factor, num_procs, cycles,
449                                     swap_target, switch_delay, temp_dir,
450                                     selections)
451            except socket.error:
452                logging.debug('swap target %f failed; oom killer?', swap_target)
453
454            shutil.rmtree(temp_dir)
455
456