Skip to content

Commit

Permalink
Improve performance of JSON encoding (#1354)
Browse files Browse the repository at this point in the history
    * Instead of processing strings char-by-char with a lot of inner branching (compact strings, range checks etc.), read the whole string into a pre-allocated char array and process it instead. Also, optimistically use this very char array as a result and process escapes by shifting chars in this array
    * Pool char arrays to reduce allocation pressure
    * Benchmarks for JSON encoding and comparison with Jackson
  • Loading branch information
qwwdfsad authored Mar 12, 2021
1 parent 5d0c86c commit 333f9ff
Show file tree
Hide file tree
Showing 14 changed files with 410 additions and 95 deletions.
16 changes: 14 additions & 2 deletions benchmark/src/jmh/kotlin/kotlinx/benchmarks/json/CitmBenchmark.kt
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
package kotlinx.benchmarks.json

import kotlinx.benchmarks.model.*
import kotlinx.serialization.*
import kotlinx.serialization.json.*
import kotlinx.serialization.json.Json.Default.decodeFromString
import kotlinx.serialization.json.Json.Default.encodeToString
import org.openjdk.jmh.annotations.*
import java.util.concurrent.*

Expand All @@ -16,8 +19,17 @@ open class CitmBenchmark {
* For some reason Citm is kind of de-facto standard cross-language benchmark.
* Order of magnitude: 200 ops/sec
*/
private val citm = CitmBenchmark::class.java.getResource("/citm_catalog.json").readBytes().decodeToString()
private val input = CitmBenchmark::class.java.getResource("/citm_catalog.json").readBytes().decodeToString()
private val citm = Json.decodeFromString(CitmCatalog.serializer(), input)

@Setup
fun init() {
require(citm == Json.decodeFromString(CitmCatalog.serializer(), Json.encodeToString(citm)))
}

@Benchmark
fun decodeCitm(): CitmCatalog = Json.decodeFromString(CitmCatalog.serializer(), input)

@Benchmark
fun decodeCitm(): CitmCatalog = Json.decodeFromString(CitmCatalog.serializer(), citm)
fun encodeCitm(): String = Json.encodeToString(CitmCatalog.serializer(), citm)
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ open class JacksonComparisonBenchmark {

private val objectMapper: ObjectMapper = jacksonObjectMapper()

private val data = DefaultPixelEvent(
private val data = DefaultPixelEvent(
version = 1,
dateTime2 = System.currentTimeMillis().toString(),
serverName = "some-endpoint-qwer",
Expand All @@ -48,15 +48,46 @@ open class JacksonComparisonBenchmark {
cookies = "_ga=GA1.2.971852807.1546968515"
)

private val stringData = Json.encodeToString(DefaultPixelEvent.serializer(), data)
private val dataWithEscapes = DefaultPixelEvent(
version = 1,
dateTime2 = System.currentTimeMillis().toString(),
serverName = "some-endp\"oint-qwer",
domain = "<a href=\"some.domain.com\">",
method = "POST",
clientIp = "127.0.0.1",
queryString = "anxa=CASCative&anxv=13.901.16.34566&anxe=\"FoolbarActive\"&anxt=E7AFBF15-1761-4343-92C1-78167ED19B1C&anxtv=13.901.16.34566&anxp=%5ECQ6%5Expt292%5ES33656%5Eus&anxsi&anxd=2019-10-08T17%3A03%3A57.246Z&f=00400000&anxr=1571945992297&coid=\"66abafd0d49f42e58dc7536109395306\"&userSegment&cwsid=opgkcnbminncdgghighmimmphiooeohh",
userAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:70.0) Gecko/20100101 Firefox/70.0",
contentType = "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
browserLanguage = "\"en\"-\"US\",en;\\q=0.5",
postData = "-",
cookies = "_ga=GA1.2.971852807.1546968515"
)

private val stringData = Json.encodeToString(DefaultPixelEvent.serializer(), data)

@Serializable
private class SmallDataClass(val id: Int, val name: String)

private val smallData = SmallDataClass(42, "Vincent")

@Benchmark
fun jacksonToString(): String = objectMapper.writeValueAsString(data)

@Benchmark
fun jacksonToStringWithEscapes(): String = objectMapper.writeValueAsString(dataWithEscapes)

@Benchmark
fun jacksonSmallToString(): String = objectMapper.writeValueAsString(smallData)

@Benchmark
fun kotlinToString(): String = Json.encodeToString(DefaultPixelEvent.serializer(), data)

@Benchmark
fun kotlinToStringWithEscapes(): String = Json.encodeToString(DefaultPixelEvent.serializer(), dataWithEscapes)

@Benchmark
fun kotlinSmallToString(): String = Json.encodeToString(SmallDataClass.serializer(), smallData)

@Benchmark
fun jacksonFromString(): DefaultPixelEvent = objectMapper.readValue(stringData, DefaultPixelEvent::class.java)

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
package kotlinx.benchmarks.json

import kotlinx.benchmarks.model.*
import kotlinx.serialization.*
import kotlinx.serialization.json.*
import kotlinx.serialization.json.Json.Default.decodeFromString
import kotlinx.serialization.json.Json.Default.encodeToString
import org.openjdk.jmh.annotations.*
import java.util.concurrent.*

Expand All @@ -20,8 +23,17 @@ open class TwitterBenchmark {
* with Kotlin classes generated by Json2Kotlin plugin (and also manually adjusted)
*/
private val input = TwitterBenchmark::class.java.getResource("/twitter.json").readBytes().decodeToString()
private val twitter = Json.decodeFromString(Twitter.serializer(), input)

@Setup
fun init() {
require(twitter == Json.decodeFromString(Twitter.serializer(), Json.encodeToString(Twitter.serializer(), twitter)))
}

// Order of magnitude: 4-7 op/ms
@Benchmark
fun parseTwitter() = Json.decodeFromString(Twitter.serializer(), input)
fun decodeTwitter() = Json.decodeFromString(Twitter.serializer(), input)

@Benchmark
fun encodeTwitter() = Json.encodeToString(Twitter.serializer(), twitter)
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
package kotlinx.benchmarks.json

import kotlinx.benchmarks.model.*
import kotlinx.serialization.*
import kotlinx.serialization.json.*
import kotlinx.serialization.json.Json.Default.decodeFromString
import kotlinx.serialization.json.Json.Default.encodeToString
import org.openjdk.jmh.annotations.*
import java.util.concurrent.*

Expand All @@ -20,9 +23,17 @@ open class TwitterFeedBenchmark {
* with Kotlin classes generated by Json2Kotlin plugin (and also manually adjusted)
*/
private val input = TwitterFeedBenchmark::class.java.getResource("/twitter_macro.json").readBytes().decodeToString()
private val twitter = Json.decodeFromString(MacroTwitterFeed.serializer(), input)

@Setup
fun init() {
require(twitter == Json.decodeFromString(MacroTwitterFeed.serializer(), Json.encodeToString(MacroTwitterFeed.serializer(), twitter)))
}

// Order of magnitude: ~400 op/s
@Benchmark
fun parseTwitter() = Json.decodeFromString(MacroTwitterFeed.serializer(), input)
fun decodeTwitter() = Json.decodeFromString(MacroTwitterFeed.serializer(), input)

@Benchmark
fun encodeTwitter() = Json.encodeToString(MacroTwitterFeed.serializer(), twitter)
}
21 changes: 12 additions & 9 deletions formats/json/commonMain/src/kotlinx/serialization/json/Json.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ package kotlinx.serialization.json
import kotlinx.serialization.*
import kotlinx.serialization.json.internal.*
import kotlinx.serialization.modules.*
import kotlin.js.*

/**
* The main entry point to work with JSON serialization.
Expand Down Expand Up @@ -64,14 +63,18 @@ public sealed class Json(internal val configuration: JsonConf) : StringFormat {
* @throws [SerializationException] if the given value cannot be serialized to JSON.
*/
public final override fun <T> encodeToString(serializer: SerializationStrategy<T>, value: T): String {
val result = StringBuilder()
val encoder = StreamingJsonEncoder(
result, this,
WriteMode.OBJ,
arrayOfNulls(WriteMode.values().size)
)
encoder.encodeSerializableValue(serializer, value)
return result.toString()
val result = JsonStringBuilder()
try {
val encoder = StreamingJsonEncoder(
result, this,
WriteMode.OBJ,
arrayOfNulls(WriteMode.values().size)
)
encoder.encodeSerializableValue(serializer, value)
return result.toString()
} finally {
result.release()
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package kotlinx.serialization.json.internal

import kotlinx.serialization.json.*
import kotlin.jvm.*

internal open class Composer(@JvmField internal val sb: JsonStringBuilder, @JvmField internal val json: Json) {
private var level = 0
var writingFirst = true
private set

fun indent() {
writingFirst = true
level++
}

fun unIndent() {
level--
}

fun nextItem() {
writingFirst = false
if (json.configuration.prettyPrint) {
print("\n")
repeat(level) { print(json.configuration.prettyPrintIndent) }
}
}

fun space() {
if (json.configuration.prettyPrint)
print(' ')
}

fun print(v: Char) = sb.append(v)
fun print(v: String) = sb.append(v)
open fun print(v: Float) = sb.append(v.toString())
open fun print(v: Double) = sb.append(v.toString())
open fun print(v: Byte) = sb.append(v.toLong())
open fun print(v: Short) = sb.append(v.toLong())
open fun print(v: Int) = sb.append(v.toLong())
open fun print(v: Long) = sb.append(v)
open fun print(v: Boolean) = sb.append(v.toString())
fun printQuoted(value: String): Unit = sb.appendQuoted(value)
}

@ExperimentalUnsignedTypes
internal class ComposerForUnsignedNumbers(sb: JsonStringBuilder, json: Json) : Composer(sb, json) {
override fun print(v: Int) {
return super.print(v.toUInt().toString())
}

override fun print(v: Long) {
return super.print(v.toULong().toString())
}

override fun print(v: Byte) {
return super.print(v.toUByte().toString())
}

override fun print(v: Short) {
return super.print(v.toUShort().toString())
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package kotlinx.serialization.json.internal

internal expect class JsonStringBuilder constructor() {
fun append(value: Long)
fun append(ch: Char)
fun append(string: String)
fun appendQuoted(string: String)
override fun toString(): String
fun release()
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import kotlinx.serialization.descriptors.*
import kotlinx.serialization.encoding.*
import kotlinx.serialization.json.*
import kotlinx.serialization.modules.*
import kotlin.jvm.*
import kotlin.native.concurrent.*

@ExperimentalSerializationApi
Expand All @@ -36,11 +35,11 @@ internal class StreamingJsonEncoder(
) : JsonEncoder, AbstractEncoder() {

internal constructor(
output: StringBuilder, json: Json, mode: WriteMode,
output: JsonStringBuilder, json: Json, mode: WriteMode,
modeReuseCache: Array<JsonEncoder?>
) : this(Composer(output, json), json, mode, modeReuseCache)

public override val serializersModule: SerializersModule = json.serializersModule
override val serializersModule: SerializersModule = json.serializersModule
private val configuration = json.configuration

// Forces serializer to wrap all values into quotes
Expand Down Expand Up @@ -152,7 +151,7 @@ internal class StreamingJsonEncoder(
return if (inlineDescriptor.isUnsignedNumber) StreamingJsonEncoder(
ComposerForUnsignedNumbers(
composer.sb,
composer.json
json
), json, mode, null
)
else this
Expand Down Expand Up @@ -207,61 +206,4 @@ internal class StreamingJsonEncoder(
override fun encodeEnum(enumDescriptor: SerialDescriptor, index: Int) {
encodeString(enumDescriptor.getElementName(index))
}

internal open class Composer(@JvmField internal val sb: StringBuilder, @JvmField internal val json: Json) {
private var level = 0
var writingFirst = true
private set

fun indent() {
writingFirst = true; level++
}

fun unIndent() {
level--
}

fun nextItem() {
writingFirst = false
if (json.configuration.prettyPrint) {
print("\n")
repeat(level) { print(json.configuration.prettyPrintIndent) }
}
}

fun space() {
if (json.configuration.prettyPrint)
print(' ')
}

open fun print(v: Char) = sb.append(v)
open fun print(v: String) = sb.append(v)
open fun print(v: Float) = sb.append(v)
open fun print(v: Double) = sb.append(v)
open fun print(v: Byte) = sb.append(v)
open fun print(v: Short) = sb.append(v)
open fun print(v: Int) = sb.append(v)
open fun print(v: Long) = sb.append(v)
open fun print(v: Boolean) = sb.append(v)
open fun printQuoted(value: String): Unit = sb.printQuoted(value)
}

@ExperimentalUnsignedTypes
internal class ComposerForUnsignedNumbers(sb: StringBuilder, json: Json) : Composer(sb, json) {
override fun print(v: Int): StringBuilder {
return super.print(v.toUInt().toString())
}

override fun print(v: Long): StringBuilder {
return super.print(v.toULong().toString())
}

override fun print(v: Byte): StringBuilder {
return super.print(v.toUByte().toString())
}

override fun print(v: Short): StringBuilder {
return super.print(v.toUShort().toString())
}
}
}
Loading

0 comments on commit 333f9ff

Please sign in to comment.