Skip to content

Commit 6547fa5

Browse files
committed
Try to partition test to subprocesses better
1 parent 852ab6e commit 6547fa5

File tree

5 files changed

+768
-6
lines changed

5 files changed

+768
-6
lines changed

.gitignore

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ workingsets.xml
2121
GRAALPYTHON.dist
2222
GRAALPYTHON_UNIT_TESTS.dist
2323
mx.graalpython/eclipse-launches
24-
*.json
2524
!jbang-catalog.json
2625
!**/resources/*.json
2726
!**/META-INF/**/*.json

graalpython/com.oracle.graal.python.test/src/runner.py

Lines changed: 70 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,6 @@
4141
import enum
4242
import fnmatch
4343
import json
44-
import math
4544
import os
4645
import pickle
4746
import platform
@@ -60,6 +59,7 @@
6059
import typing
6160
import unittest
6261
import unittest.loader
62+
import urllib.request
6363
from abc import abstractmethod
6464
from collections import defaultdict
6565
from dataclasses import dataclass, field
@@ -550,10 +550,36 @@ def partition_tests_into_processes(self, suites: list['TestSuite']) -> list[list
550550
lambda suite: suite.test_file.config.new_worker_per_file,
551551
)
552552
partitions = [suite.collected_tests for suite in per_file_suites]
553-
per_partition = int(math.ceil(len(unpartitioned) / max(1, self.num_processes)))
554-
while unpartitioned:
555-
partitions.append([test for suite in unpartitioned[:per_partition] for test in suite.collected_tests])
556-
unpartitioned = unpartitioned[per_partition:]
553+
554+
# Use timings if available to partition unpartitioned optimally
555+
timings = {}
556+
if unpartitioned and self.num_processes:
557+
configdir = unpartitioned[0].test_file.config.configdir if unpartitioned else None
558+
if configdir:
559+
timing_path = configdir / f"timings-{sys.platform.lower()}.json"
560+
if timing_path.exists():
561+
with open(timing_path, "r", encoding="utf-8") as f:
562+
timings = json.load(f)
563+
564+
timed_files = []
565+
for suite in unpartitioned:
566+
file_path = str(suite.test_file.path).replace("\\", "/")
567+
total = timings.get(file_path, 20.0)
568+
timed_files.append((total, suite))
569+
570+
# Sort descending by expected time
571+
timed_files.sort(reverse=True, key=lambda x: x[0])
572+
573+
# Greedily assign to balance by timing sum
574+
process_loads = [[] for _ in range(self.num_processes)]
575+
process_times = [0.0] * self.num_processes
576+
for t, suite in timed_files:
577+
i = process_times.index(min(process_times))
578+
process_loads[i].append(suite)
579+
process_times[i] += t
580+
for group in process_loads:
581+
partitions.append([test for suite in group for test in suite.collected_tests])
582+
557583
return partitions
558584

559585
def run_tests(self, tests: list['TestSuite']):
@@ -1299,6 +1325,31 @@ def get_bool_env(name: str):
12991325
return os.environ.get(name, '').lower() in ('true', '1')
13001326

13011327

1328+
def main_extract_test_timings(args):
1329+
"""
1330+
Fetches a test log from the given URL, extracts per-file test timings, and writes the output as JSON.
1331+
"""
1332+
1333+
# Download the log file
1334+
with urllib.request.urlopen(args.url) as response:
1335+
log = response.read().decode("utf-8", errors="replace")
1336+
1337+
pattern = re.compile(
1338+
r"^(?P<path>[^\s:]+)::\S+ +\.\.\. (?:ok|FAIL|ERROR|SKIPPED|expected failure|unexpected success|\S+) \((?P<time>[\d.]+)s\)",
1339+
re.MULTILINE,
1340+
)
1341+
1342+
timings = {}
1343+
for match in pattern.finditer(log):
1344+
raw_path = match.group("path").replace("\\", "/")
1345+
t = float(match.group("time"))
1346+
timings.setdefault(raw_path, 0.0)
1347+
timings[raw_path] += t
1348+
1349+
with open(args.output, "w", encoding="utf-8") as f:
1350+
json.dump(timings, f, indent=2, sort_keys=True)
1351+
1352+
13021353
def main():
13031354
is_mx_graalpytest = get_bool_env('MX_GRAALPYTEST')
13041355
parent_parser = argparse.ArgumentParser(formatter_class=argparse.RawTextHelpFormatter)
@@ -1428,6 +1479,20 @@ def main():
14281479
merge_tags_parser.add_argument('report_path')
14291480

14301481
# run the appropriate command
1482+
1483+
# extract-test-timings command declaration
1484+
extract_parser = subparsers.add_parser(
1485+
"extract-test-timings",
1486+
help="Extract per-file test timings from a test log URL and write them as JSON"
1487+
)
1488+
extract_parser.add_argument(
1489+
"url", help="URL of the test log file"
1490+
)
1491+
extract_parser.add_argument(
1492+
"output", help="Output JSON file for per-file timings"
1493+
)
1494+
extract_parser.set_defaults(main=main_extract_test_timings)
1495+
14311496
args = parent_parser.parse_args()
14321497
args.main(args)
14331498

0 commit comments

Comments
 (0)