2

I'm writing a JMH microbenchmark for floating point printing code I wrote. I'm not overly concerned about the exact performance yet, but getting the benchmark code correct.

I want to loop over some randomly generate data, so I make some static arrays of data and keep my loop machinery (increment and mask) as simple as possible. Is this the correct way or should I be telling JMH a little more about what is going on with some annotations I'm missing?

Also, is it possible to make display groups for the test instead of just lexicographic order? I basically have two groups of test (one group for each set of random data.

The full source is at https://github.com/jnordwick/zerog-grisu

Here is the benchmark code:

package zerog.util.grisu;

import java.util.Random;

import org.openjdk.jmh.annotations.Benchmark;
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;

/* 
 * Current JMH bench, similar on small numbers (no fast path code yet)
 * and 40% faster on completely random numbers.
 * 
 * Benchmark                         Mode  Cnt         Score         Error  Units
 * JmhBenchmark.test_lowp_doubleto  thrpt   20  11439027.798 ± 2677191.952  ops/s
 * JmhBenchmark.test_lowp_grisubuf  thrpt   20  11540289.271 ±  237842.768  ops/s
 * JmhBenchmark.test_lowp_grisustr  thrpt   20   5038077.637 ±  754272.267  ops/s
 * 
 * JmhBenchmark.test_rand_doubleto  thrpt   20   1841031.602 ±  219147.330  ops/s
 * JmhBenchmark.test_rand_grisubuf  thrpt   20   2609354.822 ±   57551.153  ops/s
 * JmhBenchmark.test_rand_grisustr  thrpt   20   2078684.828 ±  298474.218  ops/s
 * 
 * This doens't account for any garbage costs either since the benchmarks
 * aren't generating enough to trigger GC, and Java internally uses per-thread
 * objects to avoid some allocations.
 * 
 * Don't call Grisu.doubleToString() except for testing. I think the extra
 * allocations and copying are killing it. I'll fix that.
 */

public class JmhBenchmark {

    static final int nmask = 1024*1024 - 1;
    static final double[] random_values = new double[nmask + 1];
    static final double[] lowp_values = new double[nmask + 1];

    static final byte[] buffer = new byte[30];
    static final byte[] bresults = new byte[30];

    static int i = 0;
    static final Grisu g = Grisu.fmt;

    static {

        Random r = new Random();
        int[] pows = new int[] { 1, 10, 100, 1000, 10000, 100000, 1000000 };

        for( int i = 0; i < random_values.length; ++i ) {
            random_values[i] = r.nextDouble();
        }

        for(int i = 0; i < lowp_values.length; ++i ) {
            lowp_values[i] = (1 + r.nextInt( 10000 )) / pows[r.nextInt( pows.length )];
        }
    }

    @Benchmark
    public String test_rand_doubleto() {
        String s = Double.toString( random_values[i] );
        i = (i + 1) & nmask;
        return s;
    }

    @Benchmark
    public String test_lowp_doubleto() {
        String s = Double.toString( lowp_values[i] );
        i = (i + 1) & nmask;
        return s;
    }

    @Benchmark
    public String test_rand_grisustr() {
        String s =  g.doubleToString( random_values[i] );
        i = (i + 1) & nmask;
        return s;
    }

    @Benchmark
    public String test_lowp_grisustr() {
        String s =  g.doubleToString( lowp_values[i] );
        i = (i + 1) & nmask;
        return s;
    }

    @Benchmark
    public byte[] test_rand_grisubuf() {
        g.doubleToBytes( bresults, 0, random_values[i] );
        i = (i + 1) & nmask;
        return bresults;
    }

    @Benchmark
    public byte[] test_lowp_grisubuf() {
        g.doubleToBytes( bresults, 0, lowp_values[i] );
        i = (i + 1) & nmask;
        return bresults;
    }

    public static void main(String[] args) throws RunnerException {
        Options opt = new OptionsBuilder()
                .include(".*" + JmhBenchmark.class.getSimpleName() + ".*")
                .warmupIterations(20)
                .measurementIterations(20)
                .forks(1)
                .build();

        new Runner(opt).run();
    }
}
JasonN
  • 1,339
  • 1
  • 15
  • 27

3 Answers3

6

You can only prove the benchmark is correct by analyzing its results. The benchmark code can only raise the red flags that you have to follow up on. I see these red flags in your code:

  1. Reliance on static final fields to store the state. The contents of these fields can be routinely "inlined" into the computation, rendering parts of your benchmark futile. JMH only saves you from constant-folding the regular fields from @State objects.

  2. Using static initializers. While this has no repercussions in current JMH, the expected way is to use @Setup methods to initialize state. For your case, it also helps to get truly random data points, e.g. if you set @Setup(Level.Iteration) to reinitialize the values before starting the next iteration of the test.

As far as the general approach is concerned, this is one of the ways to achieve safe looping: putting the loop counter outside the method. There is another arguably safe one: loop over the array in the method, but sink every iteration result into Blackhole.consume.

Aleksey Shipilev
  • 18,599
  • 2
  • 67
  • 86
  • On #1, only the array references are final, not the contents (java final mostly useless anyways), and I've only seen optimizations in the early compilations because of final. The code reached the same end state, but might have taken one fewer comp iterations because of final. Looking into how to do #2 and `perfasm` now. – JasonN Feb 02 '15 at 15:36
  • Also, how would I put the loop counter outside the method? You mean use put the increments in an Invocation level method? I wind up wanting the same static data for each group of benchmarks (e.g., all the rand benchmarks to get the same doubles) so I left the static initialization alone. I have added some other benchmarks that almost require that. But I did add a Thread state annotation to the class. Thanks for the tips. – JasonN Feb 02 '15 at 15:57
  • What you did above *is* putting the loop counter outside, which is one of the ways to achieve safe looping. `@Setup/TearDown(Invocation)` comes with a cost, and so it putting the loop counter increment in `@Benchmark` itself may be the lesser evil. – Aleksey Shipilev Feb 02 '15 at 17:41
  • I was basically lazy and kept 3 different benchmarks in the same class. After I make them into separate benchmark classes/files proper, I'll be able to fix the setup with bechmark level annotations. The JITed code looks okay around the benchmark body too (except for one inexplicable arraycopy hoist I can't seem to understand). Thanks again. – JasonN Feb 03 '15 at 15:47
2

You are unfortunately not measuring this correctly. The JVM has a lot of chance to optimize your code as it is rather predicatable despite your attempt to add some random control-flow. For example:

String s = Double.toString( random_values[i] );
i = (i + 1) & nmask;
return s;

random_values is a fixed array in a static final field. As the incrementation of i is rather straight-forward, its value can in the worst case be fully determined such that s is simply set. i is dynamic but it does not really escape while nmask is again deterministic. The JVM could still optimize code here without that I could tell you what exactly without looking at the assembly.

Instead, rather use non-final instance fields for your values, add a @State annotation to your class and setup your test in a method annotated with @Setup. If you do so, JMH takes measures to escape your state properly in order to prevent the JVM's optimizations when facing deterministic values.

Rafael Winterhalter
  • 42,759
  • 13
  • 108
  • 192
  • Why the non-finals? Since the array contents cannot be final, shouldn't I be safe from any constant effects? I'm going to play around with `-pref perfasm`. I was trying to look through mountains of assembly before, but too much was generated. – JasonN Feb 02 '15 at 15:33
  • When not using finals, zou can change the scope of the setup. Shoild not make much of a difference but avoid static. – Rafael Winterhalter Feb 02 '15 at 19:19
  • 1
    FTY, after getting perfasm to work, the statics weren't optimized any more than you would expect (the length is a constant). The reason I kept them static is because I'm essentially running three different benchmarks and I need the same data for each test. No annotation really provided for a "benchmark group" idea. I'm in the process of making them into 3 different benchmarks (more code, more copying and paste, but better flexibility) and then I'll fix those statics with setup annotations. Thanks for the advise. – JasonN Feb 03 '15 at 15:43
2

I thought it would be helpful to show an implementation based on the recommendations of Aleksey and Rafael.

The key changes:

  • The same set of random data is supplied to all benchmarks. This is achieved by serializing the dataset to temp file, supplying the path to the setup() method via the @Param mechanism and then deserializing the data into instance fields.

  • Each benchmark run the methods against the entire dataset. We use the operationsPerInvocation feature to get accurate times.

  • The results of all operations are consumed via the black hole mechanism.

I created two examples, one based on the original question using a Serializable data set class that can be used directly and another that tests everyone's favorite non-serializable class, Optional.

If Aleksey or Rafael (or anyone) has any suggestions they would be much appreciated.

With Serializable data set.

import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.Serializable;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Comparator;
import java.util.Random;
import java.util.concurrent.TimeUnit;

import org.openjdk.jmh.annotations.Benchmark;
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.infra.Blackhole;
import org.openjdk.jmh.runner.Runner;
import org.openjdk.jmh.runner.options.Options;
import org.openjdk.jmh.runner.options.OptionsBuilder;

/**
 * In this example each benchmark loops over the entire randomly generated data set.
 * The same data set is used for all benchmarks.
 * And we black hole the results.
 */
@SuppressWarnings("javadoc")
@State(Scope.Benchmark)
public class JmhBenchmark {

    static final int DATA_SET_SAMPLE_SIZE = 1024 * 1024;

    static final Random RANDOM = new Random();

    static final Grisu g = Grisu.fmt;

    double[] random_values;

    double[] lowp_values;

    byte[] bresults;

    @Param("dataSetFilename")
    String dataSetFilename;

    @Setup
    public void setup() throws FileNotFoundException, IOException, ClassNotFoundException {

        try (FileInputStream fis = new FileInputStream(new File(this.dataSetFilename));
                ObjectInputStream ois = new ObjectInputStream(fis)) {

            final DataSet dataSet = (DataSet) ois.readObject();

            this.random_values = dataSet.random_values;
            this.lowp_values = dataSet.lowp_values;
        }

        this.bresults = new byte[30];
    }

    @Benchmark
    public void test_rand_doubleto(final Blackhole bh) {

        for (double random_value : this.random_values) {

            bh.consume(Double.toString(random_value));
        }
    }

    @Benchmark
    public void test_lowp_doubleto(final Blackhole bh) {

        for (double lowp_value : this.lowp_values) {

            bh.consume(Double.toString(lowp_value));
        }
    }

    @Benchmark
    public void test_rand_grisustr(final Blackhole bh) {

        for (double random_value : this.random_values) {

            bh.consume(g.doubleToString(random_value));
        }
    }

    @Benchmark
    public void test_lowp_grisustr(final Blackhole bh) {

        for (double lowp_value : this.lowp_values) {

            bh.consume(g.doubleToString(lowp_value));
        }
    }

    @Benchmark
    public void test_rand_grisubuf(final Blackhole bh) {

        for (double random_value : this.random_values) {

            bh.consume(g.doubleToBytes(this.bresults, 0, random_value));
        }
    }

    @Benchmark
    public void test_lowp_grisubuf(final Blackhole bh) {

        for (double lowp_value : this.lowp_values) {

            bh.consume(g.doubleToBytes(this.bresults, 0, lowp_value));
        }
    }

    /**
     * Serializes an object containing random data. This data will be the same for all benchmarks.
     * We pass the file name via the "dataSetFilename" parameter.
     *
     * @param args the arguments
     */
    public static void main(final String[] args) {

        try {
            // clean up any old runs as data set files can be large
            deleteTmpDirs(JmhBenchmark.class.getSimpleName());

            // create a tempDir for the benchmark
            final Path tempDirPath = createTempDir(JmhBenchmark.class.getSimpleName());

            // create a data set file
            final Path dateSetFilePath = Files.createTempFile(tempDirPath,
                    JmhBenchmark.class.getSimpleName() + "DataSet", ".ser");
            final File dateSetFile = dateSetFilePath.toFile();
            dateSetFile.deleteOnExit();

            // create the data
            final DataSet dataset = new DataSet();

            try (FileOutputStream fos = new FileOutputStream(dateSetFile);
                    ObjectOutputStream oos = new ObjectOutputStream(fos)) {
                oos.writeObject(dataset);
                oos.flush();
                oos.close();
            }

            final Options opt = new OptionsBuilder().include(JmhBenchmark.class.getSimpleName())
                .param("dataSetFilename", dateSetFile.getAbsolutePath())
                .operationsPerInvocation(DATA_SET_SAMPLE_SIZE)
                .mode(org.openjdk.jmh.annotations.Mode.All)
                .timeUnit(TimeUnit.MICROSECONDS)
                .forks(1)
                .build();

            new Runner(opt).run();

        } catch (final Exception e) {
            System.err.println(e.getMessage());
            e.printStackTrace();
            throw new RuntimeException(e);
        }

    }

    static Path createTempDir(String prefix) throws IOException {
        final Path tempDirPath = Files.createTempDirectory(prefix);
        tempDirPath.toFile()
            .deleteOnExit();
        return tempDirPath;
    }

    static void deleteTmpDirs(final String prefix) throws IOException {

        for (Path dir : Files.newDirectoryStream(new File(System.getProperty("java.io.tmpdir")).toPath(),
                prefix + "*")) {
            for (Path toDelete : Files.walk(dir)
                .sorted(Comparator.reverseOrder())
                .toArray(Path[]::new)) {
                Files.delete(toDelete);
            }
        }
    }

    static final class DataSet implements Serializable {

        private static final long serialVersionUID = 2194487667134930491L;

        private static final int[] pows = new int[] { 1, 10, 100, 1000, 10000, 100000, 1000000 };

        final double[] random_values = new double[DATA_SET_SAMPLE_SIZE];

        final double[] lowp_values = new double[DATA_SET_SAMPLE_SIZE];

        DataSet() {

            for (int i = 0; i < DATA_SET_SAMPLE_SIZE; i++) {
                this.random_values[i] = RANDOM.nextDouble();
            }

            for (int i = 0; i < DATA_SET_SAMPLE_SIZE; i++) {
                this.lowp_values[i] = (1 + RANDOM.nextInt(10000)) / pows[RANDOM.nextInt(pows.length)];
            }
        }

    }
}

With a non-serializable test subject (Optional)

import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Comparator;
import java.util.List;
import java.util.Optional;
import java.util.Random;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import java.util.stream.IntStream;

import org.openjdk.jmh.annotations.Benchmark;
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.infra.Blackhole;
import org.openjdk.jmh.runner.Runner;
import org.openjdk.jmh.runner.options.Options;
import org.openjdk.jmh.runner.options.OptionsBuilder;

@SuppressWarnings("javadoc")
@State(Scope.Benchmark)
public class NonSerializable {

    static final int DATA_SET_SAMPLE_SIZE = 20000;

    static final Random RANDOM = new Random();

    Optional<Integer>[] optionals;

    @Param("dataSetFilename")
    String dataSetFilename;

    @Setup
    public void setup() throws FileNotFoundException, IOException, ClassNotFoundException {

        try (FileInputStream fis = new FileInputStream(new File(this.dataSetFilename));
                ObjectInputStream ois = new ObjectInputStream(fis)) {

            @SuppressWarnings("unchecked")
            List<Integer> strings = (List<Integer>) ois.readObject();

            this.optionals = strings.stream()
                .map(Optional::ofNullable)
                .toArray(Optional[]::new);
        }

    }

    @Benchmark
    public void mapAndIfPresent(final Blackhole bh) {

        for (int i = 0; i < this.optionals.length; i++) {

            this.optionals[i].map(integer -> integer.toString())
                .ifPresent(bh::consume);
        }
    }

    @Benchmark
    public void explicitGet(final Blackhole bh) {

        for (int i = 0; i < this.optionals.length; i++) {

            final Optional<Integer> optional = this.optionals[i];

            if (optional.isPresent()) {
                bh.consume(optional.get()
                    .toString());
            }
        }
    }

    /**
     * Serializes a list of integers containing random data or null. This data will be the same for all benchmarks.
     * We pass the file name via the "dataSetFilename" parameter.
     *
     * @param args the arguments
     */
    public static void main(final String[] args) {

        try {
            // clean up any old runs as data set files can be large
            deleteTmpDirs(NonSerializable.class.getSimpleName());

            // create a tempDir for the benchmark
            final Path tempDirPath = createTempDir(NonSerializable.class.getSimpleName());

            // create a data set file
            final Path dateSetFilePath = Files.createTempFile(tempDirPath,
                    NonSerializable.class.getSimpleName() + "DataSet", ".ser");
            final File dateSetFile = dateSetFilePath.toFile();
            dateSetFile.deleteOnExit();

            final List<Integer> dataSet = IntStream.range(0, DATA_SET_SAMPLE_SIZE)
                .mapToObj(i -> RANDOM.nextBoolean() ? RANDOM.nextInt() : null)
                .collect(Collectors.toList());

            try (FileOutputStream fos = new FileOutputStream(dateSetFile);
                    ObjectOutputStream oos = new ObjectOutputStream(fos)) {
                oos.writeObject(dataSet);
                oos.flush();
                oos.close();
            }

            final Options opt = new OptionsBuilder().include(NonSerializable.class.getSimpleName())
                .param("dataSetFilename", dateSetFile.getAbsolutePath())
                .operationsPerInvocation(DATA_SET_SAMPLE_SIZE)
                .mode(org.openjdk.jmh.annotations.Mode.All)
                .timeUnit(TimeUnit.MICROSECONDS)
                .forks(1)
                .build();

            new Runner(opt).run();

        } catch (final Exception e) {
            System.err.println(e.getMessage());
            e.printStackTrace();
            throw new RuntimeException(e);
        }

    }

    static Path createTempDir(String prefix) throws IOException {
        final Path tempDirPath = Files.createTempDirectory(prefix);
        tempDirPath.toFile()
            .deleteOnExit();
        return tempDirPath;
    }

    static void deleteTmpDirs(final String prefix) throws IOException {

        for (Path dir : Files.newDirectoryStream(new File(System.getProperty("java.io.tmpdir")).toPath(),
                prefix + "*")) {
            for (Path toDelete : Files.walk(dir)
                .sorted(Comparator.reverseOrder())
                .toArray(Path[]::new)) {
                Files.delete(toDelete);
            }
        }
    }

}
Community
  • 1
  • 1
Jeff
  • 3,712
  • 2
  • 22
  • 24