Fix for performance penalty in deep dependencies (#20392)

The goal of this PR is to fix #20371 (closed) #24267 and #24330 and improve the performance of csharp runtime when dealing with deep nested dependencies of .proto files.

- The fix consists in caching the recursive calls for the search of extensions.

- Two identical datasets (nested proto files) are created automatically using Bazel: one to test it with caching enabled, and another one to test it with no caching. Note: the same dataset cannot be used because once it is loaded inside the test, it cannot be unloaded.

- The created datasets have a width of 6 and a depth of 6, enough to showcase the dependency impact and to not impact the testing time for unit tests. This could be configured.

- In the dataset there is only one proto file with a message `Example` whose descriptor is loaded, and by doing so all the dependencies (and sub dependencies) that it has.

- A set of benchmark metrics have been created to evaluate the performance impact before and after the fix.

- The assertion inside the unit test makes sure that when using metrics the number of traversed extensions (dependencies of the dependencies) when using caching, is reduced by a factor of 1000x.

- The second assertion also makes sure that the time used to load the descriptor when using cache is less than the one without using cache.

Closes #20392

COPYBARA_INTEGRATE_REVIEW=https://github.com/protocolbuffers/protobuf/pull/20392 from fgarciacorona:fix_20371_csharp_deep_dependencies b1353f6fd1
PiperOrigin-RevId: 919713760
This commit is contained in:
Fernando Garcia Corona 2026-05-22 09:23:52 -07:00 committed by Copybara-Service
parent 3981478dda
commit aeb0dc2f97
7 changed files with 482 additions and 6 deletions

4
csharp/.gitignore vendored
View file

@ -34,6 +34,10 @@ lib/protoc.exe
# Benchmark output
BenchmarkDotNet.Artifacts/
# Auto generated csharp classes
UnittestDeepDependenciesCached/
UnittestDeepDependenciesNotCached/
# Reinstate generated code for test files
!any_test.pb.*
!map*unittest.pb.*

View file

@ -89,6 +89,30 @@ $PROTOC -Isrc -I. -Ijava/core/src/main/resources/ \
src/google/protobuf/unittest_proto3_optional.proto \
src/google/protobuf/unittest_retention.proto
# We can safely ignore the unused import warning as the
# purpose of the test is to work with the dependencies
# Deep dependencies test protos - Cached version
# Proto files are generated by Bazel: bazel build //csharp/protos/unittest_deep_dependencies:generate_cached_protos
mkdir -p csharp/src/Google.Protobuf.Test.TestProtos/UnittestDeepDependenciesCached
$PROTOC --proto_path=bazel-bin/csharp/protos/unittest_deep_dependencies \
--experimental_allow_proto3_optional \
--experimental_editions \
--csharp_out=csharp/src/Google.Protobuf.Test.TestProtos/UnittestDeepDependenciesCached \
--csharp_opt=file_extension=.pb.cs \
--csharp_opt=base_namespace=UnittestDeepDependenciesCached \
$(find bazel-bin/csharp/protos/unittest_deep_dependencies/unittest_deep_dependencies_cached -name "*.proto")
# Deep dependencies test protos - NotCached version
# Proto files are generated by Bazel: bazel build //csharp/protos/unittest_deep_dependencies:generate_notcached_protos
mkdir -p csharp/src/Google.Protobuf.Test.TestProtos/UnittestDeepDependenciesNotCached
$PROTOC --proto_path=bazel-bin/csharp/protos/unittest_deep_dependencies \
--experimental_allow_proto3_optional \
--experimental_editions \
--csharp_out=csharp/src/Google.Protobuf.Test.TestProtos/UnittestDeepDependenciesNotCached \
--csharp_opt=file_extension=.pb.cs \
--csharp_opt=base_namespace=UnittestDeepDependenciesNotCached \
$(find bazel-bin/csharp/protos/unittest_deep_dependencies/unittest_deep_dependencies_not_cached -name "*.proto")
# AddressBook sample protos
$PROTOC -Iexamples -Isrc --csharp_out=csharp/src/AddressBook \
--csharp_opt=file_extension=.pb.cs \

View file

@ -0,0 +1,35 @@
"""
Exponential dependency proto structure for testing deep dependencies.
Width=6, Depth=6, creating 6^6=46,656 dependency paths.
This generates two variants:
- Cached version: WITH caching
- NotCached version: WITHOUT caching
Structure:
- Level 0: 1 base file
- Level 1: 6 files, each importing Level 0
- Levels 2-5: 6 files per level, each importing ALL 6 files from previous level
- Level 6: 1 final file importing all 6 Level 5 files
- Entry point: Example/ExampleNotCached message importing the Level 6 file
NOTE: This build file is designed for MODULE.bazel (bzlmod).
Use:
bazel build //csharp/protos/unittest_deep_dependencies:generate_cached_protos
bazel build //csharp/protos/unittest_deep_dependencies:generate_notcached_protos
"""
load(":generate_protos.bzl", "generate_deep_dependencies_protos")
package(default_visibility = ["//csharp:__subpackages__"])
# Configuration
WIDTH = 6
DEPTH = 6
# Generate both cached and notcached variants
generate_deep_dependencies_protos(
depth = DEPTH,
width = WIDTH,
)

View file

@ -0,0 +1,256 @@
"""
Macro to generate exponential dependency proto structure for both cached and notcached variants.
Generates proto files into separate subdirectories without duplication.
"""
load("//bazel:proto_library.bzl", "proto_library")
def _pad(n):
"""Zero-pad a number to 2 digits."""
return str(n) if n >= 10 else "0" + str(n)
def generate_deep_dependencies_protos(width, depth):
"""
Generate proto files and proto_library targets for both cached and notcached variants.
Args:
width: Number of files per level (except last level which has 1)
depth: Number of levels
"""
# Generate both variants
_generate_variant("cached", width, depth, "unittest_deep_dependencies_cached")
_generate_variant("notcached", width, depth, "unittest_deep_dependencies_not_cached")
def _generate_variant(variant, width, depth, package):
"""
Generate proto files and libraries for a specific variant.
Args:
variant: "cached" or "notcached"
width: Number of files per level
depth: Number of levels
package: Proto package name
"""
# Calculate all proto file names - use package name for the directory
all_files = [package + "/file_00_00.proto"]
for level in range(1, depth + 1):
num_files = width if level < depth else 1
for idx in range(num_files):
all_files.append(package + "/file_" + _pad(level) + "_" + _pad(idx) + ".proto")
all_files.append(package + "/file_entry.proto")
# Single genrule to generate ALL proto files for this variant
_generate_all_protos(
name = "generate_" + variant + "_protos",
variant = variant,
width = width,
depth = depth,
package = package,
all_files = all_files,
)
# Level 0: Base proto_library
proto_library(
name = variant + "_file_00_00_proto",
srcs = [":" + package + "/file_00_00.proto"],
strip_import_prefix = "/third_party/protobuf/csharp/protos/unittest_deep_dependencies",
)
# Level 1: WIDTH proto_library targets
for i in range(width):
i_pad = _pad(i)
proto_library(
name = variant + "_file_01_" + i_pad + "_proto",
srcs = [":" + package + "/file_01_" + i_pad + ".proto"],
strip_import_prefix = "/third_party/protobuf/csharp/protos/unittest_deep_dependencies",
deps = [":" + variant + "_file_00_00_proto"],
)
# Levels 2 through DEPTH
for level in range(2, depth + 1):
num_files = width if level < depth else 1
level_pad = _pad(level)
level_prev_pad = _pad(level - 1)
for idx in range(num_files):
idx_pad = _pad(idx)
deps = [
":" + variant + "_file_" + level_prev_pad + "_" + _pad(prev_idx) + "_proto"
for prev_idx in range(width)
]
proto_library(
name = variant + "_file_" + level_pad + "_" + idx_pad + "_proto",
srcs = [":" + package + "/file_" + level_pad + "_" + idx_pad + ".proto"],
strip_import_prefix = "/third_party/protobuf/csharp/protos/unittest_deep_dependencies",
deps = deps,
)
# Entry point proto_library
proto_library(
name = variant + "_entry_proto",
srcs = [":" + package + "/file_entry.proto"],
strip_import_prefix = "/third_party/protobuf/csharp/protos/unittest_deep_dependencies",
deps = [":" + variant + "_file_" + _pad(depth) + "_00_proto"],
)
# Generate C# source code (.pb.cs) from these generated .proto files
_generate_csharp_sources(
name = variant + "_csharp_gen",
variant = variant,
all_files = all_files,
)
def _generate_all_protos(name, variant, width, depth, package, all_files):
"""Generate a single genrule that creates all proto files for a variant.
These files only contain imports to create deep dependency chains.
Only the entry file has an actual message.
"""
# Build the command to generate all files
cmd_parts = []
if variant == "cached":
# Cached version - Level 0 with a message
cmd_parts.append("""
mkdir -p $(RULEDIR)/%s
cat > $(RULEDIR)/%s/file_00_00.proto << 'EOFPROTO'
syntax = "proto3";
package %s;
message Level00Message {
string field = 1;
}
EOFPROTO
""" % (package, package, package))
# Level 1 files - with messages
for i in range(width):
i_pad = _pad(i)
cmd_parts.append("""
cat > $(RULEDIR)/%s/file_01_%s.proto << 'EOFPROTO'
syntax = "proto3";
package %s;
import "%s/file_00_00.proto";
message Level01Message%s {
Level00Message field = 1;
}
EOFPROTO
""" % (package, i_pad, package, package, i_pad))
else:
# Notcached version - Level 0 empty
cmd_parts.append("""
mkdir -p $(RULEDIR)/%s
cat > $(RULEDIR)/%s/file_00_00.proto << 'EOFPROTO'
syntax = "proto3";
package %s;
EOFPROTO
""" % (package, package, package))
# Level 1 files - just imports
for i in range(width):
i_pad = _pad(i)
cmd_parts.append("""
cat > $(RULEDIR)/%s/file_01_%s.proto << 'EOFPROTO'
syntax = "proto3";
package %s;
import "%s/file_00_00.proto";
EOFPROTO
""" % (package, i_pad, package, package))
# Levels 2 through DEPTH - just imports, no messages
for level in range(2, depth + 1):
num_files = width if level < depth else 1
level_pad = _pad(level)
level_prev_pad = _pad(level - 1)
for idx in range(num_files):
idx_pad = _pad(idx)
import_lines = []
for prev_idx in range(width):
import_lines.append('import "%s/file_%s_%s.proto";' % (package, level_prev_pad, _pad(prev_idx)))
imports_str = "\n".join(import_lines)
cmd_parts.append("""
cat > $(RULEDIR)/%s/file_%s_%s.proto << 'EOFPROTO'
syntax = "proto3";
package %s;
%s
EOFPROTO
""" % (package, level_pad, idx_pad, package, imports_str))
# Entry point file
if variant == "cached":
message_type = "Example"
else:
message_type = "ExampleNotCached"
cmd_parts.append("""
cat > $(RULEDIR)/%s/file_entry.proto << 'EOFPROTO'
syntax = "proto3";
package %s;
import "%s/file_%s_00.proto";
message %s {
string test_field = 1;
}
EOFPROTO
""" % (package, package, package, _pad(depth), message_type))
native.genrule(
name = name,
outs = all_files,
cmd = "".join(cmd_parts),
)
def _generate_csharp_sources(name, variant, all_files):
"""Generate C# source files from generated proto files using a single genrule."""
base_namespace = "UnittestDeepDependenciesCached" if variant == "cached" else "UnittestDeepDependenciesNotCached"
# Translate each proto file path to its expected C# output file name
csharp_outputs = []
for f in all_files:
basename = f.split("/")[-1] # "file_00_00.proto"
name_no_ext = basename.split(".proto")[0] # "file_00_00"
csharp_name = ""
for part in name_no_ext.split("_"):
if len(part) > 0:
csharp_name += part[0].upper() + part[1:]
csharp_outputs.append(variant + "_cs/" + csharp_name + ".pb.cs")
# Invoke protoc hermetically with the correct base namespace and include path
native.genrule(
name = name,
testonly = True,
srcs = [":" + f for f in all_files],
outs = csharp_outputs,
cmd = (
"$(location //net/proto2/compiler/public:protocol_compiler) " +
"--csharp_out=$(RULEDIR)/" + variant + "_cs " +
"--csharp_opt=file_extension=.pb.cs " +
"--csharp_opt=base_namespace=" + base_namespace + " " +
"-I$$(dirname $$(dirname $(location :" + all_files[0] + "))) " +
"$(SRCS)"
),
tools = ["//net/proto2/compiler/public:protocol_compiler"],
visibility = ["//src/google/protobuf/csharp:__subpackages__"],
)

View file

@ -13,7 +13,9 @@ using NUnit.Framework;
using ProtobufUnittest;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Reflection;
using UnitTest.Issues.TestProtos;
using static Google.Protobuf.Reflection.FeatureSet.Types;
using proto2 = Google.Protobuf.TestProtos.Proto2;
@ -35,6 +37,67 @@ namespace Google.Protobuf.Reflection
UnittestImportPublicProto3Reflection.Descriptor);
}
/// <summary>
/// Performance test comparing descriptor loading WITH vs WITHOUT extension caching.
///
/// This test uses TWO separate proto sets to measure both scenarios in a single execution:
/// - unittest_deep_dependencies_cached: Example message (WITH caching - default)
/// - unittest_deep_dependencies_notcached: ExampleNotcached message (WITHOUT caching)
///
/// Both proto sets have identical structure (WIDTH=6, DEPTH=6, 33 files total) but different
/// package names, allowing accurate measurement of caching performance in one test run.
/// </summary>
[Test]
public void FileDescriptor_ExtensionCachingPerformance()
{
// ========== Part 1: Test WITH caching (default behavior) ==========
#if DEBUG
FileDescriptor.ResetCounters();
#endif
var stopwatchWithCache = Stopwatch.StartNew();
// Access the Descriptor property to trigger static initialization if not already done.
// The first access to any member of the generated Reflection class triggers the static
// constructor which calls FileDescriptor.FromGeneratedCode(), and that's when traversal happens.
var descriptor = UnittestDeepDependenciesCached.Example.Descriptor;
var exampleWithCache = new UnittestDeepDependenciesCached.Example();
stopwatchWithCache.Stop();
#if DEBUG
var traversalsWithCache = FileDescriptor.GetAllDependedExtensionsCount;
// Verify caching is working: should have minimal redundant traversals
Assert.Less(traversalsWithCache, 100,
$"Should have very few redundant traversals with cache, but got {traversalsWithCache}");
#endif
// ========== Part 2: Test WITHOUT caching ==========
FileDescriptor.DisableExtensionCaching();
#if DEBUG
FileDescriptor.ResetCounters();
#endif
var stopwatchNoCache = Stopwatch.StartNew();
// Force descriptor initialization by accessing it - this is when extension traversal happens
var descriptorNoCache = UnittestDeepDependenciesNotCached.ExampleNotCached.Descriptor;
var exampleNoCache = new UnittestDeepDependenciesNotCached.ExampleNotCached();
stopwatchNoCache.Stop();
#if DEBUG
var traversalsWithoutCache = FileDescriptor.GetAllDependedExtensionsCount;
// Verify the problem: massive redundant traversals
Assert.Greater(traversalsWithoutCache, 8000,
$"Should have thousands of redundant traversals without cache, but got {traversalsWithoutCache}");
var improvement = (double) traversalsWithoutCache / Math.Max(1, traversalsWithCache);
Assert.Greater(improvement, 1000, $"Traversals Improvement = WITHOUT cache: {traversalsWithoutCache:N0} / WITH cache: {traversalsWithCache:N0} = {improvement:F0}x should be > 1000x !");
#endif
Assert.Greater(stopwatchNoCache.Elapsed, stopwatchWithCache.Elapsed, $"Traversals time: WITHOUT cache: {stopwatchNoCache.Elapsed} should be > WITH cache: {stopwatchWithCache.Elapsed} !");
}
[Test]
public void FileDescriptor_BuildFromByteStrings()
{

View file

@ -7,11 +7,16 @@
// https://developers.google.com/open-source/licenses/bsd
#endregion
#if DEBUG
#define WITH_BENCHMARKING
#endif
using Google.Protobuf.Collections;
using Google.Protobuf.WellKnownTypes;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using System.Threading;
using static Google.Protobuf.Reflection.SourceCodeInfo.Types;
@ -63,6 +68,62 @@ namespace Google.Protobuf.Reflection
private readonly Lazy<Dictionary<IDescriptor, DescriptorDeclaration>> declarations;
private static readonly Dictionary<string, List<Extension>> allDependedExtensionsCache = new();
private static bool extensionCachingEnabled = true;
/// <summary>
/// Disables caching of discovered extensions. Greatly reduces performance in highly nested protos but lowers the memory usage.
/// </summary>
public static void DisableExtensionCaching()
{
extensionCachingEnabled = false;
}
#if WITH_BENCHMARKING
/// <summary>
/// Resets the benchmarking counters.
/// </summary>
public static void ResetCounters()
{
GetAllExtensionsCount = 0;
TotalReturnedExtensionsCount = 0;
GetAllGeneratedExtensionsCount = 0;
GetAllDependedExtensionsCount = 0;
GetAllDependedExtensionsFromMessageCount = 0;
}
internal static long GetAllExtensionsCount { get; private set; }
internal static long TotalReturnedExtensionsCount { get; private set;}
internal static long GetAllGeneratedExtensionsCount { get; private set; }
internal static long GetAllDependedExtensionsCount { get; private set; }
internal static long GetAllDependedExtensionsFromMessageCount { get; private set; }
internal static TextWriter WriteBenchmark(TextWriter writer)
{
if (writer == null)
{
return writer;
}
writer.WriteLine($"GetAllExtensionsCount: {GetAllExtensionsCount}");
writer.WriteLine($"GetAllGeneratedExtensionsCount: {GetAllGeneratedExtensionsCount}");
writer.WriteLine($"GetAllDependedExtensionsCount: {GetAllDependedExtensionsCount}");
writer.WriteLine($"GetAllDependedExtensionsFromMessageCount: {GetAllDependedExtensionsFromMessageCount}");
writer.WriteLine($"TotalReturnedExtensionsCount: {TotalReturnedExtensionsCount}");
return writer;
}
#else
internal static TextWriter WriteBenchmark(TextWriter writer)
{
writer?.WriteLine("Benchmarking is disabled");
return writer;
}
#endif
private FileDescriptor(ByteString descriptorData, FileDescriptorProto proto, IList<FileDescriptor> dependencies, DescriptorPool pool, bool allowUnknownDependencies, GeneratedClrTypeInfo generatedCodeInfo)
{
SerializedData = descriptorData;
@ -417,7 +478,7 @@ namespace Google.Protobuf.Reflection
FileDescriptor[] dependencies,
GeneratedClrTypeInfo generatedCodeInfo)
{
ExtensionRegistry registry = new ExtensionRegistry();
ExtensionRegistry registry = new();
registry.AddRange(GetAllExtensions(dependencies, generatedCodeInfo));
FileDescriptorProto proto;
@ -444,29 +505,60 @@ namespace Google.Protobuf.Reflection
private static IEnumerable<Extension> GetAllExtensions(FileDescriptor[] dependencies, GeneratedClrTypeInfo generatedInfo)
{
return dependencies.SelectMany(GetAllDependedExtensions).Distinct(ExtensionRegistry.ExtensionComparer.Instance).Concat(GetAllGeneratedExtensions(generatedInfo));
#if WITH_BENCHMARKING
GetAllExtensionsCount++;
#endif
var allExtensions = dependencies.SelectMany(GetAllDependedExtensions).Distinct(ExtensionRegistry.ExtensionComparer.Instance).Concat(GetAllGeneratedExtensions(generatedInfo)).ToList();
#if WITH_BENCHMARKING
TotalReturnedExtensionsCount += allExtensions.Count;
#endif
return allExtensions;
}
private static IEnumerable<Extension> GetAllGeneratedExtensions(GeneratedClrTypeInfo generated)
{
return generated.Extensions.Concat(generated.NestedTypes.Where(t => t != null).SelectMany(GetAllGeneratedExtensions));
#if WITH_BENCHMARKING
GetAllGeneratedExtensionsCount++;
#endif
return generated.Extensions.Concat(generated.NestedTypes.Where(t => t != null).SelectMany(GetAllGeneratedExtensions)).ToList();
}
private static IEnumerable<Extension> GetAllDependedExtensions(FileDescriptor descriptor)
{
return descriptor.Extensions.UnorderedExtensions
if (extensionCachingEnabled && allDependedExtensionsCache.TryGetValue(descriptor.Name, out List<Extension> cachedExtensions))
{
return cachedExtensions;
}
#if WITH_BENCHMARKING
GetAllDependedExtensionsCount++;
#endif
var extensions = descriptor.Extensions.UnorderedExtensions
.Select(s => s.Extension)
.Where(e => e != null)
.Concat(descriptor.Dependencies.Concat(descriptor.PublicDependencies).SelectMany(GetAllDependedExtensions))
.Concat(descriptor.MessageTypes.SelectMany(GetAllDependedExtensionsFromMessage));
.Concat(descriptor.MessageTypes.SelectMany(GetAllDependedExtensionsFromMessage)).ToList();
if (extensionCachingEnabled)
{
allDependedExtensionsCache[descriptor.Name] = extensions;
}
return extensions;
}
private static IEnumerable<Extension> GetAllDependedExtensionsFromMessage(MessageDescriptor descriptor)
{
#if WITH_BENCHMARKING
GetAllDependedExtensionsFromMessageCount++;
#endif
return descriptor.Extensions.UnorderedExtensions
.Select(s => s.Extension)
.Where(e => e != null)
.Concat(descriptor.NestedTypes.SelectMany(GetAllDependedExtensionsFromMessage));
.Concat(descriptor.NestedTypes.SelectMany(GetAllDependedExtensionsFromMessage)).ToList();
}
/// <summary>

View file

@ -37,6 +37,8 @@ done
# shell script that generates everything required. The output files are stable,
# so just regenerating in place should be harmless.
${BazelBin} build src/google/protobuf/compiler:protoc "$@"
${BazelBin} build //csharp/protos/unittest_deep_dependencies:generate_cached_protos "$@"
${BazelBin} build //csharp/protos/unittest_deep_dependencies:generate_notcached_protos "$@"
(export PROTOC=$PWD/bazel-bin/protoc && cd csharp && ./generate_protos.sh)
echo "::endgroup::"