xref: /netbsd-src/external/bsd/zstd/dist/tests/automated_benchmarking.py (revision 3117ece4fc4a4ca4489ba793710b60b0d26bab6c)
1*3117ece4Schristos# ################################################################
2*3117ece4Schristos# Copyright (c) Meta Platforms, Inc. and affiliates.
3*3117ece4Schristos# All rights reserved.
4*3117ece4Schristos#
5*3117ece4Schristos# This source code is licensed under both the BSD-style license (found in the
6*3117ece4Schristos# LICENSE file in the root directory of this source tree) and the GPLv2 (found
7*3117ece4Schristos# in the COPYING file in the root directory of this source tree).
8*3117ece4Schristos# You may select, at your option, one of the above-listed licenses.
9*3117ece4Schristos# ##########################################################################
10*3117ece4Schristos
11*3117ece4Schristosimport argparse
12*3117ece4Schristosimport glob
13*3117ece4Schristosimport json
14*3117ece4Schristosimport os
15*3117ece4Schristosimport time
16*3117ece4Schristosimport pickle as pk
17*3117ece4Schristosimport subprocess
18*3117ece4Schristosimport urllib.request
19*3117ece4Schristos
20*3117ece4Schristos
21*3117ece4SchristosGITHUB_API_PR_URL = "https://api.github.com/repos/facebook/zstd/pulls?state=open"
22*3117ece4SchristosGITHUB_URL_TEMPLATE = "https://github.com/{}/zstd"
23*3117ece4SchristosRELEASE_BUILD = {"user": "facebook", "branch": "dev", "hash": None}
24*3117ece4Schristos
25*3117ece4Schristos# check to see if there are any new PRs every minute
26*3117ece4SchristosDEFAULT_MAX_API_CALL_FREQUENCY_SEC = 60
27*3117ece4SchristosPREVIOUS_PRS_FILENAME = "prev_prs.pk"
28*3117ece4Schristos
29*3117ece4Schristos# Not sure what the threshold for triggering alarms should be
30*3117ece4Schristos# 1% regression sounds like a little too sensitive but the desktop
31*3117ece4Schristos# that I'm running it on is pretty stable so I think this is fine
32*3117ece4SchristosCSPEED_REGRESSION_TOLERANCE = 0.01
33*3117ece4SchristosDSPEED_REGRESSION_TOLERANCE = 0.01
34*3117ece4Schristos
35*3117ece4Schristos
36*3117ece4Schristosdef get_new_open_pr_builds(prev_state=True):
37*3117ece4Schristos    prev_prs = None
38*3117ece4Schristos    if os.path.exists(PREVIOUS_PRS_FILENAME):
39*3117ece4Schristos        with open(PREVIOUS_PRS_FILENAME, "rb") as f:
40*3117ece4Schristos            prev_prs = pk.load(f)
41*3117ece4Schristos    data = json.loads(urllib.request.urlopen(GITHUB_API_PR_URL).read().decode("utf-8"))
42*3117ece4Schristos    prs = {
43*3117ece4Schristos        d["url"]: {
44*3117ece4Schristos            "user": d["user"]["login"],
45*3117ece4Schristos            "branch": d["head"]["ref"],
46*3117ece4Schristos            "hash": d["head"]["sha"].strip(),
47*3117ece4Schristos        }
48*3117ece4Schristos        for d in data
49*3117ece4Schristos    }
50*3117ece4Schristos    with open(PREVIOUS_PRS_FILENAME, "wb") as f:
51*3117ece4Schristos        pk.dump(prs, f)
52*3117ece4Schristos    if not prev_state or prev_prs == None:
53*3117ece4Schristos        return list(prs.values())
54*3117ece4Schristos    return [pr for url, pr in prs.items() if url not in prev_prs or prev_prs[url] != pr]
55*3117ece4Schristos
56*3117ece4Schristos
57*3117ece4Schristosdef get_latest_hashes():
58*3117ece4Schristos    tmp = subprocess.run(["git", "log", "-1"], stdout=subprocess.PIPE).stdout.decode(
59*3117ece4Schristos        "utf-8"
60*3117ece4Schristos    )
61*3117ece4Schristos    sha1 = tmp.split("\n")[0].split(" ")[1]
62*3117ece4Schristos    tmp = subprocess.run(
63*3117ece4Schristos        ["git", "show", "{}^1".format(sha1)], stdout=subprocess.PIPE
64*3117ece4Schristos    ).stdout.decode("utf-8")
65*3117ece4Schristos    sha2 = tmp.split("\n")[0].split(" ")[1]
66*3117ece4Schristos    tmp = subprocess.run(
67*3117ece4Schristos        ["git", "show", "{}^2".format(sha1)], stdout=subprocess.PIPE
68*3117ece4Schristos    ).stdout.decode("utf-8")
69*3117ece4Schristos    sha3 = "" if len(tmp) == 0 else tmp.split("\n")[0].split(" ")[1]
70*3117ece4Schristos    return [sha1.strip(), sha2.strip(), sha3.strip()]
71*3117ece4Schristos
72*3117ece4Schristos
73*3117ece4Schristosdef get_builds_for_latest_hash():
74*3117ece4Schristos    hashes = get_latest_hashes()
75*3117ece4Schristos    for b in get_new_open_pr_builds(False):
76*3117ece4Schristos        if b["hash"] in hashes:
77*3117ece4Schristos            return [b]
78*3117ece4Schristos    return []
79*3117ece4Schristos
80*3117ece4Schristos
81*3117ece4Schristosdef clone_and_build(build):
82*3117ece4Schristos    if build["user"] != None:
83*3117ece4Schristos        github_url = GITHUB_URL_TEMPLATE.format(build["user"])
84*3117ece4Schristos        os.system(
85*3117ece4Schristos            """
86*3117ece4Schristos            rm -rf zstd-{user}-{sha} &&
87*3117ece4Schristos            git clone {github_url} zstd-{user}-{sha} &&
88*3117ece4Schristos            cd zstd-{user}-{sha} &&
89*3117ece4Schristos            {checkout_command}
90*3117ece4Schristos            make -j &&
91*3117ece4Schristos            cd ../
92*3117ece4Schristos        """.format(
93*3117ece4Schristos                user=build["user"],
94*3117ece4Schristos                github_url=github_url,
95*3117ece4Schristos                sha=build["hash"],
96*3117ece4Schristos                checkout_command="git checkout {} &&".format(build["hash"])
97*3117ece4Schristos                if build["hash"] != None
98*3117ece4Schristos                else "",
99*3117ece4Schristos            )
100*3117ece4Schristos        )
101*3117ece4Schristos        return "zstd-{user}-{sha}/zstd".format(user=build["user"], sha=build["hash"])
102*3117ece4Schristos    else:
103*3117ece4Schristos        os.system("cd ../ && make -j && cd tests")
104*3117ece4Schristos        return "../zstd"
105*3117ece4Schristos
106*3117ece4Schristos
107*3117ece4Schristosdef parse_benchmark_output(output):
108*3117ece4Schristos    idx = [i for i, d in enumerate(output) if d == "MB/s"]
109*3117ece4Schristos    return [float(output[idx[0] - 1]), float(output[idx[1] - 1])]
110*3117ece4Schristos
111*3117ece4Schristos
112*3117ece4Schristosdef benchmark_single(executable, level, filename):
113*3117ece4Schristos    return parse_benchmark_output((
114*3117ece4Schristos        subprocess.run(
115*3117ece4Schristos            [executable, "-qb{}".format(level), filename], stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
116*3117ece4Schristos        )
117*3117ece4Schristos        .stdout.decode("utf-8")
118*3117ece4Schristos        .split(" ")
119*3117ece4Schristos    ))
120*3117ece4Schristos
121*3117ece4Schristos
122*3117ece4Schristosdef benchmark_n(executable, level, filename, n):
123*3117ece4Schristos    speeds_arr = [benchmark_single(executable, level, filename) for _ in range(n)]
124*3117ece4Schristos    cspeed, dspeed = max(b[0] for b in speeds_arr), max(b[1] for b in speeds_arr)
125*3117ece4Schristos    print(
126*3117ece4Schristos        "Bench (executable={} level={} filename={}, iterations={}):\n\t[cspeed: {} MB/s, dspeed: {} MB/s]".format(
127*3117ece4Schristos            os.path.basename(executable),
128*3117ece4Schristos            level,
129*3117ece4Schristos            os.path.basename(filename),
130*3117ece4Schristos            n,
131*3117ece4Schristos            cspeed,
132*3117ece4Schristos            dspeed,
133*3117ece4Schristos        )
134*3117ece4Schristos    )
135*3117ece4Schristos    return (cspeed, dspeed)
136*3117ece4Schristos
137*3117ece4Schristos
138*3117ece4Schristosdef benchmark(build, filenames, levels, iterations):
139*3117ece4Schristos    executable = clone_and_build(build)
140*3117ece4Schristos    return [
141*3117ece4Schristos        [benchmark_n(executable, l, f, iterations) for f in filenames] for l in levels
142*3117ece4Schristos    ]
143*3117ece4Schristos
144*3117ece4Schristos
145*3117ece4Schristosdef benchmark_dictionary_single(executable, filenames_directory, dictionary_filename, level, iterations):
146*3117ece4Schristos    cspeeds, dspeeds = [], []
147*3117ece4Schristos    for _ in range(iterations):
148*3117ece4Schristos        output = subprocess.run([executable, "-qb{}".format(level), "-D", dictionary_filename, "-r", filenames_directory], stdout=subprocess.PIPE).stdout.decode("utf-8").split(" ")
149*3117ece4Schristos        cspeed, dspeed = parse_benchmark_output(output)
150*3117ece4Schristos        cspeeds.append(cspeed)
151*3117ece4Schristos        dspeeds.append(dspeed)
152*3117ece4Schristos    max_cspeed, max_dspeed = max(cspeeds), max(dspeeds)
153*3117ece4Schristos    print(
154*3117ece4Schristos        "Bench (executable={} level={} filenames_directory={}, dictionary_filename={}, iterations={}):\n\t[cspeed: {} MB/s, dspeed: {} MB/s]".format(
155*3117ece4Schristos            os.path.basename(executable),
156*3117ece4Schristos            level,
157*3117ece4Schristos            os.path.basename(filenames_directory),
158*3117ece4Schristos            os.path.basename(dictionary_filename),
159*3117ece4Schristos            iterations,
160*3117ece4Schristos            max_cspeed,
161*3117ece4Schristos            max_dspeed,
162*3117ece4Schristos        )
163*3117ece4Schristos    )
164*3117ece4Schristos    return (max_cspeed, max_dspeed)
165*3117ece4Schristos
166*3117ece4Schristos
167*3117ece4Schristosdef benchmark_dictionary(build, filenames_directory, dictionary_filename, levels, iterations):
168*3117ece4Schristos    executable = clone_and_build(build)
169*3117ece4Schristos    return [benchmark_dictionary_single(executable, filenames_directory, dictionary_filename, l, iterations) for l in levels]
170*3117ece4Schristos
171*3117ece4Schristos
172*3117ece4Schristosdef parse_regressions_and_labels(old_cspeed, new_cspeed, old_dspeed, new_dspeed, baseline_build, test_build):
173*3117ece4Schristos    cspeed_reg = (old_cspeed - new_cspeed) / old_cspeed
174*3117ece4Schristos    dspeed_reg = (old_dspeed - new_dspeed) / old_dspeed
175*3117ece4Schristos    baseline_label = "{}:{} ({})".format(
176*3117ece4Schristos        baseline_build["user"], baseline_build["branch"], baseline_build["hash"]
177*3117ece4Schristos    )
178*3117ece4Schristos    test_label = "{}:{} ({})".format(
179*3117ece4Schristos        test_build["user"], test_build["branch"], test_build["hash"]
180*3117ece4Schristos    )
181*3117ece4Schristos    return cspeed_reg, dspeed_reg, baseline_label, test_label
182*3117ece4Schristos
183*3117ece4Schristos
184*3117ece4Schristosdef get_regressions(baseline_build, test_build, iterations, filenames, levels):
185*3117ece4Schristos    old = benchmark(baseline_build, filenames, levels, iterations)
186*3117ece4Schristos    new = benchmark(test_build, filenames, levels, iterations)
187*3117ece4Schristos    regressions = []
188*3117ece4Schristos    for j, level in enumerate(levels):
189*3117ece4Schristos        for k, filename in enumerate(filenames):
190*3117ece4Schristos            old_cspeed, old_dspeed = old[j][k]
191*3117ece4Schristos            new_cspeed, new_dspeed = new[j][k]
192*3117ece4Schristos            cspeed_reg, dspeed_reg, baseline_label, test_label = parse_regressions_and_labels(
193*3117ece4Schristos                old_cspeed, new_cspeed, old_dspeed, new_dspeed, baseline_build, test_build
194*3117ece4Schristos            )
195*3117ece4Schristos            if cspeed_reg > CSPEED_REGRESSION_TOLERANCE:
196*3117ece4Schristos                regressions.append(
197*3117ece4Schristos                    "[COMPRESSION REGRESSION] (level={} filename={})\n\t{} -> {}\n\t{} -> {} ({:0.2f}%)".format(
198*3117ece4Schristos                        level,
199*3117ece4Schristos                        filename,
200*3117ece4Schristos                        baseline_label,
201*3117ece4Schristos                        test_label,
202*3117ece4Schristos                        old_cspeed,
203*3117ece4Schristos                        new_cspeed,
204*3117ece4Schristos                        cspeed_reg * 100.0,
205*3117ece4Schristos                    )
206*3117ece4Schristos                )
207*3117ece4Schristos            if dspeed_reg > DSPEED_REGRESSION_TOLERANCE:
208*3117ece4Schristos                regressions.append(
209*3117ece4Schristos                    "[DECOMPRESSION REGRESSION] (level={} filename={})\n\t{} -> {}\n\t{} -> {} ({:0.2f}%)".format(
210*3117ece4Schristos                        level,
211*3117ece4Schristos                        filename,
212*3117ece4Schristos                        baseline_label,
213*3117ece4Schristos                        test_label,
214*3117ece4Schristos                        old_dspeed,
215*3117ece4Schristos                        new_dspeed,
216*3117ece4Schristos                        dspeed_reg * 100.0,
217*3117ece4Schristos                    )
218*3117ece4Schristos                )
219*3117ece4Schristos    return regressions
220*3117ece4Schristos
221*3117ece4Schristosdef get_regressions_dictionary(baseline_build, test_build, filenames_directory, dictionary_filename, levels, iterations):
222*3117ece4Schristos    old = benchmark_dictionary(baseline_build, filenames_directory, dictionary_filename, levels, iterations)
223*3117ece4Schristos    new = benchmark_dictionary(test_build, filenames_directory, dictionary_filename, levels, iterations)
224*3117ece4Schristos    regressions = []
225*3117ece4Schristos    for j, level in enumerate(levels):
226*3117ece4Schristos        old_cspeed, old_dspeed = old[j]
227*3117ece4Schristos        new_cspeed, new_dspeed = new[j]
228*3117ece4Schristos        cspeed_reg, dspeed_reg, baesline_label, test_label = parse_regressions_and_labels(
229*3117ece4Schristos            old_cspeed, new_cspeed, old_dspeed, new_dspeed, baseline_build, test_build
230*3117ece4Schristos        )
231*3117ece4Schristos        if cspeed_reg > CSPEED_REGRESSION_TOLERANCE:
232*3117ece4Schristos            regressions.append(
233*3117ece4Schristos                "[COMPRESSION REGRESSION] (level={} filenames_directory={} dictionary_filename={})\n\t{} -> {}\n\t{} -> {} ({:0.2f}%)".format(
234*3117ece4Schristos                    level,
235*3117ece4Schristos                    filenames_directory,
236*3117ece4Schristos                    dictionary_filename,
237*3117ece4Schristos                    baseline_label,
238*3117ece4Schristos                    test_label,
239*3117ece4Schristos                    old_cspeed,
240*3117ece4Schristos                    new_cspeed,
241*3117ece4Schristos                    cspeed_reg * 100.0,
242*3117ece4Schristos                )
243*3117ece4Schristos            )
244*3117ece4Schristos        if dspeed_reg > DSPEED_REGRESSION_TOLERANCE:
245*3117ece4Schristos            regressions.append(
246*3117ece4Schristos                "[DECOMPRESSION REGRESSION] (level={} filenames_directory={} dictionary_filename={})\n\t{} -> {}\n\t{} -> {} ({:0.2f}%)".format(
247*3117ece4Schristos                    level,
248*3117ece4Schristos                    filenames_directory,
249*3117ece4Schristos                    dictionary_filename,
250*3117ece4Schristos                    baseline_label,
251*3117ece4Schristos                    test_label,
252*3117ece4Schristos                    old_dspeed,
253*3117ece4Schristos                    new_dspeed,
254*3117ece4Schristos                    dspeed_reg * 100.0,
255*3117ece4Schristos                )
256*3117ece4Schristos            )
257*3117ece4Schristos        return regressions
258*3117ece4Schristos
259*3117ece4Schristos
260*3117ece4Schristosdef main(filenames, levels, iterations, builds=None, emails=None, continuous=False, frequency=DEFAULT_MAX_API_CALL_FREQUENCY_SEC, dictionary_filename=None):
261*3117ece4Schristos    if builds == None:
262*3117ece4Schristos        builds = get_new_open_pr_builds()
263*3117ece4Schristos    while True:
264*3117ece4Schristos        for test_build in builds:
265*3117ece4Schristos            if dictionary_filename == None:
266*3117ece4Schristos                regressions = get_regressions(
267*3117ece4Schristos                    RELEASE_BUILD, test_build, iterations, filenames, levels
268*3117ece4Schristos                )
269*3117ece4Schristos            else:
270*3117ece4Schristos                regressions = get_regressions_dictionary(
271*3117ece4Schristos                    RELEASE_BUILD, test_build, filenames, dictionary_filename, levels, iterations
272*3117ece4Schristos                )
273*3117ece4Schristos            body = "\n".join(regressions)
274*3117ece4Schristos            if len(regressions) > 0:
275*3117ece4Schristos                if emails != None:
276*3117ece4Schristos                    os.system(
277*3117ece4Schristos                        """
278*3117ece4Schristos                        echo "{}" | mutt -s "[zstd regression] caused by new pr" {}
279*3117ece4Schristos                    """.format(
280*3117ece4Schristos                            body, emails
281*3117ece4Schristos                        )
282*3117ece4Schristos                    )
283*3117ece4Schristos                    print("Emails sent to {}".format(emails))
284*3117ece4Schristos                print(body)
285*3117ece4Schristos        if not continuous:
286*3117ece4Schristos            break
287*3117ece4Schristos        time.sleep(frequency)
288*3117ece4Schristos
289*3117ece4Schristos
290*3117ece4Schristosif __name__ == "__main__":
291*3117ece4Schristos    parser = argparse.ArgumentParser()
292*3117ece4Schristos
293*3117ece4Schristos    parser.add_argument("--directory", help="directory with files to benchmark", default="golden-compression")
294*3117ece4Schristos    parser.add_argument("--levels", help="levels to test e.g. ('1,2,3')", default="1")
295*3117ece4Schristos    parser.add_argument("--iterations", help="number of benchmark iterations to run", default="1")
296*3117ece4Schristos    parser.add_argument("--emails", help="email addresses of people who will be alerted upon regression. Only for continuous mode", default=None)
297*3117ece4Schristos    parser.add_argument("--frequency", help="specifies the number of seconds to wait before each successive check for new PRs in continuous mode", default=DEFAULT_MAX_API_CALL_FREQUENCY_SEC)
298*3117ece4Schristos    parser.add_argument("--mode", help="'fastmode', 'onetime', 'current', or 'continuous' (see README.md for details)", default="current")
299*3117ece4Schristos    parser.add_argument("--dict", help="filename of dictionary to use (when set, this dictionary will be used to compress the files provided inside --directory)", default=None)
300*3117ece4Schristos
301*3117ece4Schristos    args = parser.parse_args()
302*3117ece4Schristos    filenames = args.directory
303*3117ece4Schristos    levels = [int(l) for l in args.levels.split(",")]
304*3117ece4Schristos    mode = args.mode
305*3117ece4Schristos    iterations = int(args.iterations)
306*3117ece4Schristos    emails = args.emails
307*3117ece4Schristos    frequency = int(args.frequency)
308*3117ece4Schristos    dictionary_filename = args.dict
309*3117ece4Schristos
310*3117ece4Schristos    if dictionary_filename == None:
311*3117ece4Schristos        filenames = glob.glob("{}/**".format(filenames))
312*3117ece4Schristos
313*3117ece4Schristos    if (len(filenames) == 0):
314*3117ece4Schristos        print("0 files found")
315*3117ece4Schristos        quit()
316*3117ece4Schristos
317*3117ece4Schristos    if mode == "onetime":
318*3117ece4Schristos        main(filenames, levels, iterations, frequency=frequenc, dictionary_filename=dictionary_filename)
319*3117ece4Schristos    elif mode == "current":
320*3117ece4Schristos        builds = [{"user": None, "branch": "None", "hash": None}]
321*3117ece4Schristos        main(filenames, levels, iterations, builds, frequency=frequency, dictionary_filename=dictionary_filename)
322*3117ece4Schristos    elif mode == "fastmode":
323*3117ece4Schristos        builds = [{"user": "facebook", "branch": "release", "hash": None}]
324*3117ece4Schristos        main(filenames, levels, iterations, builds, frequency=frequency, dictionary_filename=dictionary_filename)
325*3117ece4Schristos    else:
326*3117ece4Schristos        main(filenames, levels, iterations, None, emails, True, frequency=frequency, dictionary_filename=dictionary_filename)
327