blob: 15ef6e106c8813c966a748e427e40d1b955262f8 [file] [edit]
# Copyright 2025 The Emscripten Authors. All rights reserved.
# Emscripten is available under two separate licenses, the MIT license and the
# University of Illinois/NCSA Open Source License. Both these licenses can be
# found in the LICENSE file.
import gzip
import json
import math
import os
import re
import common
from common import (
RunnerCore,
compiler_for,
create_file,
read_binary,
read_file,
test_file,
)
from decorators import parameterized, requires_pthreads
from tools import building, shared
def deminify_syms(names, minification_map):
deminify_map = {}
for line in read_file(minification_map).splitlines():
minified_name, name = line.split(':')
deminify_map[minified_name] = name
# Include both the original name and the deminified name so that
# changes to minification are also visible.
return [f'{name} ({deminify_map[name]})' for name in names]
class codesize(RunnerCore):
@parameterized({
'audio_worklet': ('audio_worklet', False, True),
'hello_world_wasm': ('hello_world', False, True),
'hello_world_wasm2js': ('hello_world', True, True),
'random_printf_wasm': ('random_printf', False),
'random_printf_wasm2js': ('random_printf', True),
'hello_webgl_wasm': ('hello_webgl', False),
'hello_webgl_wasm2js': ('hello_webgl', True),
'hello_webgl2_wasm_singlefile': ('hello_webgl2_wasm_singlefile', False),
'hello_webgl2_wasm': ('hello_webgl2', False),
'hello_webgl2_wasm2js': ('hello_webgl2', True),
'math': ('math', False),
'hello_wasm_worker': ('hello_wasm_worker', False, True),
'hello_embind_val': ('embind_val', False),
'hello_embind': ('embind_hello', False),
})
def test_minimal_runtime_code_size(self, test_name, wasm2js, compare_js_output=False):
smallest_code_size_args = ['-sMINIMAL_RUNTIME=2',
'-sENVIRONMENT=web',
'-sTEXTDECODER=2',
'-sDYNAMIC_EXECUTION=0',
'-sABORTING_MALLOC=0',
'-sALLOW_MEMORY_GROWTH=0',
'-sDECLARE_ASM_MODULE_EXPORTS',
'-sMALLOC=emmalloc',
'-sGL_EMULATE_GLES_VERSION_STRING_FORMAT=0',
'-sGL_EXTENSIONS_IN_PREFIXED_FORMAT=0',
'-sGL_SUPPORT_AUTOMATIC_ENABLE_EXTENSIONS=0',
'-sGL_SUPPORT_SIMPLE_ENABLE_EXTENSIONS=0',
'-sGL_TRACK_ERRORS=0',
'-sGL_POOL_TEMP_BUFFERS=0',
'-sGL_WORKAROUND_SAFARI_GETCONTEXT_BUG=0',
'-sGL_ENABLE_GET_PROC_ADDRESS=0',
'-sNO_FILESYSTEM',
'-sSTRICT',
'--output-eol', 'linux',
'-Oz',
'--closure=1',
'-DNDEBUG',
'-ffast-math']
math_sources = [test_file('codesize/math.c')]
hello_world_sources = [test_file('hello_world_small.c'),
'-sMALLOC=none']
random_printf_sources = [test_file('hello_random_printf.c'),
'-sMALLOC=none',
'-sSINGLE_FILE']
hello_webgl_sources = [test_file('minimal_webgl/main.c'),
test_file('minimal_webgl/webgl.c'),
'--js-library', test_file('minimal_webgl/library_js.js'),
'-lGL',
'-sMODULARIZE']
hello_webgl2_sources = hello_webgl_sources + ['-sMAX_WEBGL_VERSION=2']
hello_webgl2_wasm_singlefile_sources = hello_webgl2_sources + ['-sSINGLE_FILE']
hello_wasm_worker_sources = [test_file('wasm_worker/wasm_worker_code_size.c'), '-sWASM_WORKERS', '-sENVIRONMENT=web']
audio_worklet_sources = [test_file('webaudio/audioworklet.c'), '-sWASM_WORKERS', '-sAUDIO_WORKLET', '-sENVIRONMENT=web', '-sTEXTDECODER=1']
embind_hello_sources = [test_file('codesize/embind_hello_world.cpp'), '-lembind']
embind_val_sources = [test_file('codesize/embind_val_hello_world.cpp'), '-lembind']
sources = {
'hello_world': hello_world_sources,
'random_printf': random_printf_sources,
'hello_webgl': hello_webgl_sources,
'math': math_sources,
'hello_webgl2': hello_webgl2_sources,
'hello_webgl2_wasm_singlefile': hello_webgl2_wasm_singlefile_sources,
'hello_wasm_worker': hello_wasm_worker_sources,
'audio_worklet': audio_worklet_sources,
'embind_val': embind_val_sources,
'embind_hello': embind_hello_sources,
}[test_name]
outputs = ['a.html', 'a.js']
args = smallest_code_size_args[:]
if wasm2js:
args += ['-sWASM=0']
test_name += '_wasm2js'
else:
outputs += ['a.wasm']
test_name += '_wasm'
if '-sSINGLE_FILE' in sources:
outputs = ['a.html']
args = [compiler_for(sources[0]), '-o', 'a.html'] + args + sources
self.run_process(args)
# For certain tests, don't just check the output size but check
# the full JS output matches the expectations. That means that
# any change that touches those core lines of output will need
# to rebaseline this test. However:
# a) such changes deserve extra scrutiny
# b) such changes should be few and far between
# c) rebaselining is trivial (just run with --rebaseline)
# Note that we do not compare the full wasm output since that is
# even more fragile and can change with LLVM updates.
if compare_js_output:
expected_js = test_file('codesize', test_name + '.expected.js')
terser = shared.get_npm_cmd('terser')
# N.b. this requires node in PATH, it does not run against NODE from
# Emscripten config file. If you have this line fail, make sure 'node' is
# visible in PATH.
self.run_process(terser + ['-b', 'beautify=true', 'a.js', '-o', 'pretty.js'], env=shared.env_with_node_in_path())
self.assertFilesMatch(expected_js, 'pretty.js')
self.check_output_sizes(*outputs)
def check_output_sizes(self, *outputs: str, metadata=None, skip_gz=False):
test_name = self.id().split('.')[-1]
results_file = test_file('codesize', test_name + '.json')
expected_results: dict = {}
try:
expected_results = json.loads(read_file(results_file))
except Exception:
if not common.EMTEST_REBASELINE:
raise
obtained_results = {}
def update_and_print_diff(key, actual, expected):
obtained_results[key] = actual
diff = actual - expected
s = f'{key}: size={actual}, expected={expected}'
if diff:
s += f', delta={diff} ({diff * 100.0 / expected:+.2f}%)'
print(s)
return diff
total_output_size = 0
total_expected_size = 0
total_output_size_gz = 0
total_expected_size_gz = 0
for f in outputs:
contents = read_binary(f)
size = len(contents)
expected_size = expected_results.get(f, math.inf)
if update_and_print_diff(f, size, expected_size) and common.EMTEST_VERBOSE and not common.EMTEST_REBASELINE and f.endswith(('.js', '.html')):
print(f'Contents of {f}:')
print(contents.decode('utf-8', errors='replace'))
total_output_size += size
total_expected_size += expected_size
if not skip_gz:
f_gz = f + '.gz'
size_gz = len(gzip.compress(contents))
expected_size_gz = expected_results.get(f_gz, math.inf)
update_and_print_diff(f_gz, size_gz, expected_size_gz)
total_output_size_gz += size_gz
total_expected_size_gz += expected_size_gz
if len(outputs) > 1:
update_and_print_diff('total', total_output_size, total_expected_size)
if not skip_gz:
update_and_print_diff('total_gz', total_output_size_gz, total_expected_size_gz)
if metadata:
obtained_results.update(metadata)
obtained_results_json = json.dumps(obtained_results, indent=2)
expected_results_json = json.dumps(expected_results, indent=2)
if common.EMTEST_REBASELINE:
create_file(results_file, obtained_results_json + '\n', absolute=True)
else:
if total_output_size > total_expected_size:
print(f'Oops, overall generated code size regressed by {total_output_size - total_expected_size} bytes!')
print('If this is expected, rerun the test with --rebaseline to update the expected sizes')
if total_output_size < total_expected_size:
print(f'Hey amazing, overall generated code size was improved by {total_expected_size - total_output_size} bytes!')
print('If this is expected, rerun the test with --rebaseline to update the expected sizes')
self.assertTextDataIdentical(expected_results_json, obtained_results_json)
def test_unoptimized_code_size(self):
# We don't care too about unoptimized code size but we would like to keep it
# under control to a certain extent. This test allows us to track major
# changes to the size of the unoptimized and unminified code size.
# Run with `--rebase` when this test fails.
self.build('hello_world.c', cflags=['-O0', '--output-eol=linux'])
self.build('hello_world.c', cflags=['-O0', '--output-eol=linux', '-sASSERTIONS=0'], output_basename='no_asserts')
self.build('hello_world.c', cflags=['-O0', '--output-eol=linux', '-sSTRICT'], output_basename='strict')
self.check_output_sizes('hello_world.js', 'hello_world.wasm', 'no_asserts.js', 'no_asserts.wasm', 'strict.js', 'strict.wasm')
def run_codesize_test(self, filename, cflags, check_funcs=True, check_full_js=False, skip_gz=False):
# in -Os, -Oz, we remove imports wasm doesn't need
print('Running codesize test: %s:' % filename, cflags, check_funcs, check_full_js)
filename = test_file('codesize', filename)
expected_basename = test_file('codesize', self.id().split('.')[-1])
# Under WASM_ESM_INTEGRATION the wasm<->JS boundary uses native ES
# import/export syntax and the JS glue lives in a separate support module.
esm = any(a.startswith('-sWASM_ESM_INTEGRATION') for a in cflags)
outfile = 'a.out.mjs' if esm else 'a.out.js'
js_file = 'a.out.support.mjs' if esm else outfile
# Run once without closure and parse output to find the JS->wasm imports.
build_cmd = [compiler_for(filename), filename, '-o', outfile, '--output-eol=linux', '--emit-minification-map=minify.map'] + cflags + self.get_cflags()
self.run_process(build_cmd + ['-g2'])
# find the imports we send from JS
# TODO(sbc): Find a way to do that that doesn't depend on internal details of
# the generated code.
js = read_file(js_file)
if check_full_js:
# Ignore absolute filenames in the generated code (they are likely /tmp files)
js = re.sub(r'^// include: .*[/\\].*$', '// include: <FILENAME REPLACED>', js, flags=re.MULTILINE)
js = re.sub(r'^// end include: .*[/\\].*$', '// end include: <FILENAME REPLACED>', js, flags=re.MULTILINE)
self.assertFileContents(expected_basename + '.expected.js', js)
if esm:
# The functions provided to wasm are emitted as `export { js as wasm }`
# specifiers under the "Export JS functions to the wasm module" comment.
# An empty set emits no export statement at all.
marker = js.find('Export JS functions to the wasm module')
self.assertNotEqual(marker, -1)
start = js.find('export', marker)
reexport = js.find('Re-export imported wasm functions', marker)
sent = []
if start != -1 and (reexport == -1 or start < reexport):
end = js.find('}', start)
self.assertNotEqual(end, -1)
start = js.find('{', start)
for specifier in js[start + 1:end].split(','):
specifier = specifier.strip()
if specifier:
# keep the wasm-facing (minified) name after `as`
sent.append(specifier.rsplit(' as ', 1)[-1].strip())
else:
start = js.find('wasmImports = ')
self.assertNotEqual(start, -1)
end = js.find('}', start)
self.assertNotEqual(end, -1)
start = js.find('{', start)
self.assertNotEqual(start, -1)
relevant = js[start + 2:end - 1]
relevant = relevant.replace(' ', '').replace('"', '').replace("'", '')
relevant = relevant.replace('/**@export*/', '')
relevant = relevant.split(',')
sent = [x.split(':')[0].strip() for x in relevant]
sent = [x for x in sent if x]
# Deminify the sent list, if minification occurred
if os.path.exists('minify.map'):
sent = deminify_syms(sent, 'minify.map')
os.remove('minify.map')
sent.sort()
# closure is not yet compatible with the WASM_ESM_INTEGRATION module glue.
closure_args = [] if esm else ['--closure=1']
self.run_process(build_cmd + ['--profiling-funcs'] + closure_args)
outputs = [outfile]
if esm:
outputs.append(js_file)
info = {'sent': sent}
if '-sSINGLE_FILE' not in cflags:
# measure the wasm size without the name section
building.strip_sections('a.out.wasm', 'a.out.nodebug.wasm', ['name'])
outputs.append('a.out.nodebug.wasm')
imports, exports, funcs = self.parse_wasm('a.out.wasm')
# Deminify the imports/export lists, if minification occurred
if os.path.exists('minify.map'):
exports = deminify_syms(exports, 'minify.map')
# Under ESM integration the import module is the support module path
# (which itself contains dots), so take the field after the last dot.
imports = [i.rsplit('.', 1)[1] for i in imports]
imports = deminify_syms(imports, 'minify.map')
imports.sort()
info['imports'] = imports
exports.sort()
info['exports'] = exports
if check_funcs:
# filter out _NNN suffixed that can be the result of bitcode linking when
# internal symbol names collide.
def strip_numeric_suffixes(funcname):
parts = funcname.split('_')
while parts:
if parts[-1].isdigit():
parts.pop()
else:
break
return '_'.join(parts)
funcs.sort()
info['funcs'] = [strip_numeric_suffixes(f) for f in funcs]
self.check_output_sizes(*outputs, metadata=info, skip_gz=skip_gz)
@parameterized({
'O0': ([], True),
'O1': (['-O1'],),
'O2': (['-O2'],),
# in -O3, -Os and -Oz we metadce, and they shrink it down to the minimal output we want
'O3': (['-O3'],),
'Os': (['-Os'],),
'Oz': (['-Oz'],),
'Os_mr': (['-Os', '-sMINIMAL_RUNTIME'],),
# EVAL_CTORS also removes the __wasm_call_ctors function
'Oz-ctors': (['-Oz', '-sEVAL_CTORS'],),
'64': (['-Oz', '-m64'],),
# WasmFS should not be fully linked into a minimal program.
'wasmfs': (['-Oz', '-sWASMFS'],),
'esm': (['-Oz', '-sEXPORT_ES6'],),
# a program with no JS->wasm imports emits no `export {}` statement in the
# support module, exercising that metadce edge of the ES module boundary.
'esm_integration': (['-Oz', '-sWASM_ESM_INTEGRATION', '-Wno-experimental'],),
})
def test_codesize_minimal(self, args, check_full_js=False):
self.set_setting('STRICT')
self.cflags.append('--no-entry')
self.run_codesize_test('minimal.c', args, check_full_js=check_full_js)
@requires_pthreads
@parameterized({
'': ([],),
'memgrowth': (['-sALLOW_MEMORY_GROWTH'],),
})
def test_codesize_minimal_pthreads(self, args):
self.run_codesize_test('minimal_main.c', ['-Oz', '-pthread', '-sPROXY_TO_PTHREAD', '-sSTRICT'] + args)
@parameterized({
'noexcept': (['-O2'],),
# exceptions increases code size significantly
'except': (['-O2', '-fexceptions'],),
# exceptions does not pull in demangling by default, which increases code size
'mangle': (['-O2', '-fexceptions', '-sEXPORTED_FUNCTIONS=_main,_free,___cxa_demangle', '-Wno-deprecated'],),
# Wasm EH's code size increase is smaller than that of Emscripten EH
'except_wasm': (['-O2', '-fwasm-exceptions', '-sWASM_LEGACY_EXCEPTIONS=0'],),
'except_wasm_legacy': (['-O2', '-fwasm-exceptions', '-sWASM_LEGACY_EXCEPTIONS'],),
# eval_ctors 1 can partially optimize, but runs into getenv() for locale
# code. mode 2 ignores those and fully optimizes out the ctors
'ctors1': (['-O2', '-sEVAL_CTORS'],),
'ctors2': (['-O2', '-sEVAL_CTORS=2'],),
'wasmfs': (['-O2', '-sWASMFS'],),
'lto': (['-Oz', '-flto'],),
})
def test_codesize_cxx(self, args):
# do not check functions in this test as there are a lot of libc++ functions
# pulled in here, and small LLVM backend changes can affect their size and
# lead to different inlining decisions which add or remove a function
self.run_codesize_test('hello_libcxx.cpp', args, check_funcs=False)
@parameterized({
'O0': ([],),
'O1': (['-O1'],),
'O2': (['-O2'],),
'O3': (['-O3'],), # in -O3, -Os and -Oz we metadce
'Os': (['-Os'],),
'Oz': (['-Oz'],),
# finally, check what happens when we export nothing. wasm should be almost empty
'export_nothing': (['-Os', '-sEXPORTED_FUNCTIONS=[]'],),
# we don't metadce with linkable code! other modules may want stuff
# TODO(sbc): Investigate why the number of exports is order of magnitude
# larger for wasm backend.
# This test seems to produce different results under gzip on macOS and Windows machines
# so skip the gzip size reporting here.
'dylink_all': (['-O3', '-sMAIN_MODULE', '-sFULL_ES3'], {'skip_gz': True}),
'dylink': (['-O3', '-sMAIN_MODULE=2'],),
# WasmFS should not be fully linked into a hello world program.
'wasmfs': (['-O3', '-sWASMFS'],),
'single_file': (['-O3', '-sSINGLE_FILE'],),
# metadce must understand the native ES import/export module boundary and
# prune unused imports/exports from it. See #27217.
'esm_integration': (['-O3', '-sWASM_ESM_INTEGRATION', '-Wno-experimental'],),
})
def test_codesize_hello(self, args, kwargs={}): # noqa
self.run_codesize_test('hello_world.c', args, **kwargs)
@parameterized({
'O3': ('mem.c', ['-O3']),
# argc/argv support code etc. is in the wasm
'O3_standalone': ('mem.c', ['-O3', '-sSTANDALONE_WASM']),
# without argc/argv, no support code for them is emitted
'O3_standalone_narg': ('mem_no_argv.c', ['-O3', '-sSTANDALONE_WASM']),
# without main, no support code for argc/argv is emitted either
'O3_standalone_lib': ('mem_no_main.c', ['-O3', '-sSTANDALONE_WASM', '--no-entry']),
# Growth support code is in JS, no significant change in the wasm
'O3_grow': ('mem.c', ['-O3', '-sALLOW_MEMORY_GROWTH']),
# Growth support code is in the wasm
'O3_grow_standalone': ('mem.c', ['-O3', '-sALLOW_MEMORY_GROWTH', '-sSTANDALONE_WASM']),
# without argc/argv, no support code for them is emitted, even with lto
'O3_standalone_narg_flto':
('mem_no_argv.c', ['-O3', '-sSTANDALONE_WASM', '-flto']),
})
def test_codesize_mem(self, filename, args):
self.run_codesize_test(filename, args)
@parameterized({
'O3': (['-O3'],),
# argc/argv support code etc. is in the wasm
'O3_standalone': (['-O3', '-sSTANDALONE_WASM'],),
})
def test_codesize_libcxxabi_message(self, args):
self.run_codesize_test('libcxxabi_message.cpp', args)
@parameterized({
'js_fs': (['-O3', '-sNO_WASMFS'],),
'wasmfs': (['-O3', '-sWASMFS'],),
})
def test_codesize_files(self, args):
self.run_codesize_test('files.cpp', args)
def test_codesize_file_preload(self):
create_file('somefile.txt', 'hello')
self.run_codesize_test('hello_world.c', cflags=['-sSTRICT', '-O3', '--preload-file=somefile.txt'], check_full_js=True)
def test_small_js_flags(self):
self.run_codesize_test('hello_world.c', ['-O3', '-sINCOMING_MODULE_JS_API=[]', '-sENVIRONMENT=web', '--output-eol=linux'], check_full_js=True)
# This test verifies that gzipped binary-encoded a SINGLE_FILE build results in a smaller size
# than gzipped base64-encoded version.
def test_binary_encode_is_smaller_than_base64_encode(self):
self.emcc('hello_world.c', ['-O2', '-sSINGLE_FILE', '-sSINGLE_FILE_BINARY_ENCODE'])
size_binary_encode = len(gzip.compress(read_binary('a.out.js')))
self.emcc('hello_world.c', ['-O2', '-sSINGLE_FILE', '-sSINGLE_FILE_BINARY_ENCODE=0'])
size_base64 = len(gzip.compress(read_binary('a.out.js')))
print(f'Binary encoded file size: {size_binary_encode}, base64 encoded file size: {size_base64}')
self.assertLess(size_binary_encode, size_base64)