cashapp / redwood

Multiplatform reactive UI for Android, iOS, and web using Kotlin and Jetpack Compose
https://cashapp.github.io/redwood/0.x/docs/
Apache License 2.0
1.55k stars 66 forks source link

JS optimized implementation of ProtocolWidget and ProtocolState #2076

Open swankjesse opened 1 month ago

swankjesse commented 1 month ago

We spend a lot of CPU encoding changes from Redwood. Right now the guest code goes through our very nice Kotlin APIs for ChangeSink and Change. Unfortunately kotlinx.serialization is not particularly inefficient here.

I’d like to try to write a new implementation of the guest versions of these classes that’s optimized for Kotlin/JS. I would like to skip directly to producing plain JS objects rather than creating Kotlin objects and then using kotlinx.serialization to convert ’em. (I will still need to use kotlinx.serialization to encode property values.)

JakeWharton commented 1 month ago

I don't think plain objects is the right answer because they still need to undergo JSON encoding. It'll be faster, since it's C code instead of JS-hosted Kotlin code, but it's still JSON encoding.

We can instead teach each Change subtype to JSON "encode" itself directly to a string and build up the JSON in a buildString directly, only calling out to kotlinx.serialization for user types like you said.

I mean look at what we generate:

https://github.com/cashapp/redwood/blob/89eb7bec5e4b2a2f8d48f5f057c85f468cdb2b23/redwood-protocol/src/commonTest/kotlin/app/cash/redwood/protocol/ProtocolTest.kt#L92-L99

The Create class can get a member

public fun appendJsonTo(builder: StringBuilder) {
  builder.append("""["create",{"id":""")
  builder.append(id.value)
  builder.append(""","tag":""")
  builder.append(tag.value)
  builder.append("}]")
}

and we're done. Repeat for all the other subtypes.

Then with a custom serializer for List<Change> we can do the top-level buildString + comma insertion, and then write that as an unsafe raw string into the JsonEncoder.

An even more efficient version of this bypasses allocating any Change instances and simply builds the raw JSON string directly. Unfortunately this breaks the current API unless we do something crazy like

class RawJsonChange : Change {
  val json = StringBuidler()
}

and only have this single allocation per set of changes.

swankjesse commented 1 month ago

Doing JSON encoding in JS would be more efficient if we had a good StringBuilder on QuickJS. Sigh.

JakeWharton commented 1 month ago

True. We could do a List and write them all sequentially to the underlying stream.

swankjesse commented 1 month ago

I used a sampling profiler to measure just how much time we’re spending in serialization code. For one sample I took it’s about 19% of the samples:

original

With this optimization implemented that drops down to about 3% of the samples:

optimized

JakeWharton commented 2 weeks ago

Is this done?