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

Improving I/O Efficience and Performance using NIO API instead of IO API #5341

Merged
merged 1 commit into from
May 9, 2024

Conversation

mkarg
Copy link
Member

@mkarg mkarg commented May 20, 2023

Target

The aim of this PR is to improve…

  • …🌳 Efficiency: Consume less power, produce less lost heat and CO₂.
  • …🏎 Performance: Transfer data in less time.

Justification

Jersey is a key component of tens of thousands of servers operated world-wide, hence it should perform data transfers as quickly as possible, with least resource consumption as possible.

Description

This PR replaces usage of IO API by NIO API in some parts of Jersey.

Non-Targets

  • It is not a target of this PR to replace IO API completely. I only applied NIO API where needed for the aim of this PR (plus some locations I came across incidentally while coding this PR).
  • It is not a target of this PR to ultimately reduce resource consumption / maximize performance. This PR shall only be a starter for introducing OS-offloading capabilities into Jersey 3.2.
  • It is not a target of this PR to backport changes into Jersey 3.1 or 2.x, as performance optimizations are like new features IMHO, so they should not pop up in maintenance-only branches.
  • It is not a target of this PR to completely overhaul all existing source code. Wherever I optimized or simplified the source code, it was only done in files or code lines that are to be touched by this PR anyways.

Background Information

Since Java 7 (and later, Java 9 and 11) there is a new NIO API which allows the JRE to do internal "tricks":

  • Offload transfer of data to optimized algorithms inside of the JRE (e. g. many implementations of transferTo recently got hand-optimized, to work faster than typical custom application code and/or skip execution if possible and/or offload to wrapped streams, etc.).
  • Offload transfer of data to lower leves of the technology stack (e. g. stream-to/from-file and stream-to/from-socket is offloaded to the operating system, i. e. bypasses the Java heap, hence no time is squandered by useless native-to/from-Java-RAM transfers).
  • Use JRE-internal short-cuts, which are not visible at the public API level (e. g. the JRE internally maintaines reusable buffers, while applications have to recreate buffers always).
  • Use JRE-internal optimizations basing on envidence instead of hope (e. g. the JRE's byte buffer size was recently optimized using intensive JMH benchmarks on several technology stacks, so it outperforms in all cases any kind of custom application code).
  • etc.

Note: I deliberately did not squash the commits, so reviewers get a better understanding of the particular reason for each change.

Note: I am the original author of several of the mentioned JRE-internal optimizations, and my target is to help downstream projects to adopt them, hence to make most of them. If there are any questions regarding these internals, feel free to directly ask me. NB: One of the projects I already helped to optimize was Maven, which benefits from the changes I made since several months.

public static void writeTo(InputStream in, OutputStream out) throws IOException {
ReaderWriter.writeTo(in, out);
in.transferTo(out);
Copy link
Contributor

Choose a reason for hiding this comment

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

In general, I am not in favor of replacing ReaderWriter.writeTo. While transferTo seems to do exactly the same as writeTo, Jersey has a mechanism to extend the buffer size used.

Copy link
Member Author

@mkarg mkarg May 21, 2023

Choose a reason for hiding this comment

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

In a JMH test campaign at OpenJDK we have empirically proven that it makes no sense for any application to use any other buffer sizes than the JRE's default size. In particular we have proven that it makes no sense to use buffers smaller than 8K (JRE's previous default) / larger than 16K (JRE's current default). Keeping that configuration option will in virtually all cases result in (very) worse efficience and performance compared to using transferTo. Hence I do not see what your target is by not using transferTo. Using transferTo is the sole key point of this PR, and is the sole proven driver for optimum efficience and performance. So if your decision is final, we can drop this PR -- but this means that Jersey will always have bad efficency and performance.

Copy link
Member Author

@mkarg mkarg May 21, 2023

Choose a reason for hiding this comment

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

BTW, transferTo is definitively not doing the same as Jersey's custom code in recent JREs. In fact, modern JREs offload the data transfer to the OS, which leads to dramatic performance and efficiency gains. My PR targets on moden JREs, not on JRE 11. That's why I opened it for 3.1, not for 3.x / master. Please check the actual source code of JRE 21-SNAPSHOT.

Edit: To understand where this happens, you need to see that each implementation of InputStream has its own implementation of transferTo (this is what we added in the past years since JRE 11). In particular, the optimization happens inside of channel-based input streams, e. g. the one returned by Files.newInputStream, which is working completely different to Jersey's.

Copy link
Member Author

@mkarg mkarg May 21, 2023

Choose a reason for hiding this comment

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

A compromise could be that we keep calling ReaderWriter.transferTo but inside of that method we add an option that is calling the old code if explicitly wanted. For example, we could replace the default buffer size 8192 by -1. For -1 we call transferTo. For any other number (hence explicitly set 8192 or any other value) we call the original code. @jansupol WDYT?

Copy link
Member Author

Choose a reason for hiding this comment

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

@jansupol I think this is what we could agree upon: c118b65. It preserves the option to explicitly set the buffer size, but unless one does so, it let's the JRE do its job. So user who don't care get JRE optimizations out-of-the-box, and users who want to get the old code, can explicity set 8192. This is the best of all worlds.

Copy link
Contributor

Choose a reason for hiding this comment

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

@mkarg ReaderWriter's BUFFER_SIZE is a public property with a default value of 8192. This must not be changed, think about compatibility.

I do not see much difference in my early copy of JDK 21. But I agree, the JDK internals can change. This is seen by extending the buffer values in JDK 21. While the tests with JDK may show the large buffer brings a slight performance boost, the customer may judge otherwise, based on their use case.

You are right, however, that by default the buffer size is not changed by the majority of customers. Hence, what we can do is forward to JDK's transfer method for cases where the customer does not change the BUFFER_SIZE to a value different from 8192.

Copy link
Member Author

Choose a reason for hiding this comment

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

@jansupol I see we come nearer to a final solution. :-)

What we measured at OpenJDK is that the performance / efficiency benefit strongly depends greatly on the actual platform stack, less of the actual use case, which is why we still have decided for a one size fits it all buffer size even in JDK 21. I do not see any evidence that this experience should not fit to particularly Jersey. Anyways I have no objection against keeping the IO_BUFFER_SIZE option available for those rare cases that I might not see but possibly exist (do you actually know some?).

The actual code changes BTW can be found here: https://github.com/openjdk/jdk/pulls?q=is%3Apr+is%3Aclosed+author%3Amkarg. They (mostly) are effective solely when using transferTo, in particular when using transferTo in conjunction with JRE (non-custom) streams (hence the lots of changed implementations) - as you can see, over the past years, we changed and discussed a lot of JRE code, and that for a good reason (to make the most of modern server hardware / to not stand in the way of OS-internal optimizations).

Regarding the default size: I do not think that anybody really relies on particularly the value 8192 being the default for all times, but people instead rely on "BUFFER_SIZE == IO_DEFAULT_BUFFER_SIZE", and they rely on IO_DEFAULT_BUFFER_SIZE telling them the actual value at any time they can trust upon to be the default, so I think it would be OK if we tell them that IO_DEFAULT_BUFFER_SIZE == -1 from the next minor or major release. IMHO existing applications that did not check IO_DEFAULT_BUFFER_SIZE are simply wrong because that is the reason why the docs do not say "8192" but say "IO_DEFAULT_BUFFER_SIZE", right?

Only enabling the fallback code in case the application is setting a value other than 8192 sounds rather odd and ambiguous. What if a user wants to really use just 8192 bytes, because he does not want to squander the new JRE default of 16384 (or possibly even more bytes in JRE 22 or 23)? So I really would recommend to go with IO_DEFAULT_BUFFER_SIZE == -1 instead, to make the actual behavior change visible (if we trust our users to read the release notes), or (for maximum backwards compatibility) to actually detect any change by using a Jersey-internal flag which is set to true as soon as IO_BUFFER_SIZE >= 0 (i. e. Jersey internally ignores BUFFER_SIZE, but externally the BUFFER_SIZE backward-compatibly reflects IO_DEFAULT_BUFFER_SIZE == 8192).

Having said that, in what direction shall I effectively migrate this PR now?

Copy link
Member Author

Choose a reason for hiding this comment

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

@jansupol "ping" - I need your decision regarding above proposal. Thanks.

Copy link
Contributor

Choose a reason for hiding this comment

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

Really it is not a good practice to remove or change public properties, and values.

You have a point that people might require a lower buffer than the one in JDK on purpose. However, since the change comes in JDK 21 which is not released yet, it is not very likely people will want to override that value, and it makes more sense to me to live with overriding this value than changing public constants.

If you want to be precise, you can check for JDK version (using the Jersey JdkVersion class) and if JDK is 21, keep Jersey routine for BUFFER_SIZE 16384. But then, is it not the goal to use JDK code instead of Jersey? While I can imagine the poor guy spending days on testing various BUFFER_SIZE with JDK 11, I can hardly imagine that guy doing so with beta JDK 21.

Copy link
Member Author

Choose a reason for hiding this comment

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

I actually think it is a pretty good practice to update default values from time to time, as users gain benefits without getting active themselfs.

Actually I do see a benefit of explicitly setting the BUFFER_SIZE property even on Java 11: For example setting it to 16k, as Java 11's default is 8k, so you gain performance even on old-but-widespread JREs. So it makes not much sense to keep the default value at 8k (with or without JRE 21), or speculate around which improvement might be in which JRE version from which vendor.

IMHO Jersey users prefer automatic performance improvements over buffer size being statically 8192, so my clear advice to Team Jersey would be to adopt my proposed compromise and let the default buffer size be -1 from now on, or at least modify it to 16k in Jersey 3.x and -1 in Jersey 4x. Anyways, I am neither the project lead nor a committer, so it is up to you @jansupol to decide.

Please tell me clearly the solution that you want me to add to this PR, so we get NIO-offloading ready for merge into Jersey 3.x ASAP. Thanks.

@mkarg mkarg force-pushed the nio branch 2 times, most recently from d11ab23 to c118b65 Compare May 21, 2023 14:46
@jansupol
Copy link
Contributor

jansupol commented Jun 6, 2023

Please update the copyright year in modified files, the CI tests fail otherwise.

@mkarg
Copy link
Member Author

mkarg commented Jun 8, 2023

Please update the copyright year in modified files, the CI tests fail otherwise.

Thank you, it seems I missed to update two files. Now all files how Copyright 2023.

@jansupol
Copy link
Contributor

jansupol commented Jun 9, 2023

java.lang.NegativeArraySizeException: -1
at org.glassfish.jersey.message.internal.ReaderWriter.readAllBytes(ReaderWriter.java:193)

@mkarg
Copy link
Member Author

mkarg commented Jun 9, 2023

java.lang.NegativeArraySizeException: -1
at org.glassfish.jersey.message.internal.ReaderWriter.readAllBytes(ReaderWriter.java:193)

I know - this is because I did not finish work, because the discussion whether we go with -1 or not is still open.

(Edit: This is the reason why it is marked as draft)

(Edit: Should be fixed in fa2cbd2, but the solution is temporary unless @jansupol finally decided if we go with -1 or if we stick with 8192)

@mkarg
Copy link
Member Author

mkarg commented Jun 9, 2023

@jansupol How to proceed? Agreed with -1, or want me to go with 8192?

@jansupol
Copy link
Contributor

jansupol commented Jun 9, 2023

I believe we need to keep all the properties (including IO_DEFAULT_BUFFER_SIZE) and not make any troubling changes.

Since the changes are JDK-related, I would suggest opening the PR against the master branch so that each version of Jersey can benefit from your updates.

@mkarg
Copy link
Member Author

mkarg commented Jun 9, 2023

I believe we need to keep all the properties (including IO_DEFAULT_BUFFER_SIZE) and not make any troubling changes.

I will do as you say.

Since the changes are JDK-related, I would suggest opening the PR against the master branch so that each version of Jersey can benefit from your updates.

The master branch uses Java 8 while my changes rely on Java 11, so I do not see a way to make that work.

@mkarg
Copy link
Member Author

mkarg commented Jun 9, 2023

@jansupol I have reintroduced IO_DEFAULT_BUFFER_SIZE in 2750a6f as you asked for. In that revision the user can even explicitly demand a static 8k buffer if he really wants to. Unless the buffer size is explicitly set, the JRE will decide on its own. WDYT?

@mkarg mkarg force-pushed the nio branch 4 times, most recently from 540bab9 to 1f96cbe Compare June 10, 2023 12:56
pom.xml Outdated Show resolved Hide resolved
@jansupol
Copy link
Contributor

@jansupol I have reintroduced IO_DEFAULT_BUFFER_SIZE in 2750a6f as you asked for. In that revision the user can even explicitly demand a static 8k buffer if he really wants to. Unless the buffer size is explicitly set, the JRE will decide on its own. WDYT?

This part looks good now.

@jansupol
Copy link
Contributor

Have you thought about putting the changes to 2.x? Most of it would work there (but the NullOutputStream)

@mkarg
Copy link
Member Author

mkarg commented Jun 13, 2023

Have you thought about putting the changes to 2.x? Most of it would work there (but the NullOutputStream)

As I wrote earlier, this PR will not build unchanged on Jersey 2.x, as 2.x is based on Java 8. Besides NullOutputStream, InputStream::transferTo did not exist prior to Java 9, and Reader::transferTo did not exist prior to Java 10. So what I can do (with acceptable effort) is: Once this PR is merged into the 3.1 branch, I can open a second PR for the 2.x branch with backports of just those (few) parts that actually will work in Java 8.

@mkarg mkarg marked this pull request as ready for review June 13, 2023 21:05
@mkarg
Copy link
Member Author

mkarg commented Jun 13, 2023

This part looks good now.

Thank you, Jan. I have switch this PR from draft to ready status, so Github allows to merge it. I propose you apply Github's own squash button (so I abstain from squash-and-force-push), so we do not lose the conversion history in Github.

@mkarg
Copy link
Member Author

mkarg commented Jun 18, 2023

Have you thought about putting the changes to 2.x? Most of it would work there (but the NullOutputStream)

@jansupol I have opened the separate PR #5350 which contains all JRE 8 compatible changes found in this PR.

@mkarg
Copy link
Member Author

mkarg commented Nov 12, 2023

This PR exists since May 20. Today it is November 12. It would be nice to get told why not integrating it for so long. Thank you.

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.

None yet

4 participants