// Runs a work-precision sweep in real MATLAB. Used for solvers whose // manifest declares runtime "matlab": the harness writes the problem // files, the solver, and a generated driver into a temp directory, runs // `matlab -batch` once for the whole sweep (one MATLAB startup per // instance), and reads a JSON payload back. Errors are computed on the // node side against the exact solution, as for numbl runs; timing is // MATLAB's own tic/toc with the same warmup-plus-median protocol. import { execFileSync, spawnSync } from "node:child_process"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { homedir, tmpdir } from "node:os"; import { join } from "node:path"; import type { Laplace2dInstance } from "../problems/laplace2d/spec"; import { evalErrors } from "../problems/laplace2d/exact"; import type { ResultPoint } from "../harness/resultSchema"; const CHUNKIE_GIT = "https://github.com/fastalgorithms/chunkie"; const depsDir = join(homedir(), ".cache", "fastandaccurate", "matlab-deps"); export function matlabAvailable(): boolean { try { execFileSync("which", ["matlab"], { stdio: "ignore" }); return true; } catch { return false; } } /** Clone chunkie (with the FLAM submodule) on first use and return the * MATLAB setup lines that put it on the path. chunkie's own startup.m is * deliberately not used: it attempts to compile fmm2d when a Fortran * compiler is present, which an unattended run must not do, and the * direct (accel=false) code path needs only the toolbox and FLAM. */ export function ensureChunkie(): string[] { const dir = join(depsDir, "chunkie"); if (!existsSync(join(dir, "chunkie"))) { mkdirSync(depsDir, { recursive: true }); console.log(`fetching chunkie into ${dir}`); execFileSync( "git", ["clone", "--depth", "1", "--recurse-submodules", "--shallow-submodules", CHUNKIE_GIT, dir], { stdio: "inherit" } ); } if (!existsSync(join(dir, "chunkie", "FLAM", "startup.m"))) { execFileSync( "git", ["-C", dir, "submodule", "update", "--init", "--depth", "1", "chunkie/FLAM"], { stdio: "inherit" } ); } return [ `addpath('${join(dir, "chunkie")}');`, `run('${join(dir, "chunkie", "FLAM", "startup.m")}');`, ]; } export interface MatlabSweepOptions { instance: Laplace2dInstance; ns: number[]; repeats: number; sources: { buildProblem: string; bdata: string; solver: string }; /** MATLAB lines run before anything else (addpath etc.). */ setup: string[]; onPoint?: (point: ResultPoint, index: number, total: number) => void; } export interface MatlabSweepResult { points: ResultPoint[]; matlabVersion: string; } function asArray(x: number | number[]): number[] { return Array.isArray(x) ? x : [x]; } export function runMatlabSweep(opts: MatlabSweepOptions): MatlabSweepResult { const { instance, ns, repeats } = opts; const dir = mkdtempSync(join(tmpdir(), "fastandaccurate-matlab-")); try { writeFileSync(join(dir, "build_problem.m"), opts.sources.buildProblem); writeFileSync(join(dir, "laplace2d_bdata.m"), opts.sources.bdata); writeFileSync(join(dir, "solver.m"), opts.sources.solver); const main = [ "% generated by the fastandaccurate MATLAB harness", ...opts.setup, `ns = [${ns.join(" ")}];`, `nrep = ${repeats};`, `prob = build_problem(${instance.a}, ${instance.k}, ${instance.d}, 0);`, "results = cell(numel(ns), 1);", "% Session-level warmup: the whole sweep shares one MATLAB process, so", "% without this the first resolution absorbs all of the one-time cost", "% (loading the solver's dependencies, quadrature tables, JIT).", "for w = 1:2", " solver(prob, ns(max(1, floor(numel(ns)/2))));", "end", "for i = 1:numel(ns)", " n = ns(i);", " tic; out = solver(prob, n); cold = toc;", " out = solver(prob, n);", " times = zeros(nrep, 1);", " for r = 1:nrep", " tic; out = solver(prob, n); times(r) = toc;", " end", " results{i} = struct('n', n, 'cold', cold, 'times', times, 'ueval', out.uEval);", " fprintf('point n=%d done (%.3fs)\\n', n, min(times));", "end", "payload = struct('matlabVersion', version, 'results', {results});", "fid = fopen('out_results.json', 'w');", "fwrite(fid, jsonencode(payload));", "fclose(fid);", "", ].join("\n"); writeFileSync(join(dir, "main.m"), main); const proc = spawnSync("matlab", ["-batch", "main"], { cwd: dir, encoding: "utf8", timeout: 60 * 60 * 1000, }); const outPath = join(dir, "out_results.json"); if (!existsSync(outPath)) { throw new Error( `MATLAB run failed (exit ${proc.status}):\n${(proc.stdout ?? "").slice(-2000)}\n${(proc.stderr ?? "").slice(-2000)}` ); } const payload = JSON.parse(readFileSync(outPath, "utf8")) as { matlabVersion: string; results: | { n: number; cold: number; times: number | number[]; ueval: number[] }[] | { n: number; cold: number; times: number | number[]; ueval: number[] }; }; const entries = Array.isArray(payload.results) ? payload.results : [payload.results]; const points = entries.map((e) => { const times = asArray(e.times); const { relMax, relL2 } = evalErrors(instance, e.ueval); return { n: e.n, solveSeconds: Math.min(...times), solveSecondsAll: times, coldSeconds: e.cold, relMax, relL2, }; }); points.forEach((p, i) => opts.onPoint?.(p, i, points.length)); return { points, matlabVersion: payload.matlabVersion }; } finally { rmSync(dir, { recursive: true, force: true }); } }