perf: avoid line allocations when writing generated source - #6781
Conversation
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Advanced Run ID: 📒 Files selected for processing (2)
Included review availability: Your plan provides up to 8 included reviews per hour; 6 remain after this review. 📝 WalkthroughWalkthrough
ChangesAppendRaw formatting
Priority: ⬇️ Low Estimated code review effort: 2 (Simple) | ~15 minutes Change: Refactor Merge Risk: ⚪ Minimal · up to The AppendRaw rewrite preserves its tested formatting and write-state behavior, with no current merge-blocking risk identified. 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. A rabbit checks each line with care Comment |
|
Reproduction and full evidence The benchmark loads the actual generator assemblies into separate
Build dotnet run -c Release -- --filter '*' --job Dry
dotnet run -c Release --no-build -- --filter '*RawWriterBenchmarks*' --iterationCount 15 --warmupCount 6 --launchCount 1 --exporters json
dotnet run -c Release --no-build -- --filter '*MetadataGeneratorBenchmarks*' --iterationCount 15 --warmupCount 6 --launchCount 1 --invocationCount 1 --unrollFactor 1 --exporters jsonThe writer workload includes construction, writing 100 repeated three-line fragments, and converting the result to a string. Reflection and expression compilation occur only in setup; measured calls use compiled delegates. The generator workload has 100 classes with 100 tests each. Setup checks compilation errors, generator diagnostics, and exact equality of all 100 generated source files. Each measured operation runs the generator from the initial driver state; returned drivers are not retained, so it does not benchmark cached no-op generation. Parsing and initial compilation validation occur outside measurement. Regression commands: dotnet build src/TUnit.Core/TUnit.Core.csproj -c Release -f netstandard2.0
dotnet run --project tests/TUnit.Core.SourceGenerator.Tests -c Release -f net10.0
dotnet run --project tests/TUnit.Core.SourceGenerator.Tests -c Release -f net472 -- --treenode-filter '/*/*/CodeWriterTests/*'
dotnet build src/TUnit.Core.SourceGenerator.Roslyn414/TUnit.Core.SourceGenerator.Roslyn414.csproj -c Release
dotnet build src/TUnit.Core.SourceGenerator.Roslyn44/TUnit.Core.SourceGenerator.Roslyn44.csproj -c ReleaseResults: 134 passed / 1 existing skip in the complete generator suite; 12 passed on .NET Framework 4.7.2; both variant builds passed without warnings or errors. Existing snapshots remained unchanged. The additional differential script below checked 1,000 randomized sequences against both actual assemblies, including different indentation levels, prefixes, suffixes, and Unicode whitespace; all outputs matched exactly. Place Writer results:
Generator results:
Benchmark project and source<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="BenchmarkDotNet" Version="0.15.8" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.14.0" />
<Reference Include="TUnit.Core"><HintPath>TUnit.Core.dll</HintPath></Reference>
<None Update="before/*.dll;after/*.dll" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using System.Linq.Expressions;
using System.Reflection;
using System.Runtime.Loader;
using System.Text;
BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args);
public static class Versions
{
public static Assembly Load(string name)
{
var assembly = new AssemblyLoadContext(name).LoadFromAssemblyPath(
Path.Combine(AppContext.BaseDirectory, name, "TUnit.Core.SourceGenerator.dll"));
Console.WriteLine($"{name} MVID: {assembly.ManifestModule.ModuleVersionId}");
return assembly;
}
}
[MemoryDiagnoser]
public class MetadataGeneratorBenchmarks
{
[Params("Bare", "InlineData")]
public string Scenario { get; set; } = "Bare";
private CSharpCompilation _compilation = null!;
private GeneratorDriver _before = null!;
private GeneratorDriver _after = null!;
[GlobalSetup]
public void Setup()
{
var trees = new List<SyntaxTree>();
for (var c = 0; c < 100; c++)
{
var source = new StringBuilder("using System; using TUnit.Core; public class Tests" + c + " {");
for (var m = 0; m < 100; m++)
{
source.Append("[Test]");
source.Append(Scenario == "InlineData" ? "[Arguments(42)] public void Test" : "public void Test");
source.Append(m).Append(Scenario == "InlineData" ? "(int value) {" : "() {");
source.Append("int x = 42; if (x * x + 1 != 1765) throw new Exception(); }");
}
source.Append('}');
trees.Add(CSharpSyntaxTree.ParseText(source.ToString()));
}
var references = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!).Split(Path.PathSeparator)
.Select(p => MetadataReference.CreateFromFile(p)).ToList();
references.Add(MetadataReference.CreateFromFile(typeof(TUnit.Core.TestAttribute).Assembly.Location));
_compilation = CSharpCompilation.Create("SyntheticSuite", trees, references,
new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
var errors = _compilation.GetDiagnostics().Where(d => d.Severity == DiagnosticSeverity.Error).ToArray();
if (errors.Length != 0) throw new InvalidOperationException(string.Join("\n", errors.Select(e => e.ToString())));
_before = LoadDriver("before");
_after = LoadDriver("after");
var beforeResult = _before.RunGenerators(_compilation).GetRunResult();
var afterResult = _after.RunGenerators(_compilation).GetRunResult();
if (beforeResult.Diagnostics.Concat(afterResult.Diagnostics).Any(d => d.Severity == DiagnosticSeverity.Error))
throw new InvalidOperationException("Generator diagnostics");
var beforeSources = beforeResult.Results.Single().GeneratedSources;
var afterSources = afterResult.Results.Single().GeneratedSources;
if (beforeSources.Length != 100 || afterSources.Length != 100 ||
!beforeSources.Select(s => (s.HintName, s.SourceText.ToString()))
.SequenceEqual(afterSources.Select(s => (s.HintName, s.SourceText.ToString()))))
throw new InvalidOperationException("Generated sources differ or are missing.");
}
private static GeneratorDriver LoadDriver(string name) => CSharpGeneratorDriver.Create(
(IIncrementalGenerator)Activator.CreateInstance(Versions.Load(name).GetType(
"TUnit.Core.SourceGenerator.Generators.TestMetadataGenerator")!)!);
[Benchmark(Baseline = true)]
public GeneratorDriver Before() => _before.RunGenerators(_compilation);
[Benchmark]
public GeneratorDriver After() => _after.RunGenerators(_compilation);
}
[MemoryDiagnoser]
public class RawWriterBenchmarks
{
private Func<string, string> _before = null!;
private Func<string, string> _after = null!;
private string _text = null!;
[GlobalSetup]
public void Setup()
{
_before = LoadWriter("before");
_after = LoadWriter("after");
_text = string.Concat(Enumerable.Repeat(" new TestEntry(methodIndex: 42,\r\n attributes: __Attributes), \r\n\r\n", 100));
if (_before(_text) != _after(_text)) throw new InvalidOperationException("Writer output differs.");
}
private static Func<string, string> LoadWriter(string name)
{
var type = Versions.Load(name).GetType("TUnit.Core.SourceGenerator.CodeWriter")!;
var text = Expression.Parameter(typeof(string));
var writer = Expression.Variable(type);
var body = Expression.Block([writer],
Expression.Assign(writer, Expression.New(type.GetConstructor([typeof(string), typeof(bool)])!,
Expression.Constant(" "), Expression.Constant(false))),
Expression.Call(writer, type.GetMethod("Indent")!),
Expression.Call(writer, type.GetMethod("AppendRaw")!, text),
Expression.Call(writer, type.GetMethod("ToString")!));
return Expression.Lambda<Func<string, string>>(body, text).Compile();
}
[Benchmark(Baseline = true)]
public string Before() => _before(_text);
[Benchmark]
public string After() => _after(_text);
}Differential verification script$ErrorActionPreference = 'Stop'
$types = foreach ($version in @('before', 'after')) {
$context = [System.Runtime.Loader.AssemblyLoadContext]::new($version)
$assembly = $context.LoadFromAssemblyPath((Join-Path $PSScriptRoot "RawGeneratorBench/$version/TUnit.Core.SourceGenerator.dll"))
$assembly.GetType('TUnit.Core.SourceGenerator.CodeWriter')
}
$random = [Random]::new(20260911)
$characters = [char[]]@('a', 'b', ' ', "`t", "`r", "`n", [char]0x85, [char]0xa0, [char]0x2003, [char]0x2028, [char]0)
for ($case = 0; $case -lt 1000; $case++) {
$indent = if ($case % 2) { '--' } else { ' ' }
$writers = foreach ($type in $types) {
[Activator]::CreateInstance($type, [object[]]@($indent, $false))
}
foreach ($writer in $writers) {
$writer.SetIndentLevel($case % 4) | Out-Null
if ($case % 3) { $writer.Append('prefix:') | Out-Null }
}
for ($fragment = 0; $fragment -lt 3; $fragment++) {
$buffer = [char[]]::new($random.Next(0, 100))
for ($i = 0; $i -lt $buffer.Length; $i++) {
$buffer[$i] = $characters[$random.Next($characters.Length)]
}
$inputText = [string]::new($buffer)
foreach ($writer in $writers) { $writer.AppendRaw($inputText) | Out-Null }
}
foreach ($writer in $writers) { $writer.Append('tail') | Out-Null }
if (-not [string]::Equals($writers[0].ToString(), $writers[1].ToString(), [StringComparison]::Ordinal)) {
throw "Output differs in randomized case $case."
}
}
Write-Output 'PASS: 1,000 randomized sequences of three AppendRaw calls match baseline byte-for-byte.'
|
Greptile SummaryThis PR optimizes generated-source writing by scanning multiline text directly instead of allocating an array and individual trimmed strings.
Confidence Score: 5/5The PR appears safe to merge, with no actionable correctness, compatibility, security, or repository-rule issues identified. The optimized implementation maintains the existing writer-state and formatting contracts, uses APIs supported by every configured generator target, and is covered by targeted cross-framework tests.
|
| Filename | Overview |
|---|---|
| src/TUnit.Core.SourceGenerator/CodeWriter.cs | Replaces allocation-heavy splitting in AppendRaw with behaviorally equivalent indexed scanning and direct appends. |
| tests/TUnit.Core.SourceGenerator.Tests/CodeWriterTests.cs | Adds focused regression coverage for formatting, writer state, newline variants, and whitespace edge cases. |
Reviews (1): Last reviewed commit: "perf: avoid splitting raw generated sour..." | Re-trigger Greptile
CodeWriter.AppendRawsplits every generated fragment into an array of line strings and trims each string before writing it. The per-class test-metadata pipeline calls it repeatedly while assembling generated source.Scan the original string and append each line directly with
StringBuilder.Append(string, startIndex, count). Defer blank lines until the next content line so leading/trailing blank lines remain omitted and interior blank lines remain intact. Preserve indentation, partial-line writes, CR/LF/CRLF handling, and Unicode trailing-whitespace trimming. Generated source is unchanged.BenchmarkDotNet 0.15.8 results using actual baseline (
b43fecac6d) and modified generator assemblies:The writer is about 2.9× faster and allocates 46% less in its focused workload. The complete metadata-generation passes allocate approximately 9% and 7% less. Timing varies in the larger workloads, and the inline-data confidence intervals overlap; its timing improvement is inconclusive. These measurements exclude parsing and compiling generated code, so they do not establish a whole-build speedup.
Validation:
The PR comment includes full benchmark reports, confidence intervals, source, and reproduction commands. Environment: Windows 11, i7-12700K, .NET 10.0.12, SDK 11.0.100-preview.7.26381.103; 15 measured iterations, 6 warmups, 1 launch. Generator passes use one full suite per iteration.
Summary by CodeRabbit
Bug Fixes
Tests