Skip to content

Reduce reflection use in generated C# code #2725

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
May 14, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 22 additions & 23 deletions crates/bindings-csharp/BSATN.Codegen/Type.cs
Original file line number Diff line number Diff line change
Expand Up @@ -422,22 +422,12 @@ public Scope.Extensions ToExtensions()

var bsatnDecls = Members.Cast<MemberDeclaration>();
var fieldNames = bsatnDecls.Select(m => m.Name);
var fieldNamesAndIds = fieldNames.Select((name, i) => (name, i));

extensions.BaseTypes.Add($"System.IEquatable<{ShortName}>");

if (Kind is TypeKind.Sum)
{
extensions.Contents.Append(
$$"""
private {{ShortName}}() { }

internal enum @enum: byte
{
{{string.Join(",\n ", fieldNames)}}
}

"""
);
extensions.Contents.Append(
string.Join(
"\n",
Expand All @@ -458,24 +448,24 @@ public override string ToString() =>
);

read = $$"""
__enumTag.Read(reader) switch {
return reader.ReadByte() switch {
{{string.Join(
"\n ",
fieldNames.Select(name =>
$"@enum.{name} => new {name}({name}.Read(reader)),"
fieldNames.Select((name, i) =>
$"{i} => new {name}({name}.Read(reader)),"
)
)}}
_ => throw new System.InvalidOperationException("Invalid tag value, this state should be unreachable.")
}
};
""";

write = $$"""
switch (value) {
{{string.Join(
"\n",
fieldNames.Select(name => $"""
fieldNames.Select((name, i) => $"""
case {name}(var inner):
__enumTag.Write(writer, @enum.{name});
writer.Write((byte){i});
{name}.Write(writer, inner);
break;
"""))}}
Expand All @@ -501,10 +491,6 @@ public override string ToString() =>
return 0;
}
""";

bsatnDecls = bsatnDecls.Prepend(
new("__enumTag", new ValueUse("@enum", "SpacetimeDB.BSATN.Enum<@enum>"))
);
}
else
{
Expand All @@ -526,6 +512,10 @@ public void WriteFields(System.IO.BinaryWriter writer) {
)}}
}

object SpacetimeDB.BSATN.IStructuralReadWrite.GetSerializer() {
return new BSATN();
}

"""
);

Expand All @@ -543,7 +533,14 @@ public override string ToString() =>
"""
);

read = $"SpacetimeDB.BSATN.IStructuralReadWrite.Read<{FullName}>(reader)";
// Directly allocating the result object here (instead of calling e.g. IStructuralReadWrite.Read<T>, which does the same thing)
// avoids generics; we've found that generics often result in reflective code being generated.
// Using simple code here hopefully helps IL2CPP and Mono do this faster.
read = $$"""
var ___result = new {{FullName}}();
___result.ReadFields(reader);
return ___result;
""";

write = "value.WriteFields(writer);";

Expand All @@ -566,7 +563,9 @@ public override string ToString() =>
{
{{MemberDeclaration.GenerateBsatnFields(Accessibility.Internal, bsatnDecls)}}

public {{FullName}} Read(System.IO.BinaryReader reader) => {{read}};
public {{FullName}} Read(System.IO.BinaryReader reader) {
{{read}}
}

public void Write(System.IO.BinaryWriter writer, {{FullName}} value) {
{{write}}
Expand Down
79 changes: 79 additions & 0 deletions crates/bindings-csharp/BSATN.Runtime/BSATN/Runtime.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,60 @@ namespace SpacetimeDB.BSATN;

using System.Text;

/// <summary>
/// Implemented by product types marked with [SpacetimeDB.Type].
/// All rows in SpacetimeDB are product types, so this is also implemented by all row types.
/// </summary>
public interface IStructuralReadWrite
{
/// <summary>
/// Initialize this value from the reader.
/// The reader is assumed to store a <see href="https://spacetimedb.com/docs/bsatn">BSATN-encoded</see>
/// value of this type.
/// Advances the reader to the first byte past the end of the read value.
/// Throws an exception if this would advance past the end of the reader.
/// </summary>
/// <param name="reader"></param>
void ReadFields(BinaryReader reader);

/// <summary>
/// Write the fields of this type to the writer.
/// Throws an exception if the underlying writer throws.
/// Throws if this value is malformed (i.e. has null values for fields that
/// are not explicitly marked nullable.)
/// </summary>
/// <param name="writer"></param>
void WriteFields(BinaryWriter writer);

/// <summary>
/// Get an IReadWrite implementation that can read values of this type.
/// In Rust, this would return <c>IReadWrite&lt;Self&gt;</c>, but the C# type system
/// has no equivalent Self type -- that is, you can't use the implementing type in type signatures
/// in an interface. So, you have to manually downcast.
/// A typical invocation looks like: <c>(IReadWrite&lt;Row&gt;) new Row().GetSerializer()</c>
///
/// This is an instance method because of limitations of C# interfaces.
/// (C# 11 has static virtual interface members, but Unity does not support C# 11.)
/// This method always works, whether or not the row it is called on is correctly initialized.
/// The returned serializer has nothing to do with the row GetSerializer is called on -- it returns
/// new rows and does not modify or interact with the original row.
///
/// Using the resulting serializer rather than <c>Read&lt;T&gt;</c> is usually faster in Mono/IL2CPP.
/// This is because we manually monomorphise the code to read rows in our automatically-generated
/// implementation of IReadWrite. This allows rows to be initialized with new() rather than reflection
/// in the compiled code.
/// </summary>
/// <returns>An <c>IReadWrite&lt;T&gt;</c> for <c>T : IStructuralReadWrite</c>.</returns>
object GetSerializer();

/// <summary>
/// Read a row from the reader.
/// This method usually uses Activator.CreateInstance to create the resulting row;
/// if this is too slow, prefer using GetSerializer.
/// </summary>
/// <typeparam name="T"></typeparam>
/// <param name="reader"></param>
/// <returns></returns>
static T Read<T>(BinaryReader reader)
where T : IStructuralReadWrite, new()
{
Expand Down Expand Up @@ -38,15 +86,43 @@ public static byte[] ToBytes<T>(T value)
}
}

/// <summary>
/// Interface for types that know how to serialize another type.
/// We auto-generate an implementation of <c>IReadWrite&lt;T&gt;</c> for all
/// types marked with <c>[SpacetimeDB.Type]</c>. For a type <c>T</c>, this implementation
/// is accessible at <c>T.BSATN</c>. The implementation is always a zero-sized struct.
/// </summary>
/// <typeparam name="T"></typeparam>
public interface IReadWrite<T>
{
/// <summary>
/// Read a BSATN-encoded value of type T from the reader.
/// Throws on end-of-stream or if the stream is malformed.
/// Advances the reader to the first byte past the end of the encoded value.
/// </summary>
/// <param name="reader"></param>
/// <returns></returns>
T Read(BinaryReader reader);

/// <summary>
/// Write a BSATN-encoded value of type T to the writer.
/// </summary>
void Write(BinaryWriter writer, T value);

/// <summary>
/// Get metadata for this type. Used in module initialization.
/// </summary>
/// <param name="registrar"></param>
/// <returns></returns>
AlgebraicType GetAlgebraicType(ITypeRegistrar registrar);
}

/// <summary>
/// Present for backwards-compatibility reasons, but no longer used.
/// The auto-generated serialization code for enum now reads/writes
/// the tag byte directly to the wire. This avoids calling into reflective code.
/// </summary>
/// <typeparam name="T"></typeparam>
public readonly struct Enum<T> : IReadWrite<T>
where T : struct, Enum
{
Expand All @@ -68,6 +144,9 @@ private static T Validate(T value)
// However, enum values are guaranteed to be sequential and zero based.
// Hence we only ever need to do an upper bound check.
// See `SpacetimeDB.Type.ParseEnum` for the syntax analysis.

// Later note: this STILL uses reflection. We've deprecated this class entirely
// because of this.
if (Convert.ToUInt64(value) >= NumVariants)
{
throw new ArgumentOutOfRangeException(
Expand Down
10 changes: 10 additions & 0 deletions crates/bindings-csharp/BSATN.Runtime/Builtins.cs
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,11 @@ public readonly void WriteFields(BinaryWriter writer)
BSATN.MicrosecondsSinceUnixEpoch.Write(writer, MicrosecondsSinceUnixEpoch);
}

readonly object IStructuralReadWrite.GetSerializer()
{
return new BSATN();
}

public readonly partial struct BSATN : IReadWrite<Timestamp>
{
internal static readonly I64 MicrosecondsSinceUnixEpoch = new();
Expand Down Expand Up @@ -463,6 +468,11 @@ public readonly void WriteFields(BinaryWriter writer)
BSATN.__time_duration_micros__.Write(writer, Microseconds);
}

readonly object IStructuralReadWrite.GetSerializer()
{
return new BSATN();
}

public readonly partial struct BSATN : IReadWrite<TimeDuration>
{
internal static readonly I64 __time_duration_micros__ = new();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ public void WriteFields(System.IO.BinaryWriter writer)
BSATN.StringField.Write(writer, StringField);
}

object SpacetimeDB.BSATN.IStructuralReadWrite.GetSerializer()
{
return new BSATN();
}

public override string ToString() =>
$"CustomClass {{ IntField = {SpacetimeDB.BSATN.StringUtil.GenericToString(IntField)}, StringField = {SpacetimeDB.BSATN.StringUtil.GenericToString(StringField)} }}";

Expand All @@ -24,8 +29,12 @@ public override string ToString() =>
internal static readonly SpacetimeDB.BSATN.I32 IntField = new();
internal static readonly SpacetimeDB.BSATN.String StringField = new();

public CustomClass Read(System.IO.BinaryReader reader) =>
SpacetimeDB.BSATN.IStructuralReadWrite.Read<CustomClass>(reader);
public CustomClass Read(System.IO.BinaryReader reader)
{
var ___result = new CustomClass();
___result.ReadFields(reader);
return ___result;
}

public void Write(System.IO.BinaryWriter writer, CustomClass value)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ public void WriteFields(System.IO.BinaryWriter writer)
BSATN.StringField.Write(writer, StringField);
}

object SpacetimeDB.BSATN.IStructuralReadWrite.GetSerializer()
{
return new BSATN();
}

public override string ToString() =>
$"CustomStruct {{ IntField = {SpacetimeDB.BSATN.StringUtil.GenericToString(IntField)}, StringField = {SpacetimeDB.BSATN.StringUtil.GenericToString(StringField)} }}";

Expand All @@ -26,8 +31,12 @@ public override string ToString() =>
internal static readonly SpacetimeDB.BSATN.I32 IntField = new();
internal static readonly SpacetimeDB.BSATN.String StringField = new();

public CustomStruct Read(System.IO.BinaryReader reader) =>
SpacetimeDB.BSATN.IStructuralReadWrite.Read<CustomStruct>(reader);
public CustomStruct Read(System.IO.BinaryReader reader)
{
var ___result = new CustomStruct();
___result.ReadFields(reader);
return ___result;
}

public void Write(System.IO.BinaryWriter writer, CustomStruct value)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,6 @@

partial record CustomTaggedEnum : System.IEquatable<CustomTaggedEnum>
{
private CustomTaggedEnum() { }

internal enum @enum : byte
{
IntVariant,
StringVariant
}

public sealed record IntVariant(int IntVariant_) : CustomTaggedEnum
{
public override string ToString() =>
Expand All @@ -26,31 +18,32 @@ public override string ToString() =>

public readonly partial struct BSATN : SpacetimeDB.BSATN.IReadWrite<CustomTaggedEnum>
{
internal static readonly SpacetimeDB.BSATN.Enum<@enum> __enumTag = new();
internal static readonly SpacetimeDB.BSATN.I32 IntVariant = new();
internal static readonly SpacetimeDB.BSATN.String StringVariant = new();

public CustomTaggedEnum Read(System.IO.BinaryReader reader) =>
__enumTag.Read(reader) switch
public CustomTaggedEnum Read(System.IO.BinaryReader reader)
{
return reader.ReadByte() switch
{
@enum.IntVariant => new IntVariant(IntVariant.Read(reader)),
@enum.StringVariant => new StringVariant(StringVariant.Read(reader)),
0 => new IntVariant(IntVariant.Read(reader)),
1 => new StringVariant(StringVariant.Read(reader)),
_
=> throw new System.InvalidOperationException(
"Invalid tag value, this state should be unreachable."
)
};
}

public void Write(System.IO.BinaryWriter writer, CustomTaggedEnum value)
{
switch (value)
{
case IntVariant(var inner):
__enumTag.Write(writer, @enum.IntVariant);
writer.Write((byte)0);
IntVariant.Write(writer, inner);
break;
case StringVariant(var inner):
__enumTag.Write(writer, @enum.StringVariant);
writer.Write((byte)1);
StringVariant.Write(writer, inner);
break;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,11 @@ public void WriteFields(System.IO.BinaryWriter writer)
BSATN.NullableReferenceField.Write(writer, NullableReferenceField);
}

object SpacetimeDB.BSATN.IStructuralReadWrite.GetSerializer()
{
return new BSATN();
}

public override string ToString() =>
$"PublicTable {{ ByteField = {SpacetimeDB.BSATN.StringUtil.GenericToString(ByteField)}, UshortField = {SpacetimeDB.BSATN.StringUtil.GenericToString(UshortField)}, UintField = {SpacetimeDB.BSATN.StringUtil.GenericToString(UintField)}, UlongField = {SpacetimeDB.BSATN.StringUtil.GenericToString(UlongField)}, U128Field = {SpacetimeDB.BSATN.StringUtil.GenericToString(U128Field)}, U256Field = {SpacetimeDB.BSATN.StringUtil.GenericToString(U256Field)}, SbyteField = {SpacetimeDB.BSATN.StringUtil.GenericToString(SbyteField)}, ShortField = {SpacetimeDB.BSATN.StringUtil.GenericToString(ShortField)}, IntField = {SpacetimeDB.BSATN.StringUtil.GenericToString(IntField)}, LongField = {SpacetimeDB.BSATN.StringUtil.GenericToString(LongField)}, I128Field = {SpacetimeDB.BSATN.StringUtil.GenericToString(I128Field)}, I256Field = {SpacetimeDB.BSATN.StringUtil.GenericToString(I256Field)}, BoolField = {SpacetimeDB.BSATN.StringUtil.GenericToString(BoolField)}, FloatField = {SpacetimeDB.BSATN.StringUtil.GenericToString(FloatField)}, DoubleField = {SpacetimeDB.BSATN.StringUtil.GenericToString(DoubleField)}, StringField = {SpacetimeDB.BSATN.StringUtil.GenericToString(StringField)}, IdentityField = {SpacetimeDB.BSATN.StringUtil.GenericToString(IdentityField)}, ConnectionIdField = {SpacetimeDB.BSATN.StringUtil.GenericToString(ConnectionIdField)}, CustomStructField = {SpacetimeDB.BSATN.StringUtil.GenericToString(CustomStructField)}, CustomClassField = {SpacetimeDB.BSATN.StringUtil.GenericToString(CustomClassField)}, CustomEnumField = {SpacetimeDB.BSATN.StringUtil.GenericToString(CustomEnumField)}, CustomTaggedEnumField = {SpacetimeDB.BSATN.StringUtil.GenericToString(CustomTaggedEnumField)}, ListField = {SpacetimeDB.BSATN.StringUtil.GenericToString(ListField)}, NullableValueField = {SpacetimeDB.BSATN.StringUtil.GenericToString(NullableValueField)}, NullableReferenceField = {SpacetimeDB.BSATN.StringUtil.GenericToString(NullableReferenceField)} }}";

Expand Down Expand Up @@ -100,8 +105,12 @@ internal static readonly SpacetimeDB.BSATN.RefOption<
SpacetimeDB.BSATN.String
> NullableReferenceField = new();

public PublicTable Read(System.IO.BinaryReader reader) =>
SpacetimeDB.BSATN.IStructuralReadWrite.Read<PublicTable>(reader);
public PublicTable Read(System.IO.BinaryReader reader)
{
var ___result = new PublicTable();
___result.ReadFields(reader);
return ___result;
}

public void Write(System.IO.BinaryWriter writer, PublicTable value)
{
Expand Down
Loading
Loading