Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

BigDecimal serializer memory and throughput optimizations #1014

Merged
merged 4 commits into from
Oct 13, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package com.esotericsoftware.kryo.benchmarks;

import com.esotericsoftware.kryo.Serializer;
import com.esotericsoftware.kryo.io.Input;
import com.esotericsoftware.kryo.io.Output;
import com.esotericsoftware.kryo.serializers.DefaultSerializers;
import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.Level;
import org.openjdk.jmh.annotations.Param;
import org.openjdk.jmh.annotations.Scope;
import org.openjdk.jmh.annotations.Setup;
import org.openjdk.jmh.annotations.State;
import org.openjdk.jmh.annotations.TearDown;
import org.openjdk.jmh.runner.Runner;
import org.openjdk.jmh.runner.RunnerException;
import org.openjdk.jmh.runner.options.Options;
import org.openjdk.jmh.runner.options.OptionsBuilder;

import java.math.BigDecimal;

import static java.lang.Integer.parseInt;
import static java.math.BigDecimal.ONE;
import static java.math.BigDecimal.ZERO;
import static java.util.concurrent.TimeUnit.MICROSECONDS;
import static org.openjdk.jmh.runner.options.TimeValue.seconds;

public class BigDecimalBenchmark {

@State(Scope.Thread)
public static class MyState {
final Serializer<BigDecimal> serializer = new DefaultSerializers.BigDecimalSerializer();

Output output;
Input input;

@Param({
"null", "zero", "one", "0",
"2", "10", "max_in_long", "20", // twenty is more than the number of digits in Long.MAX_VALUE
"-2", "-10", "min_in_long", "-20" // twenty is more than the number of digits in Long.MIN_VALUE
})
String numOfDigits = "5";
int scale = 2;

BigDecimal decimal;

@Setup(Level.Iteration)
public void setUp() {
decimal = newDecimal(numOfDigits, scale);
output = new Output(2, -1);
serializer.write(null, output, decimal);
input = new Input(output.toBytes());
output.reset();
}

private static BigDecimal newDecimal(String numOfDigits, int scale) {
switch (numOfDigits) {
case "null": return null;
case "zero": return ZERO;
case "one": return ONE;
case "0": return BigDecimal.valueOf(0, scale);
case "max_in_long": return BigDecimal.valueOf(Long.MAX_VALUE, scale);
case "min_in_long": return BigDecimal.valueOf(Long.MIN_VALUE, scale);
default:
int digits = parseInt(numOfDigits.replace("-", ""));
BigDecimal d = BigDecimal.valueOf(10, 1 - digits).subtract(ONE).scaleByPowerOfTen(-scale); // '9' repeated numOfDigit times
return numOfDigits.charAt(0) != '-' ? d : d.negate();
}
}

@TearDown(Level.Iteration)
public void tearDown () {
output.close();
input.close();
}
}

@Benchmark
public byte[] write (MyState state) {
state.output.reset();
state.serializer.write(null, state.output, state.decimal);
return state.output.getBuffer();
}

@Benchmark
public BigDecimal read (MyState state) {
state.input.reset();
return state.serializer.read(null, state.input, BigDecimal.class);
}

public static void main (String[] args) throws RunnerException {
final Options opt = new OptionsBuilder()
.include(".*" + BigDecimalBenchmark.class.getSimpleName() + ".*")
.timeUnit(MICROSECONDS)
.warmupIterations(1)
.warmupTime(seconds(1))
.measurementIterations(4)
.measurementTime(seconds(1))
.forks(1)
.build();
new Runner(opt).run();
}
}
144 changes: 108 additions & 36 deletions src/com/esotericsoftware/kryo/serializers/DefaultSerializers.java
Original file line number Diff line number Diff line change
Expand Up @@ -205,18 +205,7 @@ public BigInteger read (Kryo kryo, Input input, Class<? extends BigInteger> type
byte[] bytes = input.readBytes(length - 1);
if (type != BigInteger.class && type != null) {
// Use reflection for subclasses.
try {
Constructor<? extends BigInteger> constructor = type.getConstructor(byte[].class);
if (!constructor.isAccessible()) {
try {
constructor.setAccessible(true);
} catch (SecurityException ignored) {
}
}
return constructor.newInstance(bytes);
} catch (Exception ex) {
throw new KryoException(ex);
}
return newBigIntegerSubclass(type, bytes);
}
if (length == 2) {
// Fast-path optimizations for BigInteger constants.
Expand All @@ -231,13 +220,26 @@ public BigInteger read (Kryo kryo, Input input, Class<? extends BigInteger> type
}
return new BigInteger(bytes);
}

private static BigInteger newBigIntegerSubclass(Class<? extends BigInteger> type, byte[] bytes) {
try {
Constructor<? extends BigInteger> constructor = type.getConstructor(byte[].class);
if (!constructor.isAccessible()) {
try {
constructor.setAccessible(true);
} catch (SecurityException ignored) {
}
}
return constructor.newInstance(bytes);
} catch (Exception ex) {
throw new KryoException(ex);
}
}
}

/** Serializer for {@link BigDecimal} and any subclass.
* @author Tumi <serverperformance@gmail.com> (enhacements) */
public static class BigDecimalSerializer extends ImmutableSerializer<BigDecimal> {
private final BigIntegerSerializer bigIntegerSerializer = new BigIntegerSerializer();

{
setAcceptsNull(true);
}
Expand All @@ -247,42 +249,112 @@ public void write (Kryo kryo, Output output, BigDecimal object) {
output.writeByte(NULL);
return;
}
// fast-path optimizations for BigDecimal constants
if (object == BigDecimal.ZERO) {
bigIntegerSerializer.write(kryo, output, BigInteger.ZERO);
output.writeInt(0, false); // for backwards compatibility
output.writeVarInt(2, true);
output.writeByte((byte) 0);
output.writeInt(0, false);
return;
}
// default behaviour
bigIntegerSerializer.write(kryo, output, object.unscaledValue());
if (object == BigDecimal.ONE) {
output.writeVarInt(2, true);
output.writeByte((byte) 1);
output.writeInt(0, false);
return;
}

BigInteger unscaledBig = null; // avoid getting it from BigDecimal, as non-inflated BigDecimal will have to create it
boolean compactForm = object.precision() < 19; // less than nineteen decimal digits for sure fits in a long
if (!compactForm) {
unscaledBig = object.unscaledValue(); // get and remember for possible use in non-compact form
compactForm = unscaledBig.bitLength() <= 63; // check exactly if unscaled value will fit in a long
}

if (!compactForm) {
byte[] bytes = unscaledBig.toByteArray();
output.writeVarInt(bytes.length + 1, true);
output.writeBytes(bytes);
} else {
long unscaledLong = object.scaleByPowerOfTen(object.scale()).longValue(); // best way to get unscaled long value without creating unscaled BigInteger on the way
writeUnscaledLong(output, unscaledLong);
}

output.writeInt(object.scale(), false);
}

// compatible with writing unscaled value represented as BigInteger's bytes
private static void writeUnscaledLong(Output output, long unscaledLong) {
if (unscaledLong >>> 7 == 0) { // optimize for tiny values
output.writeVarInt(2, true);
output.writeByte((byte) unscaledLong);
} else {
byte[] bytes = new byte[8];
int pos = 8;
do {
bytes[--pos] = (byte) (unscaledLong & 0xFF);
unscaledLong >>= 8;
} while (unscaledLong != 0 && unscaledLong != -1); // out of bits

if (((bytes[pos] ^ unscaledLong) & 0x80) != 0) {
// sign bit didn't fit in previous byte, need to add another byte
bytes[--pos] = (byte) unscaledLong;
}

int length = 8 - pos;
output.writeVarInt(length + 1, true);
output.writeBytes(bytes, pos, length);
}
}

public BigDecimal read (Kryo kryo, Input input, Class<? extends BigDecimal> type) {
BigInteger unscaledValue = bigIntegerSerializer.read(kryo, input, BigInteger.class);
if (unscaledValue == null) return null;
BigInteger unscaledBig = null;
long unscaledLong = 0;

int length = input.readVarInt(true);
if (length == NULL) return null;
length--;

byte[] bytes = input.readBytes(length);
if (length > 8) {
unscaledBig = new BigInteger(bytes);
} else {
unscaledLong = bytes[0];
for (int i = 1; i < bytes.length; i++) {
unscaledLong <<= 8;
unscaledLong |= (bytes[i] & 0xFF);
}
}

int scale = input.readInt(false);
if (type != BigDecimal.class && type != null) {
// For subclasses, use reflection
try {
Constructor<? extends BigDecimal> constructor = type.getConstructor(BigInteger.class, int.class);
if (!constructor.isAccessible()) {
try {
constructor.setAccessible(true);
} catch (SecurityException ignored) {
}
return newBigDecimalSubclass(type, unscaledBig != null ? unscaledBig : BigInteger.valueOf(unscaledLong), scale);
} else {
// For BigDecimal, if possible use factory methods to avoid instantiating BigInteger
if (unscaledBig != null) {
return new BigDecimal(unscaledBig, scale);
} else {
if (scale == 0) {
if (unscaledLong == 0) return BigDecimal.ZERO;
if (unscaledLong == 1) return BigDecimal.ONE;
}
return constructor.newInstance(unscaledValue, scale);
} catch (Exception ex) {
throw new KryoException(ex);
return BigDecimal.valueOf(unscaledLong, scale);
}
}
// fast-path optimizations for BigDecimal constants
if (unscaledValue == BigInteger.ZERO && scale == 0) {
return BigDecimal.ZERO;
}

private static BigDecimal newBigDecimalSubclass(Class<? extends BigDecimal> type, BigInteger unscaledValue, int scale) {
try {
Constructor<? extends BigDecimal> constructor = type.getConstructor(BigInteger.class, int.class);
if (!constructor.isAccessible()) {
try {
constructor.setAccessible(true);
} catch (SecurityException ignored) {
}
}
return constructor.newInstance(unscaledValue, scale);
} catch (Exception ex) {
throw new KryoException(ex);
}
// default behaviour
return new BigDecimal(unscaledValue, scale);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -257,13 +257,60 @@ private java.sql.Timestamp newTimestamp(long time, int nanos) {
void testBigDecimalSerializer () {
kryo.register(BigDecimal.class);
kryo.register(BigDecimalSubclass.class);
roundTrip(4, BigDecimal.ZERO);

// postive values
roundTrip(5, BigDecimal.valueOf(12345, 2));
roundTrip(7, new BigDecimal("12345.12345"));
roundTrip(4, BigDecimal.ZERO);
roundTrip(4, BigDecimal.ONE);
roundTrip(4, BigDecimal.TEN);
roundTrip(5, new BigDecimalSubclass(new BigInteger("12345"), 2));
roundTrip(7, new BigDecimalSubclass("12345.12345"));
roundTrip(11, BigDecimal.valueOf(Long.MAX_VALUE, 2));
roundTrip(12, BigDecimal.valueOf(Long.MAX_VALUE, 2).add(BigDecimal.valueOf(1, 2)));

// negative values
roundTrip(5, BigDecimal.valueOf(-12345, 2));
roundTrip(7, new BigDecimal("-12345.12345"));
roundTrip(4, BigDecimal.ONE.negate());
roundTrip(4, BigDecimal.TEN.negate());
roundTrip(5, new BigDecimalSubclass(new BigInteger("-12345"), 2));
roundTrip(7, new BigDecimalSubclass("-12345.12345"));
roundTrip(11, BigDecimal.valueOf(Long.MIN_VALUE, 2));
roundTrip(12, BigDecimal.valueOf(Long.MIN_VALUE, 2).subtract(BigDecimal.valueOf(1, 2)));
}

@Test
void testBigDecimalSerializerBackwardCompatibility () {
kryo.register(BigDecimal.class);
output = new Output(8, -1);
input = new Input();
for (int i = -100000; i < 100000; i++) {
output.reset(); input.reset();
BigDecimal decimal = BigDecimal.valueOf(i, 2);

// that's how it was serialized before optimization for small values was implemented
byte[] expectedBytes = decimal.unscaledValue().toByteArray();
int expectedLength = expectedBytes.length;

// make sure that after optimizations it is serialized in the same way
kryo.writeObject(output, decimal);
input.setBuffer(output.getBuffer());
int actualLength = input.readVarInt(true) - 1;
byte[] actualBytes = input.readBytes(actualLength);

assertArrayEquals(expectedBytes, actualBytes, () -> String.format(
"for %s expected %s but got %s",
decimal, Arrays.toString(expectedBytes), Arrays.toString(actualBytes)
));
assertEquals(expectedLength, actualLength);
assertEquals(decimal.scale(), input.readInt(false));

// additionaly make sure that after deserialization we get the same value
input.reset();
BigDecimal actual = kryo.readObject(input, BigDecimal.class);
assertEquals(decimal, actual);
}
}

@Test
Expand Down