Skip to content

perf: parse resolve responses without JSON round-trips#239

Merged
fabriziodemaria merged 2 commits intomainfrom
perf/decode-from-json-element
Apr 29, 2026
Merged

perf: parse resolve responses without JSON round-trips#239
fabriziodemaria merged 2 commits intomainfrom
perf/decode-from-json-element

Conversation

@fabriziodemaria
Copy link
Copy Markdown
Member

@fabriziodemaria fabriziodemaria commented Apr 27, 2026

Summary

Replaces the Json.decodeFromString(<serializer>, element.toString()) pattern in Serializers.kt with Json.decodeFromJsonElement(<serializer>, element), the supported zero-copy entry point for nested deserialization. Also reads primitives via .jsonPrimitive.content instead of JsonElement.toString() + manual quote stripping.

For every flag in a resolve response we were previously serializing each parsed JsonElement back to a JSON string and re-tokenizing it inside a fresh Json.decodeFromString call. With ~950 flags this happens many thousands of times per resolve.

Investigation context

This is one of two PRs from a perf investigation of the Android SDK using the same load-test client (swift-load-test-950, 950 flags) we used for the Swift SDK. We instrumented the Android SDK with debug logging on the resolve hot path (network start, network response with elapsed time, cache save with payload size, cache load) and ran the demo app on a Pixel 9a emulator.

Bottlenecks identified

  1. Wasteful JSON round-trips in Serializers.kt — addressed by this PR.
  2. O(n) linear flag lookup on every evaluation — addressed by sister PR perf: O(1) flag lookup during evaluation #240.
  3. On-disk cache uses verbose polymorphic ConfidenceValue envelopes — 491 KB on Android vs 152 KB on Swift for the same 950 flags. Not addressed here; needs a versioned migration. Tracked as future work.
  4. activate() contention — not a problem on Android: the in-memory cache is a single @Volatile reference (no blocking lock, unlike Swift's DispatchQueue.sync we had to fix in confidence-sdk-swift#245).

Measured impact (950 flags, Pixel 9a emulator)

Three different scenarios — they exercise different code paths and the win is very different in each:

Scenario Baseline (main) This PR Improvement
First resolve in process (init path, cold-ish) 1027 ms 458 ms ~55% faster
1st re-apply context tap (still JIT-warming) 327 ms 195 ms ~40% faster
Steady-state same-session re-resolves 208–262 ms (avg ~244) 167–204 ms (avg ~186) ~24% faster (~58 ms)
Fresh-JVM warm (every launch a new process) 4117–5429 ms (avg ~4948) 743–928 ms (avg ~854) ~5–6× faster

The case to ship this is mostly the cold/fresh-JVM cliff — at 950 flags the baseline takes 4–5 s the first time the OS-reaped process is restarted, which is what users feel as "app is slow on launch". The steady-state same-session number (~244 ms baseline) is already fine for most apps; the ~24% it picks up is a free bonus.

The cache save/load also speed up because the on-disk cache is read back through the very same NetworkResolvedFlagSerializer and convertToValue paths.

What changed in Serializers.kt

  • Read flag, variant, reason, shouldApply via .jsonPrimitive.content / .jsonPrimitive.boolean instead of JsonElement.toString() + .replace("\"", "") / Json.decodeFromString<...>(... .toString()).
  • Replace every Json.decodeFromString(<serializer>, element.toString()) call with Json.decodeFromJsonElement(<serializer>, element) — both inside NetworkResolvedFlagSerializer, FlagsSerializer and the recursive helpers in convertToValue / convertToSchemaTypeValue.
  • Pre-size the ResolvedFlag list to the parsed array size in FlagsSerializer.deserialize (one less grow-and-copy).
  • ResolveReason is decoded with ResolveReason.valueOf(...) from .jsonPrimitive.content. The default kotlinx-serialization enum decoder uses the enum name and the enum has no @SerialName annotations, so this is behaviourally identical and avoids depending on the reified decodeFromJsonElement<T> extension for an enum that isn't @Serializable.

The on-disk JSON format is unchanged and so are existing flagSchema / value / structSchema paths.

Test plan

  • ./gradlew :Confidence:compileDebugKotlin
  • ./gradlew :Provider:testDebugUnitTest (the test target CI runs)
  • ./gradlew ktlintCheck
  • ./gradlew assembleDebug
  • On-device verification with the 950-flag load-test client on a Pixel 9a emulator (numbers above)
  • Verified flag, variant, reason, shouldApply, flagSchema and nested value parse identically (logs from the demo app's resolve tester payload look unchanged)

Future work

  • Drop the verbose polymorphic on-disk format ({"<type>": <value>} envelopes wrap every leaf). Today the on-disk cache for 950 flags is 491 KB; on Swift the same cache is 152 KB. A flatter, versioned cache format with a backward-compatible read path will follow once we pick the design (untagged values vs. preserved-schema vs. shorter type tags).

`NetworkResolvedFlagSerializer`, `FlagsSerializer` and `convertToValue`
were serializing each parsed `JsonElement` back to a string with
`JsonElement.toString()` and then handing that string to
`Json.decodeFromString(...)` (or to manual unquoting via `replace("\"", "")`).
For every flag we therefore re-tokenized JSON we had already tokenized
once.

This change reads primitives directly via `.jsonPrimitive.content`
(avoiding the `toString()` -> `replace("\"", "")` quote-stripping hack)
and replaces every `Json.decodeFromString(<serializer>, element.toString())`
call with `Json.decodeFromJsonElement(<serializer>, element)`, which is
the supported zero-copy entry point for nested deserialization.

Also pre-sizes the `ResolvedFlag` list to the parsed array size so we
don't grow-and-copy while filling it.

For ~950 flags this removes thousands of `JsonElement.toString()`
allocations + retokenizations per resolve. Behaviour is preserved
including `ResolveReason` enum decoding and existing `flagSchema` /
`value` / `structSchema` paths.

Made-with: Cursor
Add three deserialization tests to lock in equivalence on the resolve
hot path touched by this PR:

- testDeserializeMultipleFlagsInOneResponse: pins the FlagsSerializer
  loop with array.size > 1 and the ArrayList(array.size) preallocation.
  Existing payloads only had a single flag.
- testDeserializeAllResolveReasons: round-trips every declared
  ResolveReason value through the new ResolveReason.valueOf path,
  guarding against future divergence (e.g. a stray @SerialName).
- testDeserializeNestedStructSchema: pins the recursive
  convertToSchemaTypeValue / decodeFromJsonElement(SchemaTypeSerializer)
  path two levels deep with mixed leaf types. Existing tests only
  nest structSchema one level.

Made-with: Cursor
@fabriziodemaria fabriziodemaria merged commit 890879a into main Apr 29, 2026
2 checks passed
@fabriziodemaria fabriziodemaria deleted the perf/decode-from-json-element branch April 29, 2026 11:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants