Skip to content
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

Optimize System.String overloads #994

Open
jonpryor opened this issue Jun 16, 2022 · 3 comments
Open

Optimize System.String overloads #994

jonpryor opened this issue Jun 16, 2022 · 3 comments
Labels
enhancement Proposed change to current functionality generator Issues binding a Java library (generator, class-parse, etc.)

Comments

@jonpryor
Copy link
Member

generator emits System.String overloads for Java members which accept or return java.lang.ICharSequence. For example, consider TextView.setText(), which is combined with getText() into a TextFormatted property:

namespace Android.Widget {
	public partial class TextView {
		public unsafe Java.Lang.ICharSequence? TextFormatted {
			get =>,
			set {
				const string __id = "setText.(Ljava/lang/CharSequence;)V";
				IntPtr native_value = CharSequence.ToLocalJniHandle (value);
				try {
					JniArgumentValue* __args = stackalloc JniArgumentValue [1];
					__args [0] = new JniArgumentValue (native_value);
					_members.InstanceMethods.InvokeNonvirtualVoidMethod (__id, this, __args);
				} finally {
					JNIEnv.DeleteLocalRef (native_value);
					global::System.GC.KeepAlive (value);
				}
			}
		}
	}
}

To simplify use, generator "overloads" this property to accept System.String:

namespace Android.Widget {
	public partial class TextView {
		public string? Text {
			get =>  TextFormatted == null ? null : TextFormatted.ToString (),
			set {
				var jls = value == null ? null : new global::Java.Lang.String (value);
				TextFormatted = jls;
				if (jls != null) jls.Dispose ();
			}
		}
	}
}

This works, but has some implicit overheads: creating Java.Lang.String instances requires registering them with the JniRuntime.JniValueManager, which is "silly" when we will immediately Dispose() of the value. This can show up when profiling apps which call TextView.set_Text() frequently.

We could optimize these "overloads" by skipping Java.Lang.String instances, and instead using JniObjectReference and JniEnvironment.Strings.NewString(string?).

Unfortunately, just because we can doesn't mean that it will be straightforward. There are two "reasonable" ways to implement this:

Copy most of the code from e.g. set_TextFormatted() into set_Text():

namespace Android.Widget {
	public partial class TextView {
		public unsafe string? Text {
			set {
				const string __id = "setText.(Ljava/lang/CharSequence;)V";
				JniObjectReference native_value = JniEnvironment.Strings.NewString (value);
				try {
					JniArgumentValue* __args = stackalloc JniArgumentValue [1];
					__args [0] = new JniArgumentValue (native_value);
					_members.InstanceMethods.InvokeNonvirtualVoidMethod (__id, this, __args);
				} finally {
					JniObjectReference.Dispose (ref native_value);
				}
			}
		}
	}
}

Alternatively, we can look into Issue #795, and move the _members.InstanceMethods.InvokeNonvirtualVoidMethod() invocation into an "ABI method." Instead of fully adopting #795, we could put the "ABI method" into the same scope, and use that from both TextFormatted and Text:

namespace Android.Widget {
	public partial class TextView {
		public ICharSequence? TextFormatted {
			set {
				IntPtr native_value = CharSequence.ToLocalJniHandle (value);
				try {
					abi_setText (this, native_value);
				} finally {
					JNIEnv.DeleteLocalRef (native_value);
					global::System.GC.KeepAlive (value);
				}
			}
		}

		public string? Text {
			set {
				JniObjectReference native_value = JniEnvironment.Strings.NewString (value);
				try {
					abi_setText (this, native_value);
				} finally {
					JniObjectReference.Dispose (ref native_value);
				}
			}
		}

		private static void abi_setText (IJavaPeerable self, JniObjectReference text)
		{
			const string __id = "setText.(Ljava/lang/CharSequence;)V";
			try {
				JniArgumentValue* __args = stackalloc JniArgumentValue [1];
				__args [0] = new JniArgumentValue (text);
				_members.InstanceMethods.InvokeNonvirtualVoidMethod (__id, this, __args);
			} finally {
				global::System.GC.KeepAlive (self);
			}
		}
	}
}

This has the benefit of reducing code duplication.

@jpobst jpobst added enhancement Proposed change to current functionality generator Issues binding a Java library (generator, class-parse, etc.) labels Jul 5, 2022
jonathanpeppers added a commit that referenced this issue Apr 20, 2023
Context: #994

Testing a .NET MAUI application, I found this while scrolling:

    653.69ms (6.3%) mono.android!Android.Widget.TextView.set_Text(string)
    198.05ms (1.9%) mono.android!Java.Lang.String..ctor(string)
    121.57ms (1.2%) mono.android!Java.Lang.Object.Dispose()

This appears to be a high-impact area for .NET MAUI apps.

Previously, for `TextView.Text` we emitted something like:

    public partial class TextView {
        public string? Text {
            get => TextFormatted == null ? null : TextFormatted.ToString (),
            set {
                var jls = value == null ? null : new global::Java.Lang.String (value);
                TextFormatted = jls;
                if (jls != null) jls.Dispose ();
            }
        }
    }

Where instead we could emit:

    set {
        const string __id = "setText.(Ljava/lang/CharSequence;)V";
        JniObjectReference native_value = JniEnvironment.Strings.NewString (value);
        try {
            JniArgumentValue* __args = stackalloc JniArgumentValue [1];
            __args [0] = new JniArgumentValue (native_value);
            _members.InstanceMethods.InvokeNonvirtualVoidMethod (__id, this, __args);
        } finally {
            JniObjectReference.Dispose (ref native_value);
        }
    }

I added a test for these `String` overloads, as there wasn't exactly
one that tested this exact scenario.

Testing on a Pixel 5 in a .NET 8 BenchmarkDotNet project:

    public class TextViewBenchmark
    {
        readonly TextView textView = new TextView(Application.Context);

        [Benchmark]
        public void SetText() => textView.Text = "Hello World!";
    }

https://github.com/jonathanpeppers/BenchmarkDotNet-Android/blob/Android.Widget.TextView/DotNetRunner/TextViewBenchmark.cs

|         Method |     Mean |     Error |    StdDev | Allocated |
|--------------- |---------:|----------:|----------:|----------:|
| Before SetText | 7.588 us | 0.0088 us | 0.0078 us |     112 B |
| After  SetText | 1.814 us | 0.0009 us | 0.0009 us |         - |

Note that I don't think this completely solves #994, as there are other
ideas mentioned there.
jonathanpeppers added a commit that referenced this issue Apr 20, 2023
Context: #994

Testing a .NET MAUI application, I found this while scrolling:

    653.69ms (6.3%) mono.android!Android.Widget.TextView.set_Text(string)
    198.05ms (1.9%) mono.android!Java.Lang.String..ctor(string)
    121.57ms (1.2%) mono.android!Java.Lang.Object.Dispose()

This appears to be a high-impact area for .NET MAUI apps.

Previously, for `TextView.Text` we emitted something like:

    public partial class TextView {
        public string? Text {
            get => TextFormatted == null ? null : TextFormatted.ToString (),
            set {
                var jls = value == null ? null : new global::Java.Lang.String (value);
                TextFormatted = jls;
                if (jls != null) jls.Dispose ();
            }
        }
    }

Where instead we could emit:

    set {
        const string __id = "setText.(Ljava/lang/CharSequence;)V";
        JniObjectReference native_value = JniEnvironment.Strings.NewString (value);
        try {
            JniArgumentValue* __args = stackalloc JniArgumentValue [1];
            __args [0] = new JniArgumentValue (native_value);
            _members.InstanceMethods.InvokeNonvirtualVoidMethod (__id, this, __args);
        } finally {
            JniObjectReference.Dispose (ref native_value);
        }
    }

I added a test for these `String` overloads, as there wasn't exactly
one that tested this exact scenario.

Testing on a Pixel 5 in a .NET 8 BenchmarkDotNet project:

    public class TextViewBenchmark
    {
        readonly TextView textView = new TextView(Application.Context);

        [Benchmark]
        public void SetText() => textView.Text = "Hello World!";
    }

https://github.com/jonathanpeppers/BenchmarkDotNet-Android/blob/Android.Widget.TextView/DotNetRunner/TextViewBenchmark.cs

|         Method |     Mean |     Error |    StdDev | Allocated |
|--------------- |---------:|----------:|----------:|----------:|
| Before SetText | 7.588 us | 0.0088 us | 0.0078 us |     112 B |
| After  SetText | 1.814 us | 0.0009 us | 0.0009 us |         - |

Note that I don't think this completely solves #994, as there are other
ideas mentioned there.
@jonpryor
Copy link
Member Author

See also: #1101 (comment)

The optimization described in this issue is only valid if the *Formatted property is not virtual. This is the case for some properties, such as TextView.TextFormatted, but not for all properties. TextClock.Format12HourFormatted is an example of a *Formatted property which is virtual, and for which this optimization could result in a semantic break.

The semantic break scenario is:

class MyTextClock : Android.Widget.TextClock {
    public override Java.Lang.ICharSequence? Format12HourFormatted {
        get =>;
        set =>;
    }
}

// …
TextClock myTextClock = new MyTextClock ();
myTextClock.Format12Hour = "whatever";

The current expectation is that when myTextClock.Format12Hour is set, myTextClock.Format12HourFormatted will be set, and as that property is overridden, the expectation is that the MyTextClock.Format12HourFormatted property is set, not the base class' TextClock.setFormat12Hour() method.

The approaches proposed in Issue #994 break that semantic. That's Not Good™.

The only time the proposals in #994 are valid is when the "underlying" *Formatted property is not a virtual or override property.

Fortunately that restriction is (partially) the case for TextView.setText(CharSequence), but not for TextView.getText(), but we bind TextView.TextFormatted as a non-virtual property (because setText() isn't virtual), so in the context of TextView.TextFormatted, this is "fine".

It's not fine for every possible scenario.


For reference, the Android.Widget.TextClock binding is:

namespace Android.Widget {

	public partial class TextClock : Android.Widget.TextView {
		public virtual unsafe Java.Lang.ICharSequence? Format12HourFormatted {
			// Metadata.xml XPath method reference: path="/api/package[@name='android.widget']/class[@name='TextClock']/method[@name='getFormat12Hour' and count(parameter)=0]"
			[Register ("getFormat12Hour", "()Ljava/lang/CharSequence;", "GetGetFormat12HourHandler")]
			get {
				const string __id = "getFormat12Hour.()Ljava/lang/CharSequence;";
				try {
					var __rm = _members.InstanceMethods.InvokeVirtualObjectMethod (__id, this, null);
					return global::Java.Lang.Object.GetObject<Java.Lang.ICharSequence> (__rm.Handle, JniHandleOwnership.TransferLocalRef);
				} finally {
				}
			}
			// Metadata.xml XPath method reference: path="/api/package[@name='android.widget']/class[@name='TextClock']/method[@name='setFormat12Hour' and count(parameter)=1 and parameter[1][@type='java.lang.CharSequence']]"
			[Register ("setFormat12Hour", "(Ljava/lang/CharSequence;)V", "GetSetFormat12Hour_Ljava_lang_CharSequence_Handler")]
			set {
				const string __id = "setFormat12Hour.(Ljava/lang/CharSequence;)V";
				IntPtr native_value = CharSequence.ToLocalJniHandle (value);
				try {
					JniArgumentValue* __args = stackalloc JniArgumentValue [1];
					__args [0] = new JniArgumentValue (native_value);
					_members.InstanceMethods.InvokeVirtualVoidMethod (__id, this, __args);
				} finally {
					JNIEnv.DeleteLocalRef (native_value);
					global::System.GC.KeepAlive (value);
				}
			}
		}

		public string? Format12Hour {
			get { return Format12HourFormatted == null ? null : Format12HourFormatted.ToString (); }
			set {
				var jls = value == null ? null : new global::Java.Lang.String (value);
				Format12HourFormatted = jls;
				if (jls != null) jls.Dispose ();
			}
		}
	}
}

@jonpryor
Copy link
Member Author

Repro of the concern: formatted-semantics.zip

The repro has three parts:

  1. A Java class which will generate a C# TextFormatted property, backed by a field value:

    public class ContainsTextFormattedProperty
        CharSequence text;
        public CharSeqeuence getText() {return text;}
        public void setText(CharSequence value) {text = value;}
    
        public static String getText(ContainsTextFormattedProperty self) {return self.getText().toString();}
    }

    The static getText(ContainsTextFormattedProperty) method allows us to do a Java-side method invocation.

  2. A C# subclass:

    class MyType : ContainsTextFormattedProperty {
        string? text;
    
        public override Java.Lang.ICharSequence? TextFormatted {
            get => new Java.Lang.String ("From C#! " + text);
            set => text = value?.ToString();
        }
    }
  3. The setup:

    var c = new MyType {
        Text = "Default Value",
    };

