utils.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392
  1. import * as fs from "fs/promises";
  2. import path from "path";
  3. import { spawnSync } from "child_process";
  4. /** Version that is used in bench data file */
  5. export type Version = "unreleased" | (`${number}.${number}.${number}` & {});
  6. /** Persistent benchmark data(mapping of `Version -> Data`) */
  7. type Bench = {
  8. [key: string]: {
  9. /** Benchmark result for compute units consumed */
  10. computeUnits: ComputeUnits;
  11. };
  12. };
  13. /** `instruction name -> compute units consumed` */
  14. export type ComputeUnits = { [key: string]: number };
  15. /**
  16. * How much of a percentage difference between the current and the previous data
  17. * should be significant. Any difference above this number should be noted in
  18. * the benchmark file.
  19. */
  20. export const THRESHOLD_PERCENTAGE = 1;
  21. /** Path to the benchmark Markdown files */
  22. export const BENCH_DIR_PATH = path.join("..", "..", "bench");
  23. /** Command line argument for Anchor version */
  24. export const ANCHOR_VERSION_ARG = "--anchor-version";
  25. /** Utility class to handle benchmark data related operations */
  26. export class BenchData {
  27. /** Benchmark data filepath */
  28. static #PATH = "bench.json";
  29. /** Benchmark data */
  30. #data: Bench;
  31. constructor(data: Bench) {
  32. this.#data = data;
  33. }
  34. /** Open the benchmark data file */
  35. static async open() {
  36. let bench: Bench;
  37. try {
  38. const benchFile = await fs.readFile(BenchData.#PATH, {
  39. encoding: "utf8",
  40. });
  41. bench = JSON.parse(benchFile);
  42. } catch {
  43. bench = {};
  44. }
  45. return new BenchData(bench);
  46. }
  47. /** Save the benchmark data file */
  48. async save() {
  49. await fs.writeFile(BenchData.#PATH, JSON.stringify(this.#data, null, 2));
  50. }
  51. /** Get the stored results based on version */
  52. get(version: Version) {
  53. return this.#data[version];
  54. }
  55. /** Get all versions */
  56. getVersions() {
  57. return Object.keys(this.#data) as Version[];
  58. }
  59. /** Compare and update compute units changes */
  60. compareComputeUnits(
  61. newComputeUnitsResult: ComputeUnits,
  62. oldComputeUnitsResult: ComputeUnits,
  63. changeCb: (args: {
  64. ixName: string;
  65. newComputeUnits: number | null;
  66. oldComputeUnits: number | null;
  67. }) => void,
  68. noChangeCb?: (ixName: string, computeUnits: number) => void
  69. ) {
  70. let needsUpdate = false;
  71. const checkIxs = (
  72. comparedFrom: ComputeUnits,
  73. comparedTo: ComputeUnits,
  74. cb: (ixName: string, computeUnits: number) => void
  75. ) => {
  76. for (const ixName in comparedFrom) {
  77. if (comparedTo[ixName] === undefined) {
  78. cb(ixName, comparedFrom[ixName]);
  79. }
  80. }
  81. };
  82. // New instruction
  83. checkIxs(
  84. newComputeUnitsResult,
  85. oldComputeUnitsResult,
  86. (ixName, computeUnits) => {
  87. console.log(`New instruction '${ixName}'`);
  88. changeCb({
  89. ixName,
  90. newComputeUnits: computeUnits,
  91. oldComputeUnits: null,
  92. });
  93. needsUpdate = true;
  94. }
  95. );
  96. // Deleted instruction
  97. checkIxs(
  98. oldComputeUnitsResult,
  99. newComputeUnitsResult,
  100. (ixName, computeUnits) => {
  101. console.log(`Deleted instruction '${ixName}'`);
  102. changeCb({
  103. ixName,
  104. newComputeUnits: null,
  105. oldComputeUnits: computeUnits,
  106. });
  107. needsUpdate = true;
  108. }
  109. );
  110. // Compare compute units changes
  111. for (const ixName in newComputeUnitsResult) {
  112. const oldComputeUnits = oldComputeUnitsResult[ixName];
  113. const newComputeUnits = newComputeUnitsResult[ixName];
  114. const percentage = THRESHOLD_PERCENTAGE / 100;
  115. const oldMaximumAllowedDelta = oldComputeUnits * percentage;
  116. const newMaximumAllowedDelta = newComputeUnits * percentage;
  117. const delta = newComputeUnits - oldComputeUnits;
  118. const absDelta = Math.abs(delta);
  119. if (
  120. absDelta > oldMaximumAllowedDelta ||
  121. absDelta > newMaximumAllowedDelta
  122. ) {
  123. // Throw in CI
  124. if (process.env.CI) {
  125. throw new Error(
  126. [
  127. `Compute units for instruction '${ixName}' has changed more than ${THRESHOLD_PERCENTAGE}% but is not saved.`,
  128. "Run `anchor test --skip-lint` in tests/bench and commit the changes.",
  129. ].join(" ")
  130. );
  131. }
  132. console.log(
  133. `Compute units change '${ixName}' (${oldComputeUnits} -> ${newComputeUnits})`
  134. );
  135. changeCb({
  136. ixName,
  137. newComputeUnits,
  138. oldComputeUnits,
  139. });
  140. needsUpdate = true;
  141. } else {
  142. noChangeCb?.(ixName, newComputeUnits);
  143. }
  144. }
  145. return { needsUpdate };
  146. }
  147. /** Bump benchmark data version to the given version */
  148. bumpVersion(newVersion: string) {
  149. if (this.#data[newVersion]) {
  150. console.error(`Version '${newVersion}' already exists!`);
  151. process.exit(1);
  152. }
  153. const versions = this.getVersions();
  154. const unreleasedVersion = versions[versions.length - 1];
  155. // Add the new version
  156. this.#data[newVersion] = this.get(unreleasedVersion);
  157. // Delete the unreleased version
  158. delete this.#data[unreleasedVersion];
  159. // Add new unreleased version
  160. this.#data[unreleasedVersion] = this.#data[newVersion];
  161. }
  162. /**
  163. * Loop through all of the markdown files and run the given callback before
  164. * saving the file.
  165. */
  166. static async forEachMarkdown(
  167. cb: (markdown: Markdown, fileName: string) => void
  168. ) {
  169. const fileNames = await fs.readdir(BENCH_DIR_PATH);
  170. const markdownFileNames = fileNames.filter((n) => n.endsWith(".md"));
  171. for (const fileName of markdownFileNames) {
  172. const markdown = await Markdown.open(path.join(BENCH_DIR_PATH, fileName));
  173. cb(markdown, fileName);
  174. await markdown.save();
  175. }
  176. // Format
  177. spawnSync("yarn", [
  178. "run",
  179. "prettier",
  180. "--write",
  181. path.join(BENCH_DIR_PATH, "*.md"),
  182. ]);
  183. }
  184. }
  185. /** Utility class to handle markdown related operations */
  186. export class Markdown {
  187. /** Unreleased version string */
  188. static #UNRELEASED_VERSION = "[Unreleased]";
  189. /** Markdown filepath */
  190. #path: string;
  191. /** Markdown text */
  192. #text: string;
  193. constructor(path: string, text: string) {
  194. this.#path = path;
  195. this.#text = text;
  196. }
  197. /** Open the markdown file */
  198. static async open(path: string) {
  199. const text = await fs.readFile(path, { encoding: "utf8" });
  200. return new Markdown(path, text);
  201. }
  202. /** Create a markdown table */
  203. static createTable(...args: string[]) {
  204. return new MarkdownTable([args]);
  205. }
  206. /** Save the markdown file */
  207. async save() {
  208. await fs.writeFile(this.#path, this.#text);
  209. }
  210. /** Change version table with the given table */
  211. updateTable(version: string, table: MarkdownTable) {
  212. const md = this.#text;
  213. let titleStartIndex = md.indexOf(`[${version}]`);
  214. if (titleStartIndex === -1) {
  215. titleStartIndex = md.indexOf(Markdown.#UNRELEASED_VERSION);
  216. }
  217. const startIndex = titleStartIndex + md.slice(titleStartIndex).indexOf("|");
  218. const endIndex = startIndex + md.slice(startIndex).indexOf("\n\n");
  219. this.#text =
  220. md.slice(0, startIndex) + table.toString() + md.slice(endIndex + 1);
  221. }
  222. /** Bump the version to the given version */
  223. bumpVersion(newVersion: string) {
  224. newVersion = `[${newVersion}]`;
  225. if (this.#text.includes(newVersion)) {
  226. console.error(`Version '${newVersion}' already exists!`);
  227. process.exit(1);
  228. }
  229. const startIndex = this.#text.indexOf(`## ${Markdown.#UNRELEASED_VERSION}`);
  230. const endIndex =
  231. startIndex + this.#text.slice(startIndex).indexOf("\n---") + 4;
  232. let unreleasedSection = this.#text.slice(startIndex, endIndex);
  233. // Update unreleased version to `newVersion`
  234. const newSection = unreleasedSection.replace(
  235. Markdown.#UNRELEASED_VERSION,
  236. newVersion
  237. );
  238. // Reset unreleased version changes
  239. unreleasedSection = unreleasedSection
  240. .split("\n")
  241. .map((line, i) => {
  242. // First 4 lines don't change
  243. if ([0, 1, 2, 3].includes(i)) return line;
  244. const regex = /\|.*\|.*\|(.*)\|/;
  245. const result = regex.exec(line);
  246. const changeStr = result?.[1];
  247. if (!changeStr) {
  248. if (line.startsWith("#")) return line;
  249. else if (line.startsWith("---")) return line + "\n";
  250. else return "";
  251. }
  252. return line.replace(changeStr, "-");
  253. })
  254. .join("\n");
  255. // Update the text
  256. this.#text =
  257. this.#text.slice(0, startIndex) +
  258. unreleasedSection +
  259. newSection +
  260. this.#text.slice(endIndex);
  261. }
  262. }
  263. /** Utility class to handle markdown table related operations */
  264. class MarkdownTable {
  265. /** Markdown rows stored as array of arrays */
  266. #rows: string[][];
  267. constructor(rows: string[][]) {
  268. this.#rows = rows;
  269. this.insert("-", "-", "-");
  270. }
  271. /** Insert a new row to the markdown table */
  272. insert(...args: string[]) {
  273. this.#rows.push(args);
  274. }
  275. /** Convert the stored rows to a markdown table */
  276. toString() {
  277. return this.#rows.reduce(
  278. (acc, row) =>
  279. acc + row.reduce((acc, cur) => `${acc} ${cur} |`, "|") + "\n",
  280. ""
  281. );
  282. }
  283. }
  284. /** Utility class to handle TOML related operations */
  285. export class Toml {
  286. /** TOML filepath */
  287. #path: string;
  288. /** TOML text */
  289. #text: string;
  290. constructor(path: string, text: string) {
  291. this.#path = path;
  292. this.#text = text;
  293. }
  294. /** Open the TOML file */
  295. static async open(tomlPath: string) {
  296. tomlPath = path.join(__dirname, tomlPath);
  297. const text = await fs.readFile(tomlPath, {
  298. encoding: "utf8",
  299. });
  300. return new Toml(tomlPath, text);
  301. }
  302. /** Save the TOML file */
  303. async save() {
  304. await fs.writeFile(this.#path, this.#text);
  305. }
  306. /** Replace the value for the given key */
  307. replaceValue(
  308. key: string,
  309. cb: (previous: string) => string,
  310. opts?: { insideQuotes: boolean }
  311. ) {
  312. this.#text = this.#text.replace(
  313. new RegExp(`${key}\\s*=\\s*${opts?.insideQuotes ? `"(.*)"` : "(.*)"}`),
  314. (line, value) => line.replace(value, cb(value))
  315. );
  316. }
  317. }
  318. /**
  319. * Get Anchor version from the passed arguments.
  320. *
  321. * Defaults to `unreleased`.
  322. */
  323. export const getVersionFromArgs = () => {
  324. const args = process.argv;
  325. const anchorVersionArgIndex = args.indexOf(ANCHOR_VERSION_ARG);
  326. return anchorVersionArgIndex === -1
  327. ? "unreleased"
  328. : (args[anchorVersionArgIndex + 1] as Version);
  329. };