java.interop icon indicating copy to clipboard operation
java.interop copied to clipboard

[generator] Cache static final field values.

Open jpobst opened this issue 1 year ago • 4 comments

Fixes: https://github.com/dotnet/java-interop/issues/1243

Consider the Thread.State enum:

/* partial */ class Thread {
    public static final /* partial */ enum State {
        NEW, RUNNABLE, BLOCKED, WAITING, TIMED_WAITING, TERMINATED
    }
}

This "actually" is a class with fields for each enum value:

% javap java.lang.Thread.State
Compiled from "Thread.java"
public final class java.lang.Thread$State extends java.lang.Enum<java.lang.Thread$State> {
  public static final java.lang.Thread$State NEW;
  public static final java.lang.Thread$State RUNNABLE;
  public static final java.lang.Thread$State BLOCKED;
  public static final java.lang.Thread$State WAITING;
  public static final java.lang.Thread$State TIMED_WAITING;
  public static final java.lang.Thread$State TERMINATED;
  public static java.lang.Thread$State[] values();
  public static java.lang.Thread$State valueOf(java.lang.String);
  static {};
}

When we bind it, we bind the fields as properties:

https://github.com/dotnet/java-interop/blob/fcad3368815dffd0f38f64384aa21b0b65367d68/src/Java.Base-ref.cs#L5490-L5506

However, each of those properties involves a ValueManager lookup:

namespace Java.Lang {
	public partial class Thread {
		public sealed partial class State : global::Java.Lang.Enum {
			public static global::Java.Lang.Thread.State? Runnable {
				get {
					const string __id = "RUNNABLE.Ljava/lang/Thread$State;";

					var __v = _members.StaticFields.GetObjectValue (__id);
					return global::Java.Interop.JniEnvironment.Runtime.ValueManager.GetValue<global::Java.Lang.Thread.State? >(ref __v, JniObjectReferenceOptions.Copy);
				}
			}
		}
	}
}

This is JavaInterop1, not XAJavaInterop1, but .NET for Android isn't much different:

			public static Java.Lang.Thread.State? Runnable {
				get {
					const string __id = "RUNNABLE.Ljava/lang/Thread$State;";

					var __v = _members.StaticFields.GetObjectValue (__id);
					return global::Java.Lang.Object.GetObject<Java.Lang.Thread.State> (__v.Handle, JniHandleOwnership.TransferLocalRef);
				}
			}

The problem is that value lookup is not fast -- identity hash code needs to be obtained, locks obtained, etc. -- to the point that repeated enum lookups can actually show up in profiles.

Instead, update property generation for all final fields to cache the return value. This means we'd only need to call StaticFields.GetObjectValue() and "GetValue" once, instead of once per-access:

			global::Java.Lang.Thread.State? _Runnable_cache;
			public static global::Java.Lang.Thread.State? Runnable {
				get {
					if (_Runnable_cache != null) return _Runnable_cache;
					const string __id = "RUNNABLE.Ljava/lang/Thread$State;";

					var __v = _members.StaticFields.GetObjectValue (__id);
					return _Runnable_cache  = global::Java.Interop.JniEnvironment.Runtime.ValueManager.GetValue<global::Java.Lang.Thread.State? >(ref __v, JniObjectReferenceOptions.Copy);
				}
			}

Note there are a few wrinkles here:

  • If the field value is null, we will set the cached value to null which will trigger us to re-request the value. IE: the cache will not work for null values. This likely doesn't occur enough to be a concern.
  • The cache field (_Runnable_cache) could be a nullable value type or a nullable reference type. This complicates things as .NET treats these as very different things. A solution was found using casting that allows us to treat them the same without needing to explicitly use Nullable.HasValue or Nullable.Value.
  • Generating the correct code when NRT is not enabled gets pretty complex, as we then have to track what field types are value types and which are reference types to generate different code. To avoid this complexity, this entire feature is only enabled when NRT is enabled for the binding project. This includes Mono.Android, every AndroidX/GPS library we bind, and every user binding created from our templates (unless the user explicitly disabled NRT). This limitation should not impact too many bindings.

jpobst avatar Aug 27 '24 21:08 jpobst

Draft commit message:

Fixes: https://github.com/dotnet/java-interop/issues/1243

Consider the [`Thread.State` enum][0]:

	// Java
	/* partial */ class Thread {
	    public static final /* partial */ enum State {
	        NEW, RUNNABLE, BLOCKED, WAITING, TIMED_WAITING, TERMINATED
	    }
	}

This "actually" is a class with *fields* for each enum value:

	% javap java.lang.Thread.State
	Compiled from "Thread.java"
	public final class java.lang.Thread$State extends java.lang.Enum<java.lang.	Thread$State> {
	  public static final java.lang.Thread$State BLOCKED;
	  public static final java.lang.Thread$State NEW;
	  …
	}

When we bind it, we bind the fields as properties:

	// C# Binding
	partial class Thread {
	    public sealed partial class State : Java.Lang.Enum {
	        public static Java.Lang.Thread.State Blocked { get { throw null; } }
	        public static Java.Lang.Thread.State New { get { throw null; } }
	        // …
	    }
	}

However, each of those properties involves a ValueManager lookup:

	// C# Binding
	public partial class Thread {
	    public sealed partial class State : global::Java.Lang.Enum {
	        public static global::Java.Lang.Thread.State? Blocked {
	            get {
	                const string __id = "BLOCKED.Ljava/lang/Thread$State;";

	                var __v = _members.StaticFields.GetObjectValue (__id);
	                // JavaInterop1 / Java.Base.dll
	                return global::Java.Interop.JniEnvironment.Runtime.ValueManager.GetValue<global::Java.Lang.Thread.State? >(ref __v, JniObjectReferenceOptions.Copy);
	                // XAJavaInterop1 / Mono.Android.dll
	                return global::Java.Lang.Object.GetObject<Java.Lang.Thread.State> (__v.Handle, JniHandleOwnership.TransferLocalRef);
	            }
	        }
	    }
	}

The problem is that value lookup is not fast -- identity hash code
needs to be obtained, locks obtained, etc. -- to the point that
[repeated enum lookups can actually show up in profiles][1],
[necessitating manual caching of properties][2].

Instead, for all static "readonly" fields, fields which are `final`
but have no value:

	% xpath -e '//field[@final="true" and @static="true" and not(@value)]' src/Java.Base/obj/Release-net8.0/mcw/api.xml
	…
	<field deprecated="not deprecated" final="true" name="BLOCKED" static="true" synthetic="false" transient="false" type="java.lang.Thread.State" type-generic-aware="java.lang.Thread.State" jni-signature="Ljava/lang/Thread$State;" visibility="public" volatile="false" />

update binding generation to *cache* the value returned.  This means
we'd only need to call `StaticFields.GetObjectValue()` and "GetValue"
*once*, instead of once per-access:

	// C# Binding, now with caching!
	public partial class Thread {
	    public sealed partial class State : global::Java.Lang.Enum {
	        static global::Java.Lang.Thread.State? _Runnable_cache;
	        public static global::Java.Lang.Thread.State? Blocked {
	            get {
	                if (_Runnable_cache != null) {
	                    return _Runnable_cache;
			}
	                const string __id = "BLOCKED.Ljava/lang/Thread$State;";

	                var __v = _members.StaticFields.GetObjectValue (__id);
	                // JavaInterop1 / Java.Base.dll
	                return (Thread.State?) (_Runnable_cache = global::Java.Interop.JniEnvironment.Runtime.ValueManager.GetValue<global::Java.Lang.Thread.State? >(ref __v, JniObjectReferenceOptions.Copy));
	                // XAJavaInterop1 / Mono.Android.dll
	                return (Thread.State?) (_Runnable_cache = global::Java.Lang.Object.GetObject<Java.Lang.Thread.State> (__v.Handle, JniHandleOwnership.TransferLocalRef));
	            }
	        }
	    }
	}

Note there are a few wrinkles here:

  - If the field value is `null`, we will set the cached value to
    `null` which will trigger us to re-request the value. i.e.: the
    cache will not work for `null` values.  This likely doesn't occur
    enough to be a concern.

  - The cache field (`_Runnable_cache`) could be a nullable value
    type or a nullable reference type.  This complicates things as
    .NET treats these as very different things.  A solution was found
    using casting that allows us to treat them the same without
    needing to explicitly use `Nullable.HasValue` or `Nullable.Value`.

  - Generating the correct code when NRT is not enabled gets pretty
    complex, as we then have to track what field types are value
    types and which are reference types to generate different code.
    To avoid this complexity, this entire feature is only enabled
    when NRT is enabled for the binding project.  This includes
    `Mono.Android`, every AndroidX/GPS library we bind, and every
    user binding created from our templates (unless the user
    explicitly disabled NRT).
    
    This limitation should not impact too many bindings.

[0]: https://developer.android.com/reference/java/lang/Thread.State
[1]: https://github.com/dotnet/java-interop/issues/1243#issuecomment-2291308192
[2]: https://github.com/dotnet/maui/pull/24248

jonpryor avatar Sep 03 '24 16:09 jonpryor

@jpobst: a problem with this approach occurred to me around thread safety: reads and writes of pointer-sized values are atomic, and thus writes to and reads from the *_cache fields of reference types are atomic.

What won't be atomic are reads and writes to int?/Nullable<T>.

For now, could we modify the PR so that only reference types are supported, skipping "readonly" int/etc. fields?

jonpryor avatar Sep 03 '24 16:09 jonpryor

On Discord, @jpobst wrote:

i was really trying to avoid treating value/reference types differently.

I think we can do this, by using two fields instead of one. Instead of _SdkInt_cache in:

// android.os.Build
partial class Build {
    static Android.OS.BuildVersionCodes? _SdkInt_cache;

    public static Android.OS.BuildVersionCodes SdkInt {
        get {
            if (_SdkInt_cache != null) return (BuildVersionCodes) _SdkInt_cache;
            const string __id = "SDK_INT.I";
            var __v = _members.StaticFields.GetInt32Value(__id);
            return (BuildVersionCodes) (_SdkInt_cache = __v);
        }
    }
}

we would instead have the fields:

  • static int _have_*: 0 if not set, 1 if set.
  • static T _*_cache: cached value.

a'la:

// android.os.Build
partial class Build {
    static int _have_SdkInt;
    static Android.OS.BuildVersionCodes _SdkInt_cache;

    public static Android.OS.BuildVersionCodes SdkInt {
        get {
            if (1 == Interlocked.CompareExchange (ref _have_SdkInt, 1, 0)) return _SdkInt_cache;
            const string __id = "SDK_INT.I";
            var __v = _members.StaticFields.GetInt32Value(__id);
            _SdkInt_cache = __v;
            return _SdkInt_cache;
        }
    }
}

TODO: is the above actually thread-safe?

jonpryor avatar Sep 03 '24 18:09 jonpryor

This is on hold due to:

  • Concerns about thread-safety
  • Making this change this late in the .NET 9 development cycle

We intend to pick this back up for .NET 10.

jpobst avatar Sep 10 '24 21:09 jpobst