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

Improve ByteBufferGuard in Java 11 [LUCENE-8780] #9824

Open
asfimport opened this issue Apr 27, 2019 · 8 comments
Open

Improve ByteBufferGuard in Java 11 [LUCENE-8780] #9824

asfimport opened this issue Apr 27, 2019 · 8 comments

Comments

@asfimport
Copy link

asfimport commented Apr 27, 2019

In #8462 we added ByteBufferGuard to protect MMapDirectory from crushing the JVM with SIGSEGV when you close and unmap the mmapped buffers of an IndexInput, while another thread is accessing it.

The idea was to do a volatile write access to flush the caches (to trigger a full fence) and set a non-volatile boolean to true. All accesses would check the boolean and stop the caller from accessing the underlying ByteBuffer. This worked most of the time, until the JVM optimized away the plain read access to the boolean (you can easily see this after some runtime of our by-default ignored testcase).

With master on Java 11, we can improve the whole thing. Using VarHandles you can use any access type when reading or writing the boolean. After reading Doug Lea's expanation <http://gee.cs.oswego.edu/dl/html/j9mm.html> and some testing, I was no longer able to crush my JDK (even after running for minutes unmapping bytebuffers).

The apraoch is the same, we do a full-fenced write (standard volatile write) when we unmap, then we yield the thread (to finish in-flight reads in other threads) and then unmap all byte buffers.

On the test side (read access), instead of using a plain read, we use the new "opaque read". Opaque reads are the same as plain reads, there are only different order requirements. Actually the main difference is explained by Doug like this: "For example in constructions in which the only modification of some variable x is for one thread to write in Opaque (or stronger) mode, X.setOpaque(this, 1), any other thread spinning in while(X.getOpaque(this)!=1){} will eventually terminate. Note that this guarantee does NOT hold in Plain mode, in which spin loops may (and usually do) infinitely loop – they are not required to notice that a write ever occurred in another thread if it was not seen on first encounter." - And that's waht we want to have: We don't want to do volatile reads, but we want to prevent the compiler from optimizing away our read to the boolean. So we want it to "eventually" see the change. By the much stronger volatile write, the cache effects should be visible even faster (like in our Java 8 approach, just now we improved our read side).

The new code is much slimmer (theoretically we could also use a AtomicBoolean for that and use the new method getOpaque(), but I wanted to prevent extra method calls, so I used a VarHandle directly).

It's setup like this:

  • The underlying boolean field is a private member (with unused SuppressWarnings, as its unused by the java compiler), marked as volatile (that's the recommendation, but in reality it does not matter at all).
  • We create a VarHandle to access this boolean, we never do this directly (this is why the volatile marking does not affect us).
  • We use VarHandle.setVolatile() to change our "invalidated" boolean to "true", so enforcing a full fence
  • On the read side we use VarHandle.getOpaque() instead of VarHandle.get() (like in our old code for Java 8).

I had to tune our test a bit, as the VarHandles make it take longer until it actually crushes (as optimizations jump in later). I also used a random for the reads to prevent the optimizer from removing all the bytebuffer reads. When we commit this, we can disable the test again (it takes approx 50 secs on my machine).

I'd still like to see the differences between the plain read and the opaque read in production, so maybe @mikemccand or @rmuir can do a comparison with nightly benchmarker?

Have fun, maybe @dweiss has some ideas, too.


Migrated from LUCENE-8780 by Uwe Schindler (@uschindler), updated Apr 29 2019
Attachments: LUCENE-8780.patch

@asfimport
Copy link
Author

asfimport commented Apr 27, 2019

Uwe Schindler (@uschindler) (migrated from JIRA)

Patch: LUCENE-8780.patch

LUCENE-8780.patch
 .../org/apache/lucene/store/ByteBufferGuard.java   | 42 ++++++++++++----------
 .../org/apache/lucene/store/TestMmapDirectory.java | 13 +++----
 2 files changed, 31 insertions(+), 24 deletions(-)

diff --git a/lucene/core/src/java/org/apache/lucene/store/ByteBufferGuard.java b/lucene/core/src/java/org/apache/lucene/store/ByteBufferGuard.java
index 95fa17d..e3ee12d 100644
--- a/lucene/core/src/java/org/apache/lucene/store/ByteBufferGuard.java
+++ b/lucene/core/src/java/org/apache/lucene/store/ByteBufferGuard.java
@@ -17,8 +17,9 @@
 package org.apache.lucene.store;
 
 import java.io.IOException;
+import java.lang.invoke.MethodHandles;
+import java.lang.invoke.VarHandle;
 import java.nio.ByteBuffer;
-import java.util.concurrent.atomic.AtomicInteger;
 
 /**
  * A guard that is created for every {@link ByteBufferIndexInput} that tries on best effort
@@ -26,8 +27,11 @@ import java.util.concurrent.atomic.AtomicInteger;
  * of this is used for the original and all clones, so once the original is closed and unmapped
  * all clones also throw {@link AlreadyClosedException}, triggered by a {@link NullPointerException}.
  * <p>
- * This code tries to hopefully flush any CPU caches using a store-store barrier. It also yields the
- * current thread to give other threads a chance to finish in-flight requests...
+ * This code tries to hopefully flush any CPU caches using a full fence (volatile write) and
+ * <em>eventually</em> see the state change using opaque reads. It also yields the current thread
+ * to give other threads a chance to finish in-flight requests...
+ * 
+ * @see <a href="http://gee.cs.oswego.edu/dl/html/j9mm.html">Doug Lea: Using JDK 9 Memory Order Modes</a>
  */
 final class ByteBufferGuard {
   
@@ -43,11 +47,19 @@ final class ByteBufferGuard {
   private final String resourceDescription;
   private final BufferCleaner cleaner;
   
-  /** Not volatile; see comments on visibility below! */
-  private boolean invalidated = false;
-  
-  /** Used as a store-store barrier; see comments below! */
-  private final AtomicInteger barrier = new AtomicInteger();
+  @SuppressWarnings("unused")
+  private volatile boolean invalidated = false;
+  
+  /** Used to access the volatile variable with different memory semantics
+   * (volatile write for barrier with memory_order_seq_cst semantics, opaque reads with memory_order_relaxed semantics): */
+  private static final VarHandle VH_INVALIDATED;
+  static {
+    try {
+      VH_INVALIDATED = MethodHandles.lookup().findVarHandle(ByteBufferGuard.class, "invalidated", boolean.class);
+    } catch (ReflectiveOperationException e) {
+      throw new ExceptionInInitializerError(e);
+    }
+  }
   
   /**
    * Creates an instance to be used for a single {@link ByteBufferIndexInput} which
@@ -63,15 +75,9 @@ final class ByteBufferGuard {
    */
   public void invalidateAndUnmap(ByteBuffer... bufs) throws IOException {
     if (cleaner != null) {
-      invalidated = true;
-      // This call should hopefully flush any CPU caches and as a result make
-      // the "invalidated" field update visible to other threads. We specifically
-      // don't make "invalidated" field volatile for performance reasons, hoping the
-      // JVM won't optimize away reads of that field and hardware should ensure
-      // caches are in sync after this call. This isn't entirely "fool-proof" 
-      // (see #8462 discussion), but it has been shown to work in practice
-      // and we count on this behavior.
-      barrier.lazySet(0);
+      // This call should flush any CPU caches and as a result make
+      // the "invalidated" field update visible to other threads:
+      VH_INVALIDATED.setVolatile(this, true); 
       // we give other threads a bit of time to finish reads on their ByteBuffer...:
       Thread.yield();
       // finally unmap the ByteBuffers:
@@ -82,7 +88,7 @@ final class ByteBufferGuard {
   }
   
   private void ensureValid() {
-    if (invalidated) {
+    if (cleaner != null && (boolean) VH_INVALIDATED.getOpaque(this)) {
       // this triggers an AlreadyClosedException in ByteBufferIndexInput:
       throw new NullPointerException();
     }
diff --git a/lucene/core/src/test/org/apache/lucene/store/TestMmapDirectory.java b/lucene/core/src/test/org/apache/lucene/store/TestMmapDirectory.java
index 3d6e532..17e13f1 100644
--- a/lucene/core/src/test/org/apache/lucene/store/TestMmapDirectory.java
+++ b/lucene/core/src/test/org/apache/lucene/store/TestMmapDirectory.java
@@ -22,7 +22,7 @@ import java.nio.file.Path;
 import java.util.Random;
 import java.util.concurrent.CountDownLatch;
 
-import org.junit.Ignore;
+//import org.junit.Ignore;
 
 /**
  * Tests MMapDirectory
@@ -43,12 +43,12 @@ public class TestMmapDirectory extends BaseDirectoryTestCase {
     assumeTrue(MMapDirectory.UNMAP_NOT_SUPPORTED_REASON, MMapDirectory.UNMAP_SUPPORTED);
   }
   
-  @Ignore("This test is for JVM testing purposes. There are no guarantees that it may not fail with SIGSEGV!")
+  //@Ignore("This test is for JVM testing purposes. There are no guarantees that it may not fail with SIGSEGV!")
   public void testAceWithThreads() throws Exception {
-    for (int iter = 0; iter < 10; iter++) {
+    final Random random = random();
+    for (int iter = 0; iter < 300; iter++) {
       Directory dir = getDirectory(createTempDir("testAceWithThreads"));
       IndexOutput out = dir.createOutput("test", IOContext.DEFAULT);
-      Random random = random();
       for (int i = 0; i < 8 * 1024 * 1024; i++) {
         out.writeInt(random.nextInt());
       }
@@ -58,11 +58,12 @@ public class TestMmapDirectory extends BaseDirectoryTestCase {
       final byte accum[] = new byte[32 * 1024 * 1024];
       final CountDownLatch shotgun = new CountDownLatch(1);
       Thread t1 = new Thread(() -> {
+        final Random rnd = random();
         try {
           shotgun.await();
-          for (int i = 0; i < 10; i++) {
+          for (int i = 0; i < 15000; i++) {
             clone.seek(0);
-            clone.readBytes(accum, 0, accum.length);
+            clone.readBytes(accum, 0, rnd.nextInt(accum.length) + 1);
           }
         } catch (IOException | AlreadyClosedException ok) {
           // OK

@asfimport
Copy link
Author

Uwe Schindler (@uschindler) (migrated from JIRA)

I created a pull request for easier review and perf testing (easy chackout of branch from my github repo): apache/lucene-solr#658

@asfimport
Copy link
Author

Uwe Schindler (@uschindler) (migrated from JIRA)

Thats the result after 20 runs of wikimediumall with 6 searcher threads (with ParallelGC) on Mike's lucenebench:

use java command /home/jenkins/tools/java/64bit/jdk-11.0.2/bin/java -server -Xms2g -Xmx2g -XX:+UseParallelGC -Xbatch

JAVA:
openjdk version "11.0.2" 2019-01-15
OpenJDK Runtime Environment 18.9 (build 11.0.2+9)
OpenJDK 64-Bit Server VM 18.9 (build 11.0.2+9, mixed mode)

OS:
Linux serv1.sd-datasolutions.de 4.18.0-17-generic #18\~18.04.1-Ubuntu SMP Fri Mar 15 15:27:12 UTC 2019 x86_64 x86_64 x86_64 GNU/Linux

[...]

Report after iter 19:
                    Task    QPS orig      StdDev   QPS patch      StdDev                Pct diff
                  IntNRQ       30.88      (0.6%)       26.33      (0.8%)  -14.7% ( -16% -  -13%)
                PKLookup      107.70      (2.7%)       94.31      (2.9%)  -12.4% ( -17% -   -7%)
             AndHighHigh       10.76     (11.5%)       10.17      (3.3%)   -5.4% ( -18% -   10%)
                  Fuzzy2       45.10      (7.7%)       43.21      (9.0%)   -4.2% ( -19% -   13%)
         LowSloppyPhrase        7.28     (16.8%)        6.98      (6.3%)   -4.2% ( -23% -   22%)
            OrHighNotLow      783.24      (7.1%)      751.37      (2.5%)   -4.1% ( -12% -    5%)
           OrHighNotHigh      934.39      (6.5%)      896.38      (2.1%)   -4.1% ( -11% -    4%)
                 Respell       45.36     (10.6%)       43.65      (7.0%)   -3.8% ( -19% -   15%)
           OrNotHighHigh      779.95      (3.8%)      752.28      (1.8%)   -3.5% (  -8% -    2%)
        HighSloppyPhrase       10.37     (12.8%)       10.03      (3.5%)   -3.3% ( -17% -   14%)
               LowPhrase       11.60      (8.9%)       11.23      (1.7%)   -3.2% ( -12% -    8%)
                 LowTerm     1694.00      (8.9%)     1642.34      (5.5%)   -3.0% ( -16% -   12%)
                 MedTerm     1292.82      (9.3%)     1253.69      (8.2%)   -3.0% ( -18% -   15%)
              AndHighMed       71.41      (9.9%)       69.77      (7.5%)   -2.3% ( -17% -   16%)
            OrNotHighMed      634.32      (7.2%)      620.67      (7.5%)   -2.2% ( -15% -   13%)
                 Prefix3      110.65     (14.9%)      108.55      (8.7%)   -1.9% ( -22% -   25%)
               OrHighLow      347.02      (4.3%)      340.51      (9.9%)   -1.9% ( -15% -   12%)
            OrNotHighLow      591.61      (5.5%)      580.60      (9.0%)   -1.9% ( -15% -   13%)
            OrHighNotMed     1258.21      (1.8%)     1237.28      (5.0%)   -1.7% (  -8% -    5%)
                  Fuzzy1       91.79      (4.3%)       90.77     (11.1%)   -1.1% ( -15% -   14%)
               OrHighMed       10.29      (7.9%)       10.25     (11.8%)   -0.4% ( -18% -   20%)
                Wildcard       52.28      (6.3%)       52.21      (6.8%)   -0.1% ( -12% -   13%)
              OrHighHigh        8.16      (6.9%)        8.22      (9.3%)    0.8% ( -14% -   18%)
              AndHighLow      563.89      (9.1%)      569.31     (15.3%)    1.0% ( -21% -   27%)
              HighPhrase       15.88      (9.3%)       16.04     (13.0%)    1.0% ( -19% -   25%)
               MedPhrase       14.84      (9.0%)       15.15     (12.8%)    2.1% ( -18% -   26%)
            HighSpanNear        2.16      (9.8%)        2.21     (10.1%)    2.3% ( -16% -   24%)
         MedSloppyPhrase       18.48     (15.4%)       18.96     (18.9%)    2.6% ( -27% -   43%)
             MedSpanNear       17.75      (3.8%)       18.31     (10.0%)    3.1% ( -10% -   17%)
                HighTerm     1031.00      (9.9%)     1068.12     (17.1%)    3.6% ( -21% -   33%)
             LowSpanNear        8.22      (5.5%)        8.53     (13.3%)    3.7% ( -14% -   23%)
   HighTermDayOfYearSort        9.78     (11.0%)       10.25     (18.2%)    4.8% ( -21% -   38%)
       HighTermMonthSort       23.40     (26.5%)       27.11     (32.1%)   15.9% ( -33% -  101%)

The total runtime of each run did not change, always approx 280s per run patched and unpatched. Not sure how to interpret this.

@asfimport
Copy link
Author

Michael Sokolov (@msokolov) (migrated from JIRA)

I don't have a good theory, but I was curious so I ran a few tests, and one thing I saw is that if you limit to a single searcher thread, you see only the negative side of this distribution, or at least it becomes more negative.

@asfimport
Copy link
Author

Dawid Weiss (@dweiss) (migrated from JIRA)

Every time I read about those memory ordering models I seem to get it and two weeks later I'm again confused... So I don't know if what you propose is right or wrong. The volatile mode always seemed to me to be regulating relationships among other volatile reads/ writes (and not specifying what happens to other variable reads/ writes not covered by volatiles)... perhaps you can piggyback fences on top of that, but maybe you can't. We could fire an e-mail to concurrency dev list so that more knowledgeable folks take a peek and let us know? Just saying...

Also: "until the JVM optimized away the plain read access to the boolean" – when did that happen, do you have a reference openjdk Jira issue?

@asfimport
Copy link
Author

Uwe Schindler (@uschindler) (migrated from JIRA)

Hi,
I did some tests over the last night. The fluctuating results can be improved by removing the null-check on the "cleaner". Normally this one should be optimized away, but that seems to be too hard. Without it, you still have some variance in the results, but it's better.

In general the new code is a bit slower, which is more visible if you have shorter runtime, so it looks like optimizing aways the VarHandle simply takes longer, and this degrades oaverage performance. I don't know how long the benchmark runs a warmup.

Every time I read about those memory ordering models I seem to get it and two weeks later I'm again confused...

That's the ssme for me. But our use-case seems to be pretty good explained in the above quotes by Doug Lea. It clearly says that a volatile write is harder than a opaque read, the volatile write is is only visible eventually to the opaque read (but never for a plain read, if it was optimized away).

I was already talking with Hotspot guys: In short, there is no way to make ByteBufferGuard "safe" unless you use a volatile or with an aquire/release fence (that can be implemented also with the varhandles). When using it, I was unable to crush my VM, but slowdown is definitely there. Also manually putting in fences does not help.

The main difference between plain read and opaque (as used here) is not that the produced CPU instructions are different, the difference is only how the optimizer may optimize code. With plain reads, a simple spin-loop will never end, as the hotspot compiler clearly sees that the value can't change, and because it's not volatile, opaque or whatever, he will remove it for this thread. This does not only happen in spin loops, it also happens in our code if it's executed often enough. This is not a bug, it's wanted.

I tuned the testcase to crush always: Just insert a loop of 10.000 reads BEFORE you unmap, then it fails always (in Java 8). With opaque it was also doing this, but not reproducible, so there is an improvement. IMHO, the reason why we see a slowdown for some queries could be coming from that: Hotspot is removing the "if (invalidated)" check, as it cannot change in the current thread. With opaque it can't do it, so the code is slower on the long term. I will try to get some assembly output later, just have to install hsdis.dll.

To make it bulletproof, we have to wait for official support by the ByteBuffer API to officially unmap (https://bugs.openjdk.java.net/browse/JDK-4724038), with our checks we cannot make it safe. Another approach would be to expand the SIGSEGV signal handler checks in the JVM to throw an InternalError (they do it now if the operating system truncates the file and so the mapped are changes). I don't know why they not generally do a SIGSEGV check and if it happens inside DirectBuffer they just throw an Exception.... (possibly delayed as in the truncated file case).

So we have to decide what we want to do as a workaround:

  • We can't be bulletproof
  • We can catch with our current code most cases where you open an index, start a query and then close the reader. This only works shortly after program started and everything is not yet optimized. As soon as there were multiple queries already running and you close the JVM later, it SIGSEGV almost always. Not even "opaque" semantics help here. With "plain" code (current code in branch_8x), at the time when optimizer decides to optimize, the checks are gone.
  • With the current "opaque" code we can go a bit further, but we have some slowdown, but it's still not save.

@asfimport
Copy link
Author

Robert Muir (@rmuir) (migrated from JIRA)

i imagine any slowdown only impacts stuff doing lots of tiny reads vs reading big byte[].
The current code in most cases will deliver ACE in someones unit test rather than a crash. Maybe if you are lucky you even get an ACE rather than sigsegv in production too. To me it seems like the promise of this patch is that you stand a better chance, even after code is running for a while (10k times or whatever). our best effort check will no longer be optimized away? but thats also its downside: it wont get optimized away even if you have no bugs in your code.

seems like we just decide on the correct tradeoff. personally i lean towards more aggressive safety: the user is using java, they dont expect sigsegv. just like they dont expect to run out of mmaps because its tied to gc, and they dont expect all their searches bottlenecked on one file handle, due to stupid synchronizatiom on a relative file pointer we dont even care about.

@asfimport
Copy link
Author

Uwe Schindler (@uschindler) (migrated from JIRA)

I did a second patch that uses AtomicBoolean instead of VarHandles. The underlying code is the same: AtomicBoolean.getOpaque() calls a VarHandle behind the scenes (BTW, AtomicInteger, AtomicBoolean,... all were rewritten and use the VarHandle mechanism, see e.g., https://hg.openjdk.java.net/jdk/jdk11/file/1ddf9a99e4ad/src/java.base/share/classes/java/util/concurrent/atomic/AtomicBoolean.java).

Here is this version #2: apache/lucene-solr@master...uschindler:jira/LUCENE-8780-v2

The effect was worse, so it's not an option. But this brings me to the conclusion: The actual calls using the other memory models are not actually the problem. Also the VarHandles and AtomicBooleans are correctly optimized away, but it looks like because of the complexity of the optimizations on the lowest level, it takes much longer until it gets faster and some optimizations are not applied at all (you cannot remove opaque reads, because the memory model eventually makes changes from other threads visible).

Here are the results from the AtomicBoolean:

Report after iter 10:
                    Task    QPS orig      StdDev   QPS patch      StdDev                Pct diff
                  IntNRQ       22.48     (12.8%)       16.86     (13.0%)  -25.0% ( -45% -    0%)
                PKLookup      112.32     (14.5%)       92.37      (7.6%)  -17.8% ( -34% -    5%)
               OrHighLow      207.87     (16.6%)      185.52      (4.1%)  -10.8% ( -26% -   11%)
            OrNotHighMed      992.06      (9.3%)      914.79      (1.5%)   -7.8% ( -16% -    3%)
            HighSpanNear        5.22     (10.2%)        4.83      (5.4%)   -7.5% ( -20% -    9%)
                  Fuzzy1       44.66      (9.7%)       41.46      (2.1%)   -7.2% ( -17% -    5%)
             MedSpanNear        8.24     (18.2%)        7.67     (12.4%)   -6.9% ( -31% -   28%)
         LowSloppyPhrase        6.91     (19.1%)        6.46     (13.4%)   -6.6% ( -32% -   31%)
                Wildcard       43.23     (13.9%)       40.47      (6.0%)   -6.4% ( -23% -   15%)
               LowPhrase       11.89     (11.4%)       11.18      (3.7%)   -6.0% ( -18% -   10%)
            OrHighNotMed     1188.55      (6.3%)     1118.58      (1.3%)   -5.9% ( -12% -    1%)
                  Fuzzy2       66.58      (1.4%)       62.85      (1.7%)   -5.6% (  -8% -   -2%)
              HighPhrase       32.87     (11.8%)       31.15      (8.8%)   -5.2% ( -23% -   17%)
            OrNotHighLow      537.79      (2.2%)      511.56      (8.8%)   -4.9% ( -15% -    6%)
         MedSloppyPhrase       44.16     (10.0%)       42.08      (2.3%)   -4.7% ( -15% -    8%)
           OrNotHighHigh      984.54      (2.2%)      942.20      (1.7%)   -4.3% (  -8% -    0%)
             AndHighHigh        6.40     (12.1%)        6.15     (12.8%)   -3.9% ( -25% -   23%)
              AndHighMed       57.29      (9.9%)       55.19      (3.4%)   -3.7% ( -15% -   10%)
        HighSloppyPhrase        4.60     (14.3%)        4.44      (6.0%)   -3.5% ( -20% -   19%)
           OrHighNotHigh      853.88      (2.7%)      824.51      (3.7%)   -3.4% (  -9% -    3%)
               MedPhrase       73.25      (2.6%)       70.85      (3.4%)   -3.3% (  -9% -    2%)
                 LowTerm     1130.00      (5.4%)     1093.38      (2.5%)   -3.2% ( -10% -    4%)
               OrHighMed       34.61      (2.3%)       33.58      (3.1%)   -3.0% (  -8% -    2%)
                 MedTerm      994.47      (7.9%)      975.29      (7.9%)   -1.9% ( -16% -   15%)
            OrHighNotLow      762.68      (3.0%)      749.09      (5.2%)   -1.8% (  -9% -    6%)
                 Respell       53.06      (6.9%)       52.44     (11.5%)   -1.2% ( -18% -   18%)
             LowSpanNear        8.29      (5.3%)        8.30     (10.0%)    0.1% ( -14% -   16%)
   HighTermDayOfYearSort       22.78      (6.4%)       22.88      (7.1%)    0.4% ( -12% -   14%)
                HighTerm      822.80      (3.2%)      827.84      (8.2%)    0.6% ( -10% -   12%)
              OrHighHigh        8.85     (10.0%)        8.92     (14.5%)    0.8% ( -21% -   28%)
              AndHighLow      258.08      (4.7%)      261.18     (10.0%)    1.2% ( -12% -   16%)
       HighTermMonthSort       13.86      (9.2%)       14.63     (22.9%)    5.6% ( -24% -   41%)
                 Prefix3       27.01     (10.1%)       28.72     (25.0%)    6.3% ( -26% -   46%)

As said before, removing the null check does not matter at all, it just makes the variance on short running tests less evident, but the average is identical.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

2 participants