// 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 under the same policy as the numbl runner. import { execFileSync, spawnSync } from "node:child_process"; import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { 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"; import { timedRunLines, type TimingPolicy } from "../harness/timing"; export function matlabAvailable(): boolean { try { execFileSync("which", ["matlab"], { stdio: "ignore" }); return true; } catch { return false; } } /** The MATLAB setup lines a solver needs before its sweep. Only * chunkie-dlp has an external dependency; the -mat solvers are plain * MATLAB and need nothing. * * chunkie comes from mip (https://mip.sh), which the harness expects to * find on the MATLAB path; --install fetches chunkie, and with it FLAM * and fmm2d, on first use. Taking chunkie from mip rather than from a * git clone is what makes the accelerated code path available: the mip * fmm2d package ships a compiled MEX binary for the platform, so * chunkie's FMM evaluation runs without a Fortran compiler on the * machine. chunkie's own startup.m is not used; mip puts the three * packages on the path itself. */ export function matlabSetup(solverId: string): string[] { if (solverId !== "chunkie-dlp") return []; return [ // which('/mip') is mip's own idiom for "a non-builtin function named // mip", so a variable of that name in a user startup cannot mask the // check. "if isempty(which('/mip'))", " error(['chunkie-dlp needs the mip package manager on the MATLAB ' ...", " 'path. Install it from inside MATLAB with ' ...", " 'eval(webread(''https://mip.sh/install.txt''))']);", "end", "mip load --install chunkie;", ]; } export interface MatlabSweepOptions { instance: Laplace2dInstance; ns: number[]; timing: TimingPolicy; 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, timing } = 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(" ")}];`, `prob = build_problem(${instance.a}, ${instance.k}, ${instance.p ?? 0}, ` + `${instance.d}, 0, ${instance.nearBoundary ? 1 : 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);", ...timedRunLines("out = solver(prob, n)", "times", timing).map((l) => ` ${l}`), " 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 }); } }