Thus, the "punch":

var s = MyType.GetText(c);
Console.WriteLine ($"           c.Text={c.Text}");
Console.WriteLine ($"MyType.GetText(c)={s}");

What should happen? What should happen is:

  1. c.Text prints the same value as MyType.GetText(c)
  2. The value is From C#: Default Value

We expect (1) because c.get_Text() calls the overriding c.get_TextFormatted(), and MyType.GetText() calls ContainsTextFormattedProperty.getText(), which virtually calls getText(), which is overridden by c.get_TextFormatted().

Now imagine what happens with the original PR #1101:

  1. c.Text = "Default Value" would not virtually invoke the c.TextFormatted setter; it would instead non-virtually invoke ContainsTextFormattedProperty.setText().
  2. The c.Text property getter would not virtually invoke the c.TextFormatted getter; it would instead non-virtually invoke ContainsTextFormattedProperty.getText(), which would be Default Value.
  3. MyType.GetText() would cause Java to virtually invoke getText(), which is overridden to return From C#! Default Value
  4. c.Text and MyType.GetText(c) would thus return different values.

This behavior makes no sense, and would confuse everyone. It's not only a semantic break; it's bad semantics all around!


That said, there is an issue with the *Formatted infrastructure, which didn't occur to me until creating this sample. What happens if MyText doesn't store strings?

class MyType : ContainsTextFormattedProperty {
    Java.Lang.ICharSequence? text;

    public override Java.Lang.ICharSequence? TextFormatted {
        get => new Java.Lang.String ("From C#! " + text?.ToString ());
        set => text = value;
    }
}

The answer is that c.Text returns From C#! . The substring Default Value is not present! The reason for this is that the Text property setter disposes of the instance!

	public partial class ContainsTextFormattedProperty : global::Java.Lang.Object {
		public string? Text {
			get { return TextFormatted == null ? null : TextFormatted.ToString (); }
			set {
				var jls = value == null ? null : new global::Java.Lang.String (value);
				TextFormatted = jls;
				if (jls != null) jls.Dispose ();
			}
		}
	}

Thus after MyType.set_TextFormatted() is called, MyType.text is referencing a disposed String instance, and <disposed-value>.ToString() returns the empty string instead of throwing an exception (?!).

This is decidedly a leaky abstraction. Kinda surprising nobody has tripped up against this.

jonpryor pushed a commit that referenced this issue Apr 25, 2023
)

Context: dotnet/maui#12130
Context: #994
Context: 79a8e1e
Context: xamarin/monodroid@23f4212

When binding members which have parameter types or return types which
are `java.lang.CharSequence`, the member is "overloaded" to replace
`CharSequence` with `System.String`, and the "original" member has a
`Formatted` *suffix*.

For example, consider [`android.widget.TextView`][1], which has
[`getText()`][1] and [`setText()`][2] methods which have parameter
types and return types which are `java.lang.CharSequence`:

	// Java
	/* partial */ class TextView extends View {
	    public CharSequence getText();
	    public final void setText(CharSequence text);
	}

When bound, this results in *two* properties:

	// C#
	partial class TextView : View {
	    public  Java.Lang.ICharSequence?    TextFormatted   {get => …; set => …; }
	    public  string?                     Text            {get => …; set => …; }
	}

This is also done for methods; see also 79a8e1e.

The "non-`Formatted` overload" works by creating `String` temporaries
to invoke the `Formatted` overload:

	partial class TextView {
	    public string? Text {
	        get => TextFormatted?.ToString ();
	        set {
	            var jls = value == null ? null : new Java.Lang.String (value);
	            TextFormatted = jls;
	            jls?.Dispose ();
	        }
	    }
	}

*Why* was this done?  Because [C# 4.0][3] didn't allow interfaces to
provide conversion operators.  ([C# 8.0][4] would add support for
interfaces to contain operators.)  "Overloading" in this fashion made
it easier to use `System.String` literals with `ICharSequence` members;
compare:

	view.Text           = "string literal";
	// vs.
	view.TextFormatted  = new Java.Lang.String("string literal");
	// …and who would know how to do this?

A problem with the this approach is performance: creating a new
`Java.Lang.String` instance requires:

 1. Creating the managed peer (the `Java.Lang.String` instance),
 2. Creating the native peer (the `java.lang.String` instance),
 3. And *registering the mapping* between (1) and (2)

which feels a bit "silly" when we immediately dispose of the value.

This is particularly noticeable with .NET MAUI apps.  Consider the
[angelru/CvSlowJittering][5] app, which uses XAML to set `Text`
properties, which eventually hit `TextView.Text`.  Profiling shows:

	653.69ms (6.3%) mono.android!Android.Widget.TextView.set_Text(string)
	198.05ms (1.9%) mono.android!Java.Lang.String..ctor(string)
	121.57ms (1.2%) mono.android!Java.Lang.Object.Dispose()

*6.3%* of scrolling time is spent in the `TextView.Text` property
setter!

*Partially optimize* this case: if the `*Formatted` member is
(1) a property, and (2) *not* `virtual`, then we can directly call
the Java setter method.  This avoids the need to create a managed
peer and to register a mapping between the peers:

	// New hotness
	partial class TextView {
	    public string? Text {
	        get => TextFormatted?.ToString (); // unchanged
	        set {
	            const string __id               = "setText.(Ljava/lang/CharSequence;)V";
	            JniObjectReference native_value = JniEnvironment.Strings.NewString (value);
	            try {
	                JniArgumentValue* __args    = stackalloc JniArgumentValue [1];
	                __args [0] = new JniArgumentValue (native_value);
	                _members.InstanceMethods.InvokeNonvirtualVoidMethod (__id, this, __args);
	            } finally {
	                JniObjectReference.Dispose (ref native_value);
	            }
	        }
	    }
	}

[The result][6]?

|              Method |     Mean |     Error |    StdDev | Allocated |
|-------------------- |---------:|----------:|----------:|----------:|
| Before SetFinalText | 6.632 us | 0.0101 us | 0.0079 us |     112 B |
| After SetFinalText  | 1.361 us | 0.0022 us | 0.0019 us |         - |

The `TextView.Text` property setter invocation time is reduced to 20%
of the previous average invocation time.

Note: We *cannot* do this "inlining" if the "`Formatted` overload" is
[`virtual`][7], as that will result in broken semantics that make
sense to *nobody*.

TODO: Consider Optimizing the "`virtual` overload" scenario?  This
could be done by updating `Java.Lang.String`:

	partial interface ICharSequence {
	    public static String FromJniObjectReference (ref JniObjectReference reference, JniObjectReferenceOptions options);
	}

Then updating the "non-`Formatted` overload" to use
`String.FromJniHandle()`:

	partial class TextView {
	    public string? Text {
	        get => TextFormatted?.ToString (); // unchanged
	        set {
	            JniObjectReference native_value = JniEnvironment.Strings.NewString (value);
	            var                java_value   = Java.Lang.ICharSequence.FromJniObjectReference (ref native_value, JniObjectReferenceOptions.CopyAndDoNotRegister);
	            TextFormatted = java_value;
	            java_value?.Dispose ();
	        }
	    }
	}

[0]: https://developer.android.com/reference/android/widget/TextView
[1]: https://developer.android.com/reference/android/widget/TextView#getText()
[2]: https://developer.android.com/reference/android/widget/TextView#setText(java.lang.CharSequence)
[3]: https://learn.microsoft.com/en-us/dotnet/csharp/whats-new/csharp-version-history#c-version-40
[4]: https://learn.microsoft.com/en-us/dotnet/csharp/whats-new/csharp-version-history#c-version-80
[5]: https://github.com/angelru/CvSlowJittering
[6]: https://github.com/jonathanpeppers/BenchmarkDotNet-Android/tree/Android.Widget.TextView
[7]: #994 (comment)
@jonpryor
Copy link
Member Author

For a brief moment, I thought that this entire question might be "moot" in Java.Base (#858), because I thought that C# 8+ -- which allows static members on interfaces -- would thus also allow implicit conversion operators.

It does not. This is not valid:

partial interface ICharSequence {
    public static implicit operator ICharSequence? (string? value) =>
        value == null ? null : new Java.Lang.String (value);
    // CS0567, CS0552
}

…which makes me sad. :-(

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement Proposed change to current functionality generator Issues binding a Java library (generator, class-parse, etc.)
Projects
None yet
Development

No branches or pull requests

2 participants