| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436 |
- """
- Copyright (c) Contributors to the Open 3D Engine Project.
- For complete copyright and license terms please see the LICENSE at the root of this distribution.
- SPDX-License-Identifier: Apache-2.0 OR MIT
- """
- """
- This file generates automated asset load benchmark timings for different asset loading scenarios. It uses the launcher
- so that it can be run on any platform. The test works as follows:
- - Starts with a series of predefined "benchmarksettings" asset files that are used by the Asset Processor to generate
- "benchmark" files for loading
- - These get copied to the current project, where AssetProcessor will process them
- - After they're processed, the Launcher is launched and tries to load each set of benchmark assets N times in a row
- - Once that's done, the results are gathered and summarized into a CSV
- """
- import os
- import shutil
- import pytest
- import logging
- import re
- from statistics import mean, median
- from ctypes import *
- from ctypes.wintypes import *
- pytest.importorskip("ly_test_tools")
- import time as time
- import ly_test_tools.launchers.launcher_helper as launcher_helper
- import ly_remote_console.remote_console_commands as remote_console_commands
- from ly_remote_console.remote_console_commands import (
- send_command_and_expect_response as send_command_and_expect_response,
- )
- import ly_test_tools.environment.waiter as waiter
- from automatedtesting_shared.network_utils import check_for_listening_port
- from automatedtesting_shared.file_utils import delete_check
- remote_console_port = 4600
- remote_command_timeout = 20
- listener_timeout = 120
- logger = logging.getLogger(__name__)
- # Number of times to run each benchmark test.
- benchmark_trials = 5
- class Benchmark:
- """
- Data structure used to define the list of benchmark tests we want to run.
-
- :attr base_name: The base name of the .benchmarksettings test file. This is also used as
- a tag when logging.
- :attr num_copies: Number of copies to make of the .benchmarksettings test file; the
- generated .benchmark files will all get loaded separately in parallel.
- :attr load_times: The set of load times that have been captured.
- """
- def __init__(self, base_name, num_copies):
- self.base_name = base_name
- self.num_copies = num_copies
- self.load_times = []
- @pytest.mark.parametrize("project", ["AutomatedTesting"])
- @pytest.mark.parametrize("level", ["Simple"])
- class TestBenchmarkAssetLoads(object):
- @pytest.fixture
- def launcher_instance(self, request, workspace, level):
- self.launcher = launcher_helper.create_game_launcher(workspace)
- @pytest.fixture
- def remote_console_instance(self, request):
- console = remote_console_commands.RemoteConsole()
- def teardown():
- if console.connected:
- console.stop()
- request.addfinalizer(teardown)
- return console
- @pytest.fixture(autouse=True)
- def setup_teardown(self, request, workspace, project):
- def teardown():
- # Clean up in the following order:
- # 1) Kill the interactive AP
- # 2) Remove our source .benchmarksettings files that we put in the project
- # 3) Run APBatch to process the missing source files to clean up our generated cached files
- # By going in this order, we guarantee that we won't hit any file locks and we can
- # easily detect when AP is done cleaning everything up for us.
- workspace.asset_processor.stop()
- logger.debug('Cleaning up the benchmark asset artifacts.')
- if self.remove_benchmark_assets(self.get_temp_benchmark_asset_directory(workspace)):
- self.run_ap_batch(workspace.asset_processor)
- # On setup, make sure to clean up any benchmark assets that stuck around from a previous run.
- request.addfinalizer(teardown)
- logger.debug('Cleaning up any previously-existing benchmark asset artifacts.')
- if self.remove_benchmark_assets(self.get_temp_benchmark_asset_directory(workspace)):
- self.run_ap_batch(workspace.asset_processor)
- def run_ap_batch(self, asset_processor):
- """
- Run AssetProcessorBatch to process any changed assets.
-
- :param asset_processor: The asset processor object
- """
- process_assets_success = asset_processor.batch_process(timeout=600, fastscan=True)
- assert process_assets_success, 'Assets did not process correctly'
- def get_temp_benchmark_asset_directory(self, workspace):
- return os.path.join(workspace.paths.engine_root(), workspace.project, 'benchmark')
- def create_benchmark_assets(self, dest_directory, benchmarks):
- """
- Create a directory of benchmarksettings assets in the selected project.
- :param dest_direcotry: The path of the temporary directory to create the benchmark assets in
- :param benchmarks: The list of Benchmark objects to process and create
- """
- source_directory = os.path.join(os.path.dirname(__file__), 'assets')
- logger.debug(f'Creating directory: {dest_directory}')
- os.makedirs(dest_directory, exist_ok=True)
-
- # For each Benchmark object, either just copy the .benchmarksettings file as-is with the same name
- # if we only want one copy, or else append _<num> to the name for each copy if we want multiples.
- for benchmark in benchmarks:
- if benchmark.num_copies > 1:
- for asset_num in range (0, benchmark.num_copies):
- shutil.copy(os.path.join(source_directory, f'{benchmark.base_name}.benchmarksettings'),
- os.path.join(dest_directory, f'{benchmark.base_name}_{asset_num}.benchmarksettings'))
- else:
- shutil.copy(os.path.join(source_directory, f'{benchmark.base_name}.benchmarksettings'), dest_directory)
- def remove_benchmark_assets(self, dest_directory):
- """
- Remove the project/benchmark directory that we created.
- :param dest_directory: The path to the temp directory that we created the source benchmarks assets in
- :return True if assets were found and removed, False if they weren't
- """
- return delete_check(dest_directory)
-
- def set_cvar(self, remote_console_instance, cvar_name, cvar_value):
- """
- Helper function to set a CVar value and wait for confirmation.
- """
- send_command_and_expect_response(remote_console_instance, f"{cvar_name} {cvar_value}",
- f"{cvar_name} : {cvar_value}", timeout=remote_command_timeout)
- def run_benchmark(self, remote_console_instance, num_trials, benchmark):
- """
- Run a series of benchmark trials.
- :param remote_console_instance: The remote console instance
- :param num_trials: The number of times to run the same benchmark in a row
- :param benchmark: The Benchmark object describing the set of assets to try loading
- """
- # The root benchmark asset is the one with sub ID 1, so it has '_00000001' at the end of the base name.
- name_suffix='_00000001.benchmark'
-
- for trial in range (0, num_trials):
- # Set a unique log label to make it easier to parse for the results
- self.set_cvar(remote_console_instance, 'benchmarkLoadAssetLogLabel', f"{benchmark.base_name}_{trial}")
- # Clear the list of assets to load for the benchmark
- send_command_and_expect_response(remote_console_instance,
- f"benchmarkClearAssetList",
- f"({benchmark.base_name}_{trial}) - Benchmark asset list cleared.", timeout=remote_command_timeout)
- # Build up the load asset command.
- # Note that there is a limit to the number of parameters we can send in each console command, so we need to split it up
- # if we have too many assets.
- max_params_per_line = 50
- if benchmark.num_copies > 1:
- benchmark_command = f"benchmarkAddAssetsToList"
- for asset_num in range (0, benchmark.num_copies):
- benchmark_command += f" benchmark/{benchmark.base_name}_{asset_num}{name_suffix}"
- if (asset_num % max_params_per_line == (max_params_per_line - 1)) or (asset_num == benchmark.num_copies - 1):
- send_command_and_expect_response(remote_console_instance,
- benchmark_command,
- f"({benchmark.base_name}_{trial}) - All requested assets added.", timeout=remote_command_timeout)
- benchmark_command = f"benchmarkAddAssetsToList"
- else:
- send_command_and_expect_response(remote_console_instance,
- f"benchmarkAddAssetsToList benchmark/{benchmark.base_name}{name_suffix}",
- f"({benchmark.base_name}_{trial}) - All requested assets added.", timeout=remote_command_timeout)
- # Attempt to clear the Windows file cache between each run
- self.clear_windows_file_cache()
- # Send it and wait for the benchmark to complete
- send_command_and_expect_response(remote_console_instance,
- f"benchmarkLoadAssetList",
- f"({benchmark.base_name}_{trial}) - Benchmark run complete.", timeout=remote_command_timeout)
- def move_artifact(self, artifact_manager, artifact_name):
- """
- Move the artifact to our TestResults.
- """
- # Save the artifact to be examined later
- logger.debug('Preserving artifact: {}'.format(artifact_name))
- artifact_manager.save_artifact(artifact_name)
- os.remove(artifact_name)
- def extract_benchmark_results(self, launcher_log, extracted_log, benchmarks):
- """
- Extract the benchmark timing results, save them in a summary file, and put the timings into our benchmarks structure.
- :param launcher_log: the path to the game.log to parse
- :param extracted_log: the output path to save the summarized log
- :param benchmarks: the benchmarks structure to fill in with our timing results
- """
- logger.debug('Parsing benchmark results.')
- with open(extracted_log, 'w') as summary_log:
- with open(launcher_log) as log:
- for line in log:
- for benchmark in benchmarks:
- for trial in range (0, benchmark_trials):
- if f'({benchmark.base_name}_{trial}) - Results:' in line:
- # For each timing result we find, write it as-is into our summary log.
- summary_log.write(line)
- # Pull out the timing value and save it in the appropriate benchmark entry.
- match = re.search("Time=([\\d]+)", line)
- if match:
- benchmark.load_times.append(int(match.groups()[0]))
- def clear_windows_file_cache(self):
- """
- Clear the Windows file cache, using the undocumented NtSetSystemInformation call.
- For this to work, the process needs to be run with Administrator privileges.
- :return True if file cache cleared, False if not.
- """
- # Define the LUID Windows class
- class LUID(ctypes.Structure):
- _fields_ = [
- ('low_part', c_uint32),
- ('high_part', c_int32),
- ]
- # Define an abbreviated version of the TOKEN_PRIVILEGES Windows class.
- # The correct version would use an array of dynamic size for LUID_AND_ATTRIBUTES inside of itself,
- # but since we're only every using one entry, we can simplify the code and hard-code the definition at
- # a single entry.
- class TOKEN_PRIVILEGES(ctypes.Structure):
- _fields_ = [
- ('count', c_uint32),
- ('luid', LUID),
- ('attributes', c_uint32),
- ]
- # Force the construction to always fill in the array count with 1, since we've hard-coded a single
- # array definition into our structure definition.
- def __init__(self, luid, attributes):
- super(TOKEN_PRIVILEGES, self).__init__(1, luid, attributes)
- # Windows constants that we'll need
- TOKEN_QUERY = 0x0008
- TOKEN_ADJUST_PRIVILEGES = 0x0020
- SE_PRIVILEGE_ENABLED = 0x0002
- STATUS_PRIVILEGE_NOT_HELD = 0xc0000061
- ERROR_NOT_ALL_ASSIGNED = 1300
- SystemMemoryListInformation = 0x0050
- MemoryPurgeStandbyList = 4
- logger.info('Attempting to clear the Windows file cache')
- # Get the token for the currently-running process
- token_handle = HANDLE()
- result = windll.advapi32.OpenProcessToken(
- windll.kernel32.GetCurrentProcess(),
- TOKEN_QUERY | TOKEN_ADJUST_PRIVILEGES,
- byref(token_handle))
- if (result == 0):
- logger.error('Failed on the OpenProcessToken call')
- # Get the LUID for the privilege we need to escalate
- luid = LUID()
- if (result > 0):
- result = windll.advapi32.LookupPrivilegeValueW(None, 'SeProfileSingleProcessPrivilege', byref(luid))
- if (result == 0):
- logger.error('Failed on the LookupPrivilegeValueW call')
- # Adjust the privilege for the current process so that we have access to clear the Windows file cache.
- # This will only succeed if we're running with Administrator privileges.
- if (result > 0):
- tp = TOKEN_PRIVILEGES(luid, SE_PRIVILEGE_ENABLED)
- result = windll.advapi32.AdjustTokenPrivileges(token_handle, False, byref(tp), 0, None, None)
- if (result > 0):
- # The AdjustTokenPrivileges call itself succeeded, but we need to separately check to see if the privilege was escalated
- errCode = windll.kernel32.GetLastError()
- if (errCode == ERROR_NOT_ALL_ASSIGNED):
- # This is an acceptable error, but it means we won't be able to clear the file cache.
- # Don't fail the benchmark run due to it though.
- windll.kernel32.CloseHandle(token_handle)
- logger.info('Windows file cache will not be cleared, insufficient privileges.')
- logger.info('If you wish to clear the cache when running this benchmark, run with Administrator privileges.')
- return False
- elif (errCode > 0):
- # Flag that something unexpected happened.
- result = 0
- if (result == 0):
- logger.error('Failed on the AdjustTokenPrivileges call')
- # Privileges have been escalated, so clear the Windows file cache.
- if (result > 0):
- command = c_uint32(MemoryPurgeStandbyList)
- status = c_int32(windll.ntdll.NtSetSystemInformation(SystemMemoryListInformation, byref(command), sizeof(command) ))
- if (status.value == c_int32(STATUS_PRIVILEGE_NOT_HELD).value):
- # This is an acceptable error, but it means we won't be able to clear the file cache.
- # Don't fail the benchmark run due to it though.
- windll.kernel32.CloseHandle(token_handle)
- logger.info('Windows file cache will not be cleared, insufficient privileges.')
- logger.info('If you wish to clear the cache when running this benchmark, run with Administrator privileges.')
- return False
- elif (status.value < 0):
- # Flag that something unexpected happened.
- result = 0
- logger.error('Failed on the NtSetSystemInformation call')
- # Print the final results
- if (result > 0):
- logger.info('Successfully cleared the Windows file cache.')
- else:
- errCode = windll.kernel32.GetLastError()
- logger.error(f'Unexpected error while attempting to clear the Windows file cache: {errCode}')
- windll.kernel32.CloseHandle(token_handle)
- return (result > 0)
- def base_test_BenchmarkAssetLoads(self, workspace, launcher, level, remote_console_instance, use_dev_mode):
- benchmarks = [
- # The choice of 650 MB as the benchmark size is arbitrary. It was picked as large enough to get reasonably stable
- # and measureable results even when running on an SSD, and because the "balanced tree" test causes a total size
- # of (2^N + 1)*AssetSize. Working with that constraint, and a base asset size of 10 MB, the main contenders were
- # either 330 MB, 650 MB, or 1290 MB.
- # The first set of benchmarks measures the load time effects of different asset dependency hierarchies.
- # Load 650 MB from a single root 10MB asset where each asset has 2 dependent 10MB assets 6 levels deep
- Benchmark('10mb_2x6', 1),
- # Load 650 MB from a single root 10MB asset where each asset has 4 dependent 10MB assets 3 levels deep
- Benchmark('10mb_4x3', 1),
- # Load 650 MB from a single root 10MB asset that has 64 dependent 10MB assets
- Benchmark('10mb_64x1', 1),
- # Load 650 MB from a single root 10MB asset where each asset has 1 dependent 10MB asset 64 levels deep
- # (Currently removed because it crashes Open 3D Engine, re-enable once LY can handle it - SPEC-1314)
- #Benchmark('10mb_1x64', 1),
- # The second set of benchmarks measures the load time effects of different quantities of parallel asset loads.
- # Load a single 650 MB asset
- Benchmark('650mb', 1),
- # Load 2 x 325 MB assets in parallel
- Benchmark('325mb', 2),
- # Load 3 x 216 MB assets in parallel
- Benchmark('216mb', 3),
- # Load 4 x 162 MB assets in parallel
- Benchmark('162mb', 4),
- # Load 65 x 10 MB assets in parallel
- Benchmark('10mb', 65),
- # Load 650 x 1 MB assets in parallel
- Benchmark('1mb', 650),
- ]
- # Create test assets
- logger.debug('Generating new benchmark asset artifacts.')
- self.create_benchmark_assets(self.get_temp_benchmark_asset_directory(workspace), benchmarks)
- self.run_ap_batch(workspace.asset_processor)
- # Set the launcher arguments to choose either dev mode or optimized mode for the streamer.
- if (use_dev_mode):
- launcher.args = ['-NullRenderer', '-cl_streamerDevMode=true']
- else:
- launcher.args = ['-NullRenderer', '-cl_streamerDevMode=false']
- streamer_mode = 'devMode' if use_dev_mode else 'optMode'
- # Run the benchmarks in the launcher
- logger.debug(f'Running benchmark in launcher with args={launcher.args}')
- with launcher.start():
- waiter.wait_for(
- lambda: check_for_listening_port(remote_console_port),
- listener_timeout,
- exc=AssertionError("Port {} not listening.".format(remote_console_port)),
- )
- remote_console_instance.start(timeout=remote_command_timeout)
- # Set up the benchmarking parameters to use across all the tests
- self.set_cvar(remote_console_instance, "benchmarkLoadAssetTimeoutMs", "30000")
- self.set_cvar(remote_console_instance, "benchmarkLoadAssetPollMs", "20")
- self.set_cvar(remote_console_instance, "benchmarkLoadAssetDisplayProgress", "false")
- self.set_cvar(remote_console_instance, "cl_assetLoadWarningEnable", "false")
- # Run all our benchmarks
- for benchmark in benchmarks:
- self.run_benchmark(remote_console_instance, benchmark_trials, benchmark)
- # Give the launcher a chance to quit before forcibly stopping it.
- # This way our game.log will contain all the information we want instead of potentially truncating early.
- send_command_and_expect_response(remote_console_instance, f"quit", "Executing console command 'quit'",
- timeout=remote_command_timeout)
- launcher.stop()
- remote_console_instance.stop()
- # Extract all the benchmark timings out of the game.log, save it into a summary log file,
- # and put the timings into the benchmarks list so we can gather summarized metrics
- launcher_log = os.path.join(launcher.workspace.paths.project_log(), 'Game.log')
- summary_log = f'load_benchmark_{level}_{streamer_mode}.log'
- self.extract_benchmark_results(launcher_log, summary_log, benchmarks)
- self.move_artifact(workspace.artifact_manager, summary_log)
- # Generate a CSV of the min/max/avg results for each test.
- results_csv = f'load_benchmark_{level}_{streamer_mode}.csv'
- with open(results_csv, 'w') as csv_file:
- csv_file.write(f'"Test Name","Num Assets Loaded","First Time(ms)","Last Time(ms)","Max Time(ms)","Min Time (ms)",'+
- f'"Avg Time (ms)","Median Time (ms)"\n')
- for benchmark in benchmarks:
- csv_file.write(f'{benchmark.base_name},{benchmark.num_copies},{benchmark.load_times[0]},{benchmark.load_times[-1]},'+
- f'{max(benchmark.load_times)},{min(benchmark.load_times)},{mean(benchmark.load_times)},'+
- f'{median(benchmark.load_times)}\n')
- self.move_artifact(workspace.artifact_manager, results_csv)
- @pytest.mark.SUITE_benchmark
- def test_BenchmarkAssetLoads_optMode(self, workspace, launcher, level, remote_console_instance):
- self.base_test_BenchmarkAssetLoads(workspace, launcher, level, remote_console_instance, use_dev_mode = False)
- @pytest.mark.SUITE_benchmark
- def test_BenchmarkAssetLoads_devMode(self, workspace, launcher, level, remote_console_instance):
- self.base_test_BenchmarkAssetLoads(workspace, launcher, level, remote_console_instance, use_dev_mode = True)
|