From 44d24bf888321f42686c342b32de96108261ba9c Mon Sep 17 00:00:00 2001 From: Marc Sallin Date: Sun, 6 Sep 2026 21:08:55 +0200 Subject: [PATCH] perf: enumerate result pages without boxing SvgResult and PngResult forwarded GetEnumerator to the list behind their Pages property. The list hands its enumerator back as an IEnumerator, so every foreach boxed the struct: 80 bytes measured for one pass over an SVG result and a PNG result. A struct enumerator, which foreach binds to in preference to the interface, removes that. The interface implementations stay for LINQ and IEnumerable callers. --- src/typstsharp.tests/Tests.cs | 59 +++++++++++++++++++++++++++++++++ src/typstsharp/TypstCompiler.cs | 54 ++++++++++++++++++++++++++++-- 2 files changed, 111 insertions(+), 2 deletions(-) diff --git a/src/typstsharp.tests/Tests.cs b/src/typstsharp.tests/Tests.cs index 4a5b967..7cfc7ff 100644 --- a/src/typstsharp.tests/Tests.cs +++ b/src/typstsharp.tests/Tests.cs @@ -900,6 +900,65 @@ public async Task StreamingToAFileAsynchronouslyReturnsCompilerWarnings() } } + /// + /// foreach binds to the struct enumerator rather than the interface, so walking the pages of a + /// result costs nothing on the heap. Both results forward to a list held behind + /// IReadOnlyList, whose own enumerator would be boxed once per enumeration. + /// + [Test] + public async Task EnumeratingResultPagesDoesNotAllocate() + { + using var compiler = TypstCompiler.FromSource(TwoPageSource); + var svg = compiler.CompileSvg(); + var png = compiler.CompilePng(); + + // Warm up so that nothing on the first pass is counted. + foreach (var page in svg) { _ = page; } + foreach (var page in png) { _ = page; } + + // No await may sit between these two reads: the counter is per thread. + long before = GC.GetAllocatedBytesForCurrentThread(); + foreach (var page in svg) { _ = page; } + foreach (var page in png) { _ = page; } + long allocated = GC.GetAllocatedBytesForCurrentThread() - before; + + await Assert.That(allocated).IsEqualTo(0); + } + + /// + /// The struct enumerator must yield exactly what the indexer does, and the interface path that + /// LINQ and IEnumerable callers take has to keep working alongside it. + /// + [Test] + public async Task ResultPagesEnumerateInIndexOrderThroughBothPaths() + { + using var compiler = TypstCompiler.FromSource(TwoPageSource); + var svg = compiler.CompileSvg(); + + var byForeach = new List(); + foreach (var page in svg) + { + byForeach.Add(page); + } + + var byIndexer = Enumerable.Range(0, svg.Count).Select(i => svg[i]).ToList(); + var byLinq = svg.ToList(); + + await Assert.That(byForeach.Count).IsEqualTo(2); + await Assert.That(byForeach.SequenceEqual(byIndexer)).IsTrue(); + await Assert.That(byLinq.SequenceEqual(byIndexer)).IsTrue(); + + var png = compiler.CompilePng(); + var pngByForeach = new List(); + foreach (var page in png) + { + pngByForeach.Add(page); + } + + await Assert.That(pngByForeach.Count).IsEqualTo(png.Count); + await Assert.That(pngByForeach.SequenceEqual(png.ToList())).IsTrue(); + } + [Test] public async Task WarningsFromADocumentCannotBeMutatedByCallers() { diff --git a/src/typstsharp/TypstCompiler.cs b/src/typstsharp/TypstCompiler.cs index bbf1b22..ec57a3e 100644 --- a/src/typstsharp/TypstCompiler.cs +++ b/src/typstsharp/TypstCompiler.cs @@ -670,6 +670,40 @@ public Task SaveAsync(string path, CancellationToken cancellationToken = default File.WriteAllBytesAsync(path, Bytes, cancellationToken); } +/// +/// Walks the pages of a compile result without allocating. +/// +/// +/// and hold their pages behind +/// . Returning that list's own enumerator would box it, because the +/// list hands it back as an rather than as its own struct. Indexing +/// instead costs one interface call per page and nothing on the heap. +/// +/// The page type: an SVG string or the bytes of a PNG. +public struct PageEnumerator : IEnumerator +{ + private readonly IReadOnlyList _pages; + private int _index; + + internal PageEnumerator(IReadOnlyList pages) + { + _pages = pages; + _index = -1; + } + + public readonly T Current => _pages[_index]; + + readonly object? System.Collections.IEnumerator.Current => Current; + + public bool MoveNext() => ++_index < _pages.Count; + + public void Reset() => _index = -1; + + public readonly void Dispose() + { + } +} + /// /// Represents the result of compiling a document to SVG format (one SVG string per page). /// Supports implicit conversion to (returning the primary page SVG). @@ -678,7 +712,15 @@ public sealed record SvgResult(IReadOnlyList Pages, IReadOnlyList Pages.Count; public string this[int index] => Pages[index]; - public IEnumerator GetEnumerator() => Pages.GetEnumerator(); + + /// + /// Returns a struct enumerator, which foreach binds to in preference to the interface. + /// Forwarding straight to Pages.GetEnumerator() would hand back the underlying list's + /// enumerator through and box it once per enumeration. + /// + public PageEnumerator GetEnumerator() => new(Pages); + + IEnumerator IEnumerable.GetEnumerator() => Pages.GetEnumerator(); System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => Pages.GetEnumerator(); /// @@ -740,7 +782,15 @@ public sealed record PngResult(IReadOnlyList Pages, IReadOnlyList Pages.Count; public byte[] this[int index] => Pages[index]; - public IEnumerator GetEnumerator() => Pages.GetEnumerator(); + + /// + /// Returns a struct enumerator, which foreach binds to in preference to the interface. + /// Forwarding straight to Pages.GetEnumerator() would hand back the underlying list's + /// enumerator through and box it once per enumeration. + /// + public PageEnumerator GetEnumerator() => new(Pages); + + IEnumerator IEnumerable.GetEnumerator() => Pages.GetEnumerator(); System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => Pages.GetEnumerator(); ///