blob: 11efbc3094d08bc2019c1c88f7df9584b6eb9430 [file]
#! /usr/bin/env python3
#
# Copyright 2026 The ANGLE Project Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
#
'''
Script that detects interleaved attributes in existing traces, gathers
data, and, if requested, updates the client array attribute indices,
offsets and the helper API (UpdateClientArrayPointer()) to update the
data for those attributes prior to draw, allowing the retracing script
to preserve the interleaved aspect of the said attributes.
'''
import argparse
import glob
import json
import os
import pathlib
import shutil
import stat
import struct
import sys
import time
import zlib
from collections import OrderedDict
from pathlib import Path
SCRIPT_DIR = str(pathlib.Path(__file__).resolve().parent)
COMPRESSION_WBITS = 31
DEFAULT_MERGE_FOLDER_NAME = 'merged_traces'
DEFAULT_BLOCK_SIZE = 256 * 1024 * 1024
FILE_BLOCK_INFO_SIZE = 24 # Size of FileBlockInfo
TIMEOUT_FOR_ANALYSIS = 600 # In seconds
def _is_match_with_offset(bytearr1, bytearr2, offset):
"""Compares two byte arrays, with one of them truncated
from the beginning from a certain offset.
* If the offset is positive, Array 1 is truncated.
* If it is negative, Array 2 is truncated.
Then it returns whether the remaining data matches.
"""
comp_range_1 = bytearr1 if offset <= 0 else bytearr1[offset:]
comp_range_2 = bytearr2 if offset >= 0 else bytearr2[-offset:]
min_len = min(len(comp_range_1), len(comp_range_2))
for i in range(min_len):
if comp_range_1[i] != comp_range_2[i]:
return False
return True
def find_offset(bytearr1, bytearr2):
"""Determines whether two byte arrays have an offset to one another
in terms of bytes.
* If Array 2 is ahead of Array 1, the returned offset is positive.
* Example: #1 ZZABCDEF
#2 ABCDEFZZ
* If Array 2 is behind Array 1, the returned offset is negative.
* Example: #1 ABCDEFZZ
#2 ZZABCDEF
* If the two arrays do not match in any offset, None is returned.
"""
if _is_match_with_offset(bytearr1, bytearr2, 0):
return 0
# To prevent false positives (e.g., they intertwine only at the last element or two),
# the range will be limited to half the length of the arrays.
min_len = int(min(len(bytearr1) / 2, len(bytearr2) / 2))
for i in range(min_len):
if _is_match_with_offset(bytearr1, bytearr2, i):
return i
if _is_match_with_offset(bytearr1, bytearr2, -i):
return -i
return None
def decompress_trace_binary(trace_dir, is_verbose=False):
"""Decompress the trace binary file (*.angledata.gz).
* If there is a binary metadata entry in the JSON file in the trace folder,
it is used to determine the block offsets and sizes for the decompression
of each block.
* If there is no binary metadata entry, the binary is decompressed with the
values given below.
In the end, a single decompressed byte array is returned.
"""
# Default values if there is no trace binary metadata
index_offset = 0
block_count = 1
block_size = DEFAULT_BLOCK_SIZE
trace_name = os.path.basename(trace_dir)
compressed_filename = os.path.join(trace_dir, trace_name + '.angledata.gz')
json_filename = os.path.join(trace_dir, trace_name + '.json')
gzdata = bytearray()
should_extract_block_info = False
with open(json_filename, 'r') as fjson:
json_data = json.loads(fjson.read())
if 'BinaryMetadata' not in json_data:
if is_verbose:
print('Binary metadata does not exist; will load the entire binary file')
else:
if is_verbose:
print('Binary metadata found ({})'.format(', '.join([
'{}: {}'.format(name, metadata)
for name, metadata in json_data['BinaryMetadata'].items()
])))
index_offset = int(json_data['BinaryMetadata']['IndexOffset'])
block_count = int(json_data['BinaryMetadata']['BlockCount'])
block_size = int(json_data['BinaryMetadata']['BlockSize'])
should_extract_block_info = True
with open(compressed_filename, 'rb') as fcompressed:
if should_extract_block_info:
fcompressed.seek(index_offset)
block_info = fcompressed.read(block_count * FILE_BLOCK_INFO_SIZE)
for index in range(block_count):
file_offset, data_offset, data_size = struct.unpack(
'<QQQ',
block_info[index * FILE_BLOCK_INFO_SIZE:(index + 1) * FILE_BLOCK_INFO_SIZE])
fcompressed.seek(file_offset)
try:
decomp_block = zlib.decompress(fcompressed.read(), COMPRESSION_WBITS)
gzdata.extend(decomp_block[0:data_size])
if is_verbose:
print(
'Successfully decompressed block {}/{} (Size: {}, Total uncompressed size: {})'
.format(index + 1, block_count, len(decomp_block), len(gzdata)))
except Exception as e:
if is_verbose:
print('Decompression failed: {}'.format(e))
return bytearray()
else:
fcompressed.seek(0)
try:
decomp_block = zlib.decompress(fcompressed.read(), COMPRESSION_WBITS)
gzdata.extend(decomp_block)
if is_verbose:
print(
'Successfully decompressed data (Size: {}, Uncompressed size: {})'.format(
len(decomp_block), len(gzdata)))
block_size = len(gzdata)
except Exception as e:
if is_verbose:
print('Decompression failed: {}'.format(e))
return bytearray()
return gzdata
def extract_update_client_array_pointer_args(ucap_func_call_line):
"""It returns the args in the helper API UpdateClientArrayPointer(),
which is used to copy the data to the client attributes prior to a
draw call if needed.
UpdateClientArrayPointer() takes three args:
* Attribute index
* Start offset
* Size
"""
# 'UpdateClientArrayPointerWithOffset' is generated as an output of the interleaving analysis in this script,
# and is not expected in the input trace.
assert not 'UpdateClientArrayPointerWithOffset' in ucap_func_call_line, 'This type of trace cannot be analyzed.'
# The API, the parentheses and the semicolon are removed and split to get the arg strings.
func_args_list = ucap_func_call_line.strip()[len('UpdateClientArrayPointer') +
1:-2].split(', ')
assert len(func_args_list) == 3 # Attribute index, start offset, size
attrib_index = int(func_args_list[0])
start_offset_str = func_args_list[1]
size = int(func_args_list[2])
# The trace may be using either gBinaryData[] or GetBinaryData() with the start offset to load the trace data.
start_offset = None
if 'gBinaryData' in start_offset_str:
findstart = start_offset_str.find('gBinaryData') + len('gBinaryData')
assert start_offset_str[findstart] == '['
findend = start_offset_str[findstart:].find(']')
start_offset = int(start_offset_str[findstart + 1:findstart + findend])
if 'GetBinaryData' in start_offset_str:
findstart = start_offset_str.find('GetBinaryData') + len('GetBinaryData')
assert start_offset_str[findstart] == '('
findend = start_offset_str[findstart:].find(')')
start_offset = int(start_offset_str[findstart + 1:findstart + findend])
assert start_offset is not None
return attrib_index, start_offset, size
def find_merged_indices_and_offsets(gzdata, ucap_info):
"""Based on the extracted UpdateClientArrayPointer(), extract the corresponding data
from the trace binary data and compare them to determine whether they are interleaved.
Then return the merge indices and the offsets for each attribute index.
"""
merge_indices = dict()
merged_offsets = dict()
# Initialize all merge attributes as unmerged (same as attribute index, offset 0)
for attr_index, _ in ucap_info.items():
merge_indices[attr_index] = attr_index
merged_offsets[attr_index] = 0
for attr_index_1, attr_data_1 in ucap_info.items():
if merge_indices[attr_index_1] != attr_index_1:
# This means that this attribute is already found to be part of another merge group. So this can be skipped.
continue
for attr_index_2, attr_data_2 in ucap_info.items():
if attr_index_1 >= attr_index_2:
# To avoid duplicate comparisons or self-comparisons
continue
if merge_indices[attr_index_2] != attr_index_2:
# This means that this attribute is already found to be part of another merge group. So this can be skipped.
continue
# We should make a merge index list and an offset list, so we know what the range of the merged memory space would be.
start_offset_1, size_1 = attr_data_1['start_offset'], attr_data_1['size']
start_offset_2, size_2 = attr_data_2['start_offset'], attr_data_2['size']
# If either range size is 0, there is nothing to merge.
if size_1 == 0 or size_2 == 0:
continue
data_range_1 = gzdata[start_offset_1:start_offset_1 + size_1]
data_range_2 = gzdata[start_offset_2:start_offset_2 + size_2]
offset = find_offset(data_range_1, data_range_2)
if offset is not None:
merge_indices[attr_index_2] = merge_indices[attr_index_1]
merged_offsets[attr_index_2] = offset + merged_offsets[attr_index_1]
return merge_indices, merged_offsets
def collect_merged_attribs_per_merged_index(merge_indices):
"""Per merge index, it shows which attributes have been
merged into it.
"""
merged_lists = dict()
for attr_index, merged_index in merge_indices.items():
if merged_index not in merged_lists:
merged_lists[merged_index] = set()
merged_lists[merged_index].update({attr_index})
return merged_lists
def normalize_offsets_and_update_merged_indices_if_needed(merged_lists, merge_indices,
merged_offsets):
"""The offsets should be non-negative and at least one of them (the leader) should be 0 per merge group.
If that was not the case, we should switch the merge groups in a way that the merged index used
is the one with the 0 offset and the other indices merged into it have non-negative offsets.
* Example: Offsets [0, -6, 2] (attributes merged to index 0) -> [6, 0, 8] (merged to index 1)
"""
# The minimum offset is tracked per merge group.
merged_global_offsets = dict()
# In case the offsets were updated, the merged index should become the one with the offset 0
merged_index_to_switch = list()
for merged_index, merged_list in merged_lists.items():
# Find minimum offset per merge group, and level them all to 0 and above.
min_offset = min([merged_offsets[i] for i in merged_list])
merged_global_offsets[merged_index] = min_offset
if merged_global_offsets[merged_index] != 0:
new_merged_index = merged_index
for i in merged_list:
merged_offsets[i] -= merged_global_offsets[merged_index]
if merged_offsets[i] == 0 and new_merged_index == merged_index:
new_merged_index = i
if new_merged_index != merged_index:
merged_index_to_switch.append((merged_index, new_merged_index))
for merged_index, new_merged_index in merged_index_to_switch:
merged_lists[new_merged_index] = merged_lists[merged_index]
del merged_lists[merged_index]
for i in merged_lists[new_merged_index]:
merge_indices[i] = new_merged_index
def get_merged_attribute_data(gzdata, ucap_info):
"""Returns the following data:
* Merged index: Maps each client attribute to the index it is merged to
* Merged offset: Maps each client attribute to the offset it has in the merged attribute
* Merged lists: Maps a merged index to the list of client attributes that merged to it.
"""
merge_indices, merged_offsets = find_merged_indices_and_offsets(gzdata, ucap_info)
merged_lists = collect_merged_attribs_per_merged_index(merge_indices)
normalize_offsets_and_update_merged_indices_if_needed(merged_lists, merge_indices,
merged_offsets)
return merge_indices, merged_offsets, merged_lists
def get_client_array_index_and_offset(func_call_line):
"""Get the client array index (the index of gClientArrays[]) and the offset
in front of it (if any).
"""
findstart = func_call_line.find('gClientArrays') + len('gClientArrays')
assert func_call_line[findstart] == '['
findend = func_call_line[findstart:].find(']')
client_array_index = int(func_call_line[findstart + 1:findstart + findend])
# Look for offset (expected to be positive if any)
client_array_offset = 0
find_start_offset = findstart + findend + 1
offset_search_window = func_call_line[find_start_offset + 1:]
assert '-' not in offset_search_window
if '+' in offset_search_window:
find_start_offset = offset_search_window.find('+')
find_end_offset = offset_search_window.find(')')
client_array_offset = int(offset_search_window[find_start_offset + 1:find_start_offset +
find_end_offset].strip())
return client_array_index, client_array_offset
def apply_change_list_to_trace_files(trace_dir, change_list, is_verbose=False):
"""Apply the change list to the trace code. The list includes the new lines that
should be written to specific lines of each file based on the interleaving analysis.
"""
if len(change_list) == 0:
# There is no change to be applied.
return
trace_files = glob.glob(os.path.join(trace_dir, '*'), recursive=False)
for trace_file_num, trace_file in enumerate(trace_files):
if '.cpp' not in trace_file and '.h' not in trace_file:
continue
trace_file_basename = os.path.basename(trace_file)
if is_verbose:
progress_log = 'Updating file {} ({}/{})'.format(trace_file_basename,
trace_file_num + 1, len(trace_files))
# Do not print anything if the string is longer than the terminal column size to avoid spamming.
if len(progress_log) < shutil.get_terminal_size().columns:
print(progress_log.ljust(shutil.get_terminal_size().columns), end='\r')
with open(trace_file, 'r', encoding='utf-8', errors='ignore') as f:
filedata = f.readlines()
for change in change_list[trace_file]:
assert change['filename'] == trace_file
filedata[change['line_num']] = change['new_line']
with open(trace_file, 'w', encoding='utf-8', errors='ignore') as fnew:
fnew.writelines(filedata)
if is_verbose:
print('\nChanges applied to trace files')
def update_max_client_attrib_size_change(size_change, new_size):
"""This function receives a pending change regarding the maximum client
array size, which is used to initialize gClientArrays[]. It will then
update the size to reflect the new required size for the attributes.
"""
line = size_change['new_line']
assert 'InitializeReplay' in line
# After splitting by comma, element 0 would include the API and binaryDataFileName, which
# will stay the same. However, element 1 would be maxClientArraySize, which will be updated.
temp_args = line.split(', ')
temp_args[1] = str(new_size)
size_change['new_line'] = ', '.join(temp_args)
def analyze_trace_for_interleaved_attributes(trace_dir,
apply_changes=False,
is_verbose=False,
disable_timeout=False):
"""This function analyzes the code in order to determine if the current client attribute
data intersect and were meant to be interleaved.
* If apply_changes is disabled, the test will check the ranges from the compressed
data (from UpdateClientArrayPointer()) without changing the code.
* However, it would still return the changes that would have been made if it was
enabled. This allows the user to apply the changes later.
* If apply_changes is enabled, the test will also apply the changes in this function.
"""
# Output stats are initialized.
output_stats = dict()
output_stats['TotalGroupCount'] = 0
output_stats['FullyMergedGroupCount'] = 0
output_stats['PartiallyMergedGroupCount'] = 0
output_stats['UnmergedGroupCount'] = 0
output_stats['OneMemberGroupCount'] = 0
output_stats['TotalUCAPCalls'] = 0
output_stats['MaxClientArraySize'] = 0
output_stats['ExistingNonZeroOffsetForClientArray'] = 0
# List of changes to be applied when the processing ends
change_list = dict()
# Last client attribute pointer calls per attrib index
last_client_attr_calls = dict()
# Max client attrib size
max_client_attrib_size = 0
client_attrib_size_change = dict()
# UpdateClientArrayPointer() calls before a draw call are grouped and processed together.
ucap_group = list()
# Decompress trace compressed binary for the analysis
gzdata = decompress_trace_binary(trace_dir, is_verbose)
if len(gzdata) == 0:
if is_verbose:
print('No decompressed data found')
return output_stats, change_list
# It is important to sort the trace files to keep track of the last client attribute calls per index.
trace_files = sorted(glob.glob(os.path.join(trace_dir, '*'), recursive=False))
if is_verbose:
print('Interleaving analysis begins')
start_time = time.time()
for trace_file_num, trace_file in enumerate(trace_files):
if '.cpp' not in trace_file and '.h' not in trace_file:
continue
current_time = time.time()
if not disable_timeout:
current_time = time.time()
if current_time - start_time > TIMEOUT_FOR_ANALYSIS:
# In case of timeout, return without applying any change to the code.
if is_verbose:
print(
'\nTimeout occurred during interleaving analysis ({:.3f} seconds)'.format(
current_time - start_time))
return output_stats, dict()
with open(trace_file, 'r', encoding='utf-8', errors='ignore') as f:
filedata = f.readlines()
change_list[trace_file] = list()
trace_file_basename = os.path.basename(trace_file)
for line_num, line in enumerate(filedata):
if is_verbose:
progress_log = 'Processing file {} ({}/{}) | Line {}/{}'.format(
trace_file_basename, trace_file_num + 1, len(trace_files), line_num + 1,
len(filedata))
# Do not print anything if the string is longer than the terminal column size to avoid spamming.
if len(progress_log) < shutil.get_terminal_size().columns:
print(progress_log.ljust(shutil.get_terminal_size().columns), end='\r')
if 'UpdateClientArrayPointer' in line:
# 'UpdateClientArrayPointerWithOffset' is generated as an output of the interleaving analysis in this script,
# and is not expected in the input trace.
assert not 'UpdateClientArrayPointerWithOffset' in line, 'This type of trace cannot be analyzed.'
ucap_group.append((line_num, line))
output_stats['TotalUCAPCalls'] += 1
elif 'gClientArrays' in line:
# Expected in vertex attribute pointer calls or GLES1 equivalents
client_array_index, client_array_offset = get_client_array_index_and_offset(line)
last_client_attr_calls[client_array_index] = {
'file_num': trace_file_num,
'line_num': line_num,
'line': line,
'offset': client_array_offset
}
if client_array_offset != 0:
# In case of non-zero offset in front of gClientArrays[], it is likely that the trace has already
# been processed in terms of interleaved attributes.
output_stats['ExistingNonZeroOffsetForClientArray'] += 1
elif 'glDraw' in line:
if len(ucap_group) == 0:
# There is nothing to look at.
continue
# OrderedDict was used for this to keep the order in which the client array data is updated via UpdateClientArrayPointer().
ucap_info = OrderedDict()
for ucap_line_num, ucap in ucap_group:
# The API args are extracted, which will be used to compare the memory spaces to determine if the attributes are interleaved.
attr_index, start_offset, size = extract_update_client_array_pointer_args(ucap)
ucap_info[attr_index] = {
'start_offset': start_offset,
'size': size,
'line_num': ucap_line_num,
'line': ucap
}
# Based on the data from the compressed file and the UpdateClientArrayPointer() args, the merge indices and offsets are determined.
merge_indices, merged_offsets, merged_lists = get_merged_attribute_data(
gzdata, ucap_info)
# In the following loop, the line changes to the trace code are inserted into a list based on the file name.
for attr_index, attr_data in ucap_info.items():
# Change the line with UpdateClientArrayPointer(). It will be replaced with UpdateClientArrayPointerWithOffset(),
# which has an extra starting offset in the end as a fourth arg to store on the corresponding gClientArrays[].
new_line = attr_data['line'].replace(
'UpdateClientArrayPointer({}'.format(attr_index),
'UpdateClientArrayPointerWithOffset({}'.format(
merge_indices[attr_index])).replace(
');', ', {});'.format(merged_offsets[attr_index]))
change_list[trace_file].append({
'filename': trace_file,
'file_num': trace_file_num,
'line_num': attr_data['line_num'],
'new_line': new_line
})
# The maximum client attrib size is updated. It will be applied to the code in the end if needed.
max_client_attrib_size = max(max_client_attrib_size,
attr_data['size'] + merged_offsets[attr_index])
# Change the line about the corresponding client array call. Then remove it from the dict.
if attr_index in last_client_attr_calls:
new_line = last_client_attr_calls[attr_index]['line'].replace(
'gClientArrays[{}]'.format(attr_index), 'gClientArrays[{}]{}'.format(
merge_indices[attr_index],
' + {}'.format(merged_offsets[attr_index])
if merged_offsets[attr_index] != 0 else ''))
last_client_attr_trace_file = trace_files[
last_client_attr_calls[attr_index]['file_num']]
change_list[last_client_attr_trace_file].append({
'filename': last_client_attr_trace_file,
'file_num': last_client_attr_calls[attr_index]['file_num'],
'line_num': last_client_attr_calls[attr_index]['line_num'],
'new_line': new_line
})
del last_client_attr_calls[attr_index]
# Collect statistics regarding count, merging/not merging, cases with one attribute, etc.
output_stats['TotalGroupCount'] += 1
if len(merged_lists) == 1 and len(ucap_info) > 1:
output_stats['FullyMergedGroupCount'] += 1
if len(merged_lists) > 1 and len(merged_lists) < len(ucap_info) and len(
ucap_info) > 1:
output_stats['PartiallyMergedGroupCount'] += 1
if len(merged_lists) == 1 and len(ucap_info) == 1:
output_stats['OneMemberGroupCount'] += 1
if len(merged_lists) > 1 and len(merged_lists) == len(ucap_info):
output_stats['UnmergedGroupCount'] += 1
# End of attribute processing at the draw call. The processed group is cleared.
ucap_group = []
elif 'InitializeReplay' in line:
# This line is used to initialize the maximum client array size. The size will be updated after the analysis.
client_attrib_size_change = {
'filename': trace_file,
'file_num': trace_file_num,
'line_num': line_num,
'new_line': line
}
# Compressed data is no longer needed at this point.
del gzdata
# Finalize the change related to the max client attrib size.
update_max_client_attrib_size_change(client_attrib_size_change, max_client_attrib_size)
change_list[client_attrib_size_change['filename']].append(client_attrib_size_change)
output_stats['MaxClientArraySize'] = max_client_attrib_size
if is_verbose:
end_time = time.time()
print('\nInterleaving analysis complete ({:.3f} seconds)'.format(end_time - start_time))
# Apply the code changes here if requested.
if apply_changes:
apply_change_list_to_trace_files(trace_dir, change_list, is_verbose)
return output_stats, change_list
def get_trace_list_from_json(json_file_dir):
""""Expected JSON format is under 'traces' in which the trace names are
in the beginning (such as restricted_traces.json).
"""
with open(json_file_dir, 'r') as ftracejson:
trace_list = json.loads(ftracejson.read())
json_trace_names = [entry.split()[0] for entry in trace_list['traces']]
return json_trace_names
def main():
"""This script intends to take a list of trace names and analyze
how interleaved its client array data is (if any).
"""
parser = argparse.ArgumentParser()
parser.add_argument('--trace', help='Trace name to look into for attribute merge', default='')
parser.add_argument(
'--tracelist',
help='List of trace names (JSON file) to look into for attribute merge '
'(Expected format: Under `traces`; with each entry starting with the trace name)',
default='')
parser.add_argument(
'--apply-changes',
'-a',
help='Apply changes to the code to merge interleaved client attributes',
action='store_true')
parser.add_argument(
'--output-dir',
'-o',
help='Output directory for adding the updated traces (under `{}/`)'.format(
DEFAULT_MERGE_FOLDER_NAME),
default='')
parser.add_argument('--verbose', '-v', help='Indicates verbose output', action='store_true')
parser.add_argument(
'--disable-timeout', help='Disables the timeout for the analysis', action='store_true')
args = parser.parse_args()
if not args.trace and not args.tracelist:
print(
'ERROR: No trace given for processing. '
'Please use --trace for a single trace or --tracelist for a JSON file containing a list of traces.'
)
return
if args.trace and args.tracelist:
print('ERROR: Cannot use --trace and --tracelist together.')
return
if args.output_dir and not args.apply_changes:
print('ERROR: Output directory not used without applied changes')
return
should_apply_changes = args.apply_changes
output_dir = os.path.join(args.output_dir, DEFAULT_MERGE_FOLDER_NAME)
trace_name_list = list()
if args.trace:
trace_name_list.append(args.trace)
if args.tracelist:
trace_name_list.extend(get_trace_list_from_json(args.tracelist))
for trace_id, trace_name in enumerate(trace_name_list):
if args.verbose:
print('Processing trace `{}` ({}/{})'.format(trace_name, trace_id + 1,
len(trace_name_list)))
trace_dir = os.path.join(SCRIPT_DIR, trace_name)
if not os.path.exists(trace_dir):
if args.verbose:
print('Trace `{}` does not exist'.format(trace_name))
continue
# Copy the trace to the output directory only if the changes should be applied to the code
if should_apply_changes:
if not os.path.exists(output_dir):
os.makedirs(output_dir)
new_trace_dir = os.path.join(output_dir, trace_name)
if os.path.exists(new_trace_dir):
shutil.rmtree(new_trace_dir)
shutil.copytree(trace_dir, new_trace_dir)
trace_dir = new_trace_dir
for file in os.listdir(new_trace_dir):
os.chmod(os.path.join(new_trace_dir, file), stat.S_IREAD | stat.S_IWRITE)
# Analyze the code for interleaved client attributes and merge them if needed
output_stats, _ = analyze_trace_for_interleaved_attributes(trace_dir, should_apply_changes,
args.verbose,
args.disable_timeout)
if args.verbose:
print('Output stats for attribute memory comparison for trace `{}`: \n{}\n'.format(
trace_name, '\n'.join([
'* {}: {}'.format(stat_name, stat_value)
for stat_name, stat_value in output_stats.items()
])))
if __name__ == '__main__':
sys.exit(main())