incomp-flame-post/code/tune_blocksize.py
ignis 78fbedc9a1
All checks were successful
CI Test Suite / run-tests (push) Successful in 1m14s
Deploy Documentation / build-and-deploy (push) Successful in 56s
feat: implement transpose block size auto-tuning and benchmark
- Added benchmark_transpose.f90 to measure blocked transpose performance.
- Added tune_blocksize.py to auto-detect grid sizes, benchmark valid block size candidates, and apply the optimal value.
- Updated makefile to support benchmark targets and default BLOCKSIZE=16 based on autotuning.
- Saved tuning results and system specifications to tuning_results.md.
2026-06-05 13:25:17 +00:00

216 lines
7.9 KiB
Python
Executable file

#!/usr/bin/env python3
import os
import sys
import argparse
import struct
import subprocess
import time
import re
def parse_args():
parser = argparse.ArgumentParser(description="Auto-tune transpose block size for m_calculate.f90")
parser.add_argument("--nxp", type=int, help="Override grid size in X direction")
parser.add_argument("--nyp", type=int, help="Override grid size in Y direction")
parser.add_argument("--nzp", type=int, help="Override grid size in Z direction")
parser.add_argument("--iters", type=int, default=5, help="Number of iterations for benchmark run")
parser.add_argument("--apply", action="store_true", help="Apply the optimal block size to the makefile")
return parser.parse_args()
def read_fortran_grid_sizes(filepath):
try:
with open(filepath, 'rb') as f:
# First 4 bytes is the record length
len_bytes = f.read(4)
if len(len_bytes) < 4:
return None
rec_len = struct.unpack('i', len_bytes)[0]
# Read the record data
rec_data = f.read(rec_len)
if len(rec_data) < rec_len:
return None
# Record structure: tnow (double), nx (int64), ny (int64), nz (int64)
# Since compiler has -fdefault-integer-8, these are 64-bit integers (8 bytes)
# Format: 'dqqq' -> double, int64, int64, int64
tnow, nx, ny, nz = struct.unpack('dqqq', rec_data[:32])
return nx, ny, nz
except Exception as e:
print(f"Error reading grid size from {filepath}: {e}")
return None
def detect_grid_sizes():
# Try current directory first, then parent directory
search_dirs = [".", "..", "../scratch/ic1_regression_test", "scratch/ic1_regression_test"]
intro_file = None
intro_dir = None
for d in search_dirs:
p = os.path.join(d, "post-edge-cold-bc-hybrid-intro")
if os.path.exists(p):
intro_file = p
intro_dir = d
break
if intro_file:
print(f"Found intro config file at {intro_file}")
startnum = None
try:
with open(intro_file, 'r') as f:
for line in f:
if 'startnum' in line:
# Extract the first integer in the line
match = re.search(r'\d+', line)
if match:
startnum = int(match.group())
break
except Exception as e:
print(f"Error reading startnum from {intro_file}: {e}")
if startnum is not None:
# Search for fort.<startnum> in search_dirs
for d in search_dirs:
fort_path = os.path.join(d, f"fort.{startnum}")
if os.path.exists(fort_path):
sizes = read_fortran_grid_sizes(fort_path)
if sizes:
print(f"Successfully auto-detected grid size from {fort_path}: NX={sizes[0]}, NY={sizes[1]}, NZ={sizes[2]}")
return sizes
# Fallback to default
print("Could not auto-detect grid sizes. Using defaults: NX=512, NY=256, NZ=256")
return 512, 256, 256
def find_divisors(n):
divisors = []
for i in range(1, int(n**0.5) + 1):
if n % i == 0:
divisors.append(i)
if i*i != n:
divisors.append(n // i)
return sorted(divisors)
def run_command(args, cwd=None):
res = subprocess.run(args, cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
return res.returncode == 0, res.stdout, res.stderr
def update_makefile(best_bs):
makefile_path = "makefile"
if not os.path.exists(makefile_path):
makefile_path = "code/makefile"
if not os.path.exists(makefile_path):
makefile_path = "../code/makefile"
if not os.path.exists(makefile_path):
print("Error: makefile not found. Cannot apply changes.")
return False
try:
with open(makefile_path, 'r') as f:
content = f.read()
new_content = re.sub(r'(BLOCKSIZE\s*\?=\s*)\d+', f'\\g<1>{best_bs}', content)
with open(makefile_path, 'w') as f:
f.write(new_content)
print(f"Successfully updated {makefile_path} with BLOCKSIZE={best_bs}")
return True
except Exception as e:
print(f"Error updating makefile: {e}")
return False
def main():
args = parse_args()
# Grid sizes detection
detected_nx, detected_ny, detected_nz = detect_grid_sizes()
nx = args.nxp if args.nxp else detected_nx
ny = args.nyp if args.nyp else detected_ny
nz = args.nzp if args.nzp else detected_nz
print(f"Target grid size for tuning: NX={nx}, NY={ny}, NZ={nz}")
# Calculate candidate block sizes (must be divisors of NY)
all_divisors = find_divisors(ny)
# Filter to block sizes >= 4 to avoid excessive overhead
candidates = [d for d in all_divisors if d >= 4]
if not candidates:
print(f"No suitable divisors found for NY={ny}. Testing all divisors: {all_divisors}")
candidates = all_divisors
print(f"Candidate block sizes to test: {candidates}")
code_dir = "."
if not os.path.exists("makefile") and os.path.exists("code/makefile"):
code_dir = "code"
print(f"Using build directory: {os.path.abspath(code_dir)}")
print(f"Running each benchmark with {args.iters} iterations...")
print("\n" + "="*80)
print(f"{'Block Size':^12} | {'Compile Time (s)':^18} | {'Avg Run Time (s)':^20} | {'Status':^12}")
print("-"*80)
results = []
for bs in candidates:
# 1. Compile
t_start = time.time()
ok_clean, _, _ = run_command(["make", "clean"], cwd=code_dir)
if not ok_clean:
print(f"{bs:^12} | {'-':^18} | {'-':^20} | {'CLEAN FAILED':^12}")
continue
compile_cmd = ["make", "benchmark_transpose", f"BLOCKSIZE={bs}"]
ok_compile, stdout_c, stderr_c = run_command(compile_cmd, cwd=code_dir)
t_compile = time.time() - t_start
if not ok_compile:
print(f"{bs:^12} | {t_compile:^18.2f} | {'-':^20} | {'COMP. FAILED':^12}")
# print(stderr_c)
continue
# 2. Run benchmark
# benchmark_transpose binary is in code_dir
binary_path = "./benchmark_transpose"
run_args = [binary_path, str(nx), str(ny), str(nz), str(args.iters)]
ok_run, stdout_r, stderr_r = run_command(run_args, cwd=code_dir)
if not ok_run:
print(f"{bs:^12} | {t_compile:^18.2f} | {'-':^20} | {'RUN FAILED':^12}")
# print(stderr_r)
continue
try:
# Parse the time output from the program
avg_time = float(stdout_r.strip())
print(f"{bs:^12d} | {t_compile:^18.2f} | {avg_time:^20.6f} | {'SUCCESS':^12}")
results.append((bs, avg_time))
except ValueError:
print(f"{bs:^12} | {t_compile:^18.2f} | {'-':^20} | {'PARSE ERROR':^12}")
# print(f"Output was: {stdout_r}")
print("="*80)
if not results:
print("Tuning failed: no successful candidate runs.")
sys.exit(1)
# Find the best block size
best_bs, best_time = min(results, key=lambda x: x[1])
print(f"\nOptimal BLOCKSIZE found: {best_bs} with average time of {best_time:.6f} seconds.")
# Recommendations
print("\nTested results:")
for bs, t in results:
rel = (t - best_time) / best_time * 100.0
marker = " (Best)" if bs == best_bs else f" (+{rel:.1f}%)"
print(f" Block Size {bs:3d}: {t:.6f} s{marker}")
if args.apply:
update_makefile(best_bs)
else:
print(f"\nTo apply this block size, run with --apply or manually update 'BLOCKSIZE?={best_bs}' in the makefile.")
if __name__ == '__main__':
main()