From b89219939bee046b351e7592e3afb3c9c7a126f5 Mon Sep 17 00:00:00 2001 From: Hadrian Tang Date: Mon, 31 Aug 2026 10:16:59 +0800 Subject: [PATCH 1/2] perf: add reproducible font startup profiler --- .github/workflows/Benchmark.yml | 9 +- .../CSharpMath.Rendering.Benchmarks.csproj | 3 +- .../FontProfiling.md | 27 ++++++ CSharpMath.Rendering.Benchmarks/Program.cs | 88 ++++++++++++++++--- ReadMe.md | 17 ++++ 5 files changed, 131 insertions(+), 13 deletions(-) create mode 100644 CSharpMath.Rendering.Benchmarks/FontProfiling.md diff --git a/.github/workflows/Benchmark.yml b/.github/workflows/Benchmark.yml index ceef8dd23..14755b1b3 100644 --- a/.github/workflows/Benchmark.yml +++ b/.github/workflows/Benchmark.yml @@ -14,6 +14,13 @@ jobs: with: dotnet-version: '10.x' - run: dotnet run -p CSharpMath.Rendering.Benchmarks + - name: Font profile smoke + run: dotnet run -c Release -p CSharpMath.Rendering.Benchmarks -- --font-profile --font-profile-smoke --font-profile-output .fontprofile/font-profile-smoke.json + - name: Store font profile smoke + uses: actions/upload-artifact@v4 + with: + name: font-profile-smoke + path: .fontprofile/font-profile-smoke.json - name: Store benchmark result uses: benchmark-action/github-action-benchmark@v1 with: @@ -25,4 +32,4 @@ jobs: alert-threshold: 155% # Should not be lower than 155% to take account of fluctuations of CI load, see https://github.com/verybadcat/CSharpMath/pull/156#commitcomment-42067078 comment-on-alert: true # Show alert with commit comment on detecting possible performance regression fail-on-alert: true - alert-comment-cc-users: '@Happypig375' \ No newline at end of file + alert-comment-cc-users: '@Happypig375' diff --git a/CSharpMath.Rendering.Benchmarks/CSharpMath.Rendering.Benchmarks.csproj b/CSharpMath.Rendering.Benchmarks/CSharpMath.Rendering.Benchmarks.csproj index dfa64d71a..e657e053a 100644 --- a/CSharpMath.Rendering.Benchmarks/CSharpMath.Rendering.Benchmarks.csproj +++ b/CSharpMath.Rendering.Benchmarks/CSharpMath.Rendering.Benchmarks.csproj @@ -4,6 +4,7 @@ net10.0 + @@ -11,4 +12,4 @@ - \ No newline at end of file + diff --git a/CSharpMath.Rendering.Benchmarks/FontProfiling.md b/CSharpMath.Rendering.Benchmarks/FontProfiling.md new file mode 100644 index 000000000..9a76b974a --- /dev/null +++ b/CSharpMath.Rendering.Benchmarks/FontProfiling.md @@ -0,0 +1,27 @@ +# Font profiling artifact + +Run `dotnet run -c Release -p CSharpMath.Rendering.Benchmarks -- --font-profile`. Use +`--font-profile-samples`, `--font-profile-iterations`, and `--font-profile-batches` to control work, +or `CSHARP_MATH_FONT_PROFILE_OUTPUT` / `--font-profile-output` for the output path. Invoke +`--font-profile-smoke` with or without `--font-profile`; it is a cheap deterministic correctness +check for CI. + +The artifact reports raw samples for direct per-resource SFNT parsing, separate first-math and +first-mixed cold workers, global rendering, and alternating formula/font batches. It records bytes +allocated by the managed runtime and bytes retained after a forced GC; process working set is not +used as managed retention. The three embedded faces are loaded by identity and hashed from their +actual bytes. The tracked Comic Neue asset is a real runtime `LocalTypeface`. Table tags are parsed +from each SFNT directory; CFF/glyf and hint/instruction, MATH, GSUB, GPOS, and GDEF presence can be +read directly from `tables`. The `packaging` object reports the measured bundled-font, custom-asset, +rendering-assembly, and generated-font-data fields for the current build. `exercisedPaths`, +`switchingSequence`, and `globalFaces` identify operations actually performed; each `globalFaces` +entry includes the cmap-validated corpus, LocalTypeface usage, and successful draw evidence. +Parse-only table inventory is not presented as outline/cache execution evidence. + +Compare distributions and batch retained-memory trends, not thresholds: OS, runtime, Skia, and +process startup noise affect measurements. The artifact helps investigate font startup and cache +growth discussed by issues #107 and #191; it does not prove causality or impose a performance gate. + +Captured smoke result (Release, .NET 10 on the development Windows host): 10 raw samples (three +parse-only faces, three rendered bundled-face scenarios, first math, first mixed text, global +alternation, and custom Comic Neue), four font inventories, and zero worker or schema errors. diff --git a/CSharpMath.Rendering.Benchmarks/Program.cs b/CSharpMath.Rendering.Benchmarks/Program.cs index cbd0371b4..3bb44b08c 100644 --- a/CSharpMath.Rendering.Benchmarks/Program.cs +++ b/CSharpMath.Rendering.Benchmarks/Program.cs @@ -1,14 +1,22 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text.Json; +using System.Threading.Tasks; using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Running; +using CSharpMath.SkiaSharp; +using Typography.OpenFont; + namespace CSharpMath.Rendering.Benchmarks { - using SkiaSharp; using Data = Tests.TestRenderingMathData; public class Program { - // In Windows, selecting text inside the Visual Sutdio Debug console pauses program execution. - // A simple press of the spacebar continues program execution, so don't be fooled into thinking it hanged! - - // Place a long benchmark before microbenchmarks to work around https://github.com/dotnet/BenchmarkDotNet/issues/1338 - [Benchmark] - public void AllConstantValues() { using (new MathPainter { LaTeX = Data.AllConstantValues }.DrawAsStream()) { } } + [Benchmark] public void AllConstantValues() { using (new MathPainter { LaTeX = Data.AllConstantValues }.DrawAsStream()) { } } [Benchmark] [Arguments(nameof(Data.Cases))] [Arguments(nameof(Data.Color))] @@ -22,12 +30,14 @@ public class Program { [Arguments(nameof(Data.VectorProjection))] public void IndividualTests(string key) { using (new MathPainter { LaTeX = Data.AllConstants[key] }.DrawAsStream()) { } } static void Main(string[] args) { + if (args.Contains("--font-profile-worker")) { FontProfile.Worker(args.SkipWhile(a => a != "--font-profile-worker").Skip(1).FirstOrDefault() ?? "global", args); return; } + if (args.Contains("--font-profile") || args.Contains("--font-profile-smoke")) { FontProfile.Run(args); return; } #if DEBUG System.Console.WriteLine("Starting in Debug configuration..."); static string ThisFile([System.Runtime.CompilerServices.CallerFilePath] string path = "") => path; - var p = new System.Diagnostics.Process { + var p = new Process { StartInfo = { - FileName = "dotnet", // The -- separator between arguments to dotnet and arguments to this Program is optional :) + FileName = "dotnet", Arguments = $"run -p \"{ThisFile()}/../CSharpMath.Rendering.Benchmarks.csproj\" -c Release -v n --exporters json --filter * --artifacts \"{ThisFile()}/../../.benchmarkresults\"" } }; @@ -36,9 +46,65 @@ static void Main(string[] args) { System.Console.WriteLine("Ending in Debug configuration..."); #else System.Console.WriteLine("Starting in Release configuration..."); - BenchmarkDotNet.Running.BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args); + BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args); System.Console.WriteLine("Ending in Release configuration..."); #endif } } -} \ No newline at end of file + internal static class FontProfile { +#if DEBUG + const string BuildConfiguration = "Debug"; +#elif RELEASE + const string BuildConfiguration = "Release"; +#else +#error Unsupported build configuration: define DEBUG or RELEASE. +#endif + const string Math = @"\frac{\alpha+\beta}{\sqrt{x^2+y^2}}"; + const string Mixed = @"Area: \frac{\pi r^2}{2}"; + static readonly string[] Bundled = { "latinmodern-math.otf", "AMS-Capital-Blackboard-Bold.otf", "cyrillic-modern-nmr10.otf" }; + static readonly JsonSerializerOptions Json = new() { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; + public static void Run(string[] args) { + bool smoke = args.Contains("--font-profile-smoke"); string output = GetOption(args, "--font-profile-output") ?? Environment.GetEnvironmentVariable("CSHARP_MATH_FONT_PROFILE_OUTPUT") ?? Path.Combine(".fontprofile", "font-profile.json"); + int samples = smoke ? 1 : GetInt(args, "--font-profile-samples", 3), iterations = smoke ? 1 : GetInt(args, "--font-profile-iterations", 10), batches = smoke ? 1 : GetInt(args, "--font-profile-batches", 3); + var report = new FontReport { Schema = "csharpmath.font-startup/v2", Runtime = Environment.Version.ToString(), OS = RuntimeInformation.OSDescription, Architecture = RuntimeInformation.ProcessArchitecture.ToString(), Configuration = BuildConfiguration, GitCommit = ReadGitCommit(), ColdProcess = true, Samples = samples, Iterations = iterations, WarmBatches = batches, Corpus = new[] { Math, Mixed } }; + foreach (var name in Bundled) report.Fonts.Add(Describe(name, ReadBundled(name), "embedded Reference Fonts")); string? custom = FindCustomFont(); + if (custom == null) report.Errors.Add("custom font asset is missing from benchmark output"); else report.Fonts.Add(Describe("ComicNeue_Bold.otf", File.ReadAllBytes(custom), "runtime LocalTypeface")); + report.Packaging = new PackageEvidence { BundledEmbeddedBytes = report.Fonts.Where(f => f.Source.StartsWith("embedded")).Sum(f => f.Bytes), CustomAssetBytes = custom == null ? 0 : new FileInfo(custom).Length, GeneratedFontDataBytes = 0, GeneratedFontDataCount = 0, RenderingAssemblyBytes = new FileInfo(typeof(CSharpMath.Rendering.BackEnd.Fonts).Assembly.Location).Length }; + foreach (var scenario in Bundled.Select(n => "parse:" + n).Concat(Bundled.Select(n => "font:" + n)).Concat(new[] { "first-math", "first-mixed", "global-batch", "custom-comic-neue" })) for (int i = 0; i < samples; i++) report.Raw.Add(RunChild(scenario, iterations, batches)); + if (report.Fonts.Count != Bundled.Length + 1) report.Errors.Add("not all bundled and custom faces were reachable"); if (report.Raw.Any(s => s.Errors.Count != 0)) report.Errors.Add("one or more worker samples failed"); + if (report.Raw.Where(s => s.Scenario.StartsWith("font:", StringComparison.Ordinal)).Any(s => !s.GlyphsAvailable) || report.Raw.Where(s => s.Scenario == "custom-comic-neue").Any(s => !s.GlyphsAvailable)) report.Errors.Add("one or more LocalTypeface corpora were not confirmed by cmap"); + var switching = report.Raw.FirstOrDefault(s => s.Scenario == "global-batch"); if (switching != null && (!switching.SwitchingSequence.All(n => switching.SelectedFaces.Contains(n)) || switching.GlobalFaces.Count != 4 || switching.GlobalFaces.Any(f => !f.CmapValidated || !f.LocalTypeface || !f.DrawSucceeded))) report.Errors.Add("global switching did not prove four cmap-validated LocalTypeface renders"); + Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(output))!); File.WriteAllText(output, JsonSerializer.Serialize(report, Json)); Console.WriteLine($"font profile: {output} ({report.Raw.Count} raw samples)"); if (report.Errors.Count != 0) Environment.ExitCode = 2; + } + public static void Worker(string scenario, string[] args) { + try { + int iterations = GetInt(args, "--font-profile-iterations", 10), batches = GetInt(args, "--font-profile-batches", 3); var result = new Sample { Scenario = scenario }; long before = GC.GetTotalAllocatedBytes(true), retainedBefore = GC.GetTotalMemory(true); int[] gcBefore = { GC.CollectionCount(0), GC.CollectionCount(1), GC.CollectionCount(2) }; var timer = Stopwatch.StartNew(); + if (scenario.StartsWith("parse:", StringComparison.Ordinal)) result.Font = Describe(scenario[6..], ReadBundled(scenario[6..]), "embedded Reference Fonts"); else if (scenario.StartsWith("font:", StringComparison.Ordinal)) { byte[] face = ReadBundled(scenario[5..]); result.Font = Describe(scenario[5..], face, "embedded Reference Fonts"); result.ExercisedPaths.Add("LocalTypeface:" + scenario[5..]); result.GlyphCorpus = GlyphCorpusFor(face, scenario[5..]); result.GlyphsAvailable = HasCorpusGlyphs(face, result.GlyphCorpus); for (int i = 0; i < iterations; i++) RenderMath(result.GlyphCorpus, face); } else if (scenario == "first-math") { result.ExercisedPaths.Add("global math parse/layout/draw"); RenderMath(Math); } else if (scenario == "first-mixed") { result.ExercisedPaths.Add("TextPainter text+math layout/draw"); RenderText(Mixed); } else if (scenario == "global-batch" || scenario == "custom-comic-neue") { byte[] custom = ReadCustom(); var faces = Bundled.Select(ReadBundled).Concat(new[] { custom }).ToArray(); result.SwitchingSequence = Bundled.Concat(new[] { "ComicNeue_Bold.otf" }).ToArray(); var corpora = faces.Select((face, i) => { string corpus = GlyphCorpusFor(face, result.SwitchingSequence[i]); return new FaceRenderEvidence { Face = result.SwitchingSequence[i], RequestedCorpus = corpus, CmapGlyphIndices = GlyphIndicesFor(face, corpus), CmapValidated = HasCorpusGlyphs(face, corpus), LocalTypeface = true }; }).ToArray(); if (scenario == "custom-comic-neue") { result.GlyphCorpus = corpora[^1].RequestedCorpus; result.GlyphsAvailable = corpora[^1].CmapValidated; } int iterationsToRun = System.Math.Max(iterations, faces.Length); for (int batch = 0; batch < batches; batch++) { long batchBefore = GC.GetTotalMemory(true); for (int i = 0; i < iterationsToRun; i++) { int selected = i % faces.Length; var evidence = corpora[selected]; RenderMath(evidence.RequestedCorpus, faces[selected]); RenderText(Mixed); evidence.DrawSucceeded = true; result.SelectedFaces.Add(evidence.Face); if (!result.GlobalFaces.Any(f => f.Face == evidence.Face)) result.GlobalFaces.Add(evidence); } GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); result.Batches.Add(new BatchResult { Number = batch, RetainedManagedBytesAfterGc = GC.GetTotalMemory(true) - batchBefore }); } result.ExercisedPaths.Add("repeated local-face switching with cmap-validated corpora"); } else throw new ArgumentException("unknown profile worker scenario: " + scenario); + timer.Stop(); result.WallMilliseconds = timer.Elapsed.TotalMilliseconds; result.TotalManagedAllocatedBytes = GC.GetTotalAllocatedBytes(true) - before; result.RetainedManagedBytesAfterGc = GC.GetTotalMemory(true) - retainedBefore; result.GcCollections = new[] { GC.CollectionCount(0) - gcBefore[0], GC.CollectionCount(1) - gcBefore[1], GC.CollectionCount(2) - gcBefore[2] }; Console.Write(JsonSerializer.Serialize(result, Json)); + } catch (Exception ex) { Console.Write(JsonSerializer.Serialize(new Sample { Scenario = scenario, Errors = new List { ex.ToString() } }, Json)); Environment.ExitCode = 1; } + } + static void RenderMath(string latex, byte[]? custom = null) { MemoryStream? stream = null; Typeface[] faces = Array.Empty(); if (custom != null) { var face = ReadTypeface(custom, out stream); faces = new[] { face }; } using (stream) { var painter = new MathPainter { LaTeX = latex, LocalTypefaces = faces }; using (painter.DrawAsStream()) { } } } + static void RenderText(string latex) { var painter = new TextPainter { LaTeX = latex }; using (painter.DrawAsStream()) { } } + static string CorpusFor(string name) => name.StartsWith("cyrillic", StringComparison.Ordinal) ? "\u0410 \u0411 \u0412 \u0413 \u0414 \u0416 \u042F" : name.StartsWith("AMS-", StringComparison.Ordinal) ? FindAvailableGlyphs(ReadBundled(name), 7) : name.StartsWith("Comic", StringComparison.Ordinal) ? "Comic Neue sample" : Math; + static string GlyphCorpusFor(byte[] bytes, string name) => name.StartsWith("AMS-", StringComparison.Ordinal) ? "ℂℍℕℙℚℝℤ" : name.StartsWith("cyrillic", StringComparison.Ordinal) ? "АБВГДЖЯ" : name.StartsWith("Comic", StringComparison.Ordinal) ? "Comic Neue sample" : "xy"; + static bool HasCorpusGlyphs(byte[] bytes, string corpus) { using var stream = new MemoryStream(bytes, false); var face = new OpenFontReader().Read(stream) ?? throw new InvalidDataException("font parse failed"); var codepoints = corpus.EnumerateRunes().Where(r => !char.IsWhiteSpace((char)r.Value)).Select(r => r.Value).Distinct(); return codepoints.All(cp => face.GetGlyphIndex(cp) != 0); } + static ushort[] GlyphIndicesFor(byte[] bytes, string corpus) { using var stream = new MemoryStream(bytes, false); var face = new OpenFontReader().Read(stream) ?? throw new InvalidDataException("font parse failed"); return corpus.EnumerateRunes().Where(r => !char.IsWhiteSpace((char)r.Value)).Select(r => face.GetGlyphIndex(r.Value)).Distinct().ToArray(); } + static string FindAvailableGlyphs(byte[] bytes, int count) { using var stream = new MemoryStream(bytes, false); var face = new OpenFontReader().Read(stream) ?? throw new InvalidDataException("font parse failed"); var chars = Enumerable.Range(0, 0x10000).Where(cp => !char.IsControl((char)cp) && !char.IsWhiteSpace((char)cp) && face.GetGlyphIndex(cp) != 0).Take(count).Select(cp => (char)cp).ToArray(); return chars.Length == count ? new string(chars) : throw new InvalidDataException("font lacks expected mapped glyphs"); } + static Typeface ReadTypeface(byte[] bytes, out MemoryStream stream) { stream = new MemoryStream(bytes, writable: false); return new OpenFontReader().Read(stream) ?? throw new InvalidDataException("custom font could not be parsed"); } + static byte[] ReadCustom() { var p = FindCustomFont() ?? throw new FileNotFoundException("ComicNeue_Bold.otf"); return File.ReadAllBytes(p); } + static FontInfo Describe(string name, byte[] bytes, string source) { using var stream = new MemoryStream(bytes, false); _ = new OpenFontReader().Read(stream) ?? throw new InvalidDataException(name); var tables = ParseTableDirectory(bytes); return new FontInfo { Name = name, Source = source, Bytes = bytes.Length, Sha256 = Convert.ToHexString(SHA256.HashData(bytes)), Tables = tables, Outline = tables.Any(t => t == "CFF " || t == "CFF2") ? "CFF" : tables.Contains("glyf") ? "glyf" : "unknown", Notes = "OpenFontReader parsed the font; the reachable SFNT table directory is recorded for availability only. Actual rendering paths are reported per sample." }; } + static string[] ParseTableDirectory(byte[] bytes) { if (bytes.Length < 12) throw new InvalidDataException("truncated SFNT"); int count = (bytes[4] << 8) | bytes[5]; if (12 + count * 16 > bytes.Length) throw new InvalidDataException("truncated SFNT directory"); var names = new string[count]; for (int i = 0; i < count; i++) names[i] = System.Text.Encoding.ASCII.GetString(bytes, 12 + i * 16, 4); return names.OrderBy(x => x, StringComparer.Ordinal).ToArray(); } + static byte[] ReadBundled(string name) => typeof(CSharpMath.Rendering.BackEnd.Fonts).Assembly.GetManifestResourceStream("CSharpMath.Rendering.Reference_Fonts." + name) is Stream s ? ReadAll(s) : throw new FileNotFoundException(name); + static byte[] ReadAll(Stream s) { using (s) { using var m = new MemoryStream(); s.CopyTo(m); return m.ToArray(); } } + static string? FindCustomFont() { var p = Path.Combine(AppContext.BaseDirectory, "ComicNeue_Bold.otf"); return File.Exists(p) ? p : null; } + static Sample RunChild(string scenario, int iterations, int batches) { var psi = new ProcessStartInfo(Environment.ProcessPath ?? "dotnet") { UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true }; if (Path.GetFileNameWithoutExtension(psi.FileName).Equals("dotnet", StringComparison.OrdinalIgnoreCase)) psi.ArgumentList.Add(typeof(Program).Assembly.Location); psi.ArgumentList.Add("--font-profile-worker"); psi.ArgumentList.Add(scenario); psi.ArgumentList.Add("--font-profile-iterations"); psi.ArgumentList.Add(iterations.ToString()); psi.ArgumentList.Add("--font-profile-batches"); psi.ArgumentList.Add(batches.ToString()); using var p = Process.Start(psi)!; var stdoutTask = p.StandardOutput.ReadToEndAsync(); var stderrTask = p.StandardError.ReadToEndAsync(); Task.WaitAll(stdoutTask, stderrTask); p.WaitForExit(); string stdout = stdoutTask.Result, stderr = stderrTask.Result; if (p.ExitCode != 0) return new Sample { Scenario = scenario, Errors = new List { $"worker exit {p.ExitCode}: {stderr} {stdout}" } }; try { return JsonSerializer.Deserialize(stdout.Trim(), Json) ?? throw new InvalidDataException("empty worker output"); } catch (Exception e) { return new Sample { Scenario = scenario, Errors = new List { $"invalid worker JSON: {e.Message}; {stdout}" } }; } } + static int GetInt(string[] a, string key, int fallback) => int.TryParse(GetOption(a, key), out var n) && n > 0 ? n : fallback; static string? GetOption(string[] a, string key) { int i = Array.IndexOf(a, key); return i >= 0 && i + 1 < a.Length ? a[i + 1] : null; } + static string ReadGitCommit() { try { using var p = Process.Start(new ProcessStartInfo("git", "rev-parse HEAD") { RedirectStandardOutput = true, UseShellExecute = false }); return p!.StandardOutput.ReadToEnd().Trim(); } catch { return "unavailable"; } } + sealed class FontReport { public string Schema { get; set; } = ""; public string Runtime { get; set; } = ""; public string OS { get; set; } = ""; public string Architecture { get; set; } = ""; public string Configuration { get; set; } = ""; public string GitCommit { get; set; } = ""; public bool ColdProcess { get; set; } public int Samples { get; set; } public int Iterations { get; set; } public int WarmBatches { get; set; } public string[] Corpus { get; set; } = Array.Empty(); public List Fonts { get; set; } = new(); public PackageEvidence Packaging { get; set; } = new(); public List Raw { get; set; } = new(); public List Errors { get; set; } = new(); } + sealed class PackageEvidence { public long BundledEmbeddedBytes { get; set; } public long CustomAssetBytes { get; set; } public long RenderingAssemblyBytes { get; set; } public long GeneratedFontDataBytes { get; set; } public int GeneratedFontDataCount { get; set; } } + sealed class FontInfo { public string Name { get; set; } = ""; public string Source { get; set; } = ""; public int Bytes { get; set; } public string Sha256 { get; set; } = ""; public string[] Tables { get; set; } = Array.Empty(); public string Outline { get; set; } = ""; public string Notes { get; set; } = ""; } + sealed class Sample { public string Scenario { get; set; } = ""; public double WallMilliseconds { get; set; } public long TotalManagedAllocatedBytes { get; set; } public long RetainedManagedBytesAfterGc { get; set; } public int[] GcCollections { get; set; } = Array.Empty(); public FontInfo? Font { get; set; } public bool GlyphsAvailable { get; set; } public string GlyphCorpus { get; set; } = ""; public string[] SwitchingSequence { get; set; } = Array.Empty(); public List SelectedFaces { get; set; } = new(); public List GlobalFaces { get; set; } = new(); public List ExercisedPaths { get; set; } = new(); public List Batches { get; set; } = new(); public List Errors { get; set; } = new(); } + sealed class FaceRenderEvidence { public string Face { get; set; } = ""; public string RequestedCorpus { get; set; } = ""; public ushort[] CmapGlyphIndices { get; set; } = Array.Empty(); public bool CmapValidated { get; set; } public bool LocalTypeface { get; set; } public bool DrawSucceeded { get; set; } } + sealed class BatchResult { public int Number { get; set; } public long RetainedManagedBytesAfterGc { get; set; } } + } +} diff --git a/ReadMe.md b/ReadMe.md index 10db33eb9..dd0c62104 100644 --- a/ReadMe.md +++ b/ReadMe.md @@ -16,6 +16,23 @@ CSharpMath is a C# port of the wonderful Date: Mon, 31 Aug 2026 10:28:58 +0800 Subject: [PATCH 2/2] ci: fix font profiler project path --- .github/workflows/Benchmark.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/Benchmark.yml b/.github/workflows/Benchmark.yml index 14755b1b3..6feae09b1 100644 --- a/.github/workflows/Benchmark.yml +++ b/.github/workflows/Benchmark.yml @@ -15,7 +15,7 @@ jobs: dotnet-version: '10.x' - run: dotnet run -p CSharpMath.Rendering.Benchmarks - name: Font profile smoke - run: dotnet run -c Release -p CSharpMath.Rendering.Benchmarks -- --font-profile --font-profile-smoke --font-profile-output .fontprofile/font-profile-smoke.json + run: dotnet run -c Release --project CSharpMath.Rendering.Benchmarks/CSharpMath.Rendering.Benchmarks.csproj -- --font-profile --font-profile-smoke --font-profile-output .fontprofile/font-profile-smoke.json - name: Store font profile smoke uses: actions/upload-artifact@v4 with: