Skip to content

Commit 5ccc012

Browse files
authored
Meta-DCE for JS+WASM (#5919)
This starts to use binaryen's wasm-metadce tool in -O3, -Os and -Oz, which lets us remove unused code along the boundary of wasm and JS. That is, it can remove things which doing DCE separately on each can't, like cycles between the two worlds. For example, if wasm imported a JS function, then if the wasm optimizer manages to remove all uses of it, this lets us remove it not just from wasm but also from the JS side too. And the same works the other way as well. So far, in -Os this shrinks hello world's .js by 3% and .wasm by 18%. This is less effective on large programs, since the removable runtime overhead is smaller - e.g. when using C++ iostreams, it shrinks by 7% / 1%. But where this really shines is on small programs like the testcase in #5836, that just do a little pure computation and don't need a runtime at all: this shrinks that .js by 5% and the .wasm by 84% (mostly thanks to getting rid of malloc/free!). The absolute numbers are interesting for the wasm, it goes from 10,729 bytes to just 1,740, which is getting us most of the way to emitting a really minimal wasm for such inputs. This reason this doesn't get us all the way is because: * We need to represent JS's full graph of reachability for wasm-dce to be maximally effective. Currently this just represents imports and exports by parsing them directly. So interesting cycles on the JS side can't be collected yet. * We still export a lot of things by default, which prevents this dead code elimination from eliminating them. So as we improve that (#5836 etc.) this will get more powerful. On the other hand, this does increase compile times noticeably, mostly on tiny files: 47% for hello world and 23% for C++ iostreams. That's why I think this makes sense to do in -Os and -Oz, which care about size, and -O3 is a mode that isn't concerned with compile times.
1 parent 6c5ec98 commit 5ccc012

14 files changed

+471
-46
lines changed

emcc.py

+20-16
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,10 @@ def save_intermediate(name=None, suffix='js'):
120120
return
121121
shutil.copyfile(final, name)
122122
Intermediate.counter += 1
123-
123+
def save_intermediate_with_wasm(name, wasm_binary):
124+
save_intermediate(name) # save the js
125+
name = os.path.join(shared.get_emscripten_temp_dir(), 'emcc-%d-%s.wasm' % (Intermediate.counter - 1, name))
126+
shutil.copyfile(wasm_binary, name)
124127

125128
class TimeLogger(object):
126129
last = time.time()
@@ -907,6 +910,9 @@ def check(input_file):
907910
# used for warnings in emscripten.py
908911
shared.Settings.ORIGINAL_EXPORTED_FUNCTIONS = original_exported_response or shared.Settings.EXPORTED_FUNCTIONS[:]
909912

913+
# Note the exports the user requested
914+
shared.Building.user_requested_exports = shared.Settings.EXPORTED_FUNCTIONS[:]
915+
910916
# -s ASSERTIONS=1 implies the heaviest stack overflow check mode. Set the implication here explicitly to avoid having to
911917
# do preprocessor "#if defined(ASSERTIONS) || defined(STACK_OVERFLOW_CHECK)" in .js files, which is not supported.
912918
if shared.Settings.ASSERTIONS:
@@ -2258,6 +2264,7 @@ def do_binaryen(target, asm_target, options, memfile, wasm_binary_target,
22582264
# normally we emit binary, but for debug info, we might emit text first
22592265
wrote_wasm_text = False
22602266
debug_info = options.debug_level >= 2 or options.profiling_funcs
2267+
emit_symbol_map = options.emit_symbol_map or shared.Settings.CYBERDWARF
22612268
# finish compiling to WebAssembly, using asm2wasm, if we didn't already emit WebAssembly directly using the wasm backend.
22622269
if not shared.Settings.WASM_BACKEND:
22632270
if DEBUG:
@@ -2295,7 +2302,7 @@ def do_binaryen(target, asm_target, options, memfile, wasm_binary_target,
22952302
cmd += ['--enable-threads']
22962303
if debug_info:
22972304
cmd += ['-g']
2298-
if options.emit_symbol_map or shared.Settings.CYBERDWARF:
2305+
if emit_symbol_map:
22992306
cmd += ['--symbolmap=' + target + '.symbols']
23002307
# we prefer to emit a binary, as it is more efficient. however, when we
23012308
# want full debug info support (not just function names), then we must
@@ -2370,20 +2377,17 @@ def do_binaryen(target, asm_target, options, memfile, wasm_binary_target,
23702377
# minify the JS
23712378
optimizer.do_minify() # calculate how to minify
23722379
if optimizer.cleanup_shell or options.use_closure_compiler:
2373-
if DEBUG: save_intermediate('preclean', 'js')
2374-
# in -Os and -Oz, run AJSDCE (aggressive JS DCE, performs multiple iterations)
2375-
passes = ['noPrintMetadata', 'JSDCE' if options.shrink_level == 0 else 'AJSDCE', 'last']
2376-
if optimizer.minify_whitespace:
2377-
passes.append('minifyWhitespace')
2378-
misc_temp_files.note(final)
2379-
logging.debug('running cleanup on shell code: ' + ' '.join(passes))
2380-
final = shared.Building.js_optimizer_no_asmjs(final, passes)
2381-
if DEBUG: save_intermediate('postclean', 'js')
2382-
if options.use_closure_compiler:
2383-
logging.debug('running closure on shell code')
2384-
misc_temp_files.note(final)
2385-
final = shared.Building.closure_compiler(final, pretty=not optimizer.minify_whitespace)
2386-
if DEBUG: save_intermediate('postclosure', 'js')
2380+
if DEBUG:
2381+
save_intermediate_with_wasm('preclean', wasm_binary_target)
2382+
final = shared.Building.minify_wasm_js(js_file=final,
2383+
wasm_file=wasm_binary_target,
2384+
expensive_optimizations=options.opt_level >= 3 or options.shrink_level > 0,
2385+
minify_whitespace=optimizer.minify_whitespace,
2386+
use_closure_compiler=options.use_closure_compiler,
2387+
debug_info=debug_info,
2388+
emit_symbol_map=emit_symbol_map)
2389+
if DEBUG:
2390+
save_intermediate_with_wasm('postclean', wasm_binary_target)
23872391
# replace placeholder strings with correct subresource locations
23882392
if shared.Settings.SINGLE_FILE:
23892393
f = open(final, 'r')

emscripten.py

+17-8
Original file line numberDiff line numberDiff line change
@@ -150,9 +150,9 @@ def parse_backend_output(backend_output, DEBUG):
150150
logging.error('emscript: failure to parse metadata output from compiler backend. raw output is: \n' + metadata_raw)
151151
raise e
152152

153-
#if DEBUG: print >> sys.stderr, "FUNCS", funcs
154-
#if DEBUG: print >> sys.stderr, "META", metadata
155-
#if DEBUG: print >> sys.stderr, "meminit", mem_init
153+
# functions marked llvm.used in the code are exports requested by the user
154+
shared.Building.user_requested_exports += metadata['exports']
155+
156156
return funcs, metadata, mem_init
157157

158158

@@ -597,11 +597,18 @@ def get_all_implemented(forwarded_json, metadata):
597597
return metadata['implementedFunctions'] + list(forwarded_json['Functions']['implementedFunctions'].keys()) # XXX perf?
598598

599599

600+
# Return the list of original exports, for error reporting. It may
601+
# be a response file, in which case, load it
602+
def get_original_exported_functions(settings):
603+
ret = settings['ORIGINAL_EXPORTED_FUNCTIONS']
604+
if ret[0] == '@':
605+
ret = json.loads(open(ret[1:]).read())
606+
return ret
607+
608+
600609
def check_all_implemented(all_implemented, pre, settings):
601610
if settings['ASSERTIONS'] and settings.get('ORIGINAL_EXPORTED_FUNCTIONS'):
602-
original_exports = settings['ORIGINAL_EXPORTED_FUNCTIONS']
603-
if original_exports[0] == '@':
604-
original_exports = json.loads(open(original_exports[1:]).read())
611+
original_exports = get_original_exported_functions(settings)
605612
for requested in original_exports:
606613
if not is_already_implemented(requested, pre, all_implemented):
607614
# could be a js library func
@@ -1865,8 +1872,7 @@ def create_exported_implemented_functions_wasm(pre, forwarded_json, metadata, se
18651872
exported_implemented_functions.add(key)
18661873

18671874
if settings['ASSERTIONS'] and settings.get('ORIGINAL_EXPORTED_FUNCTIONS'):
1868-
original_exports = settings['ORIGINAL_EXPORTED_FUNCTIONS']
1869-
if original_exports[0] == '@': original_exports = json.loads(open(original_exports[1:]).read())
1875+
original_exports = get_original_exported_functions(settings)
18701876
for requested in original_exports:
18711877
# check if already implemented
18721878
# special-case malloc, EXPORTED by default for internal use, but we bake in a trivial allocator and warn at runtime if used in ASSERTIONS \
@@ -2069,6 +2075,9 @@ def load_metadata(metadata_raw):
20692075
# Initializers call the global var version of the export, so they get the mangled name.
20702076
metadata['initializers'] = list(map(asmjs_mangle, metadata['initializers']))
20712077

2078+
# functions marked llvm.used in the code are exports requested by the user
2079+
shared.Building.user_requested_exports += metadata['exports']
2080+
20722081
return metadata
20732082

20742083

src/postamble.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,7 @@ Module['callMain'] = function callMain(args) {
192192
argv = allocate(argv, 'i32', ALLOC_NORMAL);
193193

194194
#if EMTERPRETIFY_ASYNC
195-
var initialEmtStackTop = Module['asm'].emtStackSave();
195+
var initialEmtStackTop = Module['asm']['emtStackSave']();
196196
#endif
197197

198198
try {

tests/optimizer/JSDCE-uglifyjsNodeTypes-output.js

+4-4
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
var defun = (function() {
2-
});
2+
})();
33
var name = (function() {
4-
});
4+
})();
55
var object = (function() {
6-
});
6+
})();
77
var non_reserved = (function() {
8-
});
8+
})();
99
function func_1() {
1010
}
1111
function func_2() {

tests/optimizer/JSDCE-uglifyjsNodeTypes.js

+4-4
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
var defun = function () { var a = 1; };
2-
var name = function () { var a = 1; };
3-
var object = function () { var a = 1; };
4-
var non_reserved = function () { var a = 1; };
1+
var defun = (function () { var a = 1; })();
2+
var name = (function () { var a = 1; })();
3+
var object = (function () { var a = 1; })();
4+
var non_reserved = (function () { var a = 1; })();
55

66
function func_1() { var a = 1; }
77
function func_2() { var a = 1; }
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
var name;
2+
Module.asmLibraryArg = {
3+
"save1": 1,
4+
"save2": 2
5+
};
6+
var expD1 = Module["expD1"] = asm["expD1"];
7+
var expD2 = Module["expD2"] = asm["expD2"];
8+
var expD3 = Module["expD3"] = asm["expD3"];
9+
var expD4 = undefined;
10+
var expI1 = Module["expI1"] = (function() {
11+
return Module["asm"]["expI1"].apply(null, arguments);
12+
});
13+
var expI2 = Module["expI2"] = (function() {
14+
return Module["asm"]["expI2"].apply(null, arguments);
15+
});
16+
var expI3 = Module["expI3"] = (function() {
17+
return Module["asm"]["expI3"].apply(null, arguments);
18+
});
19+
var expI4 = undefined;
20+
expD1;
21+
Module["expD2"];
22+
asm["expD3"];
23+
expI1;
24+
Module["expI2"];
25+
asm["expI3"];
26+
+33
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
var name;
2+
Module.asmLibraryArg = { 'save1': 1, 'number': 33, 'name': name, 'func': function() {}, 'save2': 2 };
3+
4+
// exports gotten directly
5+
var expD1 = Module['expD1'] = asm['expD1'];
6+
var expD2 = Module['expD2'] = asm['expD2'];
7+
var expD3 = Module['expD3'] = asm['expD3'];
8+
var expD4 = Module['expD4'] = asm['expD4'];
9+
10+
// exports gotten indirectly (async compilation
11+
var expI1 = Module['expI1'] = (function() {
12+
return Module['asm']['expI1'].apply(null, arguments);
13+
});
14+
var expI2 = Module['expI2'] = (function() {
15+
return Module['asm']['expI2'].apply(null, arguments);
16+
});
17+
var expI3 = Module['expI3'] = (function() {
18+
return Module['asm']['expI3'].apply(null, arguments);
19+
});
20+
var expI4 = Module['expI4'] = (function() {
21+
return Module['asm']['expI4'].apply(null, arguments);
22+
});
23+
24+
// add uses for some of them, leave *4 as non-roots
25+
expD1;
26+
Module['expD2'];
27+
asm['expD3'];
28+
29+
expI1;
30+
Module['expI2'];
31+
asm['expI3'];
32+
33+
// EXTRA_INFO: { "unused": ["emcc$import$number", "emcc$import$name", "emcc$import$func", "emcc$export$expD4", "emcc$export$expI4"] }
+60
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
[
2+
{
3+
"name": "emcc$import$number",
4+
"import": [
5+
"env",
6+
"number"
7+
]
8+
},
9+
{
10+
"name": "emcc$import$temp",
11+
"import": [
12+
"env",
13+
"temp"
14+
]
15+
},
16+
{
17+
"name": "emcc$export$expD1",
18+
"export": "expD1",
19+
"root": true
20+
},
21+
{
22+
"name": "emcc$export$expD2",
23+
"export": "expD2",
24+
"root": true
25+
},
26+
{
27+
"name": "emcc$export$expD3",
28+
"export": "expD3",
29+
"root": true
30+
},
31+
{
32+
"name": "emcc$export$expD4",
33+
"export": "expD4"
34+
},
35+
{
36+
"name": "emcc$export$expI1",
37+
"export": "expI1",
38+
"root": true
39+
},
40+
{
41+
"name": "emcc$export$expI2",
42+
"export": "expI2",
43+
"root": true
44+
},
45+
{
46+
"name": "emcc$export$expI3",
47+
"export": "expI3",
48+
"root": true
49+
},
50+
{
51+
"name": "emcc$export$expI4",
52+
"export": "expI4"
53+
},
54+
{
55+
"name": "emcc$export$expD1NM",
56+
"export": "expD1NM",
57+
"root": true
58+
}
59+
]
60+

tests/optimizer/emitDCEGraph.js

+36
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
var temp;
2+
Module.asmLibraryArg = { 'number': 33, 'temp': temp };
3+
4+
// exports gotten directly
5+
var expD1 = Module['expD1'] = asm['expD1'];
6+
var expD2 = Module['expD2'] = asm['expD2'];
7+
var expD3 = Module['expD3'] = asm['expD3'];
8+
var expD4 = Module['expD4'] = asm['expD4'];
9+
10+
// exports gotten indirectly (async compilation
11+
var expI1 = Module['expI1'] = (function() {
12+
return Module['asm']['expI1'].apply(null, arguments);
13+
});
14+
var expI2 = Module['expI2'] = (function() {
15+
return Module['asm']['expI2'].apply(null, arguments);
16+
});
17+
var expI3 = Module['expI3'] = (function() {
18+
return Module['asm']['expI3'].apply(null, arguments);
19+
});
20+
var expI4 = Module['expI4'] = (function() {
21+
return Module['asm']['expI4'].apply(null, arguments);
22+
});
23+
24+
// add uses for some of them, leave *4 as non-roots
25+
expD1;
26+
Module['expD2'];
27+
asm['expD3'];
28+
29+
expI1;
30+
Module['expI2'];
31+
asm['expI3'];
32+
33+
// without a Module use, not ok to remove, as this looks weird
34+
// and we don't know what's going on
35+
var expD1NM = asm['expD1NM'];
36+

tests/test_other.py

+33
Original file line numberDiff line numberDiff line change
@@ -1976,6 +1976,10 @@ def test_js_optimizer(self):
19761976
['JSDCE']),
19771977
(path_from_root('tests', 'optimizer', 'AJSDCE.js'), open(path_from_root('tests', 'optimizer', 'AJSDCE-output.js')).read(),
19781978
['AJSDCE']),
1979+
(path_from_root('tests', 'optimizer', 'emitDCEGraph.js'), open(path_from_root('tests', 'optimizer', 'emitDCEGraph-output.js')).read(),
1980+
['emitDCEGraph', 'noEmitAst']),
1981+
(path_from_root('tests', 'optimizer', 'applyDCEGraphRemovals.js'), open(path_from_root('tests', 'optimizer', 'applyDCEGraphRemovals-output.js')).read(),
1982+
['applyDCEGraphRemovals']),
19791983
]:
19801984
print(input, passes)
19811985

@@ -7664,6 +7668,35 @@ def break_cashew():
76647668
assert proc.returncode != 0, err
76657669
assert 'hello, world!' not in out, out
76667670

7671+
def test_binaryen_metadce(self):
7672+
sizes = {}
7673+
# in -Os, -Oz, we remove imports wasm doesn't need
7674+
for args, expected_len, expected_exists, expected_not_exists in [
7675+
([], 24, ['abort', 'tempDoublePtr'], ['waka']),
7676+
(['-O1'], 21, ['abort', 'tempDoublePtr'], ['waka']),
7677+
(['-O2'], 21, ['abort', 'tempDoublePtr'], ['waka']),
7678+
(['-O3'], 16, ['abort'], ['tempDoublePtr', 'waka']), # in -O3, -Os and -Oz we metadce
7679+
(['-Os'], 16, ['abort'], ['tempDoublePtr', 'waka']),
7680+
(['-Oz'], 16, ['abort'], ['tempDoublePtr', 'waka']),
7681+
# finally, check what happens when we export pretty much nothing. wasm should be almost empty
7682+
(['-Os', '-s', 'EXPORTED_FUNCTIONS=[]', '-s', 'EXPORTED_RUNTIME_METHODS=[]'], 9, ['abort'], ['tempDoublePtr', 'waka']),
7683+
]:
7684+
print(args, expected_len, expected_exists, expected_not_exists)
7685+
subprocess.check_call([PYTHON, EMCC, path_from_root('tests', 'hello_world.cpp')] + args + ['-s', 'WASM=1', '-g2'])
7686+
# find the imports we send from JS
7687+
js = open('a.out.js').read()
7688+
start = js.find('Module.asmLibraryArg = ')
7689+
end = js.find('}', start) + 1
7690+
start = js.find('{', start)
7691+
relevant = js[start+2:end-2]
7692+
relevant = relevant.replace(' ', '').replace('"', '').replace("'", '').split(',')
7693+
sent = [x.split(':')[0].strip() for x in relevant]
7694+
assert len(sent) == expected_len, (len(sent), expected_len)
7695+
for exists in expected_exists:
7696+
assert exists in sent, [exists, sent]
7697+
for not_exists in expected_not_exists:
7698+
assert not_exists not in sent, [not_exists, sent]
7699+
76677700
# test disabling of JS FFI legalization
76687701
def test_legalize_js_ffi(self):
76697702
with clean_write_access_to_canonical_temp_dir():

tools/emterpretify.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -787,7 +787,7 @@ def process(code):
787787

788788
# process functions, generating bytecode
789789
with temp_files.get_file('.js') as temp:
790-
shared.Building.js_optimizer(infile, ['emterpretify'], extra_info={ 'emterpretedFuncs': list(emterpreted_funcs), 'externalEmterpretedFuncs': list(external_emterpreted_funcs), 'opcodes': OPCODES, 'ropcodes': ROPCODES, 'ASYNC': ASYNC, 'PROFILING': PROFILING, 'ASSERTIONS': ASSERTIONS }, output_filename=temp, just_concat=True)
790+
shared.Building.js_optimizer(infile, ['emterpretify', 'noEmitAst'], extra_info={ 'emterpretedFuncs': list(emterpreted_funcs), 'externalEmterpretedFuncs': list(external_emterpreted_funcs), 'opcodes': OPCODES, 'ropcodes': ROPCODES, 'ASYNC': ASYNC, 'PROFILING': PROFILING, 'ASSERTIONS': ASSERTIONS }, output_filename=temp, just_concat=True)
791791
# load the module and modify it
792792
asm = asm_module.AsmModule(temp)
793793

0 commit comments

Comments
 (0)