diff options
Diffstat (limited to 'meta/lib/oe/buildstats.py')
-rw-r--r-- | meta/lib/oe/buildstats.py | 254 |
1 files changed, 254 insertions, 0 deletions
diff --git a/meta/lib/oe/buildstats.py b/meta/lib/oe/buildstats.py new file mode 100644 index 0000000000..2700245ec6 --- /dev/null +++ b/meta/lib/oe/buildstats.py | |||
@@ -0,0 +1,254 @@ | |||
1 | # | ||
2 | # Copyright OpenEmbedded Contributors | ||
3 | # | ||
4 | # SPDX-License-Identifier: GPL-2.0-only | ||
5 | # | ||
6 | # Implements system state sampling. Called by buildstats.bbclass. | ||
7 | # Because it is a real Python module, it can hold persistent state, | ||
8 | # like open log files and the time of the last sampling. | ||
9 | |||
10 | import time | ||
11 | import re | ||
12 | import bb.event | ||
13 | from collections import deque | ||
14 | |||
15 | class SystemStats: | ||
16 | def __init__(self, d): | ||
17 | bn = d.getVar('BUILDNAME') | ||
18 | bsdir = os.path.join(d.getVar('BUILDSTATS_BASE'), bn) | ||
19 | bb.utils.mkdirhier(bsdir) | ||
20 | file_handlers = [('diskstats', self._reduce_diskstats), | ||
21 | ('meminfo', self._reduce_meminfo), | ||
22 | ('stat', self._reduce_stat), | ||
23 | ('net/dev', self._reduce_net)] | ||
24 | |||
25 | # Some hosts like openSUSE have readable /proc/pressure files | ||
26 | # but throw errors when these files are opened. Catch these error | ||
27 | # and ensure that the reduce_proc_pressure directory is not created. | ||
28 | if os.path.exists("/proc/pressure"): | ||
29 | try: | ||
30 | with open('/proc/pressure/cpu', 'rb') as source: | ||
31 | source.read() | ||
32 | pressuredir = os.path.join(bsdir, 'reduced_proc_pressure') | ||
33 | bb.utils.mkdirhier(pressuredir) | ||
34 | file_handlers.extend([('pressure/cpu', self._reduce_pressure), | ||
35 | ('pressure/io', self._reduce_pressure), | ||
36 | ('pressure/memory', self._reduce_pressure)]) | ||
37 | except Exception: | ||
38 | pass | ||
39 | |||
40 | self.proc_files = [] | ||
41 | for filename, handler in (file_handlers): | ||
42 | # The corresponding /proc files might not exist on the host. | ||
43 | # For example, /proc/diskstats is not available in virtualized | ||
44 | # environments like Linux-VServer. Silently skip collecting | ||
45 | # the data. | ||
46 | if os.path.exists(os.path.join('/proc', filename)): | ||
47 | # In practice, this class gets instantiated only once in | ||
48 | # the bitbake cooker process. Therefore 'append' mode is | ||
49 | # not strictly necessary, but using it makes the class | ||
50 | # more robust should two processes ever write | ||
51 | # concurrently. | ||
52 | if filename == 'net/dev': | ||
53 | destfile = os.path.join(bsdir, 'reduced_proc_net.log') | ||
54 | else: | ||
55 | destfile = os.path.join(bsdir, '%sproc_%s.log' % ('reduced_' if handler else '', filename)) | ||
56 | self.proc_files.append((filename, open(destfile, 'ab'), handler)) | ||
57 | self.monitor_disk = open(os.path.join(bsdir, 'monitor_disk.log'), 'ab') | ||
58 | # Last time that we sampled /proc data resp. recorded disk monitoring data. | ||
59 | self.last_proc = 0 | ||
60 | self.last_disk_monitor = 0 | ||
61 | # Minimum number of seconds between recording a sample. This becames relevant when we get | ||
62 | # called very often while many short tasks get started. Sampling during quiet periods | ||
63 | # depends on the heartbeat event, which fires less often. | ||
64 | # By default, the Heartbeat events occur roughly once every second but the actual time | ||
65 | # between these events deviates by a few milliseconds, in most cases. Hence | ||
66 | # pick a somewhat arbitary tolerance such that we sample a large majority | ||
67 | # of the Heartbeat events. This ignores rare events that fall outside the minimum | ||
68 | # and may lead an extra sample in a given second every so often. However, it allows for fairly | ||
69 | # consistent intervals between samples without missing many events. | ||
70 | self.tolerance = 0.01 | ||
71 | self.min_seconds = 1.0 - self.tolerance | ||
72 | |||
73 | self.meminfo_regex = re.compile(rb'^(MemTotal|MemFree|Buffers|Cached|SwapTotal|SwapFree):\s*(\d+)') | ||
74 | self.diskstats_regex = re.compile(rb'^([hsv]d.|mtdblock\d|mmcblk\d|cciss/c\d+d\d+|nvme\d+n\d+.*)$') | ||
75 | self.diskstats_ltime = None | ||
76 | self.diskstats_data = None | ||
77 | self.stat_ltimes = None | ||
78 | # Last time we sampled /proc/pressure. All resources stored in a single dict with the key as filename | ||
79 | self.last_pressure = {"pressure/cpu": None, "pressure/io": None, "pressure/memory": None} | ||
80 | self.net_stats = {} | ||
81 | |||
82 | def close(self): | ||
83 | self.monitor_disk.close() | ||
84 | for _, output, _ in self.proc_files: | ||
85 | output.close() | ||
86 | |||
87 | def _reduce_meminfo(self, time, data, filename): | ||
88 | """ | ||
89 | Extracts 'MemTotal', 'MemFree', 'Buffers', 'Cached', 'SwapTotal', 'SwapFree' | ||
90 | and writes their values into a single line, in that order. | ||
91 | """ | ||
92 | values = {} | ||
93 | for line in data.split(b'\n'): | ||
94 | m = self.meminfo_regex.match(line) | ||
95 | if m: | ||
96 | values[m.group(1)] = m.group(2) | ||
97 | if len(values) == 6: | ||
98 | return (time, | ||
99 | b' '.join([values[x] for x in | ||
100 | (b'MemTotal', b'MemFree', b'Buffers', b'Cached', b'SwapTotal', b'SwapFree')]) + b'\n') | ||
101 | |||
102 | def _reduce_net(self, time, data, filename): | ||
103 | data = data.split(b'\n') | ||
104 | for line in data[2:]: | ||
105 | if b":" not in line: | ||
106 | continue | ||
107 | try: | ||
108 | parts = line.split() | ||
109 | iface = (parts[0].strip(b':')).decode('ascii') | ||
110 | receive_bytes = int(parts[1]) | ||
111 | transmit_bytes = int(parts[9]) | ||
112 | except Exception: | ||
113 | continue | ||
114 | |||
115 | if iface not in self.net_stats: | ||
116 | self.net_stats[iface] = deque(maxlen=2) | ||
117 | self.net_stats[iface].append((receive_bytes, transmit_bytes, 0, 0)) | ||
118 | prev = self.net_stats[iface][-1] if self.net_stats[iface] else (0, 0, 0, 0) | ||
119 | receive_diff = receive_bytes - prev[0] | ||
120 | transmit_diff = transmit_bytes - prev[1] | ||
121 | self.net_stats[iface].append(( | ||
122 | receive_bytes, | ||
123 | transmit_bytes, | ||
124 | receive_diff, | ||
125 | transmit_diff | ||
126 | )) | ||
127 | |||
128 | result_str = "\n".join( | ||
129 | f"{iface}: {net_data[-1][0]} {net_data[-1][1]} {net_data[-1][2]} {net_data[-1][3]}" | ||
130 | for iface, net_data in self.net_stats.items() | ||
131 | ) + "\n" | ||
132 | |||
133 | return time, result_str.encode('ascii') | ||
134 | |||
135 | def _diskstats_is_relevant_line(self, linetokens): | ||
136 | if len(linetokens) < 14: | ||
137 | return False | ||
138 | disk = linetokens[2] | ||
139 | return self.diskstats_regex.match(disk) | ||
140 | |||
141 | def _reduce_diskstats(self, time, data, filename): | ||
142 | relevant_tokens = filter(self._diskstats_is_relevant_line, map(lambda x: x.split(), data.split(b'\n'))) | ||
143 | diskdata = [0] * 3 | ||
144 | reduced = None | ||
145 | for tokens in relevant_tokens: | ||
146 | # rsect | ||
147 | diskdata[0] += int(tokens[5]) | ||
148 | # wsect | ||
149 | diskdata[1] += int(tokens[9]) | ||
150 | # use | ||
151 | diskdata[2] += int(tokens[12]) | ||
152 | if self.diskstats_ltime: | ||
153 | # We need to compute information about the time interval | ||
154 | # since the last sampling and record the result as sample | ||
155 | # for that point in the past. | ||
156 | interval = time - self.diskstats_ltime | ||
157 | if interval > 0: | ||
158 | sums = [ a - b for a, b in zip(diskdata, self.diskstats_data) ] | ||
159 | readTput = sums[0] / 2.0 * 100.0 / interval | ||
160 | writeTput = sums[1] / 2.0 * 100.0 / interval | ||
161 | util = float( sums[2] ) / 10 / interval | ||
162 | util = max(0.0, min(1.0, util)) | ||
163 | reduced = (self.diskstats_ltime, (readTput, writeTput, util)) | ||
164 | |||
165 | self.diskstats_ltime = time | ||
166 | self.diskstats_data = diskdata | ||
167 | return reduced | ||
168 | |||
169 | |||
170 | def _reduce_nop(self, time, data, filename): | ||
171 | return (time, data) | ||
172 | |||
173 | def _reduce_stat(self, time, data, filename): | ||
174 | if not data: | ||
175 | return None | ||
176 | # CPU times {user, nice, system, idle, io_wait, irq, softirq} from first line | ||
177 | tokens = data.split(b'\n', 1)[0].split() | ||
178 | times = [ int(token) for token in tokens[1:] ] | ||
179 | reduced = None | ||
180 | if self.stat_ltimes: | ||
181 | user = float((times[0] + times[1]) - (self.stat_ltimes[0] + self.stat_ltimes[1])) | ||
182 | system = float((times[2] + times[5] + times[6]) - (self.stat_ltimes[2] + self.stat_ltimes[5] + self.stat_ltimes[6])) | ||
183 | idle = float(times[3] - self.stat_ltimes[3]) | ||
184 | iowait = float(times[4] - self.stat_ltimes[4]) | ||
185 | |||
186 | aSum = max(user + system + idle + iowait, 1) | ||
187 | reduced = (time, (user/aSum, system/aSum, iowait/aSum)) | ||
188 | |||
189 | self.stat_ltimes = times | ||
190 | return reduced | ||
191 | |||
192 | def _reduce_pressure(self, time, data, filename): | ||
193 | """ | ||
194 | Return reduced pressure: {avg10, avg60, avg300} and delta total compared to the previous sample | ||
195 | for the cpu, io and memory resources. A common function is used for all 3 resources since the | ||
196 | format of the /proc/pressure file is the same in each case. | ||
197 | """ | ||
198 | if not data: | ||
199 | return None | ||
200 | tokens = data.split(b'\n', 1)[0].split() | ||
201 | avg10 = float(tokens[1].split(b'=')[1]) | ||
202 | avg60 = float(tokens[2].split(b'=')[1]) | ||
203 | avg300 = float(tokens[3].split(b'=')[1]) | ||
204 | total = int(tokens[4].split(b'=')[1]) | ||
205 | |||
206 | reduced = None | ||
207 | if self.last_pressure[filename]: | ||
208 | delta = total - self.last_pressure[filename] | ||
209 | reduced = (time, (avg10, avg60, avg300, delta)) | ||
210 | self.last_pressure[filename] = total | ||
211 | return reduced | ||
212 | |||
213 | def sample(self, event, force): | ||
214 | """ | ||
215 | Collect and log proc or disk_monitor stats periodically. | ||
216 | Return True if a new sample is collected and hence the value last_proc or last_disk_monitor | ||
217 | is changed. | ||
218 | """ | ||
219 | retval = False | ||
220 | now = time.time() | ||
221 | if (now - self.last_proc > self.min_seconds) or force: | ||
222 | for filename, output, handler in self.proc_files: | ||
223 | with open(os.path.join('/proc', filename), 'rb') as input: | ||
224 | data = input.read() | ||
225 | if handler: | ||
226 | reduced = handler(now, data, filename) | ||
227 | else: | ||
228 | reduced = (now, data) | ||
229 | if reduced: | ||
230 | if isinstance(reduced[1], bytes): | ||
231 | # Use as it is. | ||
232 | data = reduced[1] | ||
233 | else: | ||
234 | # Convert to a single line. | ||
235 | data = (' '.join([str(x) for x in reduced[1]]) + '\n').encode('ascii') | ||
236 | # Unbuffered raw write, less overhead and useful | ||
237 | # in case that we end up with concurrent writes. | ||
238 | os.write(output.fileno(), | ||
239 | ('%.0f\n' % reduced[0]).encode('ascii') + | ||
240 | data + | ||
241 | b'\n') | ||
242 | self.last_proc = now | ||
243 | retval = True | ||
244 | |||
245 | if isinstance(event, bb.event.MonitorDiskEvent) and \ | ||
246 | ((now - self.last_disk_monitor > self.min_seconds) or force): | ||
247 | os.write(self.monitor_disk.fileno(), | ||
248 | ('%.0f\n' % now).encode('ascii') + | ||
249 | ''.join(['%s: %d\n' % (dev, sample.total_bytes - sample.free_bytes) | ||
250 | for dev, sample in event.disk_usage.items()]).encode('ascii') + | ||
251 | b'\n') | ||
252 | self.last_disk_monitor = now | ||
253 | retval = True | ||
254 | return retval \ No newline at end of file | ||