1// Runs a work-precision sweep in real MATLAB. Used for solvers whose
2// manifest declares runtime "matlab": the harness writes the problem
3// files, the solver, and a generated driver into a temp directory, runs
4// `matlab -batch` once for the whole sweep (one MATLAB startup per
5// instance), and reads a JSON payload back. Errors are computed on the
6// node side against the exact solution, as for numbl runs; timing is
7// MATLAB's own tic/toc with the same warmup-plus-median protocol.
9import { execFileSync, spawnSync } from "node:child_process";
10import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
11import { homedir, tmpdir } from "node:os";
12import { join } from "node:path";
13import type { Laplace2dInstance } from "../problems/laplace2d/spec";
14import { evalErrors } from "../problems/laplace2d/exact";
15import type { ResultPoint } from "../harness/resultSchema";
17const CHUNKIE_GIT = "https://github.com/fastalgorithms/chunkie";
18const depsDir = join(homedir(), ".cache", "fastandaccurate", "matlab-deps");
20export function matlabAvailable(): boolean {
21 try {
22 execFileSync("which", ["matlab"], { stdio: "ignore" });
23 return true;
24 } catch {
25 return false;
26 }
27}
29/** Clone chunkie (with the FLAM submodule) on first use and return the
30 * MATLAB setup lines that put it on the path. chunkie's own startup.m is
31 * deliberately not used: it attempts to compile fmm2d when a Fortran
32 * compiler is present, which an unattended run must not do, and the
33 * direct (accel=false) code path needs only the toolbox and FLAM. */
34export function ensureChunkie(): string[] {
35 const dir = join(depsDir, "chunkie");
36 if (!existsSync(join(dir, "chunkie"))) {
37 mkdirSync(depsDir, { recursive: true });
38 console.log(`fetching chunkie into ${dir}`);
39 execFileSync(
40 "git",
41 ["clone", "--depth", "1", "--recurse-submodules", "--shallow-submodules", CHUNKIE_GIT, dir],
42 { stdio: "inherit" }
43 );
44 }
45 if (!existsSync(join(dir, "chunkie", "FLAM", "startup.m"))) {
46 execFileSync(
47 "git",
48 ["-C", dir, "submodule", "update", "--init", "--depth", "1", "chunkie/FLAM"],
49 { stdio: "inherit" }
50 );
51 }
52 return [
53 `addpath('${join(dir, "chunkie")}');`,
54 `run('${join(dir, "chunkie", "FLAM", "startup.m")}');`,
55 ];
56}
58export interface MatlabSweepOptions {
59 instance: Laplace2dInstance;
60 ns: number[];
61 repeats: number;
62 sources: { buildProblem: string; bdata: string; solver: string };
63 /** MATLAB lines run before anything else (addpath etc.). */
64 setup: string[];
65 onPoint?: (point: ResultPoint, index: number, total: number) => void;
66}
68export interface MatlabSweepResult {
69 points: ResultPoint[];
70 matlabVersion: string;
71}
73function asArray(x: number | number[]): number[] {
74 return Array.isArray(x) ? x : [x];
75}
77export function runMatlabSweep(opts: MatlabSweepOptions): MatlabSweepResult {
78 const { instance, ns, repeats } = opts;
79 const dir = mkdtempSync(join(tmpdir(), "fastandaccurate-matlab-"));
80 try {
81 writeFileSync(join(dir, "build_problem.m"), opts.sources.buildProblem);
82 writeFileSync(join(dir, "laplace2d_bdata.m"), opts.sources.bdata);
83 writeFileSync(join(dir, "solver.m"), opts.sources.solver);
84 const main = [
85 "% generated by the fastandaccurate MATLAB harness",
86 ...opts.setup,
87 `ns = [${ns.join(" ")}];`,
88 `nrep = ${repeats};`,
89 `prob = build_problem(${instance.a}, ${instance.k}, ${instance.d}, 0);`,
90 "results = cell(numel(ns), 1);",
91 "% Session-level warmup: the whole sweep shares one MATLAB process, so",
92 "% without this the first resolution absorbs all of the one-time cost",
93 "% (loading the solver's dependencies, quadrature tables, JIT).",
94 "for w = 1:2",
95 " solver(prob, ns(max(1, floor(numel(ns)/2))));",
96 "end",
97 "for i = 1:numel(ns)",
98 " n = ns(i);",
99 " tic; out = solver(prob, n); cold = toc;",
100 " out = solver(prob, n);",
101 " times = zeros(nrep, 1);",
102 " for r = 1:nrep",
103 " tic; out = solver(prob, n); times(r) = toc;",
104 " end",
105 " results{i} = struct('n', n, 'cold', cold, 'times', times, 'ueval', out.uEval);",
106 " fprintf('point n=%d done (%.3fs)\\n', n, min(times));",
107 "end",
108 "payload = struct('matlabVersion', version, 'results', {results});",
109 "fid = fopen('out_results.json', 'w');",
110 "fwrite(fid, jsonencode(payload));",
111 "fclose(fid);",
112 "",
113 ].join("\n");
114 writeFileSync(join(dir, "main.m"), main);
115 const proc = spawnSync("matlab", ["-batch", "main"], {
116 cwd: dir,
117 encoding: "utf8",
118 timeout: 60 * 60 * 1000,
119 });
120 const outPath = join(dir, "out_results.json");
121 if (!existsSync(outPath)) {
122 throw new Error(
123 `MATLAB run failed (exit ${proc.status}):\n${(proc.stdout ?? "").slice(-2000)}\n${(proc.stderr ?? "").slice(-2000)}`
124 );
125 }
126 const payload = JSON.parse(readFileSync(outPath, "utf8")) as {
127 matlabVersion: string;
128 results:
129 | { n: number; cold: number; times: number | number[]; ueval: number[] }[]
130 | { n: number; cold: number; times: number | number[]; ueval: number[] };
131 };
132 const entries = Array.isArray(payload.results)
133 ? payload.results
134 : [payload.results];
135 const points = entries.map((e) => {
136 const times = asArray(e.times);
137 const { relMax, relL2 } = evalErrors(instance, e.ueval);
138 return {
139 n: e.n,
140 solveSeconds: Math.min(...times),
141 solveSecondsAll: times,
142 coldSeconds: e.cold,
143 relMax,
144 relL2,
145 };
146 });
147 points.forEach((p, i) => opts.onPoint?.(p, i, points.length));
148 return { points, matlabVersion: payload.matlabVersion };
149 } finally {
150 rmSync(dir, { recursive: true, force: true });
151 }
152}