Skip to content

Rewrite TreeJsonDecoder main decoding loop to be identical to StreamingJsonDecoder - #3220

Open
qwwdfsad wants to merge 1 commit into
devfrom
qwwdfsad/tree-json-opto
Open

Rewrite TreeJsonDecoder main decoding loop to be identical to StreamingJsonDecoder#3220
qwwdfsad wants to merge 1 commit into
devfrom
qwwdfsad/tree-json-opto

Conversation

@qwwdfsad

Copy link
Copy Markdown
Member

Previously, the decoding was SerialDescriptor-driven, not JsonObject keys-driven.
It made us reimplement the logic of StreamingJsonDecoder in an inverted manner (instead of mapping actual keys to indices, we mapped indices to keys), which led to all kinds of duplicated caches and reverse quadratic lookups.

Now the logic is almost identical to the streaming decoder, the redundancy in implementation is gone, and throughput for the tree decoder is, for a baseline, twice as good, and for alternative names up to 10 times better, making alternative names to be on par with the regular mode.

Implementation-wise, it is still tagged decoder under the hood (will be fixed separately, minor inconvenience, harder to review) and contains behavioural changes, see tests for that. The behavioural changes are now more in line with streaming behaviour.

…ngJsonDecoder

Previously, the decoding was SerialDescriptor-driven, not JsonObject keys-driven.
It made us to reimplement the logic of StreamingJsonDecoder in an inverted manner (instead of mapping actual keys to indices, we mapped indices to keys), which led to all kinds of duplicated caches and reverse quadratic lookups.

Now the logic is almost identical to the streaming decoder, the redundancy in implementation is gone, and throughput for the tree decoder is, for a baseline, twice as good, and for alternative names up to 10 times better, making alternative names to be on par with the regular mode.

Implementation-wise, it is still tagged decoder under the hood (will be fixed separately, minor inconvenience, harder to review) and contains behavioural changes, see tests for that. The behavioural changes are now more in line with streaming behaviour.

Fixes #3193
@qwwdfsad

Copy link
Copy Markdown
Member Author

Benchmarks: https://gist.github.com/qwwdfsad/8523d8ff8212878b8a4b9e71c3cefe94 (decided not to commit)

Before the change:

Benchmark                           Mode  Cnt  Score   Error   Units
TreeBenchmark.withActualAlternate  thrpt    5  0.668 ± 0.007  ops/us
TreeBenchmark.withAllAlternates    thrpt    5  0.519 ± 0.004  ops/us
TreeBenchmark.withAlternates       thrpt    5  1.171 ± 0.032  ops/us
TreeBenchmark.withoutAlternates    thrpt    5  4.745 ± 0.034  ops/us

After the change:

Benchmark                           Mode  Cnt  Score   Error   Units
TreeBenchmark.withActualAlternate  thrpt    3  7.887 ± 0.482  ops/us
TreeBenchmark.withAllAlternates    thrpt    3  6.582 ± 0.098  ops/us
TreeBenchmark.withAlternates       thrpt    3  9.127 ± 1.023  ops/us
TreeBenchmark.withoutAlternates    thrpt    3  9.080 ± 0.694  ops/us

TL;DR much faster, less code, one implementation instead of two, behaviour that replicates streaming decoder

@qwwdfsad

qwwdfsad commented Jul 13, 2026

Copy link
Copy Markdown
Member Author

It would be even nicer to get rid of NamedValueDecoder and tag system. I've duct-taped (incorrect) zero-cost tagging, the perf win is around ~20% but it effectively disables error paths -- so implementation complexity is unclear; will leave it out of this PR

@sandwwraith sandwwraith left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

btw, it would be nice to apply similar fix for the DynamicInput. Or try to completely unify it with JsonElement decoder

@Test
fun testLastPropertyWinsOnAlternativeNames() {
val cases = mapOf(
"""{"data":"first","foo":"second"}""" to "second", // primary then alternative

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm a bit concerned about this change. Currently, @SerialName/property name has priority over the alternate one, and it is even documented. I hope no one actually relies on it, though. I'd rather prefer to try to restore the original behavior, but it looks like though it is not possible in this implementation unless we fix #1990 along the way

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This appears to be colliding with a whole different issue. Currently on all releases with @SerialName the behavior of TreeJsonDecoder versus StreamingJsonDecoder diverges.

@Serializable
data class Config(
    @SerialName("foo") val a: String? = null, // also works without SerialName
    @JsonNames("foo") val b: String? = null,
)

fun test() {
    val input = """{"foo":"X"}"""

    println(Json.decodeFromString<Config>(input))
    println(Json.decodeFromJsonElement<Config>(Json.parseToJsonElement(input)))
}
Config(a=X, b=null) // Streaming
Config(a=X, b=X) // Tree

On qwwdfsad/tree-json-opto it inadvertently fixes the divergence and the 2 align.

🙃

)
) continue // do not read coerced value
// Pointer to the current entry of JsonObject that is being decoded
private val entries: Iterator<Map.Entry<String, JsonElement>> by lazy(LazyThreadSafetyMode.NONE) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is lazy really needed here? decodeElementIndex is always called when class is not empty, so it looks like we can strip one level of indirection. And if value.isEmpty() you can simply have null iterator or something like that

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.

3 participants