Bruce Dawson | ffc0c7c | 2018-02-07 18:00:48 -0800 | [diff] [blame] | 1 | # Copyright (c) 2018 The Chromium Authors. All rights reserved. |
| 2 | # Use of this source code is governed by a BSD-style license that can be |
| 3 | # found in the LICENSE file. |
| 4 | |
| 5 | """Summarize the last ninja build, invoked with ninja's -C syntax. |
| 6 | |
| 7 | This script is designed to be automatically run after each ninja build in |
| 8 | order to summarize the build's performance. Making build performance information |
| 9 | more visible should make it easier to notice anomalies and opportunities. To use |
| 10 | this script just set NINJA_SUMMARIZE_BUILD=1 and run autoninja.bat. |
| 11 | |
| 12 | You can also call this script directly using ninja's syntax to specify the |
| 13 | output directory of interest: |
| 14 | |
| 15 | > python post_build_ninja_summary.py -C out/Default |
| 16 | |
| 17 | Typical output looks like this: |
| 18 | |
| 19 | >ninja -C out\debug_component base |
| 20 | ninja.exe -C out\debug_component base -j 960 -l 48 -d keeprsp |
| 21 | ninja: Entering directory `out\debug_component' |
| 22 | [1 processes, 1/1 @ 0.3/s : 3.092s ] Regenerating ninja files |
| 23 | [1 processes, 23/23 @ 0.9/s : 26.280s ] LINK(DLL) base.dll base.dll.lib |
| 24 | Longest build steps: |
| 25 | 0.9 weighted s to build obj/base/base_jumbo_17.obj (5.0 s CPU time) |
| 26 | 1.0 weighted s to build obj/base/base_jumbo_31.obj (13.1 s CPU time) |
| 27 | 1.2 weighted s to build obj/base/base_jumbo_4.obj (14.7 s CPU time) |
| 28 | 1.3 weighted s to build obj/base/base_jumbo_32.obj (15.0 s CPU time) |
| 29 | 1.6 weighted s to build obj/base/base_jumbo_26.obj (17.1 s CPU time) |
| 30 | 1.7 weighted s to build base.dll, base.dll.lib (1.7 s CPU time) |
| 31 | 1.7 weighted s to build obj/base/base_jumbo_11.obj (15.9 s CPU time) |
| 32 | 1.9 weighted s to build obj/base/base_jumbo_12.obj (18.5 s CPU time) |
| 33 | 3.6 weighted s to build obj/base/base_jumbo_34.obj (20.1 s CPU time) |
| 34 | 4.3 weighted s to build obj/base/base_jumbo_33.obj (22.3 s CPU time) |
| 35 | Time by build-step type: |
| 36 | 0.1 s weighted time to generate 1 .c files (0.1 s CPU time) |
| 37 | 0.1 s weighted time to generate 1 .stamp files (0.1 s CPU time) |
| 38 | 0.2 s weighted time to generate 1 .h files (0.2 s CPU time) |
| 39 | 1.7 s weighted time to generate 1 PEFile files (1.7 s CPU time) |
| 40 | 24.3 s weighted time to generate 19 .obj files (233.4 s CPU time) |
| 41 | 26.3 s weighted time (235.5 s CPU time, 9.0x parallelism) |
| 42 | 23 build steps completed, average of 0.88/s |
| 43 | |
| 44 | If no gn clean has been done then results will be for the last non-NULL |
| 45 | invocation of ninja. Ideas for future statistics, and implementations are |
| 46 | appreciated. |
| 47 | |
| 48 | The "weighted" time is the elapsed time of each build step divided by the number |
| 49 | of tasks that were running in parallel. This makes it an excellent approximation |
| 50 | of how "important" a slow step was. A link that is entirely or mostly serialized |
| 51 | will have a weighted time that is the same or similar to its elapsed time. A |
| 52 | compile that runs in parallel with 999 other compiles will have a weighted time |
| 53 | that is tiny.""" |
| 54 | |
| 55 | import errno |
| 56 | import os |
| 57 | import sys |
| 58 | |
| 59 | |
| 60 | # The number of long build times to report: |
| 61 | long_count = 10 |
| 62 | # The number of long times by extension to report |
| 63 | long_ext_count = 5 |
| 64 | |
| 65 | |
| 66 | class Target: |
| 67 | """Represents a single line read for a .ninja_log file.""" |
| 68 | def __init__(self, start, end): |
| 69 | """Creates a target object by passing in the start/end times in ms.""" |
| 70 | # Convert from milliseconds to seconds. |
| 71 | self.start = int(start) / 1000.0 |
| 72 | self.end = int(end) / 1000.0 |
| 73 | # A list of targets, appended to by the owner of this object. |
| 74 | self.targets = [] |
| 75 | self.weighted_duration = 0.0 |
| 76 | |
| 77 | def Duration(self): |
| 78 | """Returns the task duration in seconds as a float.""" |
| 79 | return self.end - self.start |
| 80 | |
| 81 | def SetWeightedDuration(self, weighted_duration): |
| 82 | """Sets the duration, in seconds, passed in as a float.""" |
| 83 | self.weighted_duration = weighted_duration |
| 84 | |
| 85 | def WeightedDuration(self): |
| 86 | """Returns the task's weighted duration in seconds as a float. |
| 87 | |
| 88 | Weighted_duration takes the elapsed time of the task and divides it |
| 89 | by how many other tasks were running at the same time. Thus, it |
| 90 | represents the approximate impact of this task on the total build time, |
| 91 | with serialized or serializing steps typically ending up with much |
| 92 | longer weighted durations. |
| 93 | weighted_duration should always be the same or shorter than duration. |
| 94 | """ |
| 95 | # Allow for modest floating-point errors |
| 96 | epsilon = 0.000002 |
| 97 | if (self.weighted_duration > self.Duration() + epsilon): |
| 98 | print '%s > %s?' % (self.weighted_duration, self.Duration()) |
| 99 | assert(self.weighted_duration <= self.Duration() + epsilon) |
| 100 | return self.weighted_duration |
| 101 | |
| 102 | def DescribeTargets(self): |
| 103 | """Returns a printable string that summarizes the targets.""" |
| 104 | if len(self.targets) == 1: |
| 105 | return self.targets[0] |
| 106 | # Some build steps generate dozens of outputs - handle them sanely. |
| 107 | # It's a bit odd that if there are three targets we return all three |
| 108 | # but if there are more than three we just return two, but this works |
| 109 | # well in practice. |
| 110 | elif len(self.targets) > 3: |
| 111 | return '(%d items) ' % len(self.targets) + ( |
| 112 | ', '.join(self.targets[:2]) + ', ...') |
| 113 | else: |
| 114 | return ', '.join(self.targets) |
| 115 | |
| 116 | |
| 117 | # Copied with some modifications from ninjatracing |
| 118 | def ReadTargets(log, show_all): |
| 119 | """Reads all targets from .ninja_log file |log_file|, sorted by duration. |
| 120 | |
| 121 | The result is a list of Target objects.""" |
| 122 | header = log.readline() |
| 123 | assert header == '# ninja log v5\n', \ |
| 124 | 'unrecognized ninja log version %r' % header |
| 125 | targets = {} |
| 126 | last_end_seen = 0 |
| 127 | for line in log: |
| 128 | parts = line.strip().split('\t') |
| 129 | if len(parts) != 5: |
| 130 | # If ninja.exe is rudely halted then the .ninja_log file may be |
| 131 | # corrupt. Silently continue. |
| 132 | continue |
| 133 | start, end, _, name, cmdhash = parts # Ignore restat. |
| 134 | if not show_all and int(end) < last_end_seen: |
| 135 | # An earlier time stamp means that this step is the first in a new |
| 136 | # build, possibly an incremental build. Throw away the previous |
| 137 | # data so that this new build will be displayed independently. |
| 138 | targets = {} |
| 139 | last_end_seen = int(end) |
| 140 | targets.setdefault(cmdhash, Target(start, end)).targets.append(name) |
| 141 | return targets.values() |
| 142 | |
| 143 | |
| 144 | def GetExtension(target): |
| 145 | """Return the file extension that best represents a target. |
| 146 | |
| 147 | For targets that generate multiple outputs it is important to return a |
| 148 | consistent 'canonical' extension. Ultimately the goal is to group build steps |
| 149 | by type.""" |
| 150 | for output in target.targets: |
| 151 | # Normalize all mojo related outputs to 'mojo'. |
| 152 | if output.count('.mojom') > 0: |
| 153 | extension = 'mojo' |
| 154 | break |
| 155 | # Not a true extension, but a good grouping. |
| 156 | if output.endswith('type_mappings'): |
| 157 | extension = 'type_mappings' |
| 158 | break |
| 159 | extension = os.path.splitext(output)[1] |
| 160 | if len(extension) == 0: |
| 161 | extension = '(no extension found)' |
| 162 | # AHEM____.TTF fails this check. |
| 163 | assert(extension == extension.lower() or extension == '.TTF') |
| 164 | if extension in ['.pdb', '.dll', '.exe']: |
| 165 | extension = 'PEFile (linking)' |
| 166 | # Make sure that .dll and .exe are grouped together and that the |
| 167 | # .dll.lib files don't cause these to be listed as libraries |
| 168 | break |
| 169 | return extension |
| 170 | |
| 171 | |
| 172 | def SummarizeEntries(entries): |
| 173 | """Print a summary of the passed in list of Target objects.""" |
| 174 | |
| 175 | # Create a list that is in order by time stamp and has entries for the |
| 176 | # beginning and ending of each build step (one time stamp may have multiple |
| 177 | # entries due to multiple steps starting/stopping at exactly the same time). |
| 178 | # Iterate through this list, keeping track of which tasks are running at all |
| 179 | # times. At each time step calculate a running total for weighted time so |
| 180 | # that when each task ends its own weighted time can easily be calculated. |
| 181 | task_start_stop_times = [] |
| 182 | |
| 183 | earliest = -1 |
| 184 | latest = 0 |
| 185 | total_cpu_time = 0 |
| 186 | for target in entries: |
| 187 | if earliest < 0 or target.start < earliest: |
| 188 | earliest = target.start |
| 189 | if target.end > latest: |
| 190 | latest = target.end |
| 191 | total_cpu_time += target.Duration() |
| 192 | task_start_stop_times.append((target.start, 'start', target)) |
| 193 | task_start_stop_times.append((target.end, 'stop', target)) |
| 194 | length = latest - earliest |
| 195 | weighted_total = 0.0 |
| 196 | |
| 197 | task_start_stop_times.sort() |
| 198 | # Now we have all task start/stop times sorted by when they happen. If a |
| 199 | # task starts and stops on the same time stamp then the start will come |
| 200 | # first because of the alphabet, which is important for making this work |
| 201 | # correctly. |
| 202 | # Track the tasks which are currently running. |
| 203 | running_tasks = {} |
| 204 | # Record the time we have processed up to so we know how to calculate time |
| 205 | # deltas. |
| 206 | last_time = task_start_stop_times[0][0] |
| 207 | # Track the accumulated weighted time so that it can efficiently be added |
| 208 | # to individual tasks. |
| 209 | last_weighted_time = 0.0 |
| 210 | # Scan all start/stop events. |
| 211 | for event in task_start_stop_times: |
| 212 | time, action_name, target = event |
| 213 | # Accumulate weighted time up to now. |
| 214 | num_running = len(running_tasks) |
| 215 | if num_running > 0: |
| 216 | # Update the total weighted time up to this moment. |
| 217 | last_weighted_time += (time - last_time) / float(num_running) |
| 218 | if action_name == 'start': |
| 219 | # Record the total weighted task time when this task starts. |
| 220 | running_tasks[target] = last_weighted_time |
| 221 | if action_name == 'stop': |
| 222 | # Record the change in the total weighted task time while this task ran. |
| 223 | weighted_duration = last_weighted_time - running_tasks[target] |
| 224 | target.SetWeightedDuration(weighted_duration) |
| 225 | weighted_total += weighted_duration |
| 226 | del running_tasks[target] |
| 227 | last_time = time |
| 228 | assert(len(running_tasks) == 0) |
| 229 | |
| 230 | # Warn if the sum of weighted times is off by more than half a second. |
| 231 | if abs(length - weighted_total) > 500: |
| 232 | print 'Discrepancy!!! Length = %.3f, weighted total = %.3f' % ( |
| 233 | length, weighted_total) |
| 234 | |
| 235 | # Print the slowest build steps (by weighted time). |
| 236 | print ' Longest build steps:' |
| 237 | entries.sort(key=lambda x: x.WeightedDuration()) |
| 238 | for target in entries[-long_count:]: |
| 239 | print ' %8.1f weighted s to build %s (%.1f s CPU time)' % ( |
| 240 | target.WeightedDuration(), |
| 241 | target.DescribeTargets(), target.Duration()) |
| 242 | |
| 243 | # Sum up the time by file extension/type of the output file |
| 244 | count_by_ext = {} |
| 245 | time_by_ext = {} |
| 246 | weighted_time_by_ext = {} |
| 247 | # Scan through all of the targets to build up per-extension statistics. |
| 248 | for target in entries: |
| 249 | extension = GetExtension(target) |
| 250 | time_by_ext[extension] = time_by_ext.get(extension, 0) + target.Duration() |
| 251 | weighted_time_by_ext[extension] = weighted_time_by_ext.get(extension, |
| 252 | 0) + target.WeightedDuration() |
| 253 | count_by_ext[extension] = count_by_ext.get(extension, 0) + 1 |
| 254 | |
| 255 | print ' Time by build-step type:' |
| 256 | # Copy to a list with extension name and total time swapped, to (time, ext) |
| 257 | weighted_time_by_ext_sorted = sorted((y, x) for (x, y) in |
| 258 | weighted_time_by_ext.items()) |
| 259 | # Print the slowest build target types (by weighted time): |
| 260 | for time, extension in weighted_time_by_ext_sorted[-long_ext_count:]: |
| 261 | print (' %8.1f s weighted time to generate %d %s files ' |
| 262 | '(%1.1f s CPU time)') % (time, count_by_ext[extension], |
| 263 | extension, time_by_ext[extension]) |
| 264 | |
| 265 | print ' %.1f s weighted time (%.1f s CPU time, %1.1fx parallelism)' % ( |
| 266 | length, total_cpu_time, |
| 267 | total_cpu_time * 1.0 / length) |
| 268 | print ' %d build steps completed, average of %1.2f/s' % ( |
| 269 | len(entries), len(entries) / (length)) |
| 270 | |
| 271 | |
| 272 | def main(argv): |
| 273 | log_file = '.ninja_log' |
| 274 | for pid, arg in enumerate(argv): |
| 275 | if arg == '-C': |
| 276 | log_file = os.path.join(argv[pid+1], log_file) |
| 277 | |
| 278 | try: |
| 279 | with open(log_file, 'r') as log: |
| 280 | entries = ReadTargets(log, False) |
| 281 | SummarizeEntries(entries) |
| 282 | except IOError: |
| 283 | print 'No log file found, no build summary created.' |
| 284 | return errno.ENOENT |
| 285 | |
| 286 | |
| 287 | if __name__ == '__main__': |
| 288 | sys.exit(main(sys.argv[1:])) |