1#!/usr/bin/env python3 2# 3# Copyright 2021 - The Android Open Source Project 4# 5# Licensed under the Apache License, Version 2.0 (the "License"); 6# you may 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, 13# WITHOUT 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"""Incremental dEQP 18 19This script will run a subset of dEQP test on device to get dEQP dependency. 20 21Usage 1: Compare with a base build to check if any dEQP dependency has 22changed. Output a decision if dEQP could be skipped, and a cts-tradefed 23command to be used based on the decision. 24 25python3 incremental_deqp.py -s [device serial] -t [test directory] -b 26[base build target file] -c [current build target file] 27 28Usage 2: Generate a file containing a list of dEQP dependencies for the 29build on device. 30 31python3 incremental_deqp.py -s [device serial] -t [test directory] 32--generate_deps_only 33 34""" 35import argparse 36import importlib 37import logging 38import os 39import pkgutil 40import re 41import subprocess 42import tempfile 43import time 44import uuid 45from target_file_handler import TargetFileHandler 46from custom_build_file_handler import CustomBuildFileHandler 47from zipfile import ZipFile 48 49 50DEFAULT_CTS_XML = ('<?xml version="1.0" encoding="utf-8"?>\n' 51 '<configuration description="Runs CTS from a pre-existing CTS installation">\n' 52 ' <include name="cts-common" />\n' 53 ' <include name="cts-exclude" />\n' 54 ' <include name="cts-exclude-instant" />\n' 55 ' <option name="enable-token-sharding" ' 56 'value="true" />\n' 57 ' <option name="plan" value="cts" />\n' 58 '</configuration>\n') 59 60INCREMENTAL_DEQP_XML = ('<?xml version="1.0" encoding="utf-8"?>\n' 61 '<configuration description="Runs CTS with incremental dEQP">\n' 62 ' <include name="cts-common" />\n' 63 ' <include name="cts-exclude" />\n' 64 ' <include name="cts-exclude-instant" />\n' 65 ' <option name="enable-token-sharding" ' 66 'value="true" />\n' 67 ' <option name="compatibility:exclude-filter" ' 68 'value="CtsDeqpTestCases" />\n' 69 ' <option name="plan" value="cts" />\n' 70 '</configuration>\n') 71 72logger = logging.getLogger() 73 74 75class AtsError(Exception): 76 """Error when running incremental dEQP with Android Test Station""" 77 pass 78 79class AdbError(Exception): 80 """Error when running adb command.""" 81 pass 82 83class TestError(Exception): 84 """Error when running dEQP test.""" 85 pass 86 87class TestResourceError(Exception): 88 """Error with test resource. """ 89 pass 90 91class BuildHelper(object): 92 """Helper class for analyzing build.""" 93 94 def __init__(self, custom_handler=False): 95 """Init BuildHelper. 96 97 Args: 98 custom_handler: use custom build handler. 99 """ 100 self._build_file_handler = TargetFileHandler 101 if custom_handler: 102 self._build_file_handler = CustomBuildFileHandler 103 104 105 def compare_base_build_with_current_build(self, deqp_deps, current_build_file, 106 base_build_file): 107 """Compare the difference of current build and base build with dEQP dependency. 108 109 If the difference doesn't involve dEQP dependency, current build could skip dEQP test if 110 base build has passed test. 111 112 Args: 113 deqp_deps: a set of dEQP dependency. 114 current_build_file: current build's file name. 115 base_build_file: base build's file name. 116 Returns: 117 True if current build could skip dEQP, otherwise False. 118 """ 119 print('Comparing base build and current build...') 120 current_build_handler = self._build_file_handler(current_build_file) 121 current_build_hash = current_build_handler.get_file_hash(deqp_deps) 122 123 base_build_handler = self._build_file_handler(base_build_file) 124 base_build_hash = base_build_handler.get_file_hash(deqp_deps) 125 126 return self._compare_build_hash(current_build_hash, base_build_hash) 127 128 129 def compare_base_build_with_device_files(self, deqp_deps, adb, base_build_file): 130 """Compare the difference of files on device and base build with dEQP dependency. 131 132 If the difference doesn't involve dEQP dependency, current build could skip dEQP test if 133 base build has passed test. 134 135 Args: 136 deqp_deps: a set of dEQP dependency. 137 adb: an instance of AdbHelper for current device under test. 138 base_build_file: base build file name. 139 Returns: 140 True if current build could skip dEQP, otherwise False. 141 """ 142 print('Comparing base build and current build on the device...') 143 # Get current build's hash. 144 current_build_hash = dict() 145 for dep in deqp_deps: 146 content = adb.run_shell_command('cat ' + dep) 147 current_build_hash[dep] = hash(content) 148 149 base_build_handler = self._build_file_handler(base_build_file) 150 base_build_hash = base_build_handler.get_file_hash(deqp_deps) 151 152 return self._compare_build_hash(current_build_hash, base_build_hash) 153 154 155 def _compare_build_hash(self, current_build_hash, base_build_hash): 156 """Compare the hash value of current build and base build. 157 158 Args: 159 current_build_hash: map of current build where key is file name, and value is content hash. 160 base_build_hash: map of base build where key is file name and value is content hash. 161 Returns: 162 boolean about if two builds' hash is the same. 163 """ 164 if current_build_hash == base_build_hash: 165 print('Done!') 166 return True 167 168 for key, val in current_build_hash.items(): 169 if key not in base_build_hash: 170 logger.info('File:{build_file} was not found in base build'.format(build_file=key)) 171 elif base_build_hash[key] != val: 172 logger.info('Detected dEQP dependency file difference:{deps}. Base build hash:{base}, ' 173 'current build hash:{current}'.format(deps=key, base=base_build_hash[key], 174 current=val)) 175 print('Done!') 176 return False 177 178 179class AdbHelper(object): 180 """Helper class for running adb.""" 181 182 def __init__(self, device_serial=None): 183 """Initialize AdbHelper. 184 185 Args: 186 device_serial: A string of device serial number, optional. 187 """ 188 self._device_serial = device_serial 189 190 def _run_adb_command(self, *args): 191 """Run adb command.""" 192 adb_cmd = ['adb'] 193 if self._device_serial: 194 adb_cmd.extend(['-s', self._device_serial]) 195 adb_cmd.extend(args) 196 adb_cmd = ' '.join(adb_cmd) 197 completed = subprocess.run(adb_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True) 198 if completed.returncode != 0: 199 raise AdbError('adb command: {cmd} failed with error: {error}' 200 .format(cmd=adb_cmd, error=completed.stderr)) 201 202 return completed.stdout 203 204 def push_file(self, source_file, destination_file): 205 """Push a file from device to host. 206 207 Args: 208 source_file: A string representing file to push. 209 destination_file: A string representing target on device to push to. 210 """ 211 return self._run_adb_command('push', source_file, destination_file) 212 213 def pull_file(self, source_file, destination_file): 214 """Pull a file to device. 215 216 Args: 217 source_file: A string representing file on device to pull. 218 destination_file: A string representing target on host to pull to. 219 """ 220 return self._run_adb_command('pull', source_file, destination_file) 221 222 def run_shell_command(self, command): 223 """Run a adb shell command. 224 225 Args: 226 command: A string of command to run, executed through 'adb shell' 227 """ 228 return self._run_adb_command('shell', command) 229 230class DeqpDependencyCollector(object): 231 """Collect dEQP dependency from device under test.""" 232 233 def __init__(self, work_dir, test_dir, adb): 234 """Init DeqpDependencyCollector. 235 236 Args: 237 work_dir: path of directory for saving script result and logs. 238 test_dir: path of directory for incremental dEQP test file. 239 adb: an instance of AdbHelper. 240 """ 241 self._work_dir = work_dir 242 self._test_dir = test_dir 243 self._adb = adb 244 # dEQP dependency with pattern below are not an actual file: 245 # files has prefix /data/ are not system files, e.g. intermediate files. 246 # [vdso] is virtual dynamic shared object. 247 # /dmabuf is a temporary file. 248 self._exclude_deqp_pattern = re.compile('(^/data/|^\[vdso\]|^/dmabuf)') 249 250 def check_test_log(self, test_file, log_file): 251 """Check test's log to see if all tests are executed. 252 253 Args: 254 test_file: Name of test .txt file. 255 log_content: Name of log file. 256 Returns: 257 True if all tests are executed, otherwise False. 258 """ 259 test_cnt = 0 260 with open(test_file, 'r') as f: 261 for _ in f: 262 test_cnt += 1 263 264 executed_test_cnt = 0 265 266 with open(log_file, 'r') as f: 267 for line in f: 268 # 'NotSupported' status means test is not supported in device. 269 # TODO(yichunli): Check with graphics team if failed test is allowed. 270 if ('StatusCode="Pass"' in line or 'StatusCode="NotSupported"' in line or 271 'StatusCode="Fail"' in line): 272 executed_test_cnt += 1 273 return executed_test_cnt == test_cnt 274 275 def update_dependency(self, deps, dump): 276 """Parse perf dump file and update dEQP dependency. 277 278 Below is an example of how dump file looks like: 279 630 record comm: type 3, misc 0, size 64 280 631 pid 23365, tid 23365, comm simpleperf 281 632 sample_id: pid 0, tid 0 282 633 sample_id: time 0 283 634 sample_id: id 23804 284 635 sample_id: cpu 0, res 0 285 ....... 286 684 record comm: type 3, misc 8192, size 64 287 685 pid 23365, tid 23365, comm deqp-binary64 288 686 sample_id: pid 23365, tid 23365 289 687 sample_id: time 595063921159958 290 688 sample_id: id 23808 291 689 sample_id: cpu 4, res 0 292 ....... 293 698 record mmap2: type 10, misc 8194, size 136 294 699 pid 23365, tid 23365, addr 0x58b817b000, len 0x3228000 295 700 pgoff 0x0, maj 253, min 9, ino 14709, ino_generation 2575019956 296 701 prot 1, flags 6146, filename /data/local/tmp/deqp-binary64 297 702 sample_id: pid 23365, tid 23365 298 703 sample_id: time 595063921188552 299 704 sample_id: id 23808 300 705 sample_id: cpu 4, res 0 301 302 Args: 303 deps: a set of string containing dEQP dependency. 304 dump: perf dump file's name. 305 """ 306 binary_executed = False 307 correct_mmap = False 308 with open(dump, 'r') as f: 309 for line in f: 310 # It means dEQP binary starts to be executed. 311 if re.search(' comm .*deqp-binary', line): 312 binary_executed = True 313 if not binary_executed: 314 continue 315 # We get a new perf event 316 if not line.startswith(' '): 317 # mmap with misc 1 is not for deqp binary. 318 correct_mmap = line.startswith('record mmap') and 'misc 1,' not in line 319 # Get file name in memory map. 320 if 'filename' in line and correct_mmap: 321 deps_file = line[line.find('filename') + 9:].strip() 322 if not re.search(self._exclude_deqp_pattern, deps_file): 323 deps.add(deps_file) 324 325 326 def get_test_binary_name(self, test_name): 327 """Get dEQP binary's name based on test name. 328 329 Args: 330 test_name: name of test. 331 Returns: 332 dEQP binary's name. 333 """ 334 if test_name.endswith('32'): 335 return 'deqp-binary' 336 elif test_name.endswith('64'): 337 return 'deqp-binary64' 338 else: 339 raise TestError('Fail to get dEQP binary due to unknonw test name: ' + test_name) 340 341 def get_test_log_name(self, test_name): 342 """Get test log's name based on test name. 343 344 Args: 345 test_name: name of test. 346 Returns: 347 test log's name when running dEQP test. 348 """ 349 return test_name + '.qpa' 350 351 def get_test_perf_name(self, test_name): 352 """Get perf file's name based on test name. 353 354 Args: 355 test_name: name of test. 356 Returns: 357 perf file's name. 358 """ 359 return test_name + '.data' 360 361 def get_perf_dump_name(self, test_name): 362 """Get perf dump file's name based on test name. 363 364 Args: 365 test_name: name of test. 366 Returns: 367 perf dump file's name. 368 """ 369 return test_name + '-perf-dump.txt' 370 371 def get_test_list_name(self, test_name): 372 """Get test list file's name based on test name. 373 374 test list file is used to run dEQP test. 375 376 Args: 377 test_name: name of test. 378 Returns: 379 test list file's name. 380 """ 381 if test_name.startswith('vk'): 382 return 'vk-master-subset.txt' 383 elif test_name.startswith('gles3'): 384 return 'gles3-master-subset.txt' 385 else: 386 raise TestError('Fail to get test list due to unknown test name: ' + test_name) 387 388 def get_deqp_dependency(self): 389 """Get dEQP dependency. 390 391 Returns: 392 A set of dEQP dependency. 393 """ 394 device_deqp_dir = '/data/local/tmp' 395 device_deqp_out_dir = '/data/local/tmp/out' 396 test_list = ['vk-32', 'vk-64', 'gles3-32', 'gles3-64'] 397 398 # Clean up the device. 399 self._adb.run_shell_command('rm -rRf ' + device_deqp_dir + '/*') 400 self._adb.run_shell_command('mkdir -p ' + device_deqp_out_dir) 401 402 # Copy test resources to device. 403 logger.info(self._adb.push_file(self._test_dir + '/*', device_deqp_dir)) 404 405 # Run the dEQP binary with simpleperf 406 print('Running a subset of dEQP tests as binary on the device...') 407 deqp_deps = set() 408 for test in test_list: 409 test_file = os.path.join(device_deqp_dir, self.get_test_list_name(test)) 410 log_file = os.path.join(device_deqp_out_dir, self.get_test_log_name(test)) 411 perf_file = os.path.join(device_deqp_out_dir, self.get_test_perf_name(test)) 412 deqp_binary = os.path.join(device_deqp_dir, self.get_test_binary_name(test)) 413 simpleperf_command = ('"cd {device_deqp_dir} && simpleperf record -o {perf_file} {binary} ' 414 '--deqp-caselist-file={test_list} --deqp-log-images=disable ' 415 '--deqp-log-shader-sources=disable --deqp-log-filename={log_file} ' 416 '--deqp-surface-type=fbo --deqp-surface-width=2048 ' 417 '--deqp-surface-height=2048"') 418 self._adb.run_shell_command( 419 simpleperf_command.format(device_deqp_dir=device_deqp_dir, binary=deqp_binary, 420 perf_file=perf_file, test_list=test_file, log_file=log_file)) 421 422 # Check test log. 423 host_log_file = os.path.join(self._work_dir, self.get_test_log_name(test)) 424 self._adb.pull_file(log_file, host_log_file ) 425 if not self.check_test_log(os.path.join(self._test_dir, self.get_test_list_name(test)), 426 host_log_file): 427 error_msg = ('Fail to run incremental dEQP because of crashed test. Check test' 428 'log {} for more detail.').format(host_log_file) 429 logger.error(error_msg) 430 raise TestError(error_msg) 431 print('Tests are all passed!') 432 433 # Parse perf dump result to get dependency. 434 print('Analyzing dEQP dependency...') 435 for test in test_list: 436 perf_file = os.path.join(device_deqp_out_dir, self.get_test_perf_name(test)) 437 dump_file = os.path.join(self._work_dir, self.get_perf_dump_name(test)) 438 self._adb.run_shell_command('simpleperf dump {perf_file} > {dump_file}' 439 .format(perf_file=perf_file, dump_file=dump_file)) 440 self.update_dependency(deqp_deps, dump_file) 441 print('Done!') 442 return deqp_deps 443 444def _is_deqp_dependency(dependency_name): 445 """Check if dependency is related to dEQP.""" 446 # dEQP dependency with pattern below will not be used to compare build: 447 # files has /apex/ prefix are not related to dEQP. 448 return not re.search(re.compile('^/apex/'), dependency_name) 449 450def _get_parser(): 451 parser = argparse.ArgumentParser(description='Run incremental dEQP on devices.') 452 parser.add_argument('-s', '--serial', help='Optional. Use device with given serial.') 453 parser.add_argument('-t', '--test', help=('Optional. Directory of incremental deqp test file. ' 454 'This directory should have test resources and dEQP ' 455 'binaries.')) 456 parser.add_argument('-b', '--base_build', help=('Target file of base build that has passed dEQP ' 457 'test, e.g. flame-target_files-6935423.zip.')) 458 parser.add_argument('-c', '--current_build', 459 help=('Optional. When empty, the script will read files in the build from ' 460 'the device via adb. When set, the script will read build files from ' 461 'the file provided by this argument. And this file should be the ' 462 'current build that is flashed to device, such as a target file ' 463 'like flame-target_files-6935424.zip. This argument can be used when ' 464 'some dependencies files are not accessible via adb.')) 465 parser.add_argument('--generate_deps_only', action='store_true', 466 help=('Run test and generate dEQP dependency list only ' 467 'without comparing build.')) 468 parser.add_argument('--ats_mode', action='store_true', 469 help=('Run incremental dEQP with Android Test Station.')) 470 parser.add_argument('--custom_handler', action='store_true', 471 help='Use custome build file handler') 472 return parser 473 474def _create_logger(log_file_name): 475 """Create logger. 476 477 Args: 478 log_file_name: absolute path of the log file. 479 Returns: 480 a logging.Logger 481 """ 482 logging.basicConfig(filename=log_file_name) 483 logger = logging.getLogger() 484 logger.setLevel(level=logging.NOTSET) 485 return logger 486 487def _save_deqp_deps(deqp_deps, file_name): 488 """Save dEQP dependency to file. 489 490 Args: 491 deqp_deps: a set of dEQP dependency. 492 file_name: name of the file to save dEQP dependency. 493 Returns: 494 name of the file that saves dEQP dependency. 495 """ 496 with open(file_name, 'w') as f: 497 for dep in sorted(deqp_deps): 498 f.write(dep+'\n') 499 return file_name 500 501def _local_run(args, work_dir): 502 """Run incremental dEQP locally. 503 504 Args: 505 args: return of parser.parse_args(). 506 work_dir: path of directory for saving script result and logs. 507 """ 508 print('Logs and simpleperf results will be copied to: ' + work_dir) 509 if args.test: 510 test_dir = args.test 511 else: 512 test_dir = os.path.dirname(os.path.abspath(__file__)) 513 # Extra dEQP dependencies are the files can't be loaded to memory such as firmware. 514 extra_deqp_deps = set() 515 extra_deqp_deps_file = os.path.join(test_dir, 'extra_deqp_dependency.txt') 516 if not os.path.exists(extra_deqp_deps_file): 517 if not args.generate_deps_only: 518 raise TestResourceError('{test_resource} doesn\'t exist' 519 .format(test_resource=extra_deqp_deps_file)) 520 else: 521 with open(extra_deqp_deps_file, 'r') as f: 522 for line in f: 523 extra_deqp_deps.add(line.strip()) 524 525 if args.serial: 526 adb = AdbHelper(args.serial) 527 else: 528 adb = AdbHelper() 529 530 dependency_collector = DeqpDependencyCollector(work_dir, test_dir, adb) 531 deqp_deps = dependency_collector.get_deqp_dependency() 532 deqp_deps.update(extra_deqp_deps) 533 534 deqp_deps_file_name = _save_deqp_deps(deqp_deps, 535 os.path.join(work_dir, 'dEQP-dependency.txt')) 536 print('dEQP dependency list has been generated in: ' + deqp_deps_file_name) 537 538 if args.generate_deps_only: 539 return 540 541 # Compare the build difference with dEQP dependency 542 valid_deqp_deps = [dep for dep in deqp_deps if _is_deqp_dependency(dep)] 543 build_helper = BuildHelper(args.custom_handler) 544 if args.current_build: 545 skip_dEQP = build_helper.compare_base_build_with_current_build( 546 valid_deqp_deps, args.current_build, args.base_build) 547 else: 548 skip_dEQP = build_helper.compare_base_build_with_device_files( 549 valid_deqp_deps, adb, args.base_build) 550 if skip_dEQP: 551 print('Congratulations, current build could skip dEQP test.\n' 552 'If you run CTS through suite, you could pass filter like ' 553 '\'--exclude-filter CtsDeqpTestCases\'.') 554 else: 555 print('Sorry, current build can\'t skip dEQP test because dEQP dependency has been ' 556 'changed.\nPlease check logs for more details.') 557 558def _generate_cts_xml(out_dir, content): 559 """Generate cts configuration for Android Test Station. 560 561 Args: 562 out_dir: output directory for cts confiugration. 563 content: configuration content. 564 """ 565 with open(os.path.join(out_dir, 'incremental_deqp.xml'), 'w') as f: 566 f.write(content) 567 568 569def _ats_run(args, work_dir): 570 """Run incremental dEQP with Android Test Station. 571 572 Args: 573 args: return of parser.parse_args(). 574 work_dir: path of directory for saving script result and logs. 575 """ 576 # Extra dEQP dependencies are the files can't be loaded to memory such as firmware. 577 extra_deqp_deps = set() 578 with open(os.path.join(work_dir, 'extra_deqp_dependency.txt'), 'r') as f: 579 for line in f: 580 if line.strip(): 581 extra_deqp_deps.add(line.strip()) 582 583 android_serials = os.getenv('ANDROID_SERIALS') 584 if not android_serials: 585 raise AtsError('Fail to read environment variable ANDROID_SERIALS.') 586 first_device_serial = android_serials.split(',')[0] 587 adb = AdbHelper(first_device_serial) 588 589 dependency_collector = DeqpDependencyCollector(work_dir, 590 os.path.join(work_dir, 'test_resources'), adb) 591 deqp_deps = dependency_collector.get_deqp_dependency() 592 deqp_deps.update(extra_deqp_deps) 593 594 deqp_deps_file_name = _save_deqp_deps(deqp_deps, 595 os.path.join(work_dir, 'dEQP-dependency.txt')) 596 597 if args.generate_deps_only: 598 _generate_cts_xml(work_dir, DEFAULT_CTS_XML) 599 return 600 601 # Compare the build difference with dEQP dependency 602 # base build target file is from test resources. 603 base_build_target = os.path.join(work_dir, 'base_build_target_files') 604 build_helper = BuildHelper(base_build_target, deqp_deps) 605 skip_dEQP = build_helper.compare_build(adb) 606 if skip_dEQP: 607 _generate_cts_xml(work_dir, INCREMENTAL_DEQP_XML) 608 else: 609 _generate_cts_xml(work_dir, DEFAULT_CTS_XML) 610 611def main(): 612 parser = _get_parser() 613 args = parser.parse_args() 614 if not args.generate_deps_only and not args.base_build and not args.ats_mode: 615 parser.error('Base build argument: \'-b [file] or --base_build [file]\' ' 616 'is required to compare build.') 617 618 work_dir = '' 619 log_file_name = '' 620 if args.ats_mode: 621 work_dir = os.getenv('TF_WORK_DIR') 622 log_file_name = os.path.join('/data/tmp', 'incremental-deqp-log-'+str(uuid.uuid4())) 623 else: 624 work_dir = tempfile.mkdtemp(prefix='incremental-deqp-' 625 + time.strftime("%Y%m%d-%H%M%S")) 626 log_file_name = os.path.join(work_dir, 'incremental-deqp-log') 627 global logger 628 logger = _create_logger(log_file_name) 629 630 if args.ats_mode: 631 _ats_run(args, work_dir) 632 else: 633 _local_run(args, work_dir) 634 635if __name__ == '__main__': 636 main() 637 638