aboutsummaryrefslogtreecommitdiff
path: root/afdo_tools/bisection/afdo_prof_analysis_e2e_test.py
blob: b293b8aa3a94d9f95451698a5e5cb3dd1f5847b8 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Copyright 2019 The Chromium OS Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.

"""End-to-end test for afdo_prof_analysis."""

from __future__ import absolute_import, division, print_function

import json
import os
import shutil
import tempfile
import unittest
from datetime import date

from afdo_tools.bisection import afdo_prof_analysis as analysis


class ObjectWithFields(object):
  """Turns kwargs given to the constructor into fields on an object.

  Examples:
    x = ObjectWithFields(a=1, b=2)
    assert x.a == 1
    assert x.b == 2
  """

  def __init__(self, **kwargs):
    for key, val in kwargs.items():
      setattr(self, key, val)


class AfdoProfAnalysisE2ETest(unittest.TestCase):
  """Class for end-to-end testing of AFDO Profile Analysis"""

  # nothing significant about the values, just easier to remember even vs odd
  good_prof = {
      'func_a': ':1\n 1: 3\n 3: 5\n 5: 7\n',
      'func_b': ':3\n 3: 5\n 5: 7\n 7: 9\n',
      'func_c': ':5\n 5: 7\n 7: 9\n 9: 11\n',
      'func_d': ':7\n 7: 9\n 9: 11\n 11: 13\n',
      'good_func_a': ':11\n',
      'good_func_b': ':13\n'
  }

  bad_prof = {
      'func_a': ':2\n 2: 4\n 4: 6\n 6: 8\n',
      'func_b': ':4\n 4: 6\n 6: 8\n 8: 10\n',
      'func_c': ':6\n 6: 8\n 8: 10\n 10: 12\n',
      'func_d': ':8\n 8: 10\n 10: 12\n 12: 14\n',
      'bad_func_a': ':12\n',
      'bad_func_b': ':14\n'
  }

  expected = {
      'good_only_functions': False,
      'bad_only_functions': True,
      'bisect_results': {
          'ranges': [],
          'individuals': ['func_a']
      }
  }

  def test_afdo_prof_analysis(self):
    # Individual issues take precedence by nature of our algos
    # so first, that should be caught
    good = self.good_prof.copy()
    bad = self.bad_prof.copy()
    self.run_check(good, bad, self.expected)

    # Now remove individuals and exclusively BAD, and check that range is caught
    bad['func_a'] = good['func_a']
    bad.pop('bad_func_a')
    bad.pop('bad_func_b')

    expected_cp = self.expected.copy()
    expected_cp['bad_only_functions'] = False
    expected_cp['bisect_results'] = {
        'individuals': [],
        'ranges': [['func_b', 'func_c', 'func_d']]
    }

    self.run_check(good, bad, expected_cp)

  def test_afdo_prof_state(self):
    """Verifies that saved state is correct replication."""
    temp_dir = tempfile.mkdtemp()
    self.addCleanup(shutil.rmtree, temp_dir, ignore_errors=True)

    good = self.good_prof.copy()
    bad = self.bad_prof.copy()
    # add more functions to data
    for x in range(400):
      good['func_%d' % x] = ''
      bad['func_%d' % x] = ''

    fd_first, first_result = tempfile.mkstemp(dir=temp_dir)
    os.close(fd_first)
    fd_state, state_file = tempfile.mkstemp(dir=temp_dir)
    os.close(fd_state)
    self.run_check(
        self.good_prof,
        self.bad_prof,
        self.expected,
        state_file=state_file,
        out_file=first_result)

    fd_second, second_result = tempfile.mkstemp(dir=temp_dir)
    os.close(fd_second)
    completed_state_file = '%s.completed.%s' % (state_file, str(date.today()))
    self.run_check(
        self.good_prof,
        self.bad_prof,
        self.expected,
        state_file=completed_state_file,
        no_resume=False,
        out_file=second_result)

    with open(first_result) as f:
      initial_run = json.load(f)
    with open(second_result) as f:
      loaded_run = json.load(f)
    self.assertEqual(initial_run, loaded_run)

  def test_exit_on_problem_status(self):
    temp_dir = tempfile.mkdtemp()
    self.addCleanup(shutil.rmtree, temp_dir, ignore_errors=True)

    fd_state, state_file = tempfile.mkstemp(dir=temp_dir)
    os.close(fd_state)
    with self.assertRaises(RuntimeError):
      self.run_check(
          self.good_prof,
          self.bad_prof,
          self.expected,
          state_file=state_file,
          extern_decider='problemstatus_external.sh')

  def test_state_assumption(self):

    def compare_runs(tmp_dir, first_ctr, second_ctr):
      """Compares given prof versions between first and second run in test."""
      first_prof = '%s/.first_run_%d' % (tmp_dir, first_ctr)
      second_prof = '%s/.second_run_%d' % (tmp_dir, second_ctr)
      with open(first_prof) as f:
        first_prof_text = f.read()
      with open(second_prof) as f:
        second_prof_text = f.read()
      self.assertEqual(first_prof_text, second_prof_text)

    good_prof = {'func_a': ':1\n3: 3\n5: 7\n'}
    bad_prof = {'func_a': ':2\n4: 4\n6: 8\n'}
    # add some noise to the profiles; 15 is an arbitrary choice
    for x in range(15):
      func = 'func_%d' % x
      good_prof[func] = ':%d\n' % (x)
      bad_prof[func] = ':%d\n' % (x + 1)
    expected = {
        'bisect_results': {
            'ranges': [],
            'individuals': ['func_a']
        },
        'good_only_functions': False,
        'bad_only_functions': False
    }

    # using a static temp dir rather than a dynamic one because these files are
    # shared between the bash scripts and this Python test, and the arguments
    # to the bash scripts are fixed by afdo_prof_analysis.py so it would be
    # difficult to communicate dynamically generated directory to bash scripts
    scripts_tmp_dir = '%s/afdo_test_tmp' % os.getcwd()
    os.mkdir(scripts_tmp_dir)
    self.addCleanup(shutil.rmtree, scripts_tmp_dir, ignore_errors=True)

    # files used in the bash scripts used as external deciders below
    # - count_file tracks the current number of calls to the script in total
    # - local_count_file tracks the number of calls to the script without
    # interruption
    count_file = '%s/.count' % scripts_tmp_dir
    local_count_file = '%s/.local_count' % scripts_tmp_dir

    # runs through whole thing at once
    initial_seed = self.run_check(
        good_prof,
        bad_prof,
        expected,
        extern_decider='state_assumption_external.sh')
    with open(count_file) as f:
      num_calls = int(f.read())
    os.remove(count_file)  # reset counts for second run
    finished_state_file = 'afdo_analysis_state.json.completed.%s' % str(
        date.today())
    self.addCleanup(os.remove, finished_state_file)

    # runs the same analysis but interrupted each iteration
    for i in range(2 * num_calls + 1):
      no_resume_run = (i == 0)
      seed = initial_seed if no_resume_run else None
      try:
        self.run_check(
            good_prof,
            bad_prof,
            expected,
            no_resume=no_resume_run,
            extern_decider='state_assumption_interrupt.sh',
            seed=seed)
        break
      except RuntimeError:
        # script was interrupted, so we restart local count
        os.remove(local_count_file)
    else:
      raise RuntimeError('Test failed -- took too many iterations')

    for initial_ctr in range(3):  # initial runs unaffected by interruption
      compare_runs(scripts_tmp_dir, initial_ctr, initial_ctr)

    start = 3
    for ctr in range(start, num_calls):
      # second run counter incremented by 4 for each one first run is because
      # +2 for performing initial checks on good and bad profs each time
      # +1 for PROBLEM_STATUS run which causes error and restart
      compare_runs(scripts_tmp_dir, ctr, 6 + (ctr - start) * 4)

  def run_check(self,
                good_prof,
                bad_prof,
                expected,
                state_file=None,
                no_resume=True,
                out_file=None,
                extern_decider=None,
                seed=None):

    temp_dir = tempfile.mkdtemp()
    self.addCleanup(shutil.rmtree, temp_dir, ignore_errors=True)

    good_prof_file = '%s/%s' % (temp_dir, 'good_prof.txt')
    bad_prof_file = '%s/%s' % (temp_dir, 'bad_prof.txt')
    good_prof_text = analysis.json_to_text(good_prof)
    bad_prof_text = analysis.json_to_text(bad_prof)
    with open(good_prof_file, 'w') as f:
      f.write(good_prof_text)
    with open(bad_prof_file, 'w') as f:
      f.write(bad_prof_text)

    dir_path = os.path.dirname(os.path.realpath(__file__))  # dir of this file
    external_script = '%s/%s' % (dir_path, extern_decider or 'e2e_external.sh')

    # FIXME: This test ideally shouldn't be writing to $PWD
    if state_file is None:
      state_file = '%s/afdo_analysis_state.json' % os.getcwd()

      def rm_state():
        try:
          os.unlink(state_file)
        except OSError:
          # Probably because the file DNE. That's fine.
          pass

      self.addCleanup(rm_state)

    actual = analysis.main(
        ObjectWithFields(
            good_prof=good_prof_file,
            bad_prof=bad_prof_file,
            external_decider=external_script,
            analysis_output_file=out_file or '/dev/null',
            state_file=state_file,
            no_resume=no_resume,
            remove_state_on_completion=False,
            seed=seed,
        ))
    actual_seed = actual.pop('seed')  # nothing to check
    self.assertEqual(actual, expected)
    return actual_seed


if __name__ == '__main__':
  unittest.main()