1# SPDX-License-Identifier: Apache-2.0 2# 3# Copyright (C) 2015, ARM Limited and contributors. 4# 5# Licensed under the Apache License, Version 2.0 (the "License"); you may 6# not use this file except in compliance with the License. 7# You may obtain a copy of the License at 8# 9# http://www.apache.org/licenses/LICENSE-2.0 10# 11# Unless required by applicable law or agreed to in writing, software 12# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 13# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14# See the License for the specific language governing permissions and 15# limitations under the License. 16# 17 18""" Frequency Analysis Module """ 19 20import matplotlib.gridspec as gridspec 21import matplotlib.pyplot as plt 22import pandas as pd 23import pylab as pl 24import operator 25from trappy.utils import listify 26from devlib.utils.misc import memoized 27 28from analysis_module import AnalysisModule 29from trace import ResidencyTime, ResidencyData 30from bart.common.Utils import area_under_curve 31 32 33class FrequencyAnalysis(AnalysisModule): 34 """ 35 Support for plotting Frequency Analysis data 36 37 :param trace: input Trace object 38 :type trace: :mod:`libs.utils.Trace` 39 """ 40 41 def __init__(self, trace): 42 super(FrequencyAnalysis, self).__init__(trace) 43 44############################################################################### 45# DataFrame Getter Methods 46############################################################################### 47 48 def _dfg_cpu_frequency_residency(self, cpu, total=True): 49 """ 50 Get per-CPU frequency residency, i.e. amount of 51 time CPU `cpu` spent at each frequency. 52 53 :param cpu: CPU ID 54 :type cpu: int 55 56 :param total: if true returns the "total" time, otherwise the "active" 57 time is returned 58 :type total: bool 59 60 :returns: :mod:`pandas.DataFrame` - "total" or "active" time residency 61 at each frequency. 62 63 :raises: TypeError 64 """ 65 if not isinstance(cpu, int): 66 raise TypeError('Input CPU parameter must be an integer') 67 68 residency = self._getFrequencyResidency(cpu) 69 if not residency: 70 return None 71 if total: 72 return residency.total 73 return residency.active 74 75 def _dfg_cluster_frequency_residency(self, cluster, total=True): 76 """ 77 Get per-Cluster frequency residency, i.e. amount of time CLUSTER 78 `cluster` spent at each frequency. 79 80 :param cluster: this can be either a list of CPU IDs belonging to a 81 cluster or the cluster name as specified in the platform 82 description 83 :type cluster: str or list(int) 84 85 :param total: if true returns the "total" time, otherwise the "active" 86 time is returned 87 :type total: bool 88 89 :returns: :mod:`pandas.DataFrame` - "total" or "active" time residency 90 at each frequency. 91 92 :raises: KeyError 93 """ 94 if isinstance(cluster, str): 95 try: 96 residency = self._getFrequencyResidency( 97 self._platform['clusters'][cluster.lower()] 98 ) 99 except KeyError: 100 self._log.warning( 101 'Platform descriptor has not a cluster named [%s], ' 102 'plot disabled!', cluster 103 ) 104 return None 105 else: 106 residency = self._getFrequencyResidency(cluster) 107 if not residency: 108 return None 109 if total: 110 return residency.total 111 return residency.active 112 113 114############################################################################### 115# Plotting Methods 116############################################################################### 117 118 def plotClusterFrequencies(self, title='Clusters Frequencies'): 119 """ 120 Plot frequency trend for all clusters. If sched_overutilized events are 121 available, the plots will also show the intervals of time where the 122 cluster was overutilized. 123 124 :param title: user-defined plot title 125 :type title: str 126 """ 127 if not self._trace.hasEvents('cpu_frequency'): 128 self._log.warning('Events [cpu_frequency] not found, plot DISABLED!') 129 return 130 df = self._dfg_trace_event('cpu_frequency') 131 132 pd.options.mode.chained_assignment = None 133 134 # Extract LITTLE and big clusters frequencies 135 # and scale them to [MHz] 136 if len(self._platform['clusters']['little']): 137 lfreq = df[df.cpu == self._platform['clusters']['little'][-1]] 138 lfreq['frequency'] = lfreq['frequency']/1e3 139 else: 140 lfreq = [] 141 if len(self._platform['clusters']['big']): 142 bfreq = df[df.cpu == self._platform['clusters']['big'][-1]] 143 bfreq['frequency'] = bfreq['frequency']/1e3 144 else: 145 bfreq = [] 146 147 # Compute AVG frequency for LITTLE cluster 148 avg_lfreq = 0 149 if len(lfreq) > 0: 150 lfreq['timestamp'] = lfreq.index 151 lfreq['delta'] = (lfreq['timestamp'] -lfreq['timestamp'].shift()).fillna(0).shift(-1) 152 lfreq['cfreq'] = (lfreq['frequency'] * lfreq['delta']).fillna(0) 153 timespan = lfreq.iloc[-1].timestamp - lfreq.iloc[0].timestamp 154 avg_lfreq = lfreq['cfreq'].sum()/timespan 155 156 # Compute AVG frequency for big cluster 157 avg_bfreq = 0 158 if len(bfreq) > 0: 159 bfreq['timestamp'] = bfreq.index 160 bfreq['delta'] = (bfreq['timestamp'] - bfreq['timestamp'].shift()).fillna(0).shift(-1) 161 bfreq['cfreq'] = (bfreq['frequency'] * bfreq['delta']).fillna(0) 162 timespan = bfreq.iloc[-1].timestamp - bfreq.iloc[0].timestamp 163 avg_bfreq = bfreq['cfreq'].sum()/timespan 164 165 pd.options.mode.chained_assignment = 'warn' 166 167 # Setup a dual cluster plot 168 fig, pltaxes = plt.subplots(2, 1, figsize=(16, 8)) 169 plt.suptitle(title, y=.97, fontsize=16, horizontalalignment='center') 170 171 # Plot Cluster frequencies 172 axes = pltaxes[0] 173 axes.set_title('big Cluster') 174 if avg_bfreq > 0: 175 axes.axhline(avg_bfreq, color='r', linestyle='--', linewidth=2) 176 axes.set_ylim( 177 (self._platform['freqs']['big'][0] - 100000)/1e3, 178 (self._platform['freqs']['big'][-1] + 100000)/1e3 179 ) 180 if len(bfreq) > 0: 181 bfreq['frequency'].plot(style=['r-'], ax=axes, 182 drawstyle='steps-post', alpha=0.4) 183 else: 184 self._log.warning('NO big CPUs frequency events to plot') 185 axes.set_xlim(self._trace.x_min, self._trace.x_max) 186 axes.set_ylabel('MHz') 187 axes.grid(True) 188 axes.set_xticklabels([]) 189 axes.set_xlabel('') 190 self._trace.analysis.status.plotOverutilized(axes) 191 192 axes = pltaxes[1] 193 axes.set_title('LITTLE Cluster') 194 if avg_lfreq > 0: 195 axes.axhline(avg_lfreq, color='b', linestyle='--', linewidth=2) 196 axes.set_ylim( 197 (self._platform['freqs']['little'][0] - 100000)/1e3, 198 (self._platform['freqs']['little'][-1] + 100000)/1e3 199 ) 200 if len(lfreq) > 0: 201 lfreq['frequency'].plot(style=['b-'], ax=axes, 202 drawstyle='steps-post', alpha=0.4) 203 else: 204 self._log.warning('NO LITTLE CPUs frequency events to plot') 205 axes.set_xlim(self._trace.x_min, self._trace.x_max) 206 axes.set_ylabel('MHz') 207 axes.grid(True) 208 self._trace.analysis.status.plotOverutilized(axes) 209 210 # Save generated plots into datadir 211 figname = '{}/{}cluster_freqs.png'\ 212 .format(self._trace.plots_dir, self._trace.plots_prefix) 213 pl.savefig(figname, bbox_inches='tight') 214 215 self._log.info('LITTLE cluster average frequency: %.3f GHz', 216 avg_lfreq/1e3) 217 self._log.info('big cluster average frequency: %.3f GHz', 218 avg_bfreq/1e3) 219 220 return (avg_lfreq/1e3, avg_bfreq/1e3) 221 222 def plotCPUFrequencies(self, cpus=None): 223 """ 224 Plot frequency for the specified CPUs (or all if not specified). 225 If sched_overutilized events are available, the plots will also show the 226 intervals of time where the system was overutilized. 227 228 The generated plots are also saved as PNG images under the folder 229 specified by the `plots_dir` parameter of :class:`Trace`. 230 231 :param cpus: the list of CPUs to plot, if None it generate a plot 232 for each available CPU 233 :type cpus: int or list(int) 234 235 :return: a dictionary of average frequency for each CPU. 236 """ 237 if not self._trace.hasEvents('cpu_frequency'): 238 self._log.warning('Events [cpu_frequency] not found, plot DISABLED!') 239 return 240 df = self._dfg_trace_event('cpu_frequency') 241 242 if cpus is None: 243 # Generate plots only for available CPUs 244 cpus = range(df.cpu.max()+1) 245 else: 246 # Generate plots only specified CPUs 247 cpus = listify(cpus) 248 249 chained_assignment = pd.options.mode.chained_assignment 250 pd.options.mode.chained_assignment = None 251 252 freq = {} 253 for cpu_id in listify(cpus): 254 # Extract CPUs' frequencies and scale them to [MHz] 255 _df = df[df.cpu == cpu_id] 256 if _df.empty: 257 self._log.warning('No [cpu_frequency] events for CPU%d, ' 258 'plot DISABLED!', cpu_id) 259 continue 260 _df['frequency'] = _df.frequency / 1e3 261 262 # Compute AVG frequency for this CPU 263 avg_freq = 0 264 if len(_df) > 1: 265 timespan = _df.index[-1] - _df.index[0] 266 avg_freq = area_under_curve(_df['frequency']) / timespan 267 268 # Store DF for plotting 269 freq[cpu_id] = { 270 'df' : _df, 271 'avg' : avg_freq, 272 } 273 274 pd.options.mode.chained_assignment = chained_assignment 275 276 plots_count = len(freq) 277 if not plots_count: 278 return 279 280 # Setup CPUs plots 281 fig, pltaxes = plt.subplots(len(freq), 1, figsize=(16, 4 * plots_count)) 282 283 avg_freqs = {} 284 for plot_idx, cpu_id in enumerate(freq): 285 286 # CPU frequencies and average value 287 _df = freq[cpu_id]['df'] 288 _avg = freq[cpu_id]['avg'] 289 290 # Plot average frequency 291 try: 292 axes = pltaxes[plot_idx] 293 except TypeError: 294 axes = pltaxes 295 axes.set_title('CPU{:2d} Frequency'.format(cpu_id)) 296 axes.axhline(_avg, color='r', linestyle='--', linewidth=2) 297 298 # Set plot limit based on CPU min/max frequencies 299 for cluster,cpus in self._platform['clusters'].iteritems(): 300 if cpu_id not in cpus: 301 continue 302 axes.set_ylim( 303 (self._platform['freqs'][cluster][0] - 100000)/1e3, 304 (self._platform['freqs'][cluster][-1] + 100000)/1e3 305 ) 306 break 307 308 # Plot CPU frequency transitions 309 _df['frequency'].plot(style=['r-'], ax=axes, 310 drawstyle='steps-post', alpha=0.4) 311 312 # Plot overutilzied regions (if signal available) 313 self._trace.analysis.status.plotOverutilized(axes) 314 315 # Finalize plot 316 axes.set_xlim(self._trace.x_min, self._trace.x_max) 317 axes.set_ylabel('MHz') 318 axes.grid(True) 319 if plot_idx + 1 < plots_count: 320 axes.set_xticklabels([]) 321 axes.set_xlabel('') 322 323 avg_freqs[cpu_id] = _avg/1e3 324 self._log.info('CPU%02d average frequency: %.3f GHz', 325 cpu_id, avg_freqs[cpu_id]) 326 327 # Save generated plots into datadir 328 figname = '{}/{}cpus_freqs.png'\ 329 .format(self._trace.plots_dir, self._trace.plots_prefix) 330 pl.savefig(figname, bbox_inches='tight') 331 332 return avg_freqs 333 334 335 def plotCPUFrequencyResidency(self, cpus=None, pct=False, active=False): 336 """ 337 Plot per-CPU frequency residency. big CPUs are plotted first and then 338 LITTLEs. 339 340 Requires the following trace events: 341 - cpu_frequency 342 - cpu_idle 343 344 :param cpus: list of CPU IDs. By default plot all CPUs 345 :type cpus: list(int) or int 346 347 :param pct: plot residencies in percentage 348 :type pct: bool 349 350 :param active: for percentage plot specify whether to plot active or 351 total time. Default is TOTAL time 352 :type active: bool 353 """ 354 if not self._trace.hasEvents('cpu_frequency'): 355 self._log.warning('Events [cpu_frequency] not found, plot DISABLED!') 356 return 357 if not self._trace.hasEvents('cpu_idle'): 358 self._log.warning('Events [cpu_idle] not found, plot DISABLED!') 359 return 360 361 if cpus is None: 362 # Generate plots only for available CPUs 363 cpufreq_data = self._dfg_trace_event('cpu_frequency') 364 _cpus = range(cpufreq_data.cpu.max()+1) 365 else: 366 _cpus = listify(cpus) 367 368 # Split between big and LITTLE CPUs ordered from higher to lower ID 369 _cpus.reverse() 370 big_cpus = [c for c in _cpus if c in self._platform['clusters']['big']] 371 little_cpus = [c for c in _cpus if c in 372 self._platform['clusters']['little']] 373 _cpus = big_cpus + little_cpus 374 375 # Precompute active and total time for each CPU 376 residencies = [] 377 xmax = 0.0 378 for cpu in _cpus: 379 res = self._getFrequencyResidency(cpu) 380 residencies.append(ResidencyData('CPU{}'.format(cpu), res)) 381 382 max_time = res.total.max().values[0] 383 if xmax < max_time: 384 xmax = max_time 385 386 self._plotFrequencyResidency(residencies, 'cpu', xmax, pct, active) 387 388 def plotClusterFrequencyResidency(self, clusters=None, 389 pct=False, active=False): 390 """ 391 Plot the frequency residency in a given cluster, i.e. the amount of 392 time cluster `cluster` spent at frequency `f_i`. By default, both 'big' 393 and 'LITTLE' clusters data are plotted. 394 395 Requires the following trace events: 396 - cpu_frequency 397 - cpu_idle 398 399 :param clusters: name of the clusters to be plotted (all of them by 400 default) 401 :type clusters: str ot list(str) 402 403 :param pct: plot residencies in percentage 404 :type pct: bool 405 406 :param active: for percentage plot specify whether to plot active or 407 total time. Default is TOTAL time 408 :type active: bool 409 """ 410 if not self._trace.hasEvents('cpu_frequency'): 411 self._log.warning('Events [cpu_frequency] not found, plot DISABLED!') 412 return 413 if not self._trace.hasEvents('cpu_idle'): 414 self._log.warning('Events [cpu_idle] not found, plot DISABLED!') 415 return 416 417 # Assumption: all CPUs in a cluster run at the same frequency, i.e. the 418 # frequency is scaled per-cluster not per-CPU. Hence, we can limit the 419 # cluster frequencies data to a single CPU 420 if not self._trace.freq_coherency: 421 self._log.warning('Cluster frequency is not coherent, plot DISABLED!') 422 return 423 424 # Sanitize clusters 425 if clusters is None: 426 _clusters = self._platform['clusters'].keys() 427 else: 428 _clusters = listify(clusters) 429 430 # Precompute active and total time for each cluster 431 residencies = [] 432 xmax = 0.0 433 for cluster in _clusters: 434 res = self._getFrequencyResidency( 435 self._platform['clusters'][cluster.lower()]) 436 residencies.append(ResidencyData('{} Cluster'.format(cluster), 437 res)) 438 439 max_time = res.total.max().values[0] 440 if xmax < max_time: 441 xmax = max_time 442 443 self._plotFrequencyResidency(residencies, 'cluster', xmax, pct, active) 444 445############################################################################### 446# Utility Methods 447############################################################################### 448 449 @memoized 450 def _getFrequencyResidency(self, cluster): 451 """ 452 Get a DataFrame with per cluster frequency residency, i.e. amount of 453 time spent at a given frequency in each cluster. 454 455 :param cluster: this can be either a single CPU ID or a list of CPU IDs 456 belonging to a cluster 457 :type cluster: int or list(int) 458 459 :returns: namedtuple(ResidencyTime) - tuple of total and active time 460 dataframes 461 """ 462 if not self._trace.hasEvents('cpu_frequency'): 463 self._log.warning('Events [cpu_frequency] not found, ' 464 'frequency residency computation not possible!') 465 return None 466 if not self._trace.hasEvents('cpu_idle'): 467 self._log.warning('Events [cpu_idle] not found, ' 468 'frequency residency computation not possible!') 469 return None 470 471 _cluster = listify(cluster) 472 473 freq_df = self._dfg_trace_event('cpu_frequency') 474 # Assumption: all CPUs in a cluster run at the same frequency, i.e. the 475 # frequency is scaled per-cluster not per-CPU. Hence, we can limit the 476 # cluster frequencies data to a single CPU. This assumption is verified 477 # by the Trace module when parsing the trace. 478 if len(_cluster) > 1 and not self._trace.freq_coherency: 479 self._log.warning('Cluster frequency is NOT coherent,' 480 'cannot compute residency!') 481 return None 482 cluster_freqs = freq_df[freq_df.cpu == _cluster[0]] 483 484 # Compute TOTAL Time 485 time_intervals = cluster_freqs.index[1:] - cluster_freqs.index[:-1] 486 total_time = pd.DataFrame({ 487 'time': time_intervals, 488 'frequency': [f/1000.0 for f in cluster_freqs.iloc[:-1].frequency] 489 }) 490 total_time = total_time.groupby(['frequency']).sum() 491 492 # Compute ACTIVE Time 493 cluster_active = self._trace.getClusterActiveSignal(_cluster) 494 495 # In order to compute the active time spent at each frequency we 496 # multiply 2 square waves: 497 # - cluster_active, a square wave of the form: 498 # cluster_active[t] == 1 if at least one CPU is reported to be 499 # non-idle by CPUFreq at time t 500 # cluster_active[t] == 0 otherwise 501 # - freq_active, square wave of the form: 502 # freq_active[t] == 1 if at time t the frequency is f 503 # freq_active[t] == 0 otherwise 504 available_freqs = sorted(cluster_freqs.frequency.unique()) 505 cluster_freqs = cluster_freqs.join( 506 cluster_active.to_frame(name='active'), how='outer') 507 cluster_freqs.fillna(method='ffill', inplace=True) 508 nonidle_time = [] 509 for f in available_freqs: 510 freq_active = cluster_freqs.frequency.apply(lambda x: 1 if x == f else 0) 511 active_t = cluster_freqs.active * freq_active 512 # Compute total time by integrating the square wave 513 nonidle_time.append(self._trace.integrate_square_wave(active_t)) 514 515 active_time = pd.DataFrame({'time': nonidle_time}, 516 index=[f/1000.0 for f in available_freqs]) 517 active_time.index.name = 'frequency' 518 return ResidencyTime(total_time, active_time) 519 520 def _plotFrequencyResidencyAbs(self, axes, residency, n_plots, 521 is_first, is_last, xmax, title=''): 522 """ 523 Private method to generate frequency residency plots. 524 525 :param axes: axes over which to generate the plot 526 :type axes: matplotlib.axes.Axes 527 528 :param residency: tuple of total and active time dataframes 529 :type residency: namedtuple(ResidencyTime) 530 531 :param n_plots: total number of plots 532 :type n_plots: int 533 534 :param is_first: if True this is the first plot 535 :type is_first: bool 536 537 :param is_last: if True this is the last plot 538 :type is_last: bool 539 540 :param xmax: x-axes higher bound 541 :param xmax: double 542 543 :param title: title of this subplot 544 :type title: str 545 """ 546 yrange = 0.4 * max(6, len(residency.total)) * n_plots 547 residency.total.plot.barh(ax=axes, color='g', 548 legend=False, figsize=(16, yrange)) 549 residency.active.plot.barh(ax=axes, color='r', 550 legend=False, figsize=(16, yrange)) 551 552 axes.set_xlim(0, 1.05*xmax) 553 axes.set_ylabel('Frequency [MHz]') 554 axes.set_title(title) 555 axes.grid(True) 556 if is_last: 557 axes.set_xlabel('Time [s]') 558 else: 559 axes.set_xticklabels([]) 560 561 if is_first: 562 # Put title on top of the figure. As of now there is no clean way 563 # to make the title appear always in the same position in the 564 # figure because figure heights may vary between different 565 # platforms (different number of OPPs). Hence, we use annotation 566 legend_y = axes.get_ylim()[1] 567 axes.annotate('OPP Residency Time', xy=(0, legend_y), 568 xytext=(-50, 45), textcoords='offset points', 569 fontsize=18) 570 axes.annotate('GREEN: Total', xy=(0, legend_y), 571 xytext=(-50, 25), textcoords='offset points', 572 color='g', fontsize=14) 573 axes.annotate('RED: Active', xy=(0, legend_y), 574 xytext=(50, 25), textcoords='offset points', 575 color='r', fontsize=14) 576 577 def _plotFrequencyResidencyPct(self, axes, residency_df, label, 578 n_plots, is_first, is_last, res_type): 579 """ 580 Private method to generate PERCENTAGE frequency residency plots. 581 582 :param axes: axes over which to generate the plot 583 :type axes: matplotlib.axes.Axes 584 585 :param residency_df: residency time dataframe 586 :type residency_df: :mod:`pandas.DataFrame` 587 588 :param label: label to be used for percentage residency dataframe 589 :type label: str 590 591 :param n_plots: total number of plots 592 :type n_plots: int 593 594 :param is_first: if True this is the first plot 595 :type is_first: bool 596 597 :param is_first: if True this is the last plot 598 :type is_first: bool 599 600 :param res_type: type of residency, either TOTAL or ACTIVE 601 :type title: str 602 """ 603 # Compute sum of the time intervals 604 duration = residency_df.time.sum() 605 residency_pct = pd.DataFrame( 606 {label: residency_df.time.apply(lambda x: x*100/duration)}, 607 index=residency_df.index 608 ) 609 yrange = 3 * n_plots 610 residency_pct.T.plot.barh(ax=axes, stacked=True, figsize=(16, yrange)) 611 612 axes.legend(loc='lower center', ncol=7) 613 axes.set_xlim(0, 100) 614 axes.grid(True) 615 if is_last: 616 axes.set_xlabel('Residency [%]') 617 else: 618 axes.set_xticklabels([]) 619 if is_first: 620 legend_y = axes.get_ylim()[1] 621 axes.annotate('OPP {} Residency Time'.format(res_type), 622 xy=(0, legend_y), xytext=(-50, 35), 623 textcoords='offset points', fontsize=18) 624 625 def _plotFrequencyResidency(self, residencies, entity_name, xmax, 626 pct, active): 627 """ 628 Generate Frequency residency plots for the given entities. 629 630 :param residencies: list of residencies to be plotted 631 :type residencies: list(namedtuple(ResidencyData)) - each tuple 632 contains: 633 - a label to be used as subplot title 634 - a namedtuple(ResidencyTime) 635 636 :param entity_name: name of the entity ('cpu' or 'cluster') used in the 637 figure name 638 :type entity_name: str 639 640 :param xmax: upper bound of x-axes 641 :type xmax: double 642 643 :param pct: plot residencies in percentage 644 :type pct: bool 645 646 :param active: for percentage plot specify whether to plot active or 647 total time. Default is TOTAL time 648 :type active: bool 649 """ 650 n_plots = len(residencies) 651 gs = gridspec.GridSpec(n_plots, 1) 652 fig = plt.figure() 653 654 figtype = "" 655 for idx, data in enumerate(residencies): 656 if data.residency is None: 657 plt.close(fig) 658 return 659 660 axes = fig.add_subplot(gs[idx]) 661 is_first = idx == 0 662 is_last = idx+1 == n_plots 663 if pct and active: 664 self._plotFrequencyResidencyPct(axes, data.residency.active, 665 data.label, n_plots, 666 is_first, is_last, 667 'ACTIVE') 668 figtype = "_pct_active" 669 continue 670 if pct: 671 self._plotFrequencyResidencyPct(axes, data.residency.total, 672 data.label, n_plots, 673 is_first, is_last, 674 'TOTAL') 675 figtype = "_pct_total" 676 continue 677 678 self._plotFrequencyResidencyAbs(axes, data.residency, 679 n_plots, is_first, 680 is_last, xmax, 681 title=data.label) 682 683 figname = '{}/{}{}_freq_residency{}.png'\ 684 .format(self._trace.plots_dir, 685 self._trace.plots_prefix, 686 entity_name, figtype) 687 pl.savefig(figname, bbox_inches='tight') 688 689# vim :set tabstop=4 shiftwidth=4 expandtab 690