Skip to content

Commit 28e849b

Browse files
Run spec tests in parallel to reduce the execution time (#8088)
Reduces runtime from ~15 minutes to 1.5 minutes on my machine * Run tests through a thread pool with `os.cpu_count()` threads * `os.cpu_count() * 4` shows no benefit. `os.cpu_count() // 2` also shows no regression in runtime, but might be worse for machines with less cores. There are currently 315 spec tests total for reference. * Prefixes round-trip file name tests with their test name to avoid clobbering the a.wasm / ab.wast files during tests * Add stdout and stderr params to functions that print so that lines can be captured by each thread and not interleaved * Note that we pass stdout as the stderr param in practice so that they are interleaved, otherwise all stdout lines and stderr lines will be outputted together in each test.
1 parent 3ff3762 commit 28e849b

File tree

3 files changed

+179
-85
lines changed

3 files changed

+179
-85
lines changed

check.py

Lines changed: 126 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,12 @@
2020
import sys
2121
import unittest
2222
from collections import OrderedDict
23+
from concurrent.futures import ThreadPoolExecutor
24+
from pathlib import Path
25+
import queue
26+
import io
27+
import threading
28+
from functools import partial
2329

2430
from scripts.test import binaryenjs
2531
from scripts.test import lld
@@ -175,73 +181,129 @@ def run_wasm_reduce_tests():
175181
assert after < 0.85 * before, [before, after]
176182

177183

178-
def run_spec_tests():
179-
print('\n[ checking wasm-shell spec testcases... ]\n')
184+
def run_spec_test(wast, stdout=None, stderr=None):
185+
cmd = shared.WASM_SHELL + [wast]
186+
output = support.run_command(cmd, stdout=stdout, stderr=subprocess.PIPE)
187+
# filter out binaryen interpreter logging that the spec suite
188+
# doesn't expect
189+
filtered = [line for line in output.splitlines() if not line.startswith('[trap')]
190+
return '\n'.join(filtered) + '\n'
180191

181-
for wast in shared.options.spec_tests:
182-
base = os.path.basename(wast)
183-
print('..', base)
184-
# windows has some failures that need to be investigated
185-
if base == 'names.wast' and shared.skip_if_on_windows('spec: ' + base):
186-
continue
187192

188-
def run_spec_test(wast):
189-
cmd = shared.WASM_SHELL + [wast]
190-
output = support.run_command(cmd, stderr=subprocess.PIPE)
191-
# filter out binaryen interpreter logging that the spec suite
192-
# doesn't expect
193-
filtered = [line for line in output.splitlines() if not line.startswith('[trap')]
194-
return '\n'.join(filtered) + '\n'
195-
196-
def run_opt_test(wast):
197-
# check optimization validation
198-
cmd = shared.WASM_OPT + [wast, '-O', '-all', '-q']
199-
support.run_command(cmd)
193+
def run_opt_test(wast, stdout=None, stderr=None):
194+
# check optimization validation
195+
cmd = shared.WASM_OPT + [wast, '-O', '-all', '-q']
196+
support.run_command(cmd, stdout=stdout)
197+
198+
199+
def check_expected(actual, expected, stdout=None):
200+
if expected and os.path.exists(expected):
201+
expected = open(expected).read()
202+
print(' (using expected output)', file=stdout)
203+
actual = actual.strip()
204+
expected = expected.strip()
205+
if actual != expected:
206+
shared.fail(actual, expected)
207+
208+
209+
def run_one_spec_test(wast: Path, stdout=None, stderr=None):
210+
test_name = wast.name
211+
212+
# /path/to/binaryen/test/spec/foo.wast -> test-spec-foo
213+
base_name = "-".join(wast.relative_to(Path(shared.options.binaryen_root)).with_suffix("").parts)
214+
215+
print('..', test_name, file=stdout)
216+
# windows has some failures that need to be investigated
217+
if test_name == 'names.wast' and shared.skip_if_on_windows('spec: ' + test_name):
218+
return
219+
220+
expected = os.path.join(shared.get_test_dir('spec'), 'expected-output', test_name + '.log')
221+
222+
# some spec tests should fail (actual process failure, not just assert_invalid)
223+
try:
224+
actual = run_spec_test(str(wast), stdout=stdout, stderr=stderr)
225+
except Exception as e:
226+
if ('wasm-validator error' in str(e) or 'error: ' in str(e)) and '.fail.' in test_name:
227+
print('<< test failed as expected >>', file=stdout)
228+
return # don't try all the binary format stuff TODO
229+
else:
230+
shared.fail_with_error(str(e))
231+
232+
check_expected(actual, expected, stdout=stdout)
233+
234+
# check binary format. here we can verify execution of the final
235+
# result, no need for an output verification
236+
actual = ''
237+
transformed_path = base_name + ".transformed"
238+
with open(transformed_path, 'w') as transformed_spec_file:
239+
for i, (module, asserts) in enumerate(support.split_wast(str(wast))):
240+
if not module:
241+
# Skip any initial assertions that don't have a module
242+
continue
243+
print(f' testing split module {i}', file=stdout)
244+
split_name = base_name + f'_split{i}.wast'
245+
support.write_wast(split_name, module)
246+
run_opt_test(split_name, stdout=stdout, stderr=stderr) # also that our optimizer doesn't break on it
247+
248+
result_wast_file = shared.binary_format_check(split_name, verify_final_result=False, base_name=base_name, stdout=stdout, stderr=stderr)
249+
with open(result_wast_file) as f:
250+
result_wast = f.read()
251+
# add the asserts, and verify that the test still passes
252+
transformed_spec_file.write(result_wast + '\n' + '\n'.join(asserts))
253+
254+
# compare all the outputs to the expected output
255+
actual = run_spec_test(transformed_path, stdout=stdout, stderr=stderr)
256+
check_expected(actual, os.path.join(shared.get_test_dir('spec'), 'expected-output', test_name + '.log'), stdout=stdout)
257+
258+
259+
def run_spec_test_with_wrapped_stdout(output_queue, wast: Path):
260+
out = io.StringIO()
261+
try:
262+
ret = run_one_spec_test(wast, stdout=out, stderr=out)
263+
except Exception as e:
264+
print(e, file=out)
265+
raise
266+
finally:
267+
# If a test fails, it's important to keep its output
268+
output_queue.put(out.getvalue())
269+
return ret
270+
271+
272+
def run_spec_tests():
273+
print('\n[ checking wasm-shell spec testcases... ]\n')
200274

201-
def check_expected(actual, expected):
202-
if expected and os.path.exists(expected):
203-
expected = open(expected).read()
204-
print(' (using expected output)')
205-
actual = actual.strip()
206-
expected = expected.strip()
207-
if actual != expected:
208-
shared.fail(actual, expected)
209-
210-
expected = os.path.join(shared.get_test_dir('spec'), 'expected-output', base + '.log')
211-
212-
# some spec tests should fail (actual process failure, not just assert_invalid)
213-
try:
214-
actual = run_spec_test(wast)
215-
except Exception as e:
216-
if ('wasm-validator error' in str(e) or 'error: ' in str(e)) and '.fail.' in base:
217-
print('<< test failed as expected >>')
218-
continue # don't try all the binary format stuff TODO
219-
else:
220-
shared.fail_with_error(str(e))
221-
222-
check_expected(actual, expected)
223-
224-
# check binary format. here we can verify execution of the final
225-
# result, no need for an output verification
226-
actual = ''
227-
with open(base, 'w') as transformed_spec_file:
228-
for i, (module, asserts) in enumerate(support.split_wast(wast)):
229-
if not module:
230-
# Skip any initial assertions that don't have a module
231-
continue
232-
print(f' testing split module {i}')
233-
split_name = os.path.splitext(base)[0] + f'_split{i}.wast'
234-
support.write_wast(split_name, module)
235-
run_opt_test(split_name) # also that our optimizer doesn't break on it
236-
result_wast_file = shared.binary_format_check(split_name, verify_final_result=False)
237-
with open(result_wast_file) as f:
238-
result_wast = f.read()
239-
# add the asserts, and verify that the test still passes
240-
transformed_spec_file.write(result_wast + '\n' + '\n'.join(asserts))
241-
242-
# compare all the outputs to the expected output
243-
actual = run_spec_test(base)
244-
check_expected(actual, os.path.join(shared.get_test_dir('spec'), 'expected-output', base + '.log'))
275+
output_queue = queue.Queue()
276+
277+
stop_printer = object()
278+
279+
def printer():
280+
while True:
281+
string = output_queue.get()
282+
if string is stop_printer:
283+
break
284+
285+
print(string, end="")
286+
287+
printing_thread = threading.Thread(target=printer)
288+
printing_thread.start()
289+
290+
worker_count = os.cpu_count()
291+
print("Running with", worker_count, "workers")
292+
executor = ThreadPoolExecutor(max_workers=worker_count)
293+
try:
294+
results = executor.map(partial(run_spec_test_with_wrapped_stdout, output_queue), map(Path, shared.options.spec_tests))
295+
for _ in results:
296+
# Iterating joins the threads. No return value here.
297+
pass
298+
except KeyboardInterrupt:
299+
# Hard exit to avoid threads continuing to run after Ctrl-C.
300+
# There's no concern of deadlocking during shutdown here.
301+
os._exit(1)
302+
finally:
303+
executor.shutdown(cancel_futures=True)
304+
305+
output_queue.put(stop_printer)
306+
printing_thread.join()
245307

246308

247309
def run_validator_tests():

scripts/test/shared.py

Lines changed: 19 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -519,34 +519,37 @@ def _can_run_spec_test(test):
519519

520520

521521
def binary_format_check(wast, verify_final_result=True, wasm_as_args=['-g'],
522-
binary_suffix='.fromBinary'):
522+
binary_suffix='.fromBinary', base_name=None, stdout=None, stderr=None):
523523
# checks we can convert the wast to binary and back
524524

525-
print(' (binary format check)')
526-
cmd = WASM_AS + [wast, '-o', 'a.wasm', '-all'] + wasm_as_args
527-
print(' ', ' '.join(cmd))
528-
if os.path.exists('a.wasm'):
529-
os.unlink('a.wasm')
525+
as_file = f"{base_name}-a.wasm" if base_name is not None else "a.wasm"
526+
disassembled_file = f"{base_name}-ab.wast" if base_name is not None else "ab.wast"
527+
528+
print(' (binary format check)', file=stdout)
529+
cmd = WASM_AS + [wast, '-o', as_file, '-all'] + wasm_as_args
530+
print(' ', ' '.join(cmd), file=stdout)
531+
if os.path.exists(as_file):
532+
os.unlink(as_file)
530533
subprocess.check_call(cmd, stdout=subprocess.PIPE)
531-
assert os.path.exists('a.wasm')
534+
assert os.path.exists(as_file)
532535

533-
cmd = WASM_DIS + ['a.wasm', '-o', 'ab.wast', '-all']
534-
print(' ', ' '.join(cmd))
535-
if os.path.exists('ab.wast'):
536-
os.unlink('ab.wast')
536+
cmd = WASM_DIS + [as_file, '-o', disassembled_file, '-all']
537+
print(' ', ' '.join(cmd), file=stdout)
538+
if os.path.exists(disassembled_file):
539+
os.unlink(disassembled_file)
537540
subprocess.check_call(cmd, stdout=subprocess.PIPE)
538-
assert os.path.exists('ab.wast')
541+
assert os.path.exists(disassembled_file)
539542

540543
# make sure it is a valid wast
541-
cmd = WASM_OPT + ['ab.wast', '-all', '-q']
542-
print(' ', ' '.join(cmd))
544+
cmd = WASM_OPT + [disassembled_file, '-all', '-q']
545+
print(' ', ' '.join(cmd), file=stdout)
543546
subprocess.check_call(cmd, stdout=subprocess.PIPE)
544547

545548
if verify_final_result:
546-
actual = open('ab.wast').read()
549+
actual = open(disassembled_file).read()
547550
fail_if_not_identical_to_file(actual, wast + binary_suffix)
548551

549-
return 'ab.wast'
552+
return disassembled_file
550553

551554

552555
def minify_check(wast, verify_final_result=True):

scripts/test/support.py

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15+
import io
1516
import filecmp
1617
import os
1718
import re
@@ -185,16 +186,44 @@ def write_wast(filename, wast, asserts=[]):
185186
o.write(wast + '\n'.join(asserts))
186187

187188

188-
def run_command(cmd, expected_status=0, stderr=None,
189+
# Hack to allow subprocess.Popen with stdout/stderr to StringIO, which doesn't have a fileno and doesn't work otherwise
190+
def _process_communicate(*args, **kwargs):
191+
overwrite_stderr = "stderr" in kwargs and isinstance(kwargs["stderr"], io.StringIO)
192+
overwrite_stdout = "stdout" in kwargs and isinstance(kwargs["stdout"], io.StringIO)
193+
194+
if overwrite_stdout:
195+
stdout_fd = kwargs["stdout"]
196+
kwargs["stdout"] = subprocess.PIPE
197+
if overwrite_stderr:
198+
stderr_fd = kwargs["stderr"]
199+
kwargs["stderr"] = subprocess.PIPE
200+
201+
proc = subprocess.Popen(*args, **kwargs)
202+
out, err = proc.communicate()
203+
204+
if overwrite_stdout:
205+
stdout_fd.write(out)
206+
if overwrite_stderr:
207+
stderr_fd.write(err)
208+
209+
return out, err, proc.returncode
210+
211+
212+
def run_command(cmd, expected_status=0, stdout=None, stderr=None,
189213
expected_err=None, err_contains=False, err_ignore=None):
214+
'''
215+
stderr - None, subprocess.PIPE, subprocess.STDOUT or a file handle / io.StringIO to write stdout to
216+
stdout - File handle to print debug messages to
217+
returns the process's stdout
218+
'''
190219
if expected_err is not None:
191220
assert stderr == subprocess.PIPE or stderr is None, \
192221
"Can't redirect stderr if using expected_err"
193222
stderr = subprocess.PIPE
194-
print('executing: ', ' '.join(cmd))
195-
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=stderr, universal_newlines=True, encoding='UTF-8')
196-
out, err = proc.communicate()
197-
code = proc.returncode
223+
print('executing: ', ' '.join(cmd), file=stdout)
224+
225+
out, err, code = _process_communicate(cmd, stdout=subprocess.PIPE, stderr=stderr, universal_newlines=True, encoding='UTF-8')
226+
198227
if expected_status is not None and code != expected_status:
199228
raise Exception(f"run_command `{' '.join(cmd)}` failed ({code}) {err or ''}")
200229
if expected_err is not None:

0 commit comments

Comments
 (0)