| # 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) |