From 91dc619d1905f9c46be4f1dc1dba3a9400c43f2d Mon Sep 17 00:00:00 2001 From: Keqiu Hu Date: Wed, 12 Sep 2018 22:27:40 -0400 Subject: [PATCH] Add source code --- .gitignore | 24 + .reviewboardrc | 2 + .travis.yml | 9 + CODE_OF_CONDUCT.md | 42 + CONTRIBUTING.md | 39 + LICENSE | 28 + NOTICE | 22 + build.gradle | 149 +++ gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 54788 bytes gradle/wrapper/gradle-wrapper.properties | 6 + gradlew | 172 +++ gradlew.bat | 84 ++ license_header | 2 + settings.gradle | 16 + tony-cli/build.gradle | 20 + .../linkedin/tony/cli/ClusterSubmitter.java | 63 + .../com/linkedin/tony/cli/LocalSubmitter.java | 69 ++ tony-cli/src/main/resources/log4j.properties | 8 + tony-core/build.gradle | 66 + tony-core/doc/img/arch.png | Bin 0 -> 118528 bytes tony-core/doc/img/azkaban.png | Bin 0 -> 47473 bytes .../java/com/linkedin/tony/Constants.java | 69 ++ .../java/com/linkedin/tony/ProtoUtils.java | 24 + .../linkedin/tony/TFClientSecurityInfo.java | 50 + .../com/linkedin/tony/TFPolicyProvider.java | 26 + .../java/com/linkedin/tony/TaskExecutor.java | 326 +++++ .../linkedin/tony/TonyApplicationMaster.java | 1097 +++++++++++++++++ .../java/com/linkedin/tony/TonyClient.java | 738 +++++++++++ .../linkedin/tony/TonyConfigurationKeys.java | 108 ++ .../main/java/com/linkedin/tony/Utils.java | 233 ++++ .../tony/io/HdfsAvroFileSplitReader.java | 800 ++++++++++++ .../com/linkedin/tony/rpc/ApplicationRpc.java | 26 + .../tony/rpc/ApplicationRpcServer.java | 154 +++ .../java/com/linkedin/tony/rpc/Empty.java | 8 + .../tony/rpc/GetClusterSpecRequest.java | 8 + .../tony/rpc/GetClusterSpecResponse.java | 11 + .../linkedin/tony/rpc/GetTaskUrlsRequest.java | 8 + .../tony/rpc/GetTaskUrlsResponse.java | 14 + .../linkedin/tony/rpc/HeartbeatRequest.java | 10 + .../linkedin/tony/rpc/HeartbeatResponse.java | 8 + .../rpc/RegisterExecutionResultRequest.java | 18 + .../rpc/RegisterExecutionResultResponse.java | 11 + .../rpc/RegisterTensorBoardUrlRequest.java | 11 + .../rpc/RegisterTensorBoardUrlResponse.java | 11 + .../tony/rpc/RegisterWorkerSpecRequest.java | 13 + .../tony/rpc/RegisterWorkerSpecResponse.java | 11 + .../java/com/linkedin/tony/rpc/TaskUrl.java | 41 + .../linkedin/tony/rpc/TensorFlowCluster.java | 36 + .../tony/rpc/TensorFlowClusterPB.java | 14 + .../tony/rpc/impl/ApplicationRpcClient.java | 162 +++ .../tony/rpc/impl/pb/EmptyPBImpl.java | 51 + .../impl/pb/GetClusterSpecRequestPBImpl.java | 51 + .../impl/pb/GetClusterSpecResponsePBImpl.java | 77 ++ .../rpc/impl/pb/GetTaskUrlsRequestPBImpl.java | 52 + .../impl/pb/GetTaskUrlsResponsePBImpl.java | 71 ++ .../rpc/impl/pb/HeartbeatRequestPBImpl.java | 78 ++ .../rpc/impl/pb/HeartbeatResponsePBImpl.java | 51 + .../RegisterExecutionResultRequestPBImpl.java | 142 +++ ...RegisterExecutionResultResponsePBImpl.java | 77 ++ .../RegisterTensorBoardUrlRequestPBImpl.java | 77 ++ .../RegisterTensorBoardUrlResponsePBImpl.java | 77 ++ .../pb/RegisterWorkerSpecRequestPBImpl.java | 104 ++ .../pb/RegisterWorkerSpecResponsePBImpl.java | 77 ++ .../client/TensorFlowClusterPBClientImpl.java | 159 +++ .../TensorFlowClusterPBServiceImpl.java | 139 +++ .../TensorFlowContainerRequest.java | 70 ++ .../tony/tensorflow/TensorFlowSession.java | 545 ++++++++ .../tensorflow_cluster_service_protos.proto | 19 + .../yarn_tensorflow_cluster_protos.proto | 64 + .../org.apache.hadoop.security.SecurityInfo | 3 + tony-core/src/main/resources/log4j.properties | 8 + tony-core/src/main/resources/tony-default.xml | 180 +++ .../java/com/linkedin/tony/TestReader.java | 244 ++++ .../com/linkedin/tony/TestTaskExecutor.java | 50 + .../tony/TestTensorFlowContainerRequest.java | 28 + .../tony/TestTonyApplicationMaster.java | 35 + .../com/linkedin/tony/TestTonyClient.java | 54 + .../tony/TestTonyConfigurationFields.java | 53 + .../java/com/linkedin/tony/TestTonyE2E.java | 225 ++++ .../java/com/linkedin/tony/TestUtils.java | 45 + tony-core/src/test/resources/exit_0.py | 13 + .../src/test/resources/exit_0_check_env.py | 24 + tony-core/src/test/resources/exit_1.py | 13 + tony-core/src/test/resources/log4j.properties | 8 + .../src/test/resources/resource-types.xml | 12 + tony-mini/build.gradle | 20 + .../linkedin/minitony/cluster/HDFSUtils.java | 41 + .../minitony/cluster/MiniCluster.java | 82 ++ .../minitony/cluster/MiniTonyUtils.java | 27 + 89 files changed, 8013 insertions(+) create mode 100644 .gitignore create mode 100644 .reviewboardrc create mode 100644 .travis.yml create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 NOTICE create mode 100644 build.gradle create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 license_header create mode 100644 settings.gradle create mode 100644 tony-cli/build.gradle create mode 100644 tony-cli/src/main/java/com/linkedin/tony/cli/ClusterSubmitter.java create mode 100644 tony-cli/src/main/java/com/linkedin/tony/cli/LocalSubmitter.java create mode 100644 tony-cli/src/main/resources/log4j.properties create mode 100644 tony-core/build.gradle create mode 100644 tony-core/doc/img/arch.png create mode 100644 tony-core/doc/img/azkaban.png create mode 100644 tony-core/src/main/java/com/linkedin/tony/Constants.java create mode 100644 tony-core/src/main/java/com/linkedin/tony/ProtoUtils.java create mode 100644 tony-core/src/main/java/com/linkedin/tony/TFClientSecurityInfo.java create mode 100644 tony-core/src/main/java/com/linkedin/tony/TFPolicyProvider.java create mode 100644 tony-core/src/main/java/com/linkedin/tony/TaskExecutor.java create mode 100644 tony-core/src/main/java/com/linkedin/tony/TonyApplicationMaster.java create mode 100644 tony-core/src/main/java/com/linkedin/tony/TonyClient.java create mode 100644 tony-core/src/main/java/com/linkedin/tony/TonyConfigurationKeys.java create mode 100644 tony-core/src/main/java/com/linkedin/tony/Utils.java create mode 100644 tony-core/src/main/java/com/linkedin/tony/io/HdfsAvroFileSplitReader.java create mode 100644 tony-core/src/main/java/com/linkedin/tony/rpc/ApplicationRpc.java create mode 100644 tony-core/src/main/java/com/linkedin/tony/rpc/ApplicationRpcServer.java create mode 100644 tony-core/src/main/java/com/linkedin/tony/rpc/Empty.java create mode 100644 tony-core/src/main/java/com/linkedin/tony/rpc/GetClusterSpecRequest.java create mode 100644 tony-core/src/main/java/com/linkedin/tony/rpc/GetClusterSpecResponse.java create mode 100644 tony-core/src/main/java/com/linkedin/tony/rpc/GetTaskUrlsRequest.java create mode 100644 tony-core/src/main/java/com/linkedin/tony/rpc/GetTaskUrlsResponse.java create mode 100644 tony-core/src/main/java/com/linkedin/tony/rpc/HeartbeatRequest.java create mode 100644 tony-core/src/main/java/com/linkedin/tony/rpc/HeartbeatResponse.java create mode 100644 tony-core/src/main/java/com/linkedin/tony/rpc/RegisterExecutionResultRequest.java create mode 100644 tony-core/src/main/java/com/linkedin/tony/rpc/RegisterExecutionResultResponse.java create mode 100644 tony-core/src/main/java/com/linkedin/tony/rpc/RegisterTensorBoardUrlRequest.java create mode 100644 tony-core/src/main/java/com/linkedin/tony/rpc/RegisterTensorBoardUrlResponse.java create mode 100644 tony-core/src/main/java/com/linkedin/tony/rpc/RegisterWorkerSpecRequest.java create mode 100644 tony-core/src/main/java/com/linkedin/tony/rpc/RegisterWorkerSpecResponse.java create mode 100644 tony-core/src/main/java/com/linkedin/tony/rpc/TaskUrl.java create mode 100644 tony-core/src/main/java/com/linkedin/tony/rpc/TensorFlowCluster.java create mode 100644 tony-core/src/main/java/com/linkedin/tony/rpc/TensorFlowClusterPB.java create mode 100644 tony-core/src/main/java/com/linkedin/tony/rpc/impl/ApplicationRpcClient.java create mode 100644 tony-core/src/main/java/com/linkedin/tony/rpc/impl/pb/EmptyPBImpl.java create mode 100644 tony-core/src/main/java/com/linkedin/tony/rpc/impl/pb/GetClusterSpecRequestPBImpl.java create mode 100644 tony-core/src/main/java/com/linkedin/tony/rpc/impl/pb/GetClusterSpecResponsePBImpl.java create mode 100644 tony-core/src/main/java/com/linkedin/tony/rpc/impl/pb/GetTaskUrlsRequestPBImpl.java create mode 100644 tony-core/src/main/java/com/linkedin/tony/rpc/impl/pb/GetTaskUrlsResponsePBImpl.java create mode 100644 tony-core/src/main/java/com/linkedin/tony/rpc/impl/pb/HeartbeatRequestPBImpl.java create mode 100644 tony-core/src/main/java/com/linkedin/tony/rpc/impl/pb/HeartbeatResponsePBImpl.java create mode 100644 tony-core/src/main/java/com/linkedin/tony/rpc/impl/pb/RegisterExecutionResultRequestPBImpl.java create mode 100644 tony-core/src/main/java/com/linkedin/tony/rpc/impl/pb/RegisterExecutionResultResponsePBImpl.java create mode 100644 tony-core/src/main/java/com/linkedin/tony/rpc/impl/pb/RegisterTensorBoardUrlRequestPBImpl.java create mode 100644 tony-core/src/main/java/com/linkedin/tony/rpc/impl/pb/RegisterTensorBoardUrlResponsePBImpl.java create mode 100644 tony-core/src/main/java/com/linkedin/tony/rpc/impl/pb/RegisterWorkerSpecRequestPBImpl.java create mode 100644 tony-core/src/main/java/com/linkedin/tony/rpc/impl/pb/RegisterWorkerSpecResponsePBImpl.java create mode 100644 tony-core/src/main/java/com/linkedin/tony/rpc/impl/pb/client/TensorFlowClusterPBClientImpl.java create mode 100644 tony-core/src/main/java/com/linkedin/tony/rpc/impl/pb/service/TensorFlowClusterPBServiceImpl.java create mode 100644 tony-core/src/main/java/com/linkedin/tony/tensorflow/TensorFlowContainerRequest.java create mode 100644 tony-core/src/main/java/com/linkedin/tony/tensorflow/TensorFlowSession.java create mode 100644 tony-core/src/main/proto/tensorflow_cluster_service_protos.proto create mode 100644 tony-core/src/main/proto/yarn_tensorflow_cluster_protos.proto create mode 100644 tony-core/src/main/resources/META-INF/services/org.apache.hadoop.security.SecurityInfo create mode 100644 tony-core/src/main/resources/log4j.properties create mode 100644 tony-core/src/main/resources/tony-default.xml create mode 100644 tony-core/src/test/java/com/linkedin/tony/TestReader.java create mode 100644 tony-core/src/test/java/com/linkedin/tony/TestTaskExecutor.java create mode 100644 tony-core/src/test/java/com/linkedin/tony/TestTensorFlowContainerRequest.java create mode 100644 tony-core/src/test/java/com/linkedin/tony/TestTonyApplicationMaster.java create mode 100644 tony-core/src/test/java/com/linkedin/tony/TestTonyClient.java create mode 100644 tony-core/src/test/java/com/linkedin/tony/TestTonyConfigurationFields.java create mode 100644 tony-core/src/test/java/com/linkedin/tony/TestTonyE2E.java create mode 100644 tony-core/src/test/java/com/linkedin/tony/TestUtils.java create mode 100644 tony-core/src/test/resources/exit_0.py create mode 100644 tony-core/src/test/resources/exit_0_check_env.py create mode 100644 tony-core/src/test/resources/exit_1.py create mode 100644 tony-core/src/test/resources/log4j.properties create mode 100644 tony-core/src/test/resources/resource-types.xml create mode 100644 tony-mini/build.gradle create mode 100644 tony-mini/src/main/java/com/linkedin/minitony/cluster/HDFSUtils.java create mode 100644 tony-mini/src/main/java/com/linkedin/minitony/cluster/MiniCluster.java create mode 100644 tony-mini/src/main/java/com/linkedin/minitony/cluster/MiniTonyUtils.java diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..9cb1d900 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +**/.keep +**/generated/ +*.avro +*.iml +*.ipr +*.iws +*.orig +*.rej +*.sdf +*.suo +*.swp +*.vcxproj.user +*.zip +.DS_Store +.classpath +.gradle/ +.idea +.project +.settings +.svn +build/ +dependency-reduced-pom.xml +out/ +target diff --git a/.reviewboardrc b/.reviewboardrc new file mode 100644 index 00000000..b9767bb0 --- /dev/null +++ b/.reviewboardrc @@ -0,0 +1,2 @@ +REPOSITORY='tony' +TRACKING_BRANCH='origin/master' diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 00000000..11d392e5 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,9 @@ +language: java +git: + depth: 3 +jdk: + - oraclejdk8 +env: + - HADOOP_VERSION=3.1.1 +script: "./gradlew test --stacktrace --info" + diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..a4d08984 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,42 @@ +LinkedIn Open Source Code of Conduct +------------------------------------- + +This code of conduct outlines expectations for participation in LinkedIn-managed open source communities, as well as steps for reporting unacceptable behavior. We are committed to providing a welcoming and inspiring community for all. People violating this code of conduct may be banned from the community. + +Our open source communities strive to: + +* **Be friendly and patient:** Remember you might not be communicating in someone else's primary spoken or programming language, and others may not have your level of understanding. +* **Be welcoming:** Our communities welcome and support people of all backgrounds and identities. This includes, but is not limited to members of any race, ethnicity, culture, national origin, color, immigration status, social and economic class, educational level, sex, sexual orientation, gender identity and expression, age, size, family status, political belief, religion, and mental and physical ability. +* **Be respectful:** We are a world-wide community of professionals, and we conduct ourselves professionally. Disagreement is no excuse for poor behavior and poor manners. Disrespectful and unacceptable behavior includes, but is not limited to: + * Violent threats or language. + * Discriminatory or derogatory jokes and language. + * Posting sexually explicit or violent material. + * Posting, or threatening to post, people's personally identifying information ("doxing"). + * Insults, especially those using discriminatory terms or slurs. + * Behavior that could be perceived as sexual attention. + * Advocating for or encouraging any of the above behaviors. +* **Understand disagreements:** Disagreements, both social and technical, are useful learning opportunities. Seek to understand the other viewpoints and resolve differences constructively. +* This code is not exhaustive or complete. It serves to capture our common understanding of a productive, collaborative environment. We expect the code to be followed in spirit as much as in the letter. + +### Scope + +This code of conduct applies to all repos and communities for LinkedIn-managed open source projects regardless of whether or not the repo explicitly calls out its use of this code. The code also applies in public spaces when an individual is representing a project or its community. Examples include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. + +Note: Some LinkedIn-managed communities have codes of conduct that pre-date this document and issue resolution process. While communities are not required to change their code, they are expected to use the resolution process outlined here. The review team will coordinate with the communities involved to address your concerns. + +### Reporting Code of Conduct Issues + +We encourage all communities to resolve issues on their own whenever possible. This builds a broader and deeper understanding and ultimately a healthier interaction. In the event that an issue cannot be resolved locally, please feel free to report your concerns by contacting [oss@linkedin.com](mailto:oss@linkedin.com). + +In your report please include: + +* Your contact information. +* Names (real, usernames or pseudonyms) of any individuals involved. If there are additional witnesses, please include them as well. +* Your account of what occurred, and if you believe the incident is ongoing. If there is a publicly available record (e.g. a mailing list archive or a public chat log), please include a link or attachment. +* Any additional information that may be helpful. + +All reports will be reviewed by a multi-person team and will result in a response that is deemed necessary and appropriate to the circumstances. Where additional perspectives are needed, the team may seek insight from others with relevant expertise or experience. The confidentiality of the person reporting the incident will be kept at all times. Involved parties are never part of the review team. + +Anyone asked to stop unacceptable behavior is expected to comply immediately. If an individual engages in unacceptable behavior, the review team may take any action they deem appropriate, including a permanent ban from the community. + +_This code of conduct is based on the [Microsoft](https://opensource.microsoft.com/codeofconduct/) Open Source Code of Conduct which was based on the [template](http://todogroup.org/opencodeofconduct) established by the [TODO Group](http://todogroup.org/) and used by numerous other large communities (e.g., [Facebook](https://code.facebook.com/pages/876921332402685/open-source-code-of-conduct), [Yahoo](https://yahoo.github.io/codeofconduct), [Twitter](https://engineering.twitter.com/opensource/code-of-conduct), [GitHub](http://todogroup.org/opencodeofconduct/#opensource@github.com)) and the Scope section from the [Contributor Covenant version 1.4](http://contributor-covenant.org/version/1/4/)._ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..c6569f8c --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,39 @@ +Contribution Agreement +====================== + +As a contributor, you represent that the code you submit is your original work or +that of your employer (in which case you represent you have the right to bind your +employer). By submitting code, you (and, if applicable, your employer) are +licensing the submitted code to LinkedIn and the open source community subject to +the BSD 2-Clause license. + +General Contribution Tips +========================= + +We welcome any contributions that make TonY more reliable, accurate, usable, or +extensible. It is generally preferred that new features and behaviors should be +configurable unless sufficient discussion is held to determine that there is no +situation in which the previous behavior is desirable. GitHub issues are the +appropriate forum for such discussions. + +Responsible Disclosure of Security Vulnerabilities +================================================== + +**Do not file an issue on Github for security issues.** Please review +the [guidelines for disclosure][disclosure_guidelines]. Reports should +be encrypted using PGP ([public key][pubkey]) and sent to +[security@linkedin.com][disclosure_email] preferably with the title +"Vulnerability in Github LinkedIn/tony - <short summary>". + +Tips for Getting Your Pull Request Accepted +=========================================== + +1. Make sure all new features are tested and the tests pass. +2. Bug fixes must include a test case demonstrating the error that it fixes. +3. Open an issue first and seek advice for your change before submitting + a pull request. Large features which have never been discussed are + unlikely to be accepted. **You have been warned.** + +[disclosure_guidelines]: https://www.linkedin.com/help/linkedin/answer/62924 +[pubkey]: https://gist.github.com/chriseppstein/3f45d3a8e6fb42f24cb7b3f77f21381e +[disclosure_email]: mailto:security@linkedin.com?subject=Vulnerability%20in%20Github%20LinkedIn/tony%20-%20%3Csummary%3E diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..2efde878 --- /dev/null +++ b/LICENSE @@ -0,0 +1,28 @@ +BSD 2-CLAUSE LICENSE + +Copyright 2018 LinkedIn Corporation. +All Rights Reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the + distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/NOTICE b/NOTICE new file mode 100644 index 00000000..a3416b73 --- /dev/null +++ b/NOTICE @@ -0,0 +1,22 @@ +Copyright 2018 LinkedIn Corporation +All Rights Reserved. + +Licensed under the BSD 2-Clause License (the "License"). +See LICENSE in the project root for license information. + +================================================================================ + +This product includes/uses TensorFlow (https://github.com/tensorflow) +Copyright 2018 The TensorFlow Authors +License: Apache-2.0 + +This product includes/uses TensorFlowOnYARN (https://github.com/Intel-bigdata/TensorFlowOnYARN) +Copyright 2018 Intel-bigdata +License: Apache-2.0 + +================================================================================ + +In addition, this product automatically loads third party code from an external repository +using the Gradle build system. Such third party code is subject to other license +terms than as set forth above. In addition, such third party code may also +depend on and load multiple tiers of dependencies. \ No newline at end of file diff --git a/build.gradle b/build.gradle new file mode 100644 index 00000000..347b20bf --- /dev/null +++ b/build.gradle @@ -0,0 +1,149 @@ +group "com.linkedin" +version "0.1.0" + +apply plugin: "java" +apply plugin: "eclipse" +apply plugin: "idea" + +sourceCompatibility = 1.8 + +buildscript { + repositories { + maven { + url "https://plugins.gradle.org/m2/" + } + } + dependencies { + classpath "com.github.jengelman.gradle.plugins:shadow:2.0.4" + classpath "com.google.protobuf:protobuf-gradle-plugin:0.8.1" + classpath "gradle.plugin.nl.javadude.gradle.plugins:license-gradle-plugin:0.14.0" + } +} + +def hadoopVersion = "3.1.1" + +ext.deps = [ + hadoop: [ + "common": "org.apache.hadoop:hadoop-common:${hadoopVersion}", + "common_test": "org.apache.hadoop:hadoop-common:${hadoopVersion}:tests", + "hdfs": "org.apache.hadoop:hadoop-hdfs:${hadoopVersion}", + "hdfs_client": "org.apache.hadoop:hadoop-hdfs-client:${hadoopVersion}", + "hdfs_test": "org.apache.hadoop:hadoop-hdfs:${hadoopVersion}:tests", + "mapreduce_client_core": "org.apache.hadoop:hadoop-mapreduce-client-core:${hadoopVersion}", + "minicluster": "org.apache.hadoop:hadoop-minicluster:${hadoopVersion}", + "yarn_api": "org.apache.hadoop:hadoop-yarn-api:${hadoopVersion}", + "yarn_client": "org.apache.hadoop:hadoop-yarn-client:${hadoopVersion}", + "yarn_common": "org.apache.hadoop:hadoop-yarn-common:${hadoopVersion}", + "yarn_server_test": "org.apache.hadoop:hadoop-yarn-server-tests:${hadoopVersion}:tests" + ], + external: [ + "avro": "org.apache.avro:avro:1.8.2", + "guava": "com.google.guava:guava:16.0.1", + "jackson_databind": "com.fasterxml.jackson.core:jackson-databind:2.8.3", + "jackson_dataformat_yaml": "com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.9.6", + // Only needed by Hadoop test classes + "junit": "junit:junit:4.12", + "objenesis": "org.objenesis:objenesis:2.6", + "py4j": "net.sf.py4j:py4j:0.8.2.1", + "sshd": "org.apache.sshd:sshd-core:1.1.0", + "testng": "org.testng:testng:6.4", + "text": "org.apache.commons:commons-text:1.4", + "zip4j": "net.lingala.zip4j:zip4j:1.3.2" + ] +] + +allprojects { + project.version= "0.1.0" + group = "com.linkedin.tony" +} + +subprojects { + apply plugin: "license" + apply plugin: "com.google.protobuf" + apply plugin: "java" + apply plugin: "eclipse" + apply plugin: "idea" + repositories { + mavenCentral() + } + plugins.withType(JavaPlugin) { + sourceCompatibility = 1.8 + dependencies { + // dependency defined in product_spec.json + testCompile deps.external.testng + } + + test { + useTestNG() + } + } + + license { + header rootProject.file('license_header') + // Set the year in the license + ext.year = Calendar.getInstance().get(Calendar.YEAR) + skipExistingHeaders = false + excludes(["com/linkedin/tony/rpc/proto/", "**/*.properties"]) + } + configurations { + hadoopRuntime.extendsFrom(runtime) + hadoopRuntime { + exclude group: "org.apache.hadoop" + } + } +} + +apply plugin: 'distribution' + +// Generates a closure which is used to set up the contents +// for a distribution; parametrized by the name of the +// configuration to include in the lib directory. +def generateDistContents(configurationName) { + return { + into('.') { + from rootProject.fileTree('.') { + include 'README.md' + include 'LICENSE' + include 'NOTICE' + include 'CONTRIBUTING.md' + } + } + into('bin') { + def bashFiles = [] + rootProject.subprojects.each { + bashFiles << it.fileTree("src/main/bash") { + include "*.sh" + } + } + from bashFiles + } + into('lib') { + def dependencies = files() + def jars = [] + rootProject.subprojects.each { + // Use subtraction to eliminate duplicates + dependencies = dependencies + (it.configurations[configurationName] - dependencies) + jars << it.jar + } + from dependencies + from jars + } + } +} + +distributions { + // main distribution does not include Hadoop JARs; this is the one + // typically expected to be used on a system properly set up with + // an existing Hadoop installation. + main { + baseName = rootProject.name + contents generateDistContents('hadoopRuntime') + } + // fat distribution includes all dependencies. + fat { + baseName = rootProject.name + '-fat' + contents generateDistContents('runtime') + } +} + +build.dependsOn(distZip) diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..366b57f40115b807a18348542f378da5f0995cb7 GIT binary patch literal 54788 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 \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn ( ) { + echo "$*" +} + +die ( ) { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save ( ) { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=$(save "$@") + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong +if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then + cd "$(dirname "$0")" +fi + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 00000000..e95643d6 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,84 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windows variants + +if not "%OS%" == "Windows_NT" goto win9xME_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/license_header b/license_header new file mode 100644 index 00000000..f3f9b8f8 --- /dev/null +++ b/license_header @@ -0,0 +1,2 @@ +Copyright ${year} LinkedIn Corporation. All rights reserved. Licensed under the BSD-2 Clause license. +See LICENSE in the project root for license information. \ No newline at end of file diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 00000000..81c19140 --- /dev/null +++ b/settings.gradle @@ -0,0 +1,16 @@ +rootProject.name = 'tony' + +/** + * Copyright 2017 LinkedIn Corporation. All rights reserved. Licensed under the BSD-2 Clause license. + * See LICENSE in the project root for license information. + */ +rootProject.name = 'tony' + +def modules = ['tony-core', 'tony-cli', 'tony-mini'] + +modules.each { module -> + if (!file(module).directory) { + throw new GradleException("Module '$module' specified in the settings.gradle file must be a valid directory in the root project.") + } + include "${module}" +} \ No newline at end of file diff --git a/tony-cli/build.gradle b/tony-cli/build.gradle new file mode 100644 index 00000000..8f47b173 --- /dev/null +++ b/tony-cli/build.gradle @@ -0,0 +1,20 @@ +apply plugin: 'java' +apply plugin: 'com.github.johnrengelman.shadow' + +dependencies { + compile project(':tony-core') +} + +shadowJar { + mergeServiceFiles() + dependencies { + exclude(dependency('org.apache.hadoop::.*')) + } +} + +jar.enabled = false +configurations.all { + artifacts.removeAll artifacts.findAll { !it.archiveTask.enabled } +} + +build.dependsOn(shadowJar) diff --git a/tony-cli/src/main/java/com/linkedin/tony/cli/ClusterSubmitter.java b/tony-cli/src/main/java/com/linkedin/tony/cli/ClusterSubmitter.java new file mode 100644 index 00000000..a6dfe5b0 --- /dev/null +++ b/tony-cli/src/main/java/com/linkedin/tony/cli/ClusterSubmitter.java @@ -0,0 +1,63 @@ +/** + * Copyright 2018 LinkedIn Corporation. All rights reserved. Licensed under the BSD-2 Clause license. + * See LICENSE in the project root for license information. + */ + +package com.linkedin.tony.cli; + +import com.linkedin.tony.TonyClient; +import java.io.File; +import java.io.IOException; +import java.net.URISyntaxException; +import java.util.Arrays; +import java.util.UUID; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.apache.hadoop.conf.Configuration; +import org.apache.hadoop.fs.FileSystem; +import org.apache.hadoop.fs.Path; +import static com.linkedin.tony.Constants.*; + + +/** + * ClusterSubmitter is used to submit a distributed Tony + * job on the cluster. + * + * Example usage: + * java -cp tony-cli-x.x.x-all.jar com.linkedin.tony.cli.ClusterSubmitter + * --src_dir /Users/xxx/hadoop/li-tony_trunk/tony-core/src/test/resources/ \ + * --executes /Users/xxx/hadoop/li-tony_trunk/tony/src/test/resources/exit_0_check_env.py \ + * --python_binary_path python + */ +public class ClusterSubmitter { + private static final Log LOG = LogFactory.getLog(ClusterSubmitter.class); + + private ClusterSubmitter() { } + + public static void main(String[] args) throws URISyntaxException { + LOG.info("Starting ClusterSubmitter.."); + String jarLocation = new File(ClusterSubmitter.class.getProtectionDomain().getCodeSource().getLocation().toURI()).getPath(); + Configuration hdfsConf = new Configuration(); + hdfsConf.addResource(new Path(System.getenv(HADOOP_CONF_DIR) + File.separatorChar + CORE_SITE_CONF)); + LOG.info(hdfsConf); + int exitCode; + try (FileSystem fs = FileSystem.get(hdfsConf)) { + Path cachedLibPath = new Path(fs.getHomeDirectory(), TONY_FOLDER + Path.SEPARATOR + UUID.randomUUID().toString()); + LOG.info("Copying " + jarLocation + " to: " + cachedLibPath); + fs.mkdirs(cachedLibPath); + fs.copyFromLocalFile(new Path(jarLocation), cachedLibPath); + + String[] updatedArgs = Arrays.copyOf(args, args.length + 2); + updatedArgs[args.length] = "--hdfs_classpath"; + updatedArgs[args.length + 1] = cachedLibPath.toString(); + exitCode = TonyClient.start(updatedArgs); + if (fs.exists(cachedLibPath)) { + fs.delete(cachedLibPath, true); + } + } catch (IOException e) { + LOG.fatal("Failed to create FileSystem: ", e); + exitCode = -1; + } + System.exit(exitCode); + } +} diff --git a/tony-cli/src/main/java/com/linkedin/tony/cli/LocalSubmitter.java b/tony-cli/src/main/java/com/linkedin/tony/cli/LocalSubmitter.java new file mode 100644 index 00000000..c3dd11fb --- /dev/null +++ b/tony-cli/src/main/java/com/linkedin/tony/cli/LocalSubmitter.java @@ -0,0 +1,69 @@ +/** + * Copyright 2018 LinkedIn Corporation. All rights reserved. Licensed under the BSD-2 Clause license. + * See LICENSE in the project root for license information. + */ + +package com.linkedin.tony.cli; + +import com.linkedin.minitony.cluster.HDFSUtils; +import com.linkedin.minitony.cluster.MiniCluster; +import com.linkedin.minitony.cluster.MiniTonyUtils; +import com.linkedin.tony.TonyClient; +import com.linkedin.tony.TonyConfigurationKeys; +import java.io.File; +import java.nio.file.Files; +import java.util.Arrays; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.apache.hadoop.conf.Configuration; +import org.apache.hadoop.fs.FileSystem; +import org.apache.hadoop.fs.Path; + + +/** + * LocalSubmitter is used to spin off a local Hadoop cluster and execute a distributed Tony + * job on that cluster. + * + * Example usage: + * java -cp tony-cli-x.x.x-all.jar com.linkedin.tony.cli.LocalSubmitter \ + * --src_dir /Users/xxx/hadoop/li-tony_trunk/tony-core/src/test/resources/ \ + * --executes /Users/xxx/hadoop/li-tony_trunk/tony/src/test/resources/exit_0_check_env.py \ + * --python_binary_path python \ + */ +public class LocalSubmitter { + private static final Log LOG = LogFactory.getLog(ClusterSubmitter.class); + private static final int NUM_NODE_MANAGERS = 2; + + private LocalSubmitter() { } + + public static void main(String[] args) throws Exception { + LOG.info("Starting LocalSubmitter.."); + String jarLocation = new File(ClusterSubmitter.class.getProtectionDomain().getCodeSource().getLocation().toURI()).getPath(); + MiniCluster cluster = new MiniCluster(NUM_NODE_MANAGERS); + cluster.start(); + String yarnConf = Files.createTempFile("yarn", ".xml").toString(); + String hdfsConf = Files.createTempFile("hdfs", ".xml").toString(); + + MiniTonyUtils.saveConfigToFile(cluster.getYarnConf(), yarnConf); + MiniTonyUtils.saveConfigToFile(cluster.getHdfsConf(), hdfsConf); + FileSystem fs = FileSystem.get(cluster.getHdfsConf()); + // This is the path we gonna store required libraries in the local HDFS. + Path cachedLibPath = new Path("/yarn/libs"); + if (fs.exists(cachedLibPath)) { + fs.delete(cachedLibPath, true); + } + fs.mkdirs(cachedLibPath); + HDFSUtils.copyDirectoryFilesToFolder(fs, jarLocation, "/yarn/libs"); + int exitCode; + Configuration conf = new Configuration(); + conf.set(TonyConfigurationKeys.HDFS_CONF_LOCATION, hdfsConf); + conf.set(TonyConfigurationKeys.YARN_CONF_LOCATION, yarnConf); + // Append other required parameters for TonyClient + String[] updatedArgs = Arrays.copyOf(args, args.length + 2); + updatedArgs[args.length] = "--hdfs_classpath"; + updatedArgs[args.length + 1] = cachedLibPath.toString(); + exitCode = TonyClient.start(updatedArgs, conf); + cluster.stop(); + System.exit(exitCode); + } +} diff --git a/tony-cli/src/main/resources/log4j.properties b/tony-cli/src/main/resources/log4j.properties new file mode 100644 index 00000000..79d2792a --- /dev/null +++ b/tony-cli/src/main/resources/log4j.properties @@ -0,0 +1,8 @@ +# Root logger option +log4j.rootLogger=OFF +log4j.logger.com.linkedin.tony=INFO, stdout +# Direct log messages to stdout +log4j.appender.stdout=org.apache.log4j.ConsoleAppender +log4j.appender.stdout.Target=System.out +log4j.appender.stdout.layout=org.apache.log4j.PatternLayout +log4j.appender.stdout.layout.ConversionPattern=%d{yyyy-MM-dd HH:mm:ss} %-5p %c{1}:%L - %m%n \ No newline at end of file diff --git a/tony-core/build.gradle b/tony-core/build.gradle new file mode 100644 index 00000000..eff5fc52 --- /dev/null +++ b/tony-core/build.gradle @@ -0,0 +1,66 @@ +apply plugin: 'java' +apply plugin: 'com.google.protobuf' + +dependencies { + compile project(':tony-mini') + + compile deps.external.jackson_databind + compile deps.external.py4j + compile deps.external.text + compile deps.hadoop.common + compile deps.hadoop.hdfs + compile deps.hadoop.yarn_api + compile deps.hadoop.yarn_client + compile deps.hadoop.yarn_common + compile deps.external.zip4j + + testCompile deps.external.avro + testCompile deps.external.testng + + // Only needed by Hadoop test classes + testRuntime deps.external.junit +} + +task setupHdfsLib(type: Copy) { + from jar, configurations.compile + exclude '**/*.pom' + into "$rootDir/tony-core/out/libs/" +} + +test { + workingDir = rootDir +} + +test.dependsOn(setupHdfsLib) + +sourceSets { + main { + proto { + // In addition to the default 'src/main/proto' + srcDir 'src/main/proto' + include '**/*.protodevel' + } + + java { + srcDirs += ['src/generated/main/java'] + } + } +} + +protobuf { + // Configure the protoc executable + protoc { + // Download from repositories + artifact = 'com.google.protobuf:protoc:2.5.0' + } + generatedFilesBaseDir = "$projectDir/src/generated" +} + +clean { + delete protobuf.generatedFilesBaseDir + delete "$projectDir/out" +} + +// The `generateProto` task is dynamically generated and is not available at configuration time, +// so we need to refer to it by a string name here +ideaModule.dependsOn 'generateProto' diff --git a/tony-core/doc/img/arch.png b/tony-core/doc/img/arch.png new file mode 100644 index 0000000000000000000000000000000000000000..faa78634f4f53788f04908c6fd088dbec544ec97 GIT binary patch literal 118528 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 Lf33Of{_uYQj4Dp% literal 0 HcmV?d00001 diff --git a/tony-core/src/main/java/com/linkedin/tony/Constants.java b/tony-core/src/main/java/com/linkedin/tony/Constants.java new file mode 100644 index 00000000..7e1e575b --- /dev/null +++ b/tony-core/src/main/java/com/linkedin/tony/Constants.java @@ -0,0 +1,69 @@ +/** + * Copyright 2018 LinkedIn Corporation. All rights reserved. Licensed under the BSD-2 Clause license. + * See LICENSE in the project root for license information. + */ +package com.linkedin.tony; + +import org.apache.hadoop.yarn.api.ApplicationConstants; +import org.apache.hadoop.yarn.conf.YarnConfiguration; + + +public class Constants { + + // Environment constants + public static final String TB_PORT = "TB_PORT"; + public static final String TASK_INDEX = "TASK_INDEX"; + public static final String TASK_NUM = "TASK_NUM"; + public static final String CLUSTER_SPEC = "CLUSTER_SPEC"; + // Distributed TensorFlow job name, e.g. "ps" or "worker", + // as per https://www.tensorflow.org/deploy/distributed + public static final String JOB_NAME = "JOB_NAME"; + public static final String PRE_JOB_SHELL = "PRE_JOB_SHELL"; + public static final String POST_JOB_SHELL = "POST_JOB_SHELL"; + public static final String SESSION_ID = "SESSION_ID"; + public static final String PREPROCESSING_JOB = "PREPROCESSING_JOB"; + public static final String PY4JGATEWAY = "PY4J_GATEWAY_PORT"; + + // Environment variables for resource localization + public static final String TF_ZIP_PREFIX = "TF_ZIP"; + public static final String TONY_CONF_PREFIX = "TONY_CONF"; + + public static final String PATH_SUFFIX = "_PATH"; + public static final String TIMESTAMP_SUFFIX = "_TIMESTAMP"; + public static final String LENGTH_SUFFIX = "_LENGTH"; + + public static final String TF_ZIP_NAME = "tf.zip"; + + public static final String PYTHON_VENV_DIR = "venv"; + public static final String TASK_PARAM_KEY = "MODEL_PARAMS"; + + public static final String AM_STDOUT_FILENAME = "amstdout.log"; + public static final String AM_STDERR_FILENAME = "amstderr.log"; + + public static final String HDFS_CONF_PATH = "HDFS_CONF_PATH"; + public static final String YARN_CONF_PATH = "YARN_CONF_PATH"; + public static final String HDFS_SITE_CONF = "hdfs-site.xml"; + public static final String CORE_SITE_CONF = YarnConfiguration.CORE_SITE_CONFIGURATION_FILE; + public static final String HADOOP_CONF_DIR = ApplicationConstants.Environment.HADOOP_CONF_DIR.key(); + + public static final String WORKER_JOB_NAME = "worker"; + public static final String PS_JOB_NAME = "ps"; + public static final String ATTEMPT_NUMBER = "ATTEMPT_NUMBER"; + + public static final String TEST_AM_CRASH = "TEST_AM_CRASH"; + public static final String TEST_TASK_EXECUTOR_HANG = "TEST_TASK_EXECUTOR_HANG"; + public static final String TEST_TASK_EXECUTOR_NUM_HB_MISS = "TEST_TASK_EXECUTOR_NUM_HB_MISS"; + // Should be of the form type#id#ms + public static final String TEST_TASK_EXECUTOR_SKEW = "TEST_TASK_EXECUTOR_SKEW"; + + // Used to get all Hadoop jar paths. Reference: https://www.tensorflow.org/deploy/hadoop + public static final String HADOOP_CLASSPATH_COMMAND = "CLASSPATH=$(${HADOOP_HDFS_HOME}/bin/hadoop classpath --glob) "; + public static final String SKIP_HADOOP_PATH = "SKIP_HADOOP_PATH"; + + public static final String TONY_DEFAULT_XML = "tony-default.xml"; + public static final String TONY_XML = "tony.xml"; + public static final String TONY_FINAL_XML = "tony-final.xml"; + public static final String TONY_FOLDER = ".tony"; + + private Constants() { } +} diff --git a/tony-core/src/main/java/com/linkedin/tony/ProtoUtils.java b/tony-core/src/main/java/com/linkedin/tony/ProtoUtils.java new file mode 100644 index 00000000..4c38ba20 --- /dev/null +++ b/tony-core/src/main/java/com/linkedin/tony/ProtoUtils.java @@ -0,0 +1,24 @@ +/** + * Copyright 2018 LinkedIn Corporation. All rights reserved. Licensed under the BSD-2 Clause license. + * See LICENSE in the project root for license information. + */ +package com.linkedin.tony; + +import com.linkedin.tony.rpc.TaskUrl; +import com.linkedin.tony.rpc.proto.YarnTensorFlowClusterProtos.GetTaskUrlsResponseProto.TaskUrlProto; + + +public class ProtoUtils { + private ProtoUtils() { + // to prevent instantiation + } + + public static TaskUrl taskUrlProtoToTaskUrl(TaskUrlProto taskUrlProto) { + return new TaskUrl(taskUrlProto.getName(), taskUrlProto.getIndex(), taskUrlProto.getUrl()); + } + + public static TaskUrlProto taskUrlToTaskUrlProto(TaskUrl taskUrl) { + return TaskUrlProto.newBuilder().setName(taskUrl.getName()).setIndex(taskUrl.getIndex()) + .setUrl(taskUrl.getUrl()).build(); + } +} diff --git a/tony-core/src/main/java/com/linkedin/tony/TFClientSecurityInfo.java b/tony-core/src/main/java/com/linkedin/tony/TFClientSecurityInfo.java new file mode 100644 index 00000000..a1665fe3 --- /dev/null +++ b/tony-core/src/main/java/com/linkedin/tony/TFClientSecurityInfo.java @@ -0,0 +1,50 @@ +/** + * Copyright 2018 LinkedIn Corporation. All rights reserved. Licensed under the BSD-2 Clause license. + * See LICENSE in the project root for license information. + */ +package com.linkedin.tony; + + +import java.lang.annotation.Annotation; + +import org.apache.hadoop.classification.InterfaceAudience.Public; +import org.apache.hadoop.classification.InterfaceStability.Stable; +import org.apache.hadoop.conf.Configuration; +import org.apache.hadoop.security.KerberosInfo; +import org.apache.hadoop.security.SecurityInfo; +import org.apache.hadoop.security.token.TokenIdentifier; +import org.apache.hadoop.security.token.TokenInfo; +import org.apache.hadoop.security.token.TokenSelector; +import org.apache.hadoop.yarn.security.client.ClientToAMTokenSelector; +import com.linkedin.tony.rpc.TensorFlowClusterPB; + +@Public +@Stable +public class TFClientSecurityInfo extends SecurityInfo { + + @Override + public KerberosInfo getKerberosInfo(Class protocol, Configuration conf) { + return null; + } + + @Override + public TokenInfo getTokenInfo(Class protocol, Configuration conf) { + if (!protocol.equals(TensorFlowClusterPB.class)) { + return null; + } + + return new TokenInfo() { + @Override + public Class annotationType() { + return null; + } + + @Override + public Class> + value() { + return ClientToAMTokenSelector.class; + } + }; + } +} + diff --git a/tony-core/src/main/java/com/linkedin/tony/TFPolicyProvider.java b/tony-core/src/main/java/com/linkedin/tony/TFPolicyProvider.java new file mode 100644 index 00000000..14b6583b --- /dev/null +++ b/tony-core/src/main/java/com/linkedin/tony/TFPolicyProvider.java @@ -0,0 +1,26 @@ +/** + * Copyright 2018 LinkedIn Corporation. All rights reserved. Licensed under the BSD-2 Clause license. + * See LICENSE in the project root for license information. + */ +package com.linkedin.tony; + +import org.apache.hadoop.security.authorize.PolicyProvider; +import org.apache.hadoop.security.authorize.Service; +import com.linkedin.tony.rpc.TensorFlowCluster; + +/** + * * PolicyProvider for Client to AM protocol. + * */ +public class TFPolicyProvider extends PolicyProvider { + + private static final Service[] TF_AM_SERVICE = + new Service[]{ + new Service( + "security.tf.client-am-protocol.acl", + TensorFlowCluster.class)}; + + @Override + public Service[] getServices() { + return TF_AM_SERVICE; + }; +} diff --git a/tony-core/src/main/java/com/linkedin/tony/TaskExecutor.java b/tony-core/src/main/java/com/linkedin/tony/TaskExecutor.java new file mode 100644 index 00000000..960dd186 --- /dev/null +++ b/tony-core/src/main/java/com/linkedin/tony/TaskExecutor.java @@ -0,0 +1,326 @@ +/** + * Copyright 2018 LinkedIn Corporation. All rights reserved. Licensed under the BSD-2 Clause license. + * See LICENSE in the project root for license information. + */ +package com.linkedin.tony; + +import com.google.common.annotations.VisibleForTesting; +import com.linkedin.tony.io.HdfsAvroFileSplitReader; +import com.linkedin.tony.rpc.impl.ApplicationRpcClient; +import java.io.File; +import java.io.IOException; +import java.net.InetAddress; +import java.net.ServerSocket; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import org.apache.commons.cli.CommandLine; +import org.apache.commons.cli.GnuParser; +import org.apache.commons.cli.Options; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.apache.hadoop.conf.Configuration; +import org.apache.hadoop.fs.Path; +import org.apache.hadoop.yarn.api.ApplicationConstants; +import org.apache.hadoop.yarn.api.records.ContainerId; +import py4j.GatewayServer; + +import static com.linkedin.tony.Constants.CORE_SITE_CONF; +import static com.linkedin.tony.Constants.HADOOP_CONF_DIR; + +/** + * Content that we want to run in the containers. TaskExecutor will register itself with AM and fetch cluster spec from + * AM. After the cluster spec is collected, TaskExecutor will set up local environment and start the worker task. + */ +public class TaskExecutor { + private static final Log LOG = LogFactory.getLog(TaskExecutor.class); + + private static final int MAX_NUM_FAILED_HB_ATTEMPTS = 5; + + @VisibleForTesting + protected Configuration tonyConf = new Configuration(); + private ServerSocket rpcSocket; + private int rpcPort; + private ServerSocket tbSocket; + private int tbPort; + private ServerSocket gatewayServerSocket; + private int gatewayServerPort; + private int timeOut; + private String amAddress; + private String taskCommand; + private String venv; + private String clusterSpec; + private String jobName; + private int taskIndex; + private String taskId; + private int numTasks; + private Configuration yarnConf = new Configuration(); + private ApplicationRpcClient proxy; + private Map shellEnv = new HashMap<>(); + private int hbInterval; + private final ScheduledExecutorService hbExec = Executors.newScheduledThreadPool(1); + private int numFailedHBAttempts = 0; + + protected TaskExecutor() throws IOException { + // Reserve a rpcSocket rpcPort. + this.rpcSocket = new ServerSocket(0); + this.rpcPort = this.rpcSocket.getLocalPort(); + this.tbSocket = new ServerSocket(0); + this.tbPort = this.tbSocket.getLocalPort(); + this.gatewayServerSocket = new ServerSocket(0); + this.gatewayServerPort = this.gatewayServerSocket.getLocalPort(); + + LOG.info("Reserved rpcPort: " + this.rpcPort); + LOG.info("Reserved tbPort: " + this.tbPort); + LOG.info("Reserved py4j gatewayServerPort: " + this.gatewayServerPort); + } + + public static void main(String[] args) { + LOG.info("TaskExecutor is running.."); + try { + TaskExecutor executor = new TaskExecutor(); + // Set up py4j + GatewayServer pyServer = new GatewayServer(executor, executor.gatewayServerPort); + executor.gatewayServerSocket.close(); + pyServer.start(); + boolean sanitized = executor.init(args); + if (sanitized) { + if (executor.venv != null) { + LOG.info("Unpacking python venv: " + executor.venv); + Utils.unzipArchive(Constants.TF_ZIP_NAME + + File.separatorChar + + executor.venv, Constants.PYTHON_VENV_DIR); + } else { + LOG.info("No virtual environment uploaded."); + } + + executor.jobName = System.getenv(Constants.JOB_NAME); + executor.taskIndex = Integer.parseInt(System.getenv(Constants.TASK_INDEX)); + executor.numTasks = Integer.parseInt(System.getenv(Constants.TASK_NUM)); + executor.taskId = executor.jobName + ":" + executor.taskIndex; + + LOG.info("Executor is running task " + executor.jobName + " " + executor.taskIndex); + + executor.clusterSpec = executor.registerAndGetClusterSpec(executor.amAddress); + if (executor.clusterSpec == null) { + LOG.error("Failed to register worker with AM."); + throw new Exception("Failed to register worker with AM."); + } + LOG.info("Successfully registered and got cluster spec: " + executor.clusterSpec); + + // Release the rpcPort and start the process + executor.rpcSocket.close(); + + if (executor.taskIndex == 0 && executor.jobName.equals("worker")) { + executor.registerTensorBoardUrl(); + executor.tbSocket.close(); + } + + // Execute the user command + HashMap extraEnv = new HashMap(executor.shellEnv) { + { + put(Constants.TB_PORT, String.valueOf(executor.tbPort)); + put(Constants.PY4JGATEWAY, String.valueOf(executor.gatewayServerPort)); + put(Constants.JOB_NAME, String.valueOf(executor.jobName)); + put(Constants.TASK_INDEX, String.valueOf(executor.taskIndex)); + put(Constants.CLUSTER_SPEC, String.valueOf(executor.clusterSpec)); + } + }; + + int exitCode = Utils.executeShell(executor.taskCommand, executor.timeOut, extraEnv); + // START - worker skew testing: + executor.skewAndHangIfTesting(); + // END - worker skew testing: + executor.registerExecutionResult(exitCode, executor.jobName, String.valueOf(executor.taskIndex)); + + LOG.info("Child process exited with exit code " + exitCode); + System.exit(exitCode); + } else { + System.exit(-1); + } + + } catch (Exception e) { + LOG.error("Failed to start task command with error: " + e); + e.printStackTrace(); + System.exit(-1); + } + + } + + protected boolean init(String[] args) throws Exception { + tonyConf.addResource(new Path(Constants.TONY_XML)); + Options opts = new Options(); + opts.addOption("am_address", true, "The address to the application master."); + opts.addOption("task_command", true, "The task command to run."); + opts.addOption("venv", true, "The name of python venv zip."); + opts.addOption("shell_env", true, "Environment for shell script. Specified as env_key=env_val pairs"); + CommandLine cliParser = new GnuParser().parse(opts, args); + amAddress = cliParser.getOptionValue("am_address", ""); + taskCommand = cliParser.getOptionValue("task_command", "exit 0"); + timeOut = tonyConf.getInt(TonyConfigurationKeys.WORKER_TIMEOUT, + TonyConfigurationKeys.DEFAULT_WORKER_TIMEOUT); + hbInterval = tonyConf.getInt(TonyConfigurationKeys.TASK_HEARTBEAT_INTERVAL_MS, + TonyConfigurationKeys.DEFAULT_TASK_HEARTBEAT_INTERVAL_MS); + String[] shellEnvs = cliParser.getOptionValues("shell_env"); + shellEnv = Utils.parseKeyValue(shellEnvs); + LOG.info("Task command: " + taskCommand); + venv = cliParser.getOptionValue("venv"); + if (System.getenv(Constants.YARN_CONF_PATH) != null) { + yarnConf.addResource(new Path(Constants.TF_ZIP_NAME + File.separatorChar + System.getenv(Constants.YARN_CONF_PATH))); + } + LOG.info("Setting up Rpc client, connecting to: " + amAddress); + proxy = ApplicationRpcClient.getInstance(amAddress.split(":")[0], Integer.parseInt(amAddress.split(":")[1]), yarnConf); + return true; + } + + private String registerAndGetClusterSpec(String amAddress) throws Exception { + LOG.info("Application Master address : " + amAddress); + ContainerId containerId = ContainerId.fromString(System.getenv(ApplicationConstants.Environment.CONTAINER_ID.name())); + String hostName = Utils.getCurrentHostName(); + LOG.info("ContainerId is: " + containerId + " HostName is: " + hostName); + + hangIfTesting(); + + // Start the Heartbeater.. + hbExec.scheduleAtFixedRate(new Heartbeater(), + 0, hbInterval, TimeUnit.MILLISECONDS); + + LOG.info("Connecting to " + amAddress + " to register worker spec: " + jobName + " " + taskIndex + " " + + InetAddress.getLocalHost().getHostName() + ":" + rpcPort); + return Utils.pollTillNonNull(() -> + proxy.registerWorkerSpec(jobName + ":" + taskIndex, + InetAddress.getLocalHost().getHostName() + ":" + rpcPort), 3, 120); + } + + private void registerTensorBoardUrl() throws Exception { + String hostName = Utils.getCurrentHostName(); + String tbUrl = hostName + ":" + tbPort; + LOG.info("TensorBoard address : " + tbUrl); + String response = Utils.pollTillNonNull(() -> proxy.registerTensorBoardUrl(tbUrl), 1, 60); + if (response != null) { + LOG.info("Register TensorBoard response: " + response); + } + } + + private void registerExecutionResult(int exitCode, String jobName, String jobIndex) throws Exception { + String sessionId = System.getenv(Constants.SESSION_ID); + String response = Utils.pollTillNonNull( + () -> proxy.registerExecutionResult(exitCode, jobName, jobIndex, sessionId), 1, 60); + if (response != null) { + LOG.info("AM response for result execution run: " + response); + } + } + + private class Heartbeater implements Runnable { + int hbMissCounter = 0; + int numHbToMiss; + + private Heartbeater() { + String hbMissStr = System.getenv(Constants.TEST_TASK_EXECUTOR_NUM_HB_MISS); + try { + int numMisses = Integer.parseInt(hbMissStr); + if (numMisses > 0) { + numHbToMiss = numMisses; + } + } catch (Exception e) { + numHbToMiss = 0; + } + } + + @Override + public void run() { + try { + if (hbMissCounter == 0) { + LOG.debug("[" + taskId + "] Sending Ping !!"); + proxy.taskExecutorHeartbeat(taskId); + numFailedHBAttempts = 0; + hbMissCounter = numHbToMiss; + } else { + LOG.debug("[" + taskId + "] Skipping heartbeat for Testing !!"); + hbMissCounter--; + } + } catch (Exception e) { + LOG.error("[" + taskId + "] Failed to send Heart Beat: " + e); + if (++numFailedHBAttempts > MAX_NUM_FAILED_HB_ATTEMPTS) { + LOG.error("[" + taskId + "] Exceeded Failed Heart Beat send attempts.. going to die !!"); + e.printStackTrace(); + System.exit(-1); + } else { + LOG.warn("Will retry heartbeat.."); + } + } + } + } + + //region TonyDataFeed + + // TODO : currently requires caller (tf job) to provide the path to read + // maybe a better abstraction if task executor itself figures this out (if + // possible at all.) + @SuppressWarnings("unused") + public HdfsAvroFileSplitReader getHdfsAvroFileSplitReader(List readPaths) + throws IOException { + return getHdfsAvroFileSplitReader(readPaths, true); + } + + public HdfsAvroFileSplitReader getHdfsAvroFileSplitReader(List readPaths, + boolean useRandomShuffle) + throws IOException { + Configuration hdfsConf = new Configuration(); + hdfsConf.addResource(new Path( + System.getenv(HADOOP_CONF_DIR) + File.separatorChar + CORE_SITE_CONF)); + return new HdfsAvroFileSplitReader(hdfsConf, readPaths, this.taskIndex, + this.numTasks, useRandomShuffle); + } + + + //endregion + + //region Testing + + private void hangIfTesting() { + // Simulate hanging task executor if enabled and is first attempt + String shouldHang = System.getenv(Constants.TEST_TASK_EXECUTOR_HANG); + String attempt = System.getenv(Constants.ATTEMPT_NUMBER); + int attemptNumber = attempt == null ? 0 : Integer.valueOf(attempt); + if (shouldHang != null && Boolean.parseBoolean(shouldHang) && attemptNumber < 1) { + LOG.info("Hanging for 20 seconds for testing purposes"); + try { + Thread.sleep(20000); + } catch (InterruptedException e) { + LOG.error("Thread interrupted while hanging forever", e); + } + // We still exit after 20 seconds to prevent this process from sticking around forever. + // In the cluster, when using cgroups, when the container for this process is killed, this process will also be + // killed, but when using MiniYARNCluster, that's not the case, so this process still needs to exit during tests. + System.exit(-1); + } + } + + private void skewAndHangIfTesting() { + String skewInstr = System.getenv(Constants.TEST_TASK_EXECUTOR_SKEW); + if (skewInstr != null) { + String[] instr = skewInstr.split("#"); + try { + if (instr.length == 3 && instr[0].equals(this.jobName) + && Integer.parseInt(instr[1]) == this.taskIndex) { + int waitTime = Integer.parseInt(instr[2]); + LOG.info("Will sleep for [" + waitTime + "] as instructed to simulate skew"); + try { + Thread.sleep(waitTime); + } catch (InterruptedException e) { + LOG.error("Thread interrupted while hanging..", e); + } + } + } catch (Exception e) { + // Could be due to a parsing Exception... + LOG.error("Got Exception while parsing skew instruction", e); + } + } + } + //endregion + +} diff --git a/tony-core/src/main/java/com/linkedin/tony/TonyApplicationMaster.java b/tony-core/src/main/java/com/linkedin/tony/TonyApplicationMaster.java new file mode 100644 index 00000000..06e30571 --- /dev/null +++ b/tony-core/src/main/java/com/linkedin/tony/TonyApplicationMaster.java @@ -0,0 +1,1097 @@ +/** + * Copyright 2018 LinkedIn Corporation. All rights reserved. Licensed under the BSD-2 Clause license. + * See LICENSE in the project root for license information. + */ +package com.linkedin.tony; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Preconditions; +import com.linkedin.tony.rpc.ApplicationRpc; +import com.linkedin.tony.rpc.ApplicationRpcServer; +import com.linkedin.tony.rpc.TaskUrl; +import com.linkedin.tony.tensorflow.TensorFlowContainerRequest; +import com.linkedin.tony.tensorflow.TensorFlowSession; +import com.linkedin.tony.tensorflow.TensorFlowSession.TFTask; +import java.io.BufferedReader; +import java.io.File; +import java.io.FileReader; +import java.io.IOException; +import java.lang.reflect.Method; +import java.net.ServerSocket; +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; +import org.apache.commons.cli.CommandLine; +import org.apache.commons.cli.GnuParser; +import org.apache.commons.cli.Options; +import org.apache.commons.cli.ParseException; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.apache.hadoop.conf.Configuration; +import org.apache.hadoop.fs.Path; +import org.apache.hadoop.io.DataOutputBuffer; +import org.apache.hadoop.io.Text; +import org.apache.hadoop.security.Credentials; +import org.apache.hadoop.security.UserGroupInformation; +import org.apache.hadoop.security.token.Token; +import org.apache.hadoop.security.token.TokenIdentifier; +import org.apache.hadoop.yarn.api.ApplicationConstants; +import org.apache.hadoop.yarn.api.protocolrecords.RegisterApplicationMasterResponse; +import org.apache.hadoop.yarn.api.records.ApplicationAccessType; +import org.apache.hadoop.yarn.api.records.ApplicationAttemptId; +import org.apache.hadoop.yarn.api.records.Container; +import org.apache.hadoop.yarn.api.records.ContainerExitStatus; +import org.apache.hadoop.yarn.api.records.ContainerId; +import org.apache.hadoop.yarn.api.records.ContainerLaunchContext; +import org.apache.hadoop.yarn.api.records.ContainerStatus; +import org.apache.hadoop.yarn.api.records.FinalApplicationStatus; +import org.apache.hadoop.yarn.api.records.LocalResource; +import org.apache.hadoop.yarn.api.records.NodeReport; +import org.apache.hadoop.yarn.api.records.Priority; +import org.apache.hadoop.yarn.api.records.Resource; +import org.apache.hadoop.yarn.api.records.UpdatedContainer; +import org.apache.hadoop.yarn.client.api.AMRMClient; +import org.apache.hadoop.yarn.client.api.AMRMClient.ContainerRequest; +import org.apache.hadoop.yarn.client.api.YarnClient; +import org.apache.hadoop.yarn.client.api.async.AMRMClientAsync; +import org.apache.hadoop.yarn.client.api.async.NMClientAsync; +import org.apache.hadoop.yarn.client.api.async.impl.NMClientAsyncImpl; +import org.apache.hadoop.yarn.conf.YarnConfiguration; +import org.apache.hadoop.yarn.security.AMRMTokenIdentifier; +import org.apache.hadoop.yarn.security.client.ClientToAMTokenIdentifier; +import org.apache.hadoop.yarn.security.client.ClientToAMTokenSecretManager; +import org.apache.hadoop.yarn.util.AbstractLivelinessMonitor; +import py4j.GatewayServer; + +import static com.linkedin.tony.Constants.*; + + +public class TonyApplicationMaster { + private static final Log LOG = LogFactory.getLog(TonyApplicationMaster.class); + + private ApplicationAttemptId appAttemptID; + private String appIdString; + private String amHostPort; + + // Container info + private long psMemory; + private int psVCores; + private long workerMemory; + private int workerVCores; + private int workerGPUs; + private int taskRegistrationRetryCount; + private int taskRegistrationTimeoutSec; + private int amRetryCount; + private long workerTimeout; + private String hdfsClasspath; + private String baseTaskCommand; + private int numWorkers; + private int numPs; + private String pythonVenvZip; + private int amPort; + private ByteBuffer allTokens; + private Map localResources = new ConcurrentHashMap<>(); + private Configuration tonyConf = new Configuration(); + + // The environment set up for the TaskExecutor + private Map containerEnv = new ConcurrentHashMap<>(); + + // The environment passed from users to the training job. Note this is very different from the above. + private Map shellEnv = new HashMap<>(); + private Map> sessionContainersMap = new ConcurrentHashMap<>(); + private Map containerStatusMap = new HashMap<>(); // ContainerId : if container completed + + // Node manager delegates + private NMCallbackHandler containerListener; + private NMClientAsync nmClientAsync; + + // Resource manager + private AMRMClientAsync amRMClient; + + // Job progress + private AtomicInteger numCompletedWorkerTasks = new AtomicInteger(); + private long numTotalWorkerTasks = 1; + + private AtomicInteger numRequestedContainers = new AtomicInteger(); + private Map> jobTypeToContainerRequestsMap = new HashMap<>(); + + // TensorFlow session + private TensorFlowSession session = new TensorFlowSession(); // Create a dummy session for single node training. + private TensorFlowSession.Builder sessionBuilder; + + // Configuration + private Configuration yarnConf; + private Configuration hdfsConf; + + // Cluster spec + private ApplicationRpcServer rpcServer; + + // Local mode + private boolean insecureMode; + + // Single node training + private boolean singleNode; + private boolean preprocessFinished = false; + private int preprocessExitCode = 0; + + // Preprocessing job + private boolean enablePreprocessing = false; + + // Lifecycle control + private boolean shouldExit = false; + + // HeartBeat monitor + private final AbstractLivelinessMonitor hbMonitor; + private int hbInterval; + private int maxConsecutiveHBMiss; + private volatile boolean taskHasMissesHB = false; + private Thread mainThread; + + // Failure conditions + private boolean jobFailed; + + private TonyApplicationMaster() { + hdfsConf = new Configuration(); + yarnConf = new Configuration(); + + hbMonitor = new AbstractLivelinessMonitor("TF Task liveliness Monitor") { + @Override + protected void expire(TFTask task) { + onTaskDeemedDead(task); + } + + @Override + protected void serviceStart() throws Exception { + setMonitorInterval(hbInterval * 3); + setExpireInterval(hbInterval * Math.max(3, maxConsecutiveHBMiss)); // Be at least == monitoring interval + super.serviceStart(); + } + }; + } + + /** + * Parse command line options and initialize TonyApplicationMaster + * @return whether the initialization is successful or not. + */ + private boolean init(String[] args) { + tonyConf.addResource(new Path(Constants.TONY_XML)); + if (System.getenv(HDFS_SITE_CONF) != null) { + hdfsConf.addResource(new Path(System.getenv(HADOOP_CONF_DIR) + File.separatorChar + CORE_SITE_CONF)); + yarnConf.addResource(new Path(System.getenv(HADOOP_CONF_DIR) + File.separatorChar + CORE_SITE_CONF)); + hdfsConf.addResource(new Path(System.getenv(HADOOP_CONF_DIR) + File.separatorChar + HDFS_SITE_CONF)); + } + if (System.getenv(Constants.HDFS_CONF_PATH) != null) { + hdfsConf.addResource(new Path(Constants.TF_ZIP_NAME + File.separatorChar + System.getenv(Constants.HDFS_CONF_PATH))); + containerEnv.put(Constants.HDFS_CONF_PATH, System.getenv(Constants.HDFS_CONF_PATH)); + } + if (System.getenv(Constants.YARN_CONF_PATH) != null) { + yarnConf.addResource(new Path(Constants.TF_ZIP_NAME + File.separatorChar + System.getenv(Constants.YARN_CONF_PATH))); + containerEnv.put(Constants.YARN_CONF_PATH, System.getenv(Constants.YARN_CONF_PATH)); + } + hbMonitor.init(tonyConf); + + Options opts = Utils.getCommonOptions(); + CommandLine cliParser; + try { + cliParser = new GnuParser().parse(opts, args); + } catch (ParseException e) { + LOG.error("Got exception while parsing options", e); + return false; + } + Map envs = System.getenv(); + String[] shellEnvs = cliParser.getOptionValues("shell_env"); + shellEnv = Utils.parseKeyValue(shellEnvs); + String[] containerEnvs = cliParser.getOptionValues("container_env"); + containerEnv.putAll(Utils.parseKeyValue(containerEnvs)); + pythonVenvZip = cliParser.getOptionValue("python_venv"); + + baseTaskCommand = buildBaseTaskCommand( + pythonVenvZip, + cliParser.getOptionValue("python_binary_path"), + cliParser.getOptionValue("executes"), + cliParser.getOptionValue("task_params")); + + String psMemoryString = tonyConf.get(TonyConfigurationKeys.PS_MEMORY, + TonyConfigurationKeys.DEFAULT_PS_MEMORY); + psMemory = Long.parseLong(Utils.parseMemoryString(psMemoryString)); + psVCores = tonyConf.getInt(TonyConfigurationKeys.PS_VCORES, + TonyConfigurationKeys.DEFAULT_PS_VCORES); + String workerMemoryString = tonyConf.get(TonyConfigurationKeys.WORKER_MEMORY, + TonyConfigurationKeys.DEFAULT_WORKER_MEMORY); + workerMemory = Long.parseLong(Utils.parseMemoryString(workerMemoryString)); + workerVCores = tonyConf.getInt(TonyConfigurationKeys.WORKER_VCORES, + TonyConfigurationKeys.DEFAULT_WORKER_VCORES); + workerGPUs = tonyConf.getInt(TonyConfigurationKeys.WORKER_GPUS, + TonyConfigurationKeys.DEFAULT_WORKER_GPUS); + workerTimeout = tonyConf.getInt(TonyConfigurationKeys.WORKER_TIMEOUT, + TonyConfigurationKeys.DEFAULT_WORKER_TIMEOUT); + hdfsClasspath = cliParser.getOptionValue("hdfs_classpath"); + numPs = tonyConf.getInt(TonyConfigurationKeys.PS_INSTANCES, TonyConfigurationKeys.DEFAULT_PS_INSTANCES); + numWorkers = tonyConf.getInt(TonyConfigurationKeys.WORKER_INSTANCES, TonyConfigurationKeys.DEFAULT_WORKER_INSTANCES); + taskRegistrationRetryCount = tonyConf.getInt(TonyConfigurationKeys.TASK_REGISTRATION_RETRY_COUNT, + TonyConfigurationKeys.DEFAULT_TASK_REGISTRATION_RETRY_COUNT); + taskRegistrationTimeoutSec = tonyConf.getInt(TonyConfigurationKeys.TASK_REGISTRATION_TIMEOUT_SEC, + TonyConfigurationKeys.DEFAULT_TASK_REGISTRATION_TIMEOUT_SEC); + amRetryCount = tonyConf.getInt(TonyConfigurationKeys.AM_RETRY_COUNT, + TonyConfigurationKeys.DEFAULT_AM_RETRY_COUNT); + singleNode = tonyConf.getBoolean(TonyConfigurationKeys.IS_SINGLE_NODE, + TonyConfigurationKeys.DEFAULT_IS_SINGLE_NODE); + insecureMode = tonyConf.getBoolean(TonyConfigurationKeys.IS_INSECURE_MODE, + TonyConfigurationKeys.DEFAULT_IS_INSECURE_MODE); + enablePreprocessing = tonyConf.getBoolean(TonyConfigurationKeys.ENABLE_PREPROCESSING_JOB, + TonyConfigurationKeys.DEFAULT_ENABLE_PREPROCESSING_JOB); + ContainerId containerId = ContainerId.fromString(envs.get(ApplicationConstants.Environment.CONTAINER_ID.name())); + appIdString = containerId.getApplicationAttemptId().getApplicationId().toString(); + hbInterval = tonyConf.getInt(TonyConfigurationKeys.TASK_HEARTBEAT_INTERVAL_MS, + TonyConfigurationKeys.DEFAULT_TASK_HEARTBEAT_INTERVAL_MS); + maxConsecutiveHBMiss = tonyConf.getInt(TonyConfigurationKeys.TASK_MAX_MISSED_HEARTBEATS, + TonyConfigurationKeys.DEFAULT_TASK_MAX_MISSED_HEARTBEATS); + return true; + } + + @VisibleForTesting + static String buildBaseTaskCommand(String pythonVenvZip, String pythonBinaryPath, String script, + String taskParams) { + String pythonInterpreter; + if (pythonVenvZip == null || pythonBinaryPath.startsWith("/")) { + pythonInterpreter = pythonBinaryPath; + } else { + // Note that we always extract the Python venv zip to a "venv" (Constants.PYTHON_VENV_DIR) directory. + pythonInterpreter = Constants.PYTHON_VENV_DIR + File.separatorChar + pythonBinaryPath; + } + + String baseTaskCommand = pythonInterpreter + " " + Constants.TF_ZIP_NAME + File.separatorChar + script; + + if (taskParams != null) { + baseTaskCommand += " " + taskParams; + } + + return baseTaskCommand; + } + + private void buildTensorFlowSession() { + String taskCommand = "'" + baseTaskCommand + "'"; + LOG.info("Final task command: " + taskCommand); + + /* The priority of PS and worker MUST be different. + Otherwise the requests will overwrite each other on the RM + scheduling side. See YARN-7631 for details. + */ + TensorFlowSession.Builder builder = new TensorFlowSession.Builder() + .setNumWorkers(numWorkers) + .setNumPs(numPs) + .setTaskCmd(taskCommand) + .setVenv(pythonVenvZip) + .setAMAddress(amHostPort) + .setShellEnv(shellEnv) + .setTaskExecutorJVMArgs(tonyConf.get(TonyConfigurationKeys.TASK_EXECUTOR_JVM_OPTS, + TonyConfigurationKeys.DEFAULT_TASK_EXECUTOR_JVM_OPTS)) + .setPsContainerRequest(new TensorFlowContainerRequest("ps", psVCores, psMemory, 0, 1)) + .setWorkerContainerRequest(new TensorFlowContainerRequest("worker", workerVCores, workerMemory, workerGPUs, 0)); + sessionBuilder = builder; + session = builder.build(); + } + + /** + * Entry point of TonyApplicationMaster + * The workflow of a training job in AM + * prepare -> start -> failed -> reset -> retry if amRetryCount > 0 otherwise fail the job. + * -> succeeded -> stop -> job succeeded + * @param args the args from user inputs + */ + public static void main(String[] args) { + boolean result = false; + + TonyApplicationMaster am = new TonyApplicationMaster(); + boolean sanityCheck = am.init(args); + if (!sanityCheck) { + System.exit(-1); + } + if (!am.prepare()) { + System.exit(-1); + } + am.mainThread = Thread.currentThread(); + boolean exitOnError = false; + do { + // Crash AM on purpose during AM crash tests. + String shouldCrash = System.getenv(Constants.TEST_AM_CRASH); + if (shouldCrash != null && shouldCrash.equals("true")) { + exitOnError = true; + break; + } + + try { + am.start(); + } catch (Exception e) { + LOG.error("Exception when we're starting TonyAM", e); + System.exit(-1); + } + result = am.monitor(); + if (result || !am.jobFailed || am.amRetryCount == 0) { + LOG.info("Result: " + result + ", job failed: " + am.jobFailed + ", retry count: " + am.amRetryCount); + break; + } + // Prepare for retryCount. + am.reset(); + LOG.info("Retrying, remaining retry count" + am.amRetryCount); + am.amRetryCount -= 1; + } while (!am.singleNode); // We don't retry on single node training. + // Wait for the worker nodes to finish (The interval between registering the exit code to final exit) + am.stop(); + am.printTaskUrls(); + if (exitOnError) { + LOG.fatal("Error running TonyApplicationMaster !!"); + System.exit(-1); + } + + if (result) { + LOG.info("Application Master completed successfully. exiting"); + System.exit(0); + } else { + LOG.info("Application Master failed. exiting"); + System.exit(-1); + } + } + + /** + * Prepare the application master. This part is shared across different retries. + */ + private boolean prepare() { + LOG.info("Preparing application master.."); + + containerListener = createNMCallbackHandler(); + nmClientAsync = new NMClientAsyncImpl(containerListener); + nmClientAsync.init(yarnConf); + nmClientAsync.start(); + + String hostname = Utils.getCurrentHostName(); + rpcServer = setupRPCService(hostname); + + // Init AMRMClient + AMRMClientAsync.AbstractCallbackHandler allocListener = new RMCallbackHandler(); + amRMClient = AMRMClientAsync.createAMRMClientAsync(1000, allocListener); + amRMClient.init(yarnConf); + amRMClient.start(); + + RegisterApplicationMasterResponse response; + try { + response = amRMClient.registerApplicationMaster(hostname, amPort, null); + amHostPort = hostname + ":" + amPort; + LOG.info("RPC server running at: " + amHostPort); + if (!insecureMode) { + ClientToAMTokenIdentifier identifier = + new ClientToAMTokenIdentifier(appAttemptID, UserGroupInformation.getCurrentUser().getShortUserName()); + byte[] secret = response.getClientToAMTokenMasterKey().array(); + ClientToAMTokenSecretManager secretManager = new ClientToAMTokenSecretManager(appAttemptID, secret); + Token token = new Token<>(identifier, secretManager); + token.setService(new Text(amHostPort)); + rpcServer.setSecretManager(secretManager); + UserGroupInformation.getCurrentUser().addToken(token); + setupContainerCredentials(); + } + } catch (Exception e) { + LOG.error("Exception while preparing AM", e); + return false; + } + rpcServer.start(); + + long maxMem = response.getMaximumResourceCapability().getMemorySize(); + int maxVCores = response.getMaximumResourceCapability().getVirtualCores(); + if (psMemory > maxMem) { + psMemory = maxMem; + } + if (psVCores > maxVCores) { + psVCores = maxVCores; + } + if (workerMemory > maxMem) { + workerMemory = maxMem; + } + if (workerVCores > maxVCores) { + workerVCores = maxVCores; + } + + hbMonitor.start(); + return true; + } + + /** + * This method start the training job. It also does the training preprocessing in this function as well + * preprocessing job is used to abstract out common computation in each worker to a single place, however, + * we do plan to move the preprocessing job to a worker node in the future to reduce the complexity of AM. + * @throws IOException exception during HDFS file operations. + * @throws InterruptedException during Thread.sleep. + */ + private void start() throws Exception { + + int exitCode = 0; + + // Perform the preprocess job. + if (enablePreprocessing || singleNode) { + exitCode = doPreprocessingJob(); + } + + // Early exit for single node training. + if (singleNode) { + if (exitCode != 0) { + LOG.info("Single node job exits with " + exitCode + ", exiting."); + session.setFinalStatus(FinalApplicationStatus.FAILED, "Single node training failed.."); + } else { + LOG.info("Single node job exits with " + exitCode + ", exiting."); + session.setFinalStatus(FinalApplicationStatus.SUCCEEDED, "Single node job succeeded."); + } + return; + } + + if (exitCode != 0) { + return; + } + + buildTensorFlowSession(); + scheduleTasks(); + } + + private void scheduleTasks() { + session.setResources(yarnConf, hdfsConf, localResources, containerEnv, hdfsClasspath); + List requests = session.getContainersRequests(); + numTotalWorkerTasks = requests.stream().filter(request -> request.getJobName().equals("worker")).count(); + for (TensorFlowContainerRequest request : requests) { + scheduleTask(request); + } + numRequestedContainers.set(requests.size()); + } + + private void scheduleTask(TensorFlowContainerRequest request) { + AMRMClient.ContainerRequest containerAsk = setupContainerRequestForRM(request); + if (!jobTypeToContainerRequestsMap.containsKey(request.getJobName())) { + jobTypeToContainerRequestsMap.put(request.getJobName(), new ArrayList<>()); + } + jobTypeToContainerRequestsMap.get(request.getJobName()).add(containerAsk); + amRMClient.addContainerRequest(containerAsk); + } + + // Reset state to prepare for retryCount. + private void reset() { + List containers = sessionContainersMap.get(String.valueOf(session.sessionId)); + for (Container container : containers) { + nmClientAsync.stopContainerAsync(container.getId(), container.getNodeId()); + LOG.info("Stop a task in container: containerId = " + container.getId() + ", containerNode = " + + container.getNodeId().getHost()); + } + + // Reset session and counters. + session = sessionBuilder.build(); + + numCompletedWorkerTasks.set(0); + numRequestedContainers.set(0); + jobFailed = false; + rpcServer.reset(); + session.sessionId += 1; + } + + /** + * Monitor the TensorFlow training job. + * @return if the tensorflow job finishes successfully. + */ + private boolean monitor() { + long start = System.currentTimeMillis(); + + int attempt = 0; + containerEnv.put(Constants.ATTEMPT_NUMBER, String.valueOf(attempt)); + while (true) { + if (preprocessExitCode != 0) { + LOG.info("Preprocess failed with exit code: " + preprocessExitCode); + return false; + } + if (singleNode && preprocessFinished) { + LOG.info("Single node training finished with exit code: " + preprocessExitCode); + return preprocessExitCode == 0; + } + + if (numCompletedWorkerTasks.get() == numTotalWorkerTasks) { + LOG.info("Completed jobs: " + numCompletedWorkerTasks.get() + " total jobs: " + numTotalWorkerTasks); + break; + } + List containers = sessionContainersMap.get(String.valueOf(session.sessionId)); + if (containers != null) { + int completedContainers = containers.stream() + .filter(container -> session.getTask(container.getId()).getJobName().contains("worker")) + .map(container -> containerStatusMap.containsKey(session.getTask(container.getId())) + && containerStatusMap.get(session.getTask(container.getId())) ? 1 : 0) + .reduce(Integer::sum) + .orElse(0); + if (completedContainers == numTotalWorkerTasks) { + LOG.info("All " + numTotalWorkerTasks + " worker tasks have completed."); + break; + } + } + LOG.info("Completed worker tasks: " + numCompletedWorkerTasks.get() + + ", total worker tasks: " + numTotalWorkerTasks); + + Set unregisteredTasks = getUnregisteredTasks(); + int numUnregistered = unregisteredTasks.size(); + int secondsElapsed = (int) (System.currentTimeMillis() - start) / 1000; + if (numUnregistered > 0 && secondsElapsed >= taskRegistrationTimeoutSec) { + if (attempt < taskRegistrationRetryCount) { + attempt++; + containerEnv.put(Constants.ATTEMPT_NUMBER, String.valueOf(attempt)); + LOG.info(numUnregistered + " tasks still unregistered after " + secondsElapsed + " seconds. Going to " + + "reschedule them. Starting retry attempt " + attempt); + rescheduleTasks(unregisteredTasks); + start = System.currentTimeMillis(); + } else { + LOG.error(numUnregistered + " out of " + numRequestedContainers.get() + " tasks have not registered after " + + (attempt + 1) + " attempts. Failing this job."); + break; + } + } + if (this.taskHasMissesHB) { + break; + } + try { + Thread.sleep(5000); + } catch (InterruptedException e) { + LOG.error("Thread interrupted", e); + } + } + if (this.taskHasMissesHB) { + session.setFinalStatus(FinalApplicationStatus.FAILED, + "Application failed due to missed heartbeats"); + } else { + session.updateSessionStatus(); + } + + LOG.info("Total completed worker tasks: " + numCompletedWorkerTasks.get() + + ", total worker tasks: " + numTotalWorkerTasks); + boolean success = true; + FinalApplicationStatus status = session.getFinalStatus(); + String appMessage = session.getFinalMessage(); + if (status != FinalApplicationStatus.SUCCEEDED) { + LOG.info("TensorFlow session failed: " + appMessage); + success = false; + } + return success; + } + + /** + * Returns the tasks whose containers have launched but not called {@link ApplicationRpc#registerWorkerSpec} yet. + */ + private Set getUnregisteredTasks() { + return session.getTFTasks().values().stream().flatMap(Arrays::stream) + .filter(task -> task != null && task.getHost() == null) + .collect(Collectors.toSet()); + } + + private void rescheduleTasks(Set tasks) { + removeAllContainerRequests(); + + tasks.forEach(task -> { + LOG.info("Task " + task.getJobName() + " " + task.getJobIndex() + " in " + task.getContainer().getId().toString() + + " has not registered after " + taskRegistrationTimeoutSec + " seconds. Going to release the container and " + + "request a new one."); + + // Remove container from containerStatusMap + containerStatusMap.remove(task); + + // Release container + amRMClient.releaseAssignedContainer(task.getContainer().getId()); + + // Null out task in TFTasks map + session.getTFTasks().get(task.getJobName())[Integer.valueOf(task.getJobIndex())] = null; + + // Make new container request + scheduleTask(session.createContainerRequestForType(task.getJobName())); + }); + } + + private void removeAllContainerRequests() { + for (String jobType : jobTypeToContainerRequestsMap.keySet()) { + List containerRequests = jobTypeToContainerRequestsMap.get(jobType); + containerRequests.forEach(request -> amRMClient.removeContainerRequest(request)); + jobTypeToContainerRequestsMap.put(jobType, new ArrayList<>()); + } + } + + private void stop() { + FinalApplicationStatus status = session.getFinalStatus(); + String appMessage = session.getFinalMessage(); + try { + amRMClient.unregisterApplicationMaster(status, appMessage, null); + } catch (Exception ex) { + LOG.error("Failed to unregister application", ex); + } + nmClientAsync.stop(); + amRMClient.waitForServiceToStop(5000); + amRMClient.stop(); + // Poll until TonyClient signals we should exit + boolean result = Utils.poll(() -> shouldExit, 1, 30); + if (!result) { + LOG.warn("TonyClient didn't signal Tony AM to stop."); + } + } + + // Run the preprocessing job and set up the common env variables for worker jobs. + private int doPreprocessingJob() throws Exception { + ServerSocket gatewayServerSocket = new ServerSocket(0); + int gatewayServerPort = gatewayServerSocket.getLocalPort(); + // Set up py4j + GatewayServer pyServer = new GatewayServer(this, gatewayServerPort); + gatewayServerSocket.close(); + pyServer.start(); + + HashMap extraEnv = new HashMap<>(shellEnv); + if (singleNode) { + ServerSocket tbSocket = new ServerSocket(0); + int tbPort = tbSocket.getLocalPort(); + extraEnv.put(Constants.TB_PORT, String.valueOf(tbPort)); + String tbUrl = Utils.getCurrentHostName() + ":" + tbPort; + LOG.info("Registering tensorboard url for single node training: " + tbUrl); + registerTensorBoardUrlToRM(tbUrl); + tbSocket.close(); + } + LOG.info("Start python preprocessing"); + if (pythonVenvZip != null) { + LOG.info("Unpacking python venv: " + pythonVenvZip); + Utils.unzipArchive(Constants.TF_ZIP_NAME + + File.separatorChar + + pythonVenvZip, Constants.PYTHON_VENV_DIR); + } else { + LOG.warn("No Python virtual environment uploaded, using python_binary_path directly."); + } + + extraEnv.put(Constants.PREPROCESSING_JOB, "true"); + extraEnv.put(Constants.PY4JGATEWAY, String.valueOf(gatewayServerPort)); + String taskCommand = baseTaskCommand; + LOG.info("Executing command: " + taskCommand); + int exitCode = Utils.executeShell(taskCommand, workerTimeout, extraEnv); + + preprocessExitCode = exitCode; + preprocessFinished = true; + + // Short circuit if preprocessing job fails. + if (exitCode != 0) { + LOG.error("Preprocess job exits with " + exitCode + ", exiting."); + session.setFinalStatus(FinalApplicationStatus.FAILED, "Preprocessing job failed."); + return exitCode; + } + try (BufferedReader reader = new BufferedReader(new FileReader( + System.getProperty(YarnConfiguration.YARN_APP_CONTAINER_LOG_DIR) + + File.separatorChar + Constants.AM_STDOUT_FILENAME))) { + String line; + while ((line = reader.readLine()) != null) { + if (line.contains("Model parameters: ")) { + String params = line.substring(line.indexOf("Model parameters: ") + "Model parameters: ".length()); + // Add serialized params to env + containerEnv.put(Constants.TASK_PARAM_KEY, params); + break; + } + } + } + return exitCode; + } + + private void printTaskUrls() { + if (session != null) { + session.getTFTasks() + .values() + .stream() + .flatMap(Arrays::stream) + .forEach(task -> Utils.printTaskUrl(task.getTaskUrl(), LOG)); + } + } + + private ApplicationRpcServer setupRPCService(String hostname) { + ApplicationRpcServer rpcServer = new ApplicationRpcServer(hostname, new RpcForClient(), yarnConf); + amPort = rpcServer.getRpcPort(); + return rpcServer; + } + + private final class RpcForClient implements ApplicationRpc { + private static final long REGISTRATION_STATUS_INTERVAL_MS = 15 * 1000; + + private Set registeredTasks = new HashSet<>(); + private long lastRegisterWorkerTime = System.currentTimeMillis(); + + @Override + public void reset() { + registeredTasks = new HashSet<>(); + } + + @Override + public Set getTaskUrls() { + if (session != null && session.allTasksScheduled()) { + return session.getTFTasks().values().stream() + .flatMap(tasks -> Arrays.stream(tasks).map(TFTask::getTaskUrl)) + .collect(Collectors.toSet()); + } + + return Collections.emptySet(); + } + + @Override + public String getClusterSpec() throws IOException { + ObjectMapper objectMapper = new ObjectMapper(); + return objectMapper.writeValueAsString(session.getClusterSpec()); + } + + @Override + public void taskExecutorHeartbeat(String taskId) { + TFTask task = session.getTask(taskId); + if (task != null) { + LOG.debug("[" + taskId + "] Received HB Ping !!"); + hbMonitor.receivedPing(task); + } else { + LOG.warn("[" + taskId + "] Not registered for heartbeat monitoring !!"); + } + } + + @Override + public String registerWorkerSpec(String taskId, String spec) throws IOException { + TFTask task = session.getTask(taskId); + if (task.getHost() == null) { + LOG.info("Received cluster spec registration request from task " + taskId + " with spec: " + spec); + task.setHostPort(spec); + registeredTasks.add(taskId); + // HB Registration should happen only after worker registration.. + // The Task registration timeout will take care of rescheduling the task + // on another node.. + LOG.info("[" + taskId + "] Received Registration for HB !!"); + hbMonitor.register(task); + } + + // Return null until all tasks have registered + if (registeredTasks.size() == numRequestedContainers.get()) { + LOG.info("All " + numRequestedContainers.get() + " tasks registered."); + return getClusterSpec(); + } else { + // Periodically print a list of all tasks we are still awaiting registration from. + if (System.currentTimeMillis() - lastRegisterWorkerTime > REGISTRATION_STATUS_INTERVAL_MS) { + Set unregisteredTasks = getUnregisteredTasks(); + LOG.info(String.format("Received registrations from %d tasks, awaiting registration from %d tasks.", + registeredTasks.size(), numRequestedContainers.get() - registeredTasks.size())); + unregisteredTasks.forEach(t -> LOG.info( + String.format("Awaiting registration from task %s %s in %s on host %s", + t.getJobName(), t.getJobIndex(), + (t.getContainer() != null ? t.getContainer().getId().toString() : "none"), + (t.getContainer() != null ? t.getContainer().getNodeId().getHost() : "none"))) + ); + lastRegisterWorkerTime = System.currentTimeMillis(); + } + return null; + } + } + + @Override + public String registerExecutionResult(int exitCode, String jobName, String jobIndex, String sessionId) { + LOG.info("Received result registration request with exit code " + exitCode + " from " + jobName + " " + jobIndex); + // Ignore past sessions. + if (!sessionId.equals(String.valueOf(session.sessionId))) { + LOG.info("Ignore past sessions."); + return "EXPIRED_SESSION"; + } + // Source of truth for task result. + session.onTaskCompleted(jobName, jobIndex, exitCode); + if (exitCode != 0) { + jobFailed = true; + } + + if (jobName.equals(Constants.WORKER_JOB_NAME)) { + numCompletedWorkerTasks.incrementAndGet(); + } + return "RECEIVED"; + } + + @Override + public String registerTensorBoardUrl(String spec) throws Exception { + LOG.info("Got request to update TensorBoard URL: " + spec); + return registerTensorBoardUrlToRM(spec); + } + + @Override + public void finishApplication() { + LOG.info("Client signals AM to finish application."); + shouldExit = true; + } + } + + private String registerTensorBoardUrlToRM(String spec) throws Exception { + if (spec != null && appIdString != null) { + try { + // Post YARN-7974 or Hadoop 3.1.2 release + // amRMClient.updateTrackingUrl(spec); + } catch (NoSuchMethodError nsme) { + // Pre YARN-7974 + YarnClient yarnClient = YarnClient.createYarnClient(); + yarnClient.init(yarnConf); + if (!insecureMode) { + String fileLocation = System.getenv(UserGroupInformation.HADOOP_TOKEN_FILE_LOCATION); + Credentials cred = Credentials.readTokenStorageFile(new File(fileLocation), yarnConf); + for (Token token : cred.getAllTokens()) { + UserGroupInformation.getCurrentUser().addToken(token); + } + for (Token token : UserGroupInformation.getCurrentUser().getTokens()) { + LOG.info("Current user's token : " + token); + } + } + yarnClient.start(); + //noinspection JavaReflectionMemberAccess + Method method = YarnClient.class.getMethod("updateTrackingURL", String.class, String.class); + method.invoke(yarnClient, appIdString, spec); + } + return "SUCCEEDED"; + } else { + return "FAILED"; + } + } + + // Set up credentials for the containers. + private void setupContainerCredentials() throws IOException { + Credentials credentials = UserGroupInformation.getCurrentUser().getCredentials(); + DataOutputBuffer dob = new DataOutputBuffer(); + credentials.writeTokenStorageToStream(dob); + Iterator> iter = credentials.getAllTokens().iterator(); + while (iter.hasNext()) { + Token token = iter.next(); + LOG.info(token); + if (token.getKind().equals(AMRMTokenIdentifier.KIND_NAME)) { + iter.remove(); + } + } + allTokens = ByteBuffer.wrap(dob.getData(), 0, dob.getLength()); + String submitterUserName = System.getenv(ApplicationConstants.Environment.USER.name()); + UserGroupInformation submitterUgi = UserGroupInformation.createRemoteUser(submitterUserName); + submitterUgi.addCredentials(credentials); + } + + private AMRMClient.ContainerRequest setupContainerRequestForRM(TensorFlowContainerRequest request) { + return setupContainerRequestForRM(request.getVirtualCores(), request.getMemory(), request.getGPU(), + request.getPriority()); + } + + private AMRMClient.ContainerRequest setupContainerRequestForRM(int vCores, long mem, final int gpu, int priority) { + Priority pi = Priority.newInstance(priority); + Resource capability = Resource.newInstance(mem, vCores); + Utils.setCapabilityGPU(capability, gpu); + AMRMClient.ContainerRequest request = new AMRMClient.ContainerRequest(capability, null, null, pi); + LOG.info("Requested container ask: " + request.toString()); + return request; + } + + private NMCallbackHandler createNMCallbackHandler() { + return new NMCallbackHandler(); + } + + /** + * Node manager call back handler + */ + static class NMCallbackHandler extends NMClientAsync.AbstractCallbackHandler { + @Override + public void onContainerStopped(ContainerId containerId) { + LOG.info("Succeeded to stop container " + containerId); + } + + @Override + public void onContainerStatusReceived(ContainerId containerId, ContainerStatus containerStatus) { + LOG.info("Container Status: id =" + containerId + ", status =" + containerStatus); + } + + @Override + public void onContainerStarted(ContainerId containerId, Map allServiceResponse) { + LOG.info("Successfully started container " + containerId); + } + + @Override + public void onStartContainerError(ContainerId containerId, Throwable t) { + LOG.error("Failed to start container " + containerId); + } + + @Override + public void onGetContainerStatusError(ContainerId containerId, Throwable t) { + LOG.error("Failed to query the status of container " + containerId); + } + + @Override + public void onStopContainerError(ContainerId containerId, Throwable t) { + LOG.error("Failed to stop container " + containerId); + } + + @Override + public void onContainerResourceIncreased(ContainerId containerId, Resource resource) { } + + @Override + public void onContainerResourceUpdated(ContainerId containerId, Resource resource) { } + + @Override + public void onIncreaseContainerResourceError(ContainerId containerId, Throwable t) { } + + @Override + public void onUpdateContainerResourceError(ContainerId containerId, Throwable t) { } + + } + + private class RMCallbackHandler extends AMRMClientAsync.AbstractCallbackHandler { + @Override + public void onContainersCompleted(List completedContainers) { + LOG.info("Completed containers: " + completedContainers.size()); + for (ContainerStatus containerStatus : completedContainers) { + int exitStatus = containerStatus.getExitStatus(); + LOG.info("ContainerID = " + containerStatus.getContainerId() + + ", state = " + containerStatus.getState() + + ", exitStatus = " + exitStatus); + String diagnostics = containerStatus.getDiagnostics(); + if (ContainerExitStatus.SUCCESS != containerStatus.getExitStatus()) { + LOG.error(diagnostics); + } else { + LOG.info(diagnostics); + } + TFTask task = session.getTask(containerStatus.getContainerId()); + if (task != null) { + // Unregister task after completion.. + // Since in the case of asynchronous exec, containers might + // end at different times.. + LOG.info("Unregister task [" + task.getId() + "] from Heartbeat monitor.."); + hbMonitor.unregister(task); + if (containerStatusMap.containsKey(task)) { + containerStatusMap.put(task, true); + if (exitStatus != 0) { + LOG.info("Container failed, id = " + containerStatus.getContainerId()); + } else { + LOG.info("Container succeeded, id = " + containerStatus.getContainerId()); + } + } else { + LOG.info( + "Ignoring completion of container with id " + containerStatus.getContainerId().toString() + " (exit " + + "status = " + exitStatus + ") as it was not present in the container status map. This means the " + + "container was probably released and a new container requested."); + } + } else { + LOG.warn("No task found for container : [" + containerStatus.getContainerId() + "]!"); + } + } + } + + @Override + public void onContainersAllocated(List containers) { + LOG.info("Allocated: " + containers.size() + " containers."); + for (Container container : containers) { + LOG.info("Launching a task in container" + + ", containerId = " + container.getId() + + ", containerNode = " + container.getNodeId().getHost() + ":" + container.getNodeId().getPort() + + ", resourceRequest = " + container.getResource()); + new ContainerLauncher(container, containerListener).run(); + } + } + + @Override + public void onContainersUpdated(List containers) { + } + + @Override + public void onShutdownRequest() { } + + @Override + public void onNodesUpdated(List list) { + } + + @Override + public float getProgress() { + return (float) numCompletedWorkerTasks.get() / numTotalWorkerTasks; + } + + @Override + public void onError(Throwable throwable) { + LOG.info("Error " + throwable); + amRMClient.stop(); + } + } + + /** + * The command to prepare inside containers. + */ + private class ContainerLauncher implements Runnable { + Container container; + NMCallbackHandler containerListener; + + ContainerLauncher(Container container, NMCallbackHandler containerListener) { + this.container = container; + this.containerListener = containerListener; + } + + /** + * Set up container's launch command and start the container. + */ + public void run() { + // Specify session id in the env to distinguish between different sessions. + containerEnv.put(Constants.SESSION_ID, String.valueOf(session.sessionId)); + Map containerShellEnv = new ConcurrentHashMap<>(containerEnv); + + TFTask task = session.getRemainingTask(container.getResource()); + + Preconditions.checkNotNull(task, "Task was null! Nothing to schedule."); + task.addContainer(container); + LOG.info("Setting Container [" + container.getId() + "] for task [" + task.getId() + "].."); + + // Add additional environment vars. + containerShellEnv.put(Constants.JOB_NAME, task.getJobName()); + containerShellEnv.put(Constants.TASK_INDEX, task.getJobIndex()); + containerShellEnv.put(Constants.TASK_NUM, String.valueOf(numTotalWorkerTasks)); + + List commands = new ArrayList<>(); + + List arguments = new ArrayList<>(5); + arguments.add(session.getTaskCommand()); + arguments.add("1>" + ApplicationConstants.LOG_DIR_EXPANSION_VAR + "/stdout"); + arguments.add("2>" + ApplicationConstants.LOG_DIR_EXPANSION_VAR + "/stderr"); + StringBuilder command = new StringBuilder(); + for (CharSequence str : arguments) { + command.append(str).append(" "); + } + commands.add(command.toString()); + + LOG.info("Constructed command: " + commands); + LOG.info("Container environment: " + containerShellEnv); + + + // Set logs to be readable by everyone. + Map acls = new HashMap<>(2); + acls.put(ApplicationAccessType.VIEW_APP, "*"); + acls.put(ApplicationAccessType.MODIFY_APP, " "); + + ByteBuffer tokens = null; + if (!insecureMode) { + tokens = allTokens.duplicate(); + } + ContainerLaunchContext ctx = ContainerLaunchContext.newInstance(new ConcurrentHashMap<>(localResources), + containerShellEnv, commands, null, tokens, acls); + + String sessionId = String.valueOf(session.sessionId); + sessionContainersMap.computeIfAbsent(sessionId, key -> + Collections.synchronizedList(new ArrayList<>()) + ).add(container); + containerStatusMap.put(session.getTask(container.getId()), false); + + Utils.printTaskUrl(task.getTaskUrl(), LOG); + nmClientAsync.startContainerAsync(container, ctx); + } + } + + private void onTaskDeemedDead(TFTask task) { + LOG.info("Task with id [" + task.getId() + "] has missed" + + " [" + maxConsecutiveHBMiss + "] heartbeats.. Ending application !!"); + // TODO: figure out what is the right thing to do here.. + // TODO: For the time being, we just kill the job.. + LOG.error("Task with id [" + task.getId() + "] deemed dead!!"); + taskHasMissesHB = true; + mainThread.interrupt(); + } +} diff --git a/tony-core/src/main/java/com/linkedin/tony/TonyClient.java b/tony-core/src/main/java/com/linkedin/tony/TonyClient.java new file mode 100644 index 00000000..1ae21b19 --- /dev/null +++ b/tony-core/src/main/java/com/linkedin/tony/TonyClient.java @@ -0,0 +1,738 @@ +/** + * Copyright 2018 LinkedIn Corporation. All rights reserved. Licensed under the BSD-2 Clause license. + * See LICENSE in the project root for license information. + */ +package com.linkedin.tony; + +import com.google.common.annotations.VisibleForTesting; +import com.linkedin.tony.rpc.TaskUrl; +import com.linkedin.tony.rpc.impl.ApplicationRpcClient; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.URI; +import java.net.URISyntaxException; +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import org.apache.commons.cli.CommandLine; +import org.apache.commons.cli.GnuParser; +import org.apache.commons.cli.HelpFormatter; +import org.apache.commons.cli.Options; +import org.apache.commons.cli.ParseException; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.apache.hadoop.conf.Configuration; +import org.apache.hadoop.fs.FileStatus; +import org.apache.hadoop.fs.FileSystem; +import org.apache.hadoop.fs.Path; +import org.apache.hadoop.fs.permission.FsPermission; +import org.apache.hadoop.hdfs.HdfsConfiguration; +import org.apache.hadoop.io.DataOutputBuffer; +import org.apache.hadoop.io.Text; +import org.apache.hadoop.net.NetUtils; +import org.apache.hadoop.security.Credentials; +import org.apache.hadoop.security.UserGroupInformation; +import org.apache.hadoop.security.token.Token; +import org.apache.hadoop.yarn.api.ApplicationConstants; +import org.apache.hadoop.yarn.api.protocolrecords.GetNewApplicationResponse; +import org.apache.hadoop.yarn.api.records.ApplicationAccessType; +import org.apache.hadoop.yarn.api.records.ApplicationId; +import org.apache.hadoop.yarn.api.records.ApplicationReport; +import org.apache.hadoop.yarn.api.records.ApplicationSubmissionContext; +import org.apache.hadoop.yarn.api.records.ContainerLaunchContext; +import org.apache.hadoop.yarn.api.records.FinalApplicationStatus; +import org.apache.hadoop.yarn.api.records.LocalResource; +import org.apache.hadoop.yarn.api.records.LocalResourceType; +import org.apache.hadoop.yarn.api.records.LocalResourceVisibility; +import org.apache.hadoop.yarn.api.records.Resource; +import org.apache.hadoop.yarn.api.records.YarnApplicationState; +import org.apache.hadoop.yarn.client.api.YarnClient; +import org.apache.hadoop.yarn.client.api.YarnClientApplication; +import org.apache.hadoop.yarn.client.util.YarnClientUtils; +import org.apache.hadoop.yarn.conf.YarnConfiguration; +import org.apache.hadoop.yarn.exceptions.YarnException; +import org.apache.hadoop.yarn.security.client.ClientToAMTokenIdentifier; +import org.apache.hadoop.yarn.util.ConverterUtils; +import org.apache.hadoop.yarn.util.Records; + + +/** + * User entry point to submit tensorflow job. + */ +public class TonyClient { + private static final Log LOG = LogFactory.getLog(TonyClient.class); + + private static final String APP_TYPE = "TENSORFLOW"; + private static final String RM_APP_URL_TEMPLATE = "http://%s/cluster/app/%s"; + private static final String HADOOP_CONF_DIR = ApplicationConstants.Environment.HADOOP_CONF_DIR.key(); + private static final String CORE_SITE_CONF = YarnConfiguration.CORE_SITE_CONFIGURATION_FILE; + private static final String HDFS_SITE_CONF = "hdfs-site.xml"; + + private YarnClient yarnClient; + private HdfsConfiguration hdfsConf = new HdfsConfiguration(); + private YarnConfiguration yarnConf = new YarnConfiguration(); + private Options opts; + + private String amHost; + private int amRpcPort; + private boolean amRpcServerInitialized = false; + private ApplicationRpcClient amRpcClient; + private boolean hasPrintedTaskUrls = false; + + private String hdfsConfAddress = null; + private String yarnConfAddress = null; + private long amMemory; + private int amVCores; + private int amGpus; + private String hdfsClasspath = null; + private String taskParams = null; + private String pythonBinaryPath = null; + private String pythonVenv = ""; + private String executes; + private long appTimeout; + private boolean insecureMode; + private String srcDir; + private Map shellEnv = new HashMap<>(); + private Map containerEnv = new HashMap<>(); + private static final String ARCHIVE_PATH = "tf_archive.zip"; + private Configuration tonyConf; + private final long clientStartTime = System.currentTimeMillis(); + private Path appResourcesPath; + private int hbInterval; + private int maxHbMisses; + + public TonyClient() { + this(new Configuration(false)); + } + + public TonyClient(Configuration conf) { + initOptions(); + tonyConf = conf; + } + + boolean run() throws IOException, InterruptedException, URISyntaxException, YarnException { + LOG.info("Starting client.."); + yarnClient.start(); + + YarnClientApplication app = yarnClient.createApplication(); + GetNewApplicationResponse appResponse = app.getNewApplicationResponse(); + + long maxMem = appResponse.getMaximumResourceCapability().getMemorySize(); + + // Truncate resource request to cluster's max resource capability. + if (amMemory > maxMem) { + LOG.warn("Truncating requested AM memory: " + amMemory + " to cluster's max: " + maxMem); + amMemory = maxMem; + } + int maxVCores = appResponse.getMaximumResourceCapability().getVirtualCores(); + + if (amVCores > maxVCores) { + LOG.warn("Truncating requested AM vcores: " + amVCores + " to cluster's max: " + maxVCores); + amVCores = maxVCores; + } + + zipArchive(); + + ApplicationSubmissionContext appContext = app.getApplicationSubmissionContext(); + ApplicationId appId = appContext.getApplicationId(); + + String appName = tonyConf.get(TonyConfigurationKeys.APPLICATION_NAME, + TonyConfigurationKeys.DEFAULT_APPLICATION_NAME); + appContext.setApplicationName(appName); + appContext.setApplicationType(APP_TYPE); + + // Set up resource type requirements + Resource capability = Resource.newInstance(amMemory, amVCores); + Utils.setCapabilityGPU(capability, amGpus); + appContext.setResource(capability); + + // Set the queue to which this application is to be submitted in the RM + String yarnQueue = tonyConf.get(TonyConfigurationKeys.YARN_QUEUE_NAME, + TonyConfigurationKeys.DEFAULT_YARN_QUEUE_NAME); + appContext.setQueue(yarnQueue); + + // Set the ContainerLaunchContext to describe the Container ith which the TonyApplicationMaster is launched. + ContainerLaunchContext amSpec = + createAMContainerSpec(appId, appName, + this.amMemory, this.taskParams, + this.pythonBinaryPath, this.pythonVenv, this.executes, getTokens(), + this.hdfsClasspath); + appContext.setAMContainerSpec(amSpec); + String nodeLabel = tonyConf.get(TonyConfigurationKeys.APPLICATION_NODE_LABEL); + if (nodeLabel != null) { + appContext.setNodeLabelExpression(nodeLabel); + } + LOG.info("Submitting YARN application"); + yarnClient.submitApplication(appContext); + ApplicationReport report = yarnClient.getApplicationReport(appId); + logTrackingAndRMUrls(report); + return monitorApplication(appId); + } + + private void logTrackingAndRMUrls(ApplicationReport report) { + LOG.info("URL to track running application (will proxy to TensorBoard once it has started): " + + report.getTrackingUrl()); + LOG.info("ResourceManager web address for application: " + + String.format(RM_APP_URL_TEMPLATE, + yarnConf.get(YarnConfiguration.RM_WEBAPP_ADDRESS), + report.getApplicationId())); + } + + @VisibleForTesting + void createYarnClient() { + if (this.yarnConfAddress != null) { + this.yarnConf.addResource(new Path(this.yarnConfAddress)); + } + if (this.hdfsConfAddress != null) { + this.hdfsConf.addResource(new Path(this.hdfsConfAddress)); + } + yarnConf.setLong( + YarnConfiguration.RESOURCEMANAGER_CONNECT_MAX_WAIT_MS, + 1000); + + if (System.getenv("HADOOP_CONF_DIR") != null) { + hdfsConf.addResource(new Path(System.getenv(HADOOP_CONF_DIR) + File.separatorChar + CORE_SITE_CONF)); + yarnConf.addResource(new Path(System.getenv(HADOOP_CONF_DIR) + File.separatorChar + CORE_SITE_CONF)); + hdfsConf.addResource(new Path(System.getenv(HADOOP_CONF_DIR) + File.separatorChar + HDFS_SITE_CONF)); + } + yarnClient = YarnClient.createYarnClient(); + yarnClient.init(yarnConf); + } + + private void initOptions() { + opts = Utils.getCommonOptions(); + opts.addOption("conf", true, "User specified configuration, as key=val pairs"); + opts.addOption("conf_file", true, "Name of user specified conf file, on the classpath"); + opts.addOption("src_dir", true, "Name of directory of source files."); + opts.addOption("help", false, "Print usage"); + } + + private void printUsage() { + new HelpFormatter().printHelp("TonyClient", opts); + } + + private boolean init(String[] args) throws ParseException { + CommandLine cliParser = new GnuParser().parse(opts, args, true); + if (args.length == 0) { + throw new IllegalArgumentException("No args specified for client to initialize"); + } + + if (cliParser.hasOption("help")) { + printUsage(); + return false; + } + + tonyConf.addResource(Constants.TONY_DEFAULT_XML); + if (cliParser.hasOption("conf_file")) { + tonyConf.addResource(cliParser.getOptionValue("conf_file")); + } else { + tonyConf.addResource(Constants.TONY_XML); + } + if (cliParser.hasOption("conf")) { + String[] confs = cliParser.getOptionValues("conf"); + for (Map.Entry cliConf : Utils.parseKeyValue(confs).entrySet()) { + tonyConf.set(cliConf.getKey(), cliConf.getValue()); + } + } + // Write user's overridden conf to an xml to be localized. + try (OutputStream os = new FileOutputStream(Constants.TONY_FINAL_XML)) { + tonyConf.writeXml(os); + } catch (IOException e) { + throw new RuntimeException("Failed to create " + Constants.TONY_FINAL_XML + " conf file. Exiting.", e); + } + + String amMemoryString = tonyConf.get(TonyConfigurationKeys.AM_MEMORY, + TonyConfigurationKeys.DEFAULT_AM_MEMORY); + amMemory = Integer.parseInt(Utils.parseMemoryString(amMemoryString)); + amVCores = tonyConf.getInt(TonyConfigurationKeys.AM_VCORES, + TonyConfigurationKeys.DEFAULT_AM_VCORES); + amGpus = tonyConf.getInt(TonyConfigurationKeys.AM_GPUS, + TonyConfigurationKeys.DEFAULT_AM_GPUS); + insecureMode = tonyConf.getBoolean(TonyConfigurationKeys.IS_INSECURE_MODE, + TonyConfigurationKeys.DEFAULT_IS_INSECURE_MODE); + hbInterval = tonyConf.getInt(TonyConfigurationKeys.TASK_HEARTBEAT_INTERVAL_MS, + TonyConfigurationKeys.DEFAULT_TASK_HEARTBEAT_INTERVAL_MS); + maxHbMisses = tonyConf.getInt(TonyConfigurationKeys.TASK_MAX_MISSED_HEARTBEATS, + TonyConfigurationKeys.DEFAULT_TASK_MAX_MISSED_HEARTBEATS); + + LOG.info("TonY heartbeat interval [" + hbInterval + "]"); + LOG.info("TonY max heartbeat misses allowed [" + maxHbMisses + "]"); + + yarnConfAddress = tonyConf.get(TonyConfigurationKeys.YARN_CONF_LOCATION); + hdfsConfAddress = tonyConf.get(TonyConfigurationKeys.HDFS_CONF_LOCATION); + taskParams = cliParser.getOptionValue("task_params"); + pythonBinaryPath = cliParser.getOptionValue("python_binary_path"); + pythonVenv = cliParser.getOptionValue("python_venv"); + executes = cliParser.getOptionValue("executes"); + srcDir = cliParser.getOptionValue("src_dir", "src"); + + if (amMemory < 0) { + throw new IllegalArgumentException("Invalid memory specified for application master, exiting." + + " Specified memory=" + amMemory); + } + if (amVCores < 0) { + throw new IllegalArgumentException("Invalid virtual cores specified for application master, exiting." + + " Specified virtual cores=" + amVCores); + } + + hdfsClasspath = cliParser.getOptionValue("hdfs_classpath"); + + String psMemoryString = tonyConf.get(TonyConfigurationKeys.PS_MEMORY, + TonyConfigurationKeys.DEFAULT_PS_MEMORY); + long psMemory = Long.parseLong(Utils.parseMemoryString(psMemoryString)); + int psVCores = tonyConf.getInt(TonyConfigurationKeys.PS_VCORES, + TonyConfigurationKeys.DEFAULT_PS_VCORES); + String workerMemoryString = tonyConf.get(TonyConfigurationKeys.WORKER_MEMORY, + TonyConfigurationKeys.DEFAULT_WORKER_MEMORY); + long workerMemory = Long.parseLong(Utils.parseMemoryString(workerMemoryString)); + int workerVCores = tonyConf.getInt(TonyConfigurationKeys.WORKER_VCORES, + TonyConfigurationKeys.DEFAULT_WORKER_VCORES); + + int numPs = tonyConf.getInt(TonyConfigurationKeys.PS_INSTANCES, + TonyConfigurationKeys.DEFAULT_PS_INSTANCES); + int numWorkers = tonyConf.getInt(TonyConfigurationKeys.WORKER_INSTANCES, + TonyConfigurationKeys.DEFAULT_WORKER_INSTANCES); + + if (psMemory < 0 || psVCores < 0 || workerMemory < 0 || workerVCores < 0) { + throw new IllegalArgumentException("Invalid container memory/vcores specified," + + " exiting." + + " Specified psMemory=" + psMemory + + ", psVCores=" + psVCores + + ", workerMemory=" + workerMemory + + ", workerVCores=" + workerVCores); + } + + boolean singleNode = tonyConf.getBoolean(TonyConfigurationKeys.IS_SINGLE_NODE, + TonyConfigurationKeys.DEFAULT_IS_SINGLE_NODE); + if (!singleNode) { + if (numPs < 1 || numWorkers < 1) { + throw new IllegalArgumentException( + "Cannot request non-positive ps or worker instances. requested numPs=" + numPs + + ", requested numWorkers=" + numWorkers); + } + if (amGpus > 0) { + LOG.warn("It seems you reserved " + amGpus + " GPUs in application master (driver, which doesn't perform training) during distributed training."); + } + } + + appTimeout = tonyConf.getInt(TonyConfigurationKeys.APPLICATION_TIMEOUT, + TonyConfigurationKeys.DEFAULT_APPLICATION_TIMEOUT); + + if (cliParser.hasOption("shell_env")) { + String[] envs = cliParser.getOptionValues("shell_env"); + shellEnv.putAll(Utils.parseKeyValue(envs)); + } + + if (cliParser.hasOption("container_env")) { + String[] containerEnvs = cliParser.getOptionValues("container_env"); + containerEnv.putAll(Utils.parseKeyValue(containerEnvs)); + } + return true; + } + + public ContainerLaunchContext createAMContainerSpec(ApplicationId appId, String appName, + long amMemory, + String taskParams, String pythonBinaryPath, + String pythonVenv, String executes, ByteBuffer tokens, + String hdfsClasspathDir) throws IOException { + ContainerLaunchContext amContainer = Records.newRecord(ContainerLaunchContext.class); + + FileSystem homeFS = FileSystem.get(hdfsConf); + appResourcesPath = new Path(homeFS.getHomeDirectory(), Constants.TONY_FOLDER + Path.SEPARATOR + appId.toString()); + Map localResources = new HashMap<>(); + addLocalResources(homeFS, ARCHIVE_PATH, LocalResourceType.ARCHIVE, Constants.TF_ZIP_NAME, localResources); + addLocalResources(homeFS, Constants.TONY_FINAL_XML, LocalResourceType.FILE, Constants.TONY_XML, localResources); + if (hdfsClasspathDir != null) { + try { + FileSystem remoteFS = FileSystem.get(new URI(hdfsClasspathDir), hdfsConf); + FileStatus[] ls = remoteFS.listStatus(new Path(hdfsClasspathDir)); + for (FileStatus jar : ls) { + LocalResource resource = + LocalResource.newInstance( + ConverterUtils.getYarnUrlFromURI(URI.create(jar.getPath().toString())), + LocalResourceType.FILE, LocalResourceVisibility.PRIVATE, + jar.getLen(), jar.getModificationTime()); + + localResources.put(jar.getPath().getName(), resource); + } + } catch (Exception e) { + throw new RuntimeException(e); + } + } + setAMEnvironment(localResources, homeFS); + + // Update absolute path with relative path + if (hdfsConfAddress != null) { + if (hdfsConfAddress.charAt(0) == '/') { + String hc = hdfsConfAddress.substring(1); + containerEnv.put(Constants.HDFS_CONF_PATH, hc); + } else { + containerEnv.put(Constants.HDFS_CONF_PATH, hdfsConfAddress); + } + } + if (yarnConfAddress != null) { + if (yarnConfAddress.charAt(0) == '/') { + String yc = yarnConfAddress.substring(1); + containerEnv.put(Constants.YARN_CONF_PATH, yc); + } else { + containerEnv.put(Constants.YARN_CONF_PATH, yarnConfAddress); + } + } + + // Set logs to be readable by everyone. Set app to be modifiable only by app owner. + Map acls = new HashMap<>(2); + acls.put(ApplicationAccessType.VIEW_APP, "*"); + acls.put(ApplicationAccessType.MODIFY_APP, " "); + amContainer.setApplicationACLs(acls); + + List arguments = new ArrayList<>(30); + arguments.add(ApplicationConstants.Environment.JAVA_HOME.$$() + "/bin/java"); + // Set Xmx based on am memory size + arguments.add("-Xmx" + (int) (amMemory * 0.8f) + "m"); + // Add configuration for log dir to retrieve log output from python subprocess in AM + arguments.add("-D" + YarnConfiguration.YARN_APP_CONTAINER_LOG_DIR + "=" + + ApplicationConstants.LOG_DIR_EXPANSION_VAR); + // Set class name + arguments.add("com.linkedin.tony.TonyApplicationMaster"); + + if (taskParams != null) { + arguments.add("--task_params " + "'" + String.valueOf(taskParams) + "'"); + } + if (pythonBinaryPath != null) { + arguments.add("--python_binary_path " + String.valueOf(pythonBinaryPath)); + } + if (pythonVenv != null) { + arguments.add("--python_venv " + String.valueOf(pythonVenv)); + } + if (executes != null) { + arguments.add("--executes " + String.valueOf(executes)); + } + if (hdfsClasspath != null) { + arguments.add("--hdfs_classpath " + String.valueOf(hdfsClasspath)); + } + for (Map.Entry entry : shellEnv.entrySet()) { + arguments.add("--shell_env " + entry.getKey() + "=" + entry.getValue()); + } + for (Map.Entry entry : containerEnv.entrySet()) { + arguments.add("--container_env " + entry.getKey() + "=" + entry.getValue()); + } + arguments.add("1>" + ApplicationConstants.LOG_DIR_EXPANSION_VAR + File.separatorChar + Constants.AM_STDOUT_FILENAME); + arguments.add("2>" + ApplicationConstants.LOG_DIR_EXPANSION_VAR + File.separatorChar + Constants.AM_STDERR_FILENAME); + + StringBuilder command = new StringBuilder(); + for (CharSequence str : arguments) { + command.append(str).append(" "); + } + + LOG.info("Completed setting up app master command " + command.toString()); + List commands = new ArrayList<>(); + commands.add(command.toString()); + amContainer.setCommands(commands); + if (tokens != null) { + amContainer.setTokens(tokens); + } + amContainer.setEnvironment(containerEnv); + amContainer.setLocalResources(localResources); + + return amContainer; + } + + /** + * Create zip archive required by TonY to distribute python code and virtual environment + */ + private void zipArchive() throws IOException { + FileOutputStream fos = new FileOutputStream(ARCHIVE_PATH); + ZipOutputStream zos = new ZipOutputStream(fos); + addDirToZip(zos, srcDir); + if (hdfsConfAddress != null) { + addFileToZip(zos, hdfsConfAddress); + } + if (yarnConfAddress != null) { + addFileToZip(zos, yarnConfAddress); + } + if (pythonVenv != null) { + addFileToZip(zos, pythonVenv); + } + zos.close(); + } + + private static void addDirToZip(ZipOutputStream zos, String dirName) throws IOException { + File f = new File(dirName); + if (!f.isDirectory()) { + throw new IOException(dirName + " is not a valid directory."); + } + for (File content : f.listFiles()) { + if (content.isDirectory()) { + addDirToZip(zos, content.getPath()); + } else { + addFileToZip(zos, content.getPath()); + } + } + } + + private static void addFileToZip(ZipOutputStream zos, String filePath) throws IOException { + zos.putNextEntry(new ZipEntry(filePath)); + byte[] buf = new byte[2048]; + + try (FileInputStream fos = new FileInputStream(new File(filePath))) { + int readBytes; + while ((readBytes = fos.read(buf)) > 0) { + zos.write(buf, 0, readBytes); + } + } + zos.closeEntry(); + } + + /** + * Add a local resource to HDFS and local resources map. + * @param fs HDFS file system reference + * @param resourceType the type of the src file + * @param dstPath name of the resource after localization + * @param localResources the local resources map + * @throws IOException error when writing to HDFS + */ + private void addLocalResources(FileSystem fs, String srcPath, LocalResourceType resourceType, + String dstPath, Map localResources) throws IOException { + Path dst = new Path(appResourcesPath, dstPath); + fs.copyFromLocalFile(new Path(srcPath), dst); + fs.setPermission(dst, new FsPermission((short) 0770)); + FileStatus scFileStatus = fs.getFileStatus(dst); + LocalResource scRsrc = + LocalResource.newInstance( + ConverterUtils.getYarnUrlFromURI(dst.toUri()), + resourceType, LocalResourceVisibility.PRIVATE, + scFileStatus.getLen(), scFileStatus.getModificationTime()); + localResources.put(dstPath, scRsrc); + } + + private void setAMEnvironment(Map localResources, + FileSystem fs) throws IOException { + LocalResource zipResource = localResources.get(Constants.TF_ZIP_NAME); + Utils.addEnvironmentForResource(zipResource, fs, Constants.TF_ZIP_PREFIX, containerEnv); + + LocalResource tonyConfResource = localResources.get(Constants.TONY_XML); + Utils.addEnvironmentForResource(tonyConfResource, fs, Constants.TONY_CONF_PREFIX, containerEnv); + + // Add AppMaster.jar location to classpath + // At some point we should not be required to add + // the hadoop specific classpaths to the env. + // It should be provided out of the box. + // For now setting all required classpaths including + // the classpath to "." for the application jar + StringBuilder classPathEnv = new StringBuilder(ApplicationConstants.Environment.CLASSPATH.$$()) + .append(ApplicationConstants.CLASS_PATH_SEPARATOR).append("./*"); + for (String c : yarnConf.getStrings( + YarnConfiguration.YARN_APPLICATION_CLASSPATH, + YarnConfiguration.DEFAULT_YARN_CROSS_PLATFORM_APPLICATION_CLASSPATH)) { + classPathEnv.append(ApplicationConstants.CLASS_PATH_SEPARATOR); + classPathEnv.append(c.trim()); + } + containerEnv.put("CLASSPATH", classPathEnv.toString()); + } + + // Set up delegation token + private ByteBuffer getTokens() throws IOException, URISyntaxException, YarnException { + if (this.insecureMode) { + return null; + } + Credentials cred = new Credentials(); + String fileLocation = System.getenv(UserGroupInformation.HADOOP_TOKEN_FILE_LOCATION); + if (fileLocation != null) { + cred = Credentials.readTokenStorageFile(new File(fileLocation), hdfsConf); + } else { + // Tokens have not been pre-written. We need to grab the tokens ourselves. + String tokenRenewer = YarnClientUtils.getRmPrincipal(yarnConf); + final Token rmToken = ConverterUtils.convertFromYarn(yarnClient.getRMDelegationToken(new Text(tokenRenewer)), + yarnConf.getSocketAddr(YarnConfiguration.RM_ADDRESS, + YarnConfiguration.DEFAULT_RM_ADDRESS, + YarnConfiguration.DEFAULT_RM_PORT)); + FileSystem fs = FileSystem.get(hdfsConf); + final Token fsToken = fs.getDelegationToken(tokenRenewer); + if (fsToken == null) { + throw new RuntimeException("Failed to get FS delegation token for default FS."); + } + cred.addToken(rmToken.getService(), rmToken); + cred.addToken(fsToken.getService(), fsToken); + String[] otherNamenodes = tonyConf.getStrings(TonyConfigurationKeys.OTHER_NAMENODES_TO_ACCESS); + if (otherNamenodes != null) { + for (String nnUri : otherNamenodes) { + String namenodeUri = nnUri.trim(); + FileSystem otherFS = FileSystem.get(new URI(namenodeUri), hdfsConf); + final Token otherFSToken = otherFS.getDelegationToken(tokenRenewer); + if (otherFSToken == null) { + throw new RuntimeException("Failed to get FS delegation token for configured " + + "other namenode: " + namenodeUri); + } + cred.addToken(otherFSToken.getService(), otherFSToken); + } + } + } + + LOG.info("Successfully fetched tokens."); + DataOutputBuffer buffer = new DataOutputBuffer(); + cred.writeTokenStorageToStream(buffer); + return ByteBuffer.wrap(buffer.getData(), 0, buffer.getLength()); + } + + /** + * Monitor the submitted application for completion. + * Kill application if time expires. + * @param appId Application Id of application to be monitored + * @return true if application completed successfully + * @throws org.apache.hadoop.yarn.exceptions.YarnException + * @throws java.io.IOException + */ + private boolean monitorApplication(ApplicationId appId) + throws YarnException, IOException, InterruptedException { + + while (true) { + // Check app status every 1 second. + Thread.sleep(1000); + + // Get application report for the appId we are interested in + ApplicationReport report = yarnClient.getApplicationReport(appId); + + YarnApplicationState state = report.getYarnApplicationState(); + FinalApplicationStatus dsStatus = report.getFinalApplicationStatus(); + initRpcClient(report); + printTaskUrls(); + if (YarnApplicationState.FINISHED == state) { + if (FinalApplicationStatus.SUCCEEDED == dsStatus) { + LOG.info("Application has completed successfully. " + + " Breaking monitoring loop : ApplicationId:" + appId.getId()); + return true; + } else { + LOG.info("Application finished unsuccessfully." + + " YarnState=" + state.toString() + ", DSFinalStatus=" + dsStatus.toString() + + ". Breaking monitoring loop : ApplicationId:" + appId.getId()); + return false; + } + } else if (YarnApplicationState.KILLED == state + || YarnApplicationState.FAILED == state) { + LOG.info("Application did not finish." + + " YarnState=" + state.toString() + ", DSFinalStatus=" + dsStatus.toString() + + ". Breaking monitoring loop : ApplicationId:" + appId.getId()); + return false; + } + + if (appTimeout > 0) { + if (System.currentTimeMillis() > (clientStartTime + appTimeout)) { + LOG.info("Reached client specified timeout for application. Killing application" + + ". Breaking monitoring loop : ApplicationId:" + appId.getId()); + forceKillApplication(appId); + return false; + } + } + } + } + + private void initRpcClient(ApplicationReport report) throws IOException { + if (!amRpcServerInitialized && report.getRpcPort() != -1) { + amRpcPort = report.getRpcPort(); + amHost = report.getHost(); + LOG.info("AM host: " + report.getHost()); + LOG.info("AM RPC port: " + report.getRpcPort()); + + addClientToAMTokenToUGI(report); + amRpcClient = ApplicationRpcClient.getInstance(amHost, amRpcPort, yarnConf); + amRpcServerInitialized = true; + } + } + + private void addClientToAMTokenToUGI(ApplicationReport report) throws IOException { + InetSocketAddress serviceAddr = NetUtils.createSocketAddrForHost(report.getHost(), report.getRpcPort()); + if (UserGroupInformation.isSecurityEnabled()) { + org.apache.hadoop.yarn.api.records.Token clientToAMToken = report.getClientToAMToken(); + Token token = ConverterUtils.convertFromYarn(clientToAMToken, serviceAddr); + UserGroupInformation.getCurrentUser().addToken(token); + } + } + + private void printTaskUrls() throws IOException, YarnException { + if (amRpcServerInitialized && !hasPrintedTaskUrls) { + Set taskUrls = amRpcClient.getTaskUrls(); + if (!taskUrls.isEmpty()) { + new TreeSet(taskUrls).forEach(task -> Utils.printTaskUrl(task, LOG)); + hasPrintedTaskUrls = true; + } + } + } + + + /** + * Kill a submitted application by sending a call to the ASM + * @param appId Application Id to be killed. + * @throws org.apache.hadoop.yarn.exceptions.YarnException + * @throws java.io.IOException + */ + private void forceKillApplication(ApplicationId appId) + throws YarnException, IOException { + yarnClient.killApplication(appId); + + } + + /** + * Clean up temporary files. + */ + private void cleanUp() { + try { + if (amRpcClient != null) { + amRpcClient.finishApplication(); + } + FileSystem fs = FileSystem.get(hdfsConf); + if (appResourcesPath != null && fs.exists(appResourcesPath)) { + fs.delete(appResourcesPath, true); + } + } catch (IOException | YarnException e) { + LOG.error("Failed to clean up temporary files :" + appResourcesPath + e); + } + } + + public static int start(String[] args) { + return start(args, new Configuration(false)); + } + + @VisibleForTesting + public static int start(String[] args, Configuration conf) { + boolean result = false; + TonyClient client = null; + try { + client = new TonyClient(conf); + boolean sanityCheck = client.init(args); + client.createYarnClient(); + if (!sanityCheck) { + LOG.fatal("Failed to init client."); + System.exit(-1); + } + result = client.run(); + } catch (Exception e) { + LOG.fatal("Failed to run TonyClient", e); + } finally { + if (client != null) { + client.cleanUp(); + } + } + if (result) { + LOG.info("Application completed successfully"); + return 0; + } + LOG.error("Application failed to complete successfully"); + return -1; + } + + public static void main(String[] args) { + int exitCode = start(args); + System.exit(exitCode); + } +} diff --git a/tony-core/src/main/java/com/linkedin/tony/TonyConfigurationKeys.java b/tony-core/src/main/java/com/linkedin/tony/TonyConfigurationKeys.java new file mode 100644 index 00000000..52fec913 --- /dev/null +++ b/tony-core/src/main/java/com/linkedin/tony/TonyConfigurationKeys.java @@ -0,0 +1,108 @@ +/** + * Copyright 2018 LinkedIn Corporation. All rights reserved. Licensed under the BSD-2 Clause license. + * See LICENSE in the project root for license information. + */ +package com.linkedin.tony; + +public class TonyConfigurationKeys { + + private TonyConfigurationKeys() { + + } + + public static final String TONY_PREFIX = "tony."; + + public static final String OTHER_NAMENODES_TO_ACCESS = TONY_PREFIX + "other.namenodes"; + + // Application configurations + public static final String YARN_QUEUE_NAME = TONY_PREFIX + "yarn.queue"; + public static final String DEFAULT_YARN_QUEUE_NAME = "default"; + + public static final String TONY_APPLICATION_PREFIX = TONY_PREFIX + "application."; + + public static final String APPLICATION_NAME = TONY_APPLICATION_PREFIX + "name"; + public static final String DEFAULT_APPLICATION_NAME = "TensorFlowApplication"; + + public static final String APPLICATION_NODE_LABEL = TONY_APPLICATION_PREFIX + "node-label"; + + public static final String IS_SINGLE_NODE = TONY_APPLICATION_PREFIX + "single-node"; + public static final boolean DEFAULT_IS_SINGLE_NODE = false; + + public static final String ENABLE_PREPROCESSING_JOB = TONY_APPLICATION_PREFIX + "enable-preprocess"; + public static final boolean DEFAULT_ENABLE_PREPROCESSING_JOB = false; + + public static final String APPLICATION_TIMEOUT = TONY_APPLICATION_PREFIX + "timeout"; + public static final int DEFAULT_APPLICATION_TIMEOUT = 0; + + // Task configurations + public static final String TONY_TASK_PREFIX = TONY_PREFIX + "task."; + + public static final String TASK_EXECUTOR_JVM_OPTS = TONY_TASK_PREFIX + "executor.jvm.opts"; + public static final String DEFAULT_TASK_EXECUTOR_JVM_OPTS = "-Xmx1536m"; + + public static final String TASK_REGISTRATION_TIMEOUT_SEC = TONY_TASK_PREFIX + "registration-timeout-sec"; + public static final int DEFAULT_TASK_REGISTRATION_TIMEOUT_SEC = 300; + + public static final String TASK_REGISTRATION_RETRY_COUNT = TONY_TASK_PREFIX + "registration-retry-count"; + public static final int DEFAULT_TASK_REGISTRATION_RETRY_COUNT = 0; + + public static final String TASK_HEARTBEAT_INTERVAL_MS = TONY_TASK_PREFIX + "heartbeat-interval"; + public static final int DEFAULT_TASK_HEARTBEAT_INTERVAL_MS = 1000; + + public static final String TASK_MAX_MISSED_HEARTBEATS = TONY_TASK_PREFIX + "max-missed-heartbeats"; + public static final int DEFAULT_TASK_MAX_MISSED_HEARTBEATS = 25; + + // AM configurations + public static final String AM_PREFIX = TONY_PREFIX + "am."; + + public static final String AM_RETRY_COUNT = AM_PREFIX + "retry-count"; + public static final int DEFAULT_AM_RETRY_COUNT = 0; + + public static final String AM_MEMORY = AM_PREFIX + "memory"; + public static final String DEFAULT_AM_MEMORY = "2g"; + + public static final String AM_VCORES = AM_PREFIX + "vcores"; + public static final int DEFAULT_AM_VCORES = 1; + + public static final String AM_GPUS = AM_PREFIX + "gpus"; + public static final int DEFAULT_AM_GPUS = 0; + + // PS configurations + public static final String PS_PREFIX = TONY_PREFIX + "ps."; + + public static final String PS_MEMORY = PS_PREFIX + "memory"; + public static final String DEFAULT_PS_MEMORY = "2g"; + + public static final String PS_VCORES = PS_PREFIX + "vcores"; + public static final int DEFAULT_PS_VCORES = 1; + + public static final String PS_INSTANCES = PS_PREFIX + "instances"; + public static final int DEFAULT_PS_INSTANCES = 1; + + // Worker configurations + public static final String WORKER_PREFIX = TONY_PREFIX + "worker."; + + public static final String WORKER_TIMEOUT = WORKER_PREFIX + "timeout"; + public static final int DEFAULT_WORKER_TIMEOUT = 0; + + public static final String WORKER_MEMORY = WORKER_PREFIX + "memory"; + public static final String DEFAULT_WORKER_MEMORY = "2g"; + + public static final String WORKER_VCORES = WORKER_PREFIX + "vcores"; + public static final int DEFAULT_WORKER_VCORES = 1; + + public static final String WORKER_GPUS = WORKER_PREFIX + "gpus"; + public static final int DEFAULT_WORKER_GPUS = 0; + + public static final String WORKER_INSTANCES = WORKER_PREFIX + "instances"; + public static final int DEFAULT_WORKER_INSTANCES = 1; + + // Local testing configurations + public static final String IS_INSECURE_MODE = TONY_APPLICATION_PREFIX + "insecure-mode"; + public static final boolean DEFAULT_IS_INSECURE_MODE = false; + + public static final String HDFS_CONF_LOCATION = TONY_APPLICATION_PREFIX + "hdfs-conf-path"; + + public static final String YARN_CONF_LOCATION = TONY_APPLICATION_PREFIX + "yarn-conf-path"; + +} diff --git a/tony-core/src/main/java/com/linkedin/tony/Utils.java b/tony-core/src/main/java/com/linkedin/tony/Utils.java new file mode 100644 index 00000000..96449aaf --- /dev/null +++ b/tony-core/src/main/java/com/linkedin/tony/Utils.java @@ -0,0 +1,233 @@ +/** + * Copyright 2018 LinkedIn Corporation. All rights reserved. Licensed under the BSD-2 Clause license. + * See LICENSE in the project root for license information. + */ +package com.linkedin.tony; + +import com.linkedin.tony.rpc.TaskUrl; +import java.io.File; +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.Callable; +import java.util.concurrent.TimeUnit; +import net.lingala.zip4j.exception.ZipException; +import net.lingala.zip4j.core.ZipFile; +import org.apache.commons.cli.Options; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.apache.hadoop.fs.FileStatus; +import org.apache.hadoop.fs.FileSystem; +import org.apache.hadoop.fs.Path; +import org.apache.hadoop.security.UserGroupInformation; +import org.apache.hadoop.yarn.api.ApplicationConstants; +import org.apache.hadoop.yarn.api.records.Container; +import org.apache.hadoop.yarn.api.records.LocalResource; +import org.apache.hadoop.yarn.api.records.Resource; + +import static org.apache.hadoop.yarn.api.records.ResourceInformation.*; + + +public class Utils { + private static final Log LOG = LogFactory.getLog(Utils.class); + + private static final String WORKER_LOG_URL_TEMPLATE = "http://%s/node/containerlogs/%s/%s"; + + protected Utils() { } + + /** + * Poll a callable till it returns true or time out + * @param func a function that returns a boolean + * @param interval the interval we poll (in seconds). + * @param timeOut the timeout we will stop polling (in seconds). + * @return if the func returned true before timing out. + */ + public static boolean poll(Callable func, int interval, int timeOut) { + int remainingTime = timeOut; + assert remainingTime > 0; + try { + while (remainingTime >= 0) { + if (func.call()) { + LOG.info("Poll function finished within " + timeOut + " seconds"); + return true; + } + Thread.sleep(interval * 1000); + remainingTime -= interval; + } + } catch (Exception e) { + LOG.error("Polled function throws exception: " + e); + } + LOG.warn("Function didn't return true within " + timeOut + " seconds."); + return false; + } + + public static T pollTillNonNull(Callable func, int interval, int timeout) { + int remainingTime = timeout; + T ret; + assert remainingTime > 0; + try { + while (remainingTime >= 0) { + ret = func.call(); + if (ret != null) { + LOG.info("Poll function finished within " + timeout + " seconds"); + return ret; + } + Thread.sleep(interval * 1000); + remainingTime -= interval; + } + } catch (Exception e) { + LOG.error("Polled function throws exception: " + e); + } + LOG.warn("Function didn't return true within " + timeout + " seconds."); + return null; + } + + public static String parseMemoryString(String memory) { + memory = memory.toLowerCase(); + int m = memory.indexOf('m'); + int g = memory.indexOf('g'); + if (-1 != m) { + return memory.substring(0, m); + } + if (-1 != g) { + return String.valueOf(Integer.parseInt(memory.substring(0, g)) * 1024); + } + return memory; + } + + public static void unzipArchive(String src, String dst) throws IOException { + LOG.info("Unzipping " + src + " to destination " + dst); + try { + ZipFile zipFile = new ZipFile(src); + zipFile.extractAll(dst); + } catch (ZipException e) { + LOG.fatal("Failed to unzip " + src, e); + } + } + + public static void setCapabilityGPU(Resource resource, int gpuCount) { + // short-circuit when the GPU count is 0. + if (gpuCount <= 0) { + return; + } + resource.setResourceValue(GPU_URI, gpuCount); + } + + public static String constructContainerUrl(Container container) { + try { + return String.format(WORKER_LOG_URL_TEMPLATE, container.getNodeHttpAddress(), container.getId(), + UserGroupInformation.getCurrentUser().getShortUserName()); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + public static void printTaskUrl(TaskUrl taskUrl, Log log) { + log.info(String.format("Logs for %s %s at: %s", taskUrl.getName(), taskUrl.getIndex(), taskUrl.getUrl())); + } + + /** + * Parse a list of env key-value pairs like PATH=ABC to a map of key value entries. + * @param keyValues the input key value pairs + * @return a map contains the key value {"PATH": "ABC"} + */ + public static Map parseKeyValue(String[] keyValues) { + Map keyValue = new HashMap<>(); + if (keyValues == null) { + return keyValue; + } + for (String kv : keyValues) { + String trimmedKeyValue = kv.trim(); + int index = kv.indexOf('='); + if (index == -1) { + keyValue.put(trimmedKeyValue, ""); + continue; + } + String key = trimmedKeyValue.substring(0, index); + String val = ""; + if (index < (trimmedKeyValue.length() - 1)) { + val = trimmedKeyValue.substring(index + 1); + } + keyValue.put(key, val); + } + return keyValue; + } + + /** + * This function is used by TonyApplicationMaster and TonyClient to set up + * common command line arguments. + * @return Options that contains common options + */ + public static Options getCommonOptions() { + Options opts = new Options(); + + // Container environment + // examples for env set variables: --shell_env CLASSPATH=ABC --shell_ENV LD_LIBRARY_PATH=DEF + opts.addOption("shell_env", true, "Environment for shell script, specified as env_key=env_val pairs"); + opts.addOption("container_env", true, "Environment for the worker containers, specified as key=val pairs"); + opts.addOption("hdfs_classpath", true, "Path to jars on HDFS for workers."); + + // Execution + opts.addOption("task_params", true, "The task params to pass into python entry point."); + opts.addOption("executes", true, "The file to execute on workers."); + + // Python env + opts.addOption("python_binary_path", true, "The relative path to python binary."); + opts.addOption("python_venv", true, "The python virtual environment zip."); + + return opts; + } + + /** + * Execute a shell command. + * @param taskCommand the shell command to execute + * @param timeout the timeout to stop running the shell command + * @param env the environment for this shell command + * @return the exit code of the shell command + * @throws IOException + * @throws InterruptedException + */ + public static int executeShell(String taskCommand, long timeout, Map env) throws IOException, InterruptedException { + LOG.info("Executing command: " + taskCommand); + String executablePath = taskCommand.trim().split(" ")[0]; + File executable = new File(executablePath); + if (!executable.canExecute()) { + executable.setExecutable(true); + } + + // Used for running unit tests in build boxes without Hadoop environment. + if (System.getenv(Constants.SKIP_HADOOP_PATH) == null) { + taskCommand = Constants.HADOOP_CLASSPATH_COMMAND + taskCommand; + } + ProcessBuilder taskProcessBuilder = new ProcessBuilder("bash", "-c", taskCommand); + taskProcessBuilder.redirectError(ProcessBuilder.Redirect.INHERIT); + taskProcessBuilder.redirectOutput(ProcessBuilder.Redirect.INHERIT); + if (env != null) { + taskProcessBuilder.environment().putAll(env); + } + Process taskProcess = taskProcessBuilder.start(); + if (timeout > 0) { + taskProcess.waitFor(timeout, TimeUnit.MILLISECONDS); + } else { + taskProcess.waitFor(); + } + return taskProcess.exitValue(); + + } + + public static String getCurrentHostName() { + return System.getenv(ApplicationConstants.Environment.NM_HOST.name()); + } + + public static void addEnvironmentForResource(LocalResource resource, FileSystem fs, String envPrefix, + Map env) throws IOException { + Path resourcePath = new Path(fs.getHomeDirectory(), resource.getResource().getFile()); + FileStatus resourceStatus = fs.getFileStatus(resourcePath); + long resourceLength = resourceStatus.getLen(); + long resourceTimestamp = resourceStatus.getModificationTime(); + + env.put(envPrefix + Constants.PATH_SUFFIX, resourcePath.toString()); + env.put(envPrefix + Constants.LENGTH_SUFFIX, Long.toString(resourceLength)); + env.put(envPrefix + Constants.TIMESTAMP_SUFFIX, Long.toString(resourceTimestamp)); + } +} diff --git a/tony-core/src/main/java/com/linkedin/tony/io/HdfsAvroFileSplitReader.java b/tony-core/src/main/java/com/linkedin/tony/io/HdfsAvroFileSplitReader.java new file mode 100644 index 00000000..4d796dcc --- /dev/null +++ b/tony-core/src/main/java/com/linkedin/tony/io/HdfsAvroFileSplitReader.java @@ -0,0 +1,800 @@ +/** + * Copyright 2018 LinkedIn Corporation. All rights reserved. Licensed under the BSD-2 Clause license. + * See LICENSE in the project root for license information. + */ +package com.linkedin.tony.io; + +import com.google.common.annotations.VisibleForTesting; +import com.linkedin.tony.Utils; +import java.io.ByteArrayOutputStream; +import java.io.Closeable; +import java.io.File; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.ByteBuffer; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.LinkedList; +import java.util.List; +import java.util.Random; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.Condition; +import java.util.concurrent.locks.ReentrantLock; +import org.apache.avro.Schema; +import org.apache.avro.file.DataFileReader; +import org.apache.avro.file.DataFileWriter; +import org.apache.avro.file.FileReader; +import org.apache.avro.file.SeekableInput; +import org.apache.avro.generic.GenericData; +import org.apache.avro.generic.GenericDatumReader; +import org.apache.avro.generic.GenericDatumWriter; +import org.apache.avro.generic.GenericRecord; +import org.apache.avro.io.BinaryEncoder; +import org.apache.avro.io.DatumWriter; +import org.apache.avro.io.EncoderFactory; +import org.apache.hadoop.conf.Configuration; +import org.apache.hadoop.fs.FSDataInputStream; +import org.apache.hadoop.fs.FileStatus; +import org.apache.hadoop.fs.FileSystem; +import org.apache.hadoop.fs.Path; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + + +/** + * To read avro file which is stored on a remote hdfs uri. The reader allows + * taking an offset range (specified by start offset and length), then the + * reader will locate and *try to* read only the records in the given byte + * range. Note that it is not precisely the byte range because avro record must + * be complete. This call utilize the internal block of avro file itself. So + * no matter what offset is given, always complete avro block(s) will be + * returned. + * + * Some examples, if avro file itself has three blocks, offset 0-1000 and + * 1000-2000, 2000-3000. + * + * 1. Reading bytes 900 to 1500 will actually locate to the next block + * boundary after 900, which is at 1000. Then read up to the next boundary, + * so this actually reads offset 1000 - 2000. + * + * 2. Reading bytes 600 to 700 will effectively do nothing because locating to + * the block boundary 1000 will already be after the end offset of 700. + * + * 3. Reading bytes 500 to 2100 will similarly, actually read two blocks in + * range 1000 - 3000. + * + * This is also how MR handles avro input split. + * The block boundary is specific to each avro file and it's written so there + * is no universally best range. But as long as byte splits given to workers + * are non-overlapping and cover the entire range, there will be no double + * processing or missing. The load unbalance is no larger than one avro block + * anyway. + * + * One remote avro file corresponds to one reader instance on each task. + * There are two key calls that should called by the py4j client side. + * + * nextBatchBytes(batchSize) returns a list of avro records in bytes + * getSchemaJson() returns the schema of the avro file, in string + * close() close the stream and terminates the fetcher thread. + * + * + * This class can read multiple files. For given 4 files of size: + * 100, 200, 100, 400 + * with 4 readers in total. Since the total number of bytes is 800, the 2nd + * reader with id 1 will try to read the byte range 200 - 399. Effectively + * reading half the second file and the whole third file (again it will not be + * precisely these offsets due to avro block boundary). + * + * This class exposes APIs for python code to leverage through py4j. + * To use this class, python code first calls getSchemaJson() to get + * the schema of the avro file, in the format of a json string. This + * is needed for python code to decode data into original Avro record. + * + * Then python code can keep calling nextBatch to read through the + * entire dataset. + * + * This class currently provides three version of nextBatch call, + * they are different only in terms of underlying implementation. + * Thus coming with different pros and cons. Here we assume python + * uses fastavro for Avro decoding.: + * 1. nextBatchBytes(batchSize) returns records as a list of byte + * arrays. Each byte array represents one single avro records. + * This is the most intuitive and with smallest overhead, but + * also the slowest one. As python code needs decode record + * by record, which is slow if using fastavro or pyavro. + * 2. nextBatchFile(batchSize) returns one batch of records as + * a complete Avro file, the Avro file is only in memory and + * can be treated as reading all bytes of a complete Avro file + * from disk then the bytes get fed into python code. This + * improves performance compared to 1, as fastavro performs + * much better when processing a batch of records. But this + * requires a higher memory settings to hold the batch in-mem + * file. Transferring the batch through py4j also seems + * expensive. The returned in-memory Avro file is in the format + * of a FileObject instance. + * 3. nextBatchFileLocalSpill(batchSize) returns a path to a + * file in local filesystem, pointing to a file written to + * disk. The file is the Avro batch, in form of a complete + * Avro file. The only difference compared with 2 is that the file + * is transferred through local disk rather than py4j. The + * python code will need to find the disk location, read and + * process the Avro file. This is due to that even local file + * IO seems faster then using py4j here. So this approach + * is fastest as of now, and no longer requires much memory. + * But consuming disk space. Also NOTE this approach requires + * Python code to call notifyFinish(path) when it finishes + * processing one file. + */ +public class HdfsAvroFileSplitReader implements Closeable { + + private static final Log LOG = LogFactory.getLog(HdfsAvroFileSplitReader.class); + + /** + * TODO currently, A single fetcher thread, revisit adding more. + * The single fetcher thread will iterate all the given file sections. + */ + private final Thread fetcherThread; + private final DataFetcher fetcher; + private final Set localBufferFiles; + private boolean shouldStop; + + private final int maxBufferCapacity; + + /* If using local file as data transfer, one file + * will be created for one batch. This number controls the max + * number of such local batch files. This is to prevent those + * files from exploding local disk. + * + * Effectively this may not be needed because the training + * usually proceed with one thread, one batch at a time, + * in which case no more than one file should be needed. + */ + // TODO : make below more configurable + private static final int MAX_LOCAL_FILE_NUM = 50; + private static final int MAX_BUFFER_CAPACITY_DEFAULT = 1024; + private static final double POLL_THRESHOLD = 0.8; + /* + * Used by random shuffle only. The threshold + * to start reading data. e.g. if the buffer + * is 1000, and threshold is 0.8, then only + * when there are >= 1000*0.8 = 800 entries + * in the buffer will a random record be read. + * This guarantees a record is random among + * 800 records. + */ + private final double pollingThreshold; + + private final InternalBuffer buffer; + + class DataFetcher implements Runnable { + private Schema schema; + private final FileSystem fs; + private final List fileAccessInfos; + + // indicate ALL read is done. + boolean readFinished; + + DataFetcher(FileSystem fs, List fileAccessInfos) { + this.schema = null; + this.fileAccessInfos = fileAccessInfos; + this.fs = fs; + } + + @Override + public void run() { + ByteArrayOutputStream stream = new ByteArrayOutputStream(); + BinaryEncoder encoder = EncoderFactory.get() + .binaryEncoder(stream, null); + + try { + for (int i = 0; i < fileAccessInfos.size(); i++) { + FileAccessInfo info = fileAccessInfos.get(i); + + Path inputPath = new Path(info.filePath); + FSDataInputStream inputStream = fs.open(inputPath); + this.readFinished = false; + long startOffset = info.startOffset; + long readLength = info.readLength; + + SeekableInput seekableInput = new SeekableInput() { + @Override + public void seek(long offset) throws IOException { + inputStream.seek(offset); + } + + @Override + public long tell() throws IOException { + return inputStream.getPos(); + } + + @Override + public long length() { + return info.fileLength; + } + + @Override + public int read(byte[] bytes, int offset, int length) + throws IOException { + return inputStream.read(bytes, offset, length); + } + + @Override + public void close() throws IOException { + inputStream.close(); + } + }; + FileReader dataFileReader = DataFileReader.openReader( + seekableInput, new GenericDatumReader()); + // a quick sanity check. Schema of all input files should be the same + // although even if they don't this should still work. + if (this.schema != null + && !this.schema.equals(dataFileReader.getSchema())) { + LOG.warn("Input file have different schema"); + } + this.schema = dataFileReader.getSchema(); + dataFileReader.sync(startOffset); + + DatumWriter datumWriter = + new GenericDatumWriter<>(schema); + + while (!shouldStop) { + try { + if (dataFileReader.hasNext() && !dataFileReader.pastSync( + startOffset + readLength)) { + GenericData.Record currentRecord = + dataFileReader.next(null); + datumWriter.write(currentRecord, encoder); + encoder.flush(); + stream.reset(); + buffer.put(currentRecord); + } else { + LOG.info("Finished Processing a segment " + i + " remaining " + + (fileAccessInfos.size() - 1 - i)); + break; + } + } catch (IOException ioe) { + LOG.error("Error fetching avro file", ioe); + break; + } catch (InterruptedException ie) { + LOG.debug("Fetcher interrupted", ie); + } + } + } + LOG.info("Read finished"); + readFinished = true; + } catch (IOException ioe) { + LOG.error("Fetcher failed", ioe); + } finally { + try { + stream.close(); + } catch (IOException ioe) { + LOG.error("Error terminating fetcher", ioe); + } + } + } + } + + + @VisibleForTesting + public static long computeReadSplitStart(long totalLength, int idx, + int totalIdx) { + return idx * totalLength / totalIdx; + } + + @VisibleForTesting + public static long computeReadSplitLength(long totalLength, int idx, + int totalIdx) { + long nextStart = (idx + 1) * totalLength / totalIdx; + return Math.min(nextStart, totalLength) + - computeReadSplitStart(totalLength, idx, totalIdx); + } + + public HdfsAvroFileSplitReader(Configuration conf, List readPaths, + int splitId, int numOfReaders) throws IOException { + this(FileSystem.get(conf), readPaths, splitId, numOfReaders, false); + } + + @VisibleForTesting + public HdfsAvroFileSplitReader(Configuration conf, List readPaths, + int splitId, int numOfReaders, boolean useRandomShuffle) throws IOException { + this(FileSystem.get(conf), readPaths, splitId, numOfReaders, useRandomShuffle); + } + + public HdfsAvroFileSplitReader(Configuration conf, List readPaths, + int splitId, int numOfReaders, int maxBufferCapacity, boolean useRandomShuffle, + double pollingThreshold) throws IOException { + this(FileSystem.get(conf), readPaths, splitId, numOfReaders, + maxBufferCapacity, useRandomShuffle, pollingThreshold); + } + + HdfsAvroFileSplitReader(FileSystem fs, List readPaths, + int splitId, int numOfReaders, boolean useRandomShuffle) throws IOException { + this(fs, readPaths, splitId, numOfReaders, MAX_BUFFER_CAPACITY_DEFAULT, + useRandomShuffle, POLL_THRESHOLD); + } + + /** + * Create a HdfsAvroFileSplitReader instance. If useRandomShuffle is set to + * true, pollingThreshold will be used to control the level of randomness. + * For example, if max buffer capacity is 1000, polling threshold is set to + * 0.8, then a record read will hold until there are at least 1000 * 0.8 = 800 + * entries in the buffer, then a random one of the 800 will be chosen. This + * controls the level randomness to be always 1/800. (In the case there are + * not 800 records remaining in total, this won't take effect). + * + * @param fs the Hadoop FileSystem instance + * @param readPaths the list of file path to read. + * @param splitId the id of this split reader, relative to all the reader + * instance of the same input list. For example, if there are 10 + * instances across workers, each worker has id + * 0, 1, 2..., which must be given to the reader. + * @param numOfReaders the total number of readers for the input list. For + * example, if there are 10 instances across workers, each + * reader must be given the number 10. + * @param maxBufferCapacity the max buffer size. + * @param useRandomShuffle whether to enable random shuffle + * @param pollingThreshold the polling threshold, used along with random shuffle. + * + * @throws IOException + */ + public HdfsAvroFileSplitReader(FileSystem fs, List readPaths, + int splitId, int numOfReaders, int maxBufferCapacity, boolean useRandomShuffle, + double pollingThreshold) throws IOException { + this.shouldStop = false; + LOG.info("Using random shuffle? " + useRandomShuffle); + this.maxBufferCapacity = maxBufferCapacity; + this.pollingThreshold = pollingThreshold; + this.buffer = new InternalBuffer<>(useRandomShuffle, maxBufferCapacity); + this.localBufferFiles = ConcurrentHashMap.newKeySet(); + + long totalLength = 0; + List allFileLength = new ArrayList<>(); + + for (String readPath : readPaths) { + long fileLength = getFileLength(fs, readPath); + totalLength += fileLength; + allFileLength.add(fileLength); + } + + long startOffset = + computeReadSplitStart(totalLength, splitId, numOfReaders); + long readLength = + computeReadSplitLength(totalLength, splitId, numOfReaders); + + List fileAccessInfos = createReadInfo(readPaths, + allFileLength, startOffset, readLength); + LOG.info("Initialization, creating fetcher"); + this.fetcher = new DataFetcher(fs, fileAccessInfos); + this.fetcherThread = new Thread(this.fetcher); + this.fetcherThread.start(); + } + + public List createReadInfo(List readPaths, + List allFileLength, long startOffset, long readLength) { + + List filesToRead = new ArrayList<>(); + + long accumulate = 0; + int targetStartFileIdx = -1; + long targetStartFileOffset = -1; + for (int i = 0; i < allFileLength.size(); i++) { + long currentStart = accumulate; + long currentEnd = accumulate + allFileLength.get(i); + if (currentStart <= startOffset && currentEnd > startOffset) { + targetStartFileIdx = i; + targetStartFileOffset = startOffset - accumulate; + break; + } + accumulate += allFileLength.get(i); + } + + if (targetStartFileIdx == -1 || targetStartFileOffset == -1) { + throw new RuntimeException("Could not locate the file to read with " + + "starting offset:" + startOffset); + } + + while (readLength > 0) { + String fileName = readPaths.get(targetStartFileIdx); + long fileLength = allFileLength.get(targetStartFileIdx); + long actualReadLen = Math.min( + readLength, fileLength - targetStartFileOffset); + filesToRead.add(new FileAccessInfo(fileName, targetStartFileOffset, + actualReadLen, fileLength)); + targetStartFileIdx += 1; + targetStartFileOffset = 0; + readLength -= actualReadLen; + } + LOG.debug("File info created " + filesToRead.size()); + return filesToRead; + } + + private class FileAccessInfo { + final String filePath; + final long startOffset; + final long readLength; + final long fileLength; + + FileAccessInfo(String filePath, long startOffset, long readLength, + long fileLength) { + this.filePath = filePath; + this.startOffset = startOffset; + this.readLength = readLength; + this.fileLength = fileLength; + } + + @Override + public String toString() { + return "AccessInfo:" + filePath + ":" + startOffset + ":" + + readLength + ":" + fileLength; + } + } + + private long getFileLength(FileSystem fs, String inputPathStr) + throws IOException { + Path inputPath = new Path(inputPathStr); + FileStatus status = fs.getFileStatus(inputPath); + return status.getLen(); + } + + public String getSchemaJson() { + /* + There can be a race condition here: + When this method is called, it is possible that the fetcher schema + has not been read yet. This is because fetcher thread will be reading + the schema and it could take some time. If someone tries to get + schema before fetcher reads the it, a null schema will be returned. + Which may cause issue to the caller. Add a sleep spin loop to wait + for the schema to be ready, or throw exception if the schema is + still not ready after 10 seconds. + */ + int attempt = 0; + this.fetcher.schema = Utils.pollTillNonNull(() -> this.fetcher.schema, 1, 60); + if (this.fetcher.schema == null) { + throw new RuntimeException("Could not get schema string"); + } + return this.fetcher.schema.toString(); + } + + /** + * An utility class. This is the class exposed through + * py4j for Python part to access the records. The sole + * external access point of the buffer. + * + * Specifically, one {@link FileObject} instance can be viewed as + * one single, complete avro file, maintained in memory + * as a byte array. Python code can simply parse this + * in memory byte array as if it is a regular avro file. + */ + class FileObject extends OutputStream { + + ByteArrayOutputStream stream; + + FileObject() { + // the 1 MB here is only the initial size, based on + // estimation. + stream = new ByteArrayOutputStream(1024 * 1024); + } + + @Override + public void write(int b) throws IOException { + stream.write(b); + } + + public byte[] readAll() { + return stream.toByteArray(); + } + } + + /** + * Called by external python code to generate a FileObject. + * One batch will lead to one FileObject being created. + * @param batchSize the batch size + * @return the FileObject instance for this batch + * @throws IOException + * @throws InterruptedException + */ + public FileObject nextBatchFile(int batchSize) + throws IOException, InterruptedException { + if (!hasNext()) { + LOG.warn("End of input, no more batch to read"); + } + FileObject fo = new FileObject(); + try (DataFileWriter writer = new DataFileWriter<>( + new GenericDatumWriter<>(fetcher.schema))) { + writer.create(fetcher.schema, fo); + writeBatch(writer, batchSize); + } + return fo; + } + + /** + * Called by external python code to generate a local batch file. + * One batch will lead to one batch file being created. + * @param batchSize the batch size + * @return the path to the batch file of this batch + * @throws IOException + * @throws InterruptedException + */ + public String nextBatchFileLocalSpill(int batchSize) + throws IOException, InterruptedException { + if (!hasNext()) { + LOG.warn("End of input, no more batch to read"); + } + String localPath = getLocalOutputPath(); + File file = new File(localPath); + try (DataFileWriter writer = new DataFileWriter<>( + new GenericDatumWriter<>(fetcher.schema))) { + writer.create(fetcher.schema, file); + writeBatch(writer, batchSize); + } + while (localBufferFiles.size() >= MAX_LOCAL_FILE_NUM) { + Thread.sleep(10); + } + localBufferFiles.add(localPath); + return localPath; + } + + private void writeBatch(DataFileWriter writer, + int batchSize) + throws IOException, InterruptedException { + for (int i = 0; i < batchSize; i++) { + GenericRecord record = null; + int trial = 100; + while (record == null && trial-- >= 0 && hasNext()) { + record = buffer.poll(100, TimeUnit.MILLISECONDS); + } + if (hasNext() && record == null) { + throw new IOException( + "Unable to retrieve a single record after 10 seconds"); + } + if (record != null) { + writer.append(record); + } else { + // this means there is no more data to read. + break; + } + } + } + + private String getLocalOutputPath() { + return "tmp_out-" + UUID.randomUUID().toString(); + } + + /** + * When the processing of the local shared file is done, the caller (from + * python side) needs to inform this reader to delete local spill file + * and remove it from local buffer list. NOTE: python caller failing to + * do so will not leave the files exist forever as long as this reader's + * close() gets called, but will limit the cap of how many files it can + * open at the same time. + * + * TODO : add a way to clean the files on disk even when the reader + * failed with exception. + * @param path a path to a local buffer file that has been processed + * @throws IOException + */ + public void notifyFinish(String path) throws IOException { + localBufferFiles.remove(path); + Files.deleteIfExists(Paths.get(path)); + } + + /** + * Called by external python code to generate a list of byte + * sequence. (ByteBuffer in Java, bytes in Python). + * One batch will lead to one list being created. One record + * per byte buffer. + * @param batchSize the batch size + * @return the list of the records in ByteBuffer for this batch + * @throws IOException + * @throws InterruptedException + */ + public List nextBatchBytes(int batchSize) + throws IOException, InterruptedException { + LOG.debug("Next batch called:" + batchSize); + List ret = new LinkedList<>(); + ByteArrayOutputStream stream = new ByteArrayOutputStream(); + BinaryEncoder encoder = EncoderFactory.get() + .binaryEncoder(stream, null); + + while (ret.size() < batchSize) { + /* + There is a race condition here: + when buffer is empty, may first checks and finds that + fetcher.readFinished so this thread will expect further data. + but after this check, fetcher thread changes readFinished to false, + (no more data will come). Then this thread will block as no data will + come. We may just change to a timed wait. So that this thread waits + a while and again check, it will finds that readFinished is true also, + then it can break. 100ms wait is expensive, but this may only happen + once at the very end of the read. So should be fine. + */ + if (buffer.isEmpty() && fetcher.readFinished) { + break; + } + GenericRecord record = buffer.poll(100, TimeUnit.MILLISECONDS); + + if (record != null) { + DatumWriter datumWriter = new GenericDatumWriter<>(fetcher.schema); + datumWriter.write(record, encoder); + encoder.flush(); + + ByteBuffer data = ByteBuffer.wrap(stream.toByteArray()); + stream.reset(); + ret.add(data); + } + } + return ret; + } + + public boolean hasNext() { + return !buffer.isEmpty() || !fetcher.readFinished; + } + + @Override + public void close() { + this.shouldStop = true; + if (!fetcher.readFinished) { + // the fetcher maybe blocked on writing to the buffer (buffer is full) + // interrupt it awakes it in ours case. + this.fetcherThread.interrupt(); + } + if (localBufferFiles != null && !localBufferFiles.isEmpty()) { + // getting here means the training is closing the reader while there + // is still data. + LOG.warn("Unprocessed local buffer file. Training ended prematurely?"); + // cleaning up local buffer files. + for (String path : localBufferFiles) { + try { + Files.deleteIfExists(Paths.get(path)); + } catch (IOException ioe) { + LOG.error("Getting exception when processing local buffer file " + path + + " still proceeding.", ioe); + } + } + } + } + + /** + * Internal representation of the read buffer. Underlying, + * uses either a blocking queue (for sequential read) or + * a list (for random shuffle read). But only one will be + * used, specified in constructor. Could be using list for + * both cases, but blocking queue comes with better + * performance. + * + * Sequential read with blocking queue comes with smaller + * memory usage and more performance. Random shuffle read + * will return records with certain randomness, but comes + * with a higher cost of memory and time. + * @param the type of records in the buffer + */ + class InternalBuffer { + + private final boolean useRandomShuffle; + + // blocking queue for sequential read + private final BlockingQueue queue; + + // a list with lock, and with random removal + private final ArrayList list; + private final ReentrantLock lock; + private final Random ran; + + private final Condition notFull; + private final Condition bufferReady; + + // the max size of the buffer, for both + // queue and list + private final int bufferSize; + + InternalBuffer(boolean useRandomShuffle, int bufferSize) { + this.useRandomShuffle = useRandomShuffle; + this.bufferSize = bufferSize; + this.lock = new ReentrantLock(); + this.notFull = lock.newCondition(); + this.bufferReady = lock.newCondition(); + + if (useRandomShuffle) { + list = new ArrayList<>(); + ran = new Random(); + queue = null; + } else { + list = null; + ran = null; + queue = new ArrayBlockingQueue<>(bufferSize); + } + } + + /** + * Put a record into the buffer, this a blocking call, + * can blocking indefinitely when the buffer is full. + * @param record the record to put into buffer + * @throws InterruptedException + */ + void put(T record) throws InterruptedException { + if (useRandomShuffle) { + // a blocking put + lock.lock(); + try { + while (list.size() >= this.bufferSize) { + notFull.await(); + } + list.add(record); + if (list.size() >= pollingThreshold * this.bufferSize) { + bufferReady.signal(); + } + } finally { + lock.unlock(); + } + } else { + queue.put(record); + } + } + + /** + * Try to retrieve a record from the buffer. This is + * a timed blocking call, only block up to given time. + * If timeout retrieving a record, return null. + * + * But for random shuffle read, if the buffer size is + * not large enough to meet the randomness requirement, + * this call will be blocking for some time before it + * even tries to poll data. + * + * TODO: currently, given a time and a unit, it may + * wait for more than that. Due to locking and waiting + * separately waiting. Need to have a more precise + * method signature. + * + * @param time time to block + * @param unit unit of time to block + * @return a record removed from the buffer + * @throws InterruptedException + */ + T poll(int time, TimeUnit unit) throws InterruptedException { + if (useRandomShuffle) { + boolean lockAcquired = lock.tryLock(time, unit); + if (!lockAcquired) { + return null; + } + // lock is acquired + try { + int attempt = 100; + while (list.size() < pollingThreshold * this.bufferSize + && !fetcher.readFinished && attempt-- > 0) { + bufferReady.await(10, TimeUnit.MILLISECONDS); + } + if (attempt <= 0) { + return null; + } + if (list.isEmpty()) { + return null; + } + int index = ran.nextInt(list.size()); + T ret = list.remove(index); + notFull.signal(); + return ret; + } finally { + lock.unlock(); + } + } else { + return queue.poll(time, unit); + } + } + + boolean isEmpty() { + if (useRandomShuffle) { + return list.isEmpty(); + } else { + return queue.isEmpty(); + } + } + } +} diff --git a/tony-core/src/main/java/com/linkedin/tony/rpc/ApplicationRpc.java b/tony-core/src/main/java/com/linkedin/tony/rpc/ApplicationRpc.java new file mode 100644 index 00000000..7ffd387e --- /dev/null +++ b/tony-core/src/main/java/com/linkedin/tony/rpc/ApplicationRpc.java @@ -0,0 +1,26 @@ +/** + * Copyright 2018 LinkedIn Corporation. All rights reserved. Licensed under the BSD-2 Clause license. + * See LICENSE in the project root for license information. + */ +package com.linkedin.tony.rpc; + +import java.io.IOException; +import java.util.Set; +import org.apache.hadoop.yarn.exceptions.YarnException; + + +public interface ApplicationRpc { + /** + * Returns all the task URLs once all tasks have been allocated. Before all tasks have been allocated, this will + * return an empty set. + */ + Set getTaskUrls() throws IOException, YarnException; + + String getClusterSpec() throws IOException, YarnException; + String registerWorkerSpec(String worker, String spec) throws IOException, YarnException; + String registerTensorBoardUrl(String spec) throws Exception; + String registerExecutionResult(int exitCode, String jobName, String jobIndex, String sessionId) throws Exception; + void finishApplication() throws YarnException, IOException; + void taskExecutorHeartbeat(String taskId) throws YarnException, IOException; + void reset(); +} diff --git a/tony-core/src/main/java/com/linkedin/tony/rpc/ApplicationRpcServer.java b/tony-core/src/main/java/com/linkedin/tony/rpc/ApplicationRpcServer.java new file mode 100644 index 00000000..a40c8a52 --- /dev/null +++ b/tony-core/src/main/java/com/linkedin/tony/rpc/ApplicationRpcServer.java @@ -0,0 +1,154 @@ +/** + * Copyright 2018 LinkedIn Corporation. All rights reserved. Licensed under the BSD-2 Clause license. + * See LICENSE in the project root for license information. + */ +package com.linkedin.tony.rpc; + +import com.google.protobuf.BlockingService; +import com.linkedin.tony.TFPolicyProvider; +import com.linkedin.tony.rpc.impl.pb.service.TensorFlowClusterPBServiceImpl; +import java.io.IOException; +import org.apache.hadoop.conf.Configuration; +import org.apache.hadoop.fs.CommonConfigurationKeysPublic; +import org.apache.hadoop.ipc.ProtocolSignature; +import org.apache.hadoop.ipc.ProtobufRpcEngine; +import org.apache.hadoop.ipc.RPC; +import org.apache.hadoop.ipc.Server; +import org.apache.hadoop.security.authorize.PolicyProvider; +import org.apache.hadoop.yarn.exceptions.YarnException; +import org.apache.hadoop.yarn.factories.RecordFactory; +import org.apache.hadoop.yarn.factory.providers.RecordFactoryProvider; +import org.apache.hadoop.yarn.security.client.ClientToAMTokenSecretManager; + + +public class ApplicationRpcServer extends Thread implements TensorFlowCluster { + private static final RecordFactory RECORD_FACTORY = + RecordFactoryProvider.getRecordFactory(null); + private final int rpcPort; + private final String rpcAddress; + private final ApplicationRpc appRpc; + private ClientToAMTokenSecretManager secretManager; + private Server server; + private Configuration conf; + + public ApplicationRpcServer(String hostname, ApplicationRpc rpc, Configuration conf) { + this.rpcAddress = hostname; + this.rpcPort = 10000 + ((int) (Math.random() * (5000)) + 1); + this.appRpc = rpc; + this.conf = conf; + if (conf == null) { + this.conf = new Configuration(); + } + } + + @Override + public GetTaskUrlsResponse getTaskUrls(GetTaskUrlsRequest request) throws IOException, YarnException { + GetTaskUrlsResponse response = RECORD_FACTORY.newRecordInstance(GetTaskUrlsResponse.class); + response.setTaskUrls(this.appRpc.getTaskUrls()); + return response; + } + + @Override + public GetClusterSpecResponse getClusterSpec(GetClusterSpecRequest request) + throws YarnException, IOException { + GetClusterSpecResponse response = RECORD_FACTORY.newRecordInstance(GetClusterSpecResponse.class); + response.setClusterSpec(this.appRpc.getClusterSpec()); + return response; + } + + @Override + public RegisterWorkerSpecResponse registerWorkerSpec(RegisterWorkerSpecRequest request) + throws YarnException, IOException { + RegisterWorkerSpecResponse response = RECORD_FACTORY.newRecordInstance(RegisterWorkerSpecResponse.class); + String clusterSpec = this.appRpc.registerWorkerSpec(request.getWorker(), request.getSpec()); + response.setSpec(clusterSpec); + return response; + } + + @Override + public RegisterTensorBoardUrlResponse registerTensorBoardUrl(RegisterTensorBoardUrlRequest request) + throws Exception { + RegisterTensorBoardUrlResponse response = RECORD_FACTORY.newRecordInstance(RegisterTensorBoardUrlResponse.class); + String clusterSpec = this.appRpc.registerTensorBoardUrl(request.getSpec()); + response.setSpec(clusterSpec); + return response; + + } + + @Override + public RegisterExecutionResultResponse registerExecutionResult(RegisterExecutionResultRequest request) throws Exception { + RegisterExecutionResultResponse response = RECORD_FACTORY.newRecordInstance(RegisterExecutionResultResponse.class); + String msg = this.appRpc.registerExecutionResult(request.getExitCode(), request.getJobName(), request.getJobIndex(), request.getSessionId()); + response.setMessage(msg); + return response; + } + + @Override + public Empty finishApplication(Empty request) throws IOException, YarnException { + Empty response = RECORD_FACTORY.newRecordInstance(Empty.class); + this.appRpc.finishApplication(); + return response; + } + + @Override + public HeartbeatResponse taskExecutorHeartbeat(HeartbeatRequest request) + throws YarnException, IOException { + HeartbeatResponse response = RECORD_FACTORY.newRecordInstance(HeartbeatResponse.class); + this.appRpc.taskExecutorHeartbeat(request.getTaskId()); + return response; + } + + // Reset the Application RPC's state + public void reset() { + this.appRpc.reset(); + } + + public int getRpcPort() { + return rpcPort; + } + + public void setSecretManager(ClientToAMTokenSecretManager secretManager) { + this.secretManager = secretManager; + } + + @Override + public void run() { + try { + RPC.setProtocolEngine(conf, TensorFlowClusterPB.class, ProtobufRpcEngine.class); + TensorFlowClusterPBServiceImpl + translator = new TensorFlowClusterPBServiceImpl(this); + BlockingService service = com.linkedin.tony.rpc.proto.TensorFlowCluster.TensorFlowClusterService + .newReflectiveBlockingService(translator); + server = new RPC.Builder(conf).setProtocol(TensorFlowClusterPB.class) + .setInstance(service).setBindAddress(rpcAddress) + .setPort(rpcPort) // TODO: let RPC randomly generate it + .setSecretManager(secretManager).build(); + server.start(); + if (conf.getBoolean( + CommonConfigurationKeysPublic.HADOOP_SECURITY_AUTHORIZATION, + false)) { + refreshServiceAcls(conf, new TFPolicyProvider()); + } + } catch (Exception e) { + throw new RuntimeException(e); + } + } + private synchronized void refreshServiceAcls(Configuration configuration, + PolicyProvider policyProvider) { + server.refreshServiceAclWithLoadedConfiguration(configuration, + policyProvider); + } + + @Override + public long getProtocolVersion(String protocol, long version) throws IOException { + return TensorFlowCluster.versionID; + } + + + @Override + public ProtocolSignature getProtocolSignature(String protocol, + long clientVersion, int clientMethodsHash) throws IOException { + return ProtocolSignature.getProtocolSignature(this, + protocol, clientVersion, clientMethodsHash); + } +} diff --git a/tony-core/src/main/java/com/linkedin/tony/rpc/Empty.java b/tony-core/src/main/java/com/linkedin/tony/rpc/Empty.java new file mode 100644 index 00000000..3ba8f426 --- /dev/null +++ b/tony-core/src/main/java/com/linkedin/tony/rpc/Empty.java @@ -0,0 +1,8 @@ +/** + * Copyright 2018 LinkedIn Corporation. All rights reserved. Licensed under the BSD-2 Clause license. + * See LICENSE in the project root for license information. + */ +package com.linkedin.tony.rpc; + +public interface Empty { +} diff --git a/tony-core/src/main/java/com/linkedin/tony/rpc/GetClusterSpecRequest.java b/tony-core/src/main/java/com/linkedin/tony/rpc/GetClusterSpecRequest.java new file mode 100644 index 00000000..5a2b18f6 --- /dev/null +++ b/tony-core/src/main/java/com/linkedin/tony/rpc/GetClusterSpecRequest.java @@ -0,0 +1,8 @@ +/** + * Copyright 2018 LinkedIn Corporation. All rights reserved. Licensed under the BSD-2 Clause license. + * See LICENSE in the project root for license information. + */ +package com.linkedin.tony.rpc; + +public interface GetClusterSpecRequest { +} diff --git a/tony-core/src/main/java/com/linkedin/tony/rpc/GetClusterSpecResponse.java b/tony-core/src/main/java/com/linkedin/tony/rpc/GetClusterSpecResponse.java new file mode 100644 index 00000000..965df418 --- /dev/null +++ b/tony-core/src/main/java/com/linkedin/tony/rpc/GetClusterSpecResponse.java @@ -0,0 +1,11 @@ +/** + * Copyright 2018 LinkedIn Corporation. All rights reserved. Licensed under the BSD-2 Clause license. + * See LICENSE in the project root for license information. + */ +package com.linkedin.tony.rpc; + +public interface GetClusterSpecResponse { + String getClusterSpec(); + + void setClusterSpec(String clusterSpec); +} diff --git a/tony-core/src/main/java/com/linkedin/tony/rpc/GetTaskUrlsRequest.java b/tony-core/src/main/java/com/linkedin/tony/rpc/GetTaskUrlsRequest.java new file mode 100644 index 00000000..e9627f1b --- /dev/null +++ b/tony-core/src/main/java/com/linkedin/tony/rpc/GetTaskUrlsRequest.java @@ -0,0 +1,8 @@ +/** + * Copyright 2018 LinkedIn Corporation. All rights reserved. Licensed under the BSD-2 Clause license. + * See LICENSE in the project root for license information. + */ +package com.linkedin.tony.rpc; + +public interface GetTaskUrlsRequest { +} diff --git a/tony-core/src/main/java/com/linkedin/tony/rpc/GetTaskUrlsResponse.java b/tony-core/src/main/java/com/linkedin/tony/rpc/GetTaskUrlsResponse.java new file mode 100644 index 00000000..77004f51 --- /dev/null +++ b/tony-core/src/main/java/com/linkedin/tony/rpc/GetTaskUrlsResponse.java @@ -0,0 +1,14 @@ +/** + * Copyright 2018 LinkedIn Corporation. All rights reserved. Licensed under the BSD-2 Clause license. + * See LICENSE in the project root for license information. + */ +package com.linkedin.tony.rpc; + +import java.util.Set; + + +public interface GetTaskUrlsResponse { + Set getTaskUrls(); + + void setTaskUrls(Set taskUrls); +} diff --git a/tony-core/src/main/java/com/linkedin/tony/rpc/HeartbeatRequest.java b/tony-core/src/main/java/com/linkedin/tony/rpc/HeartbeatRequest.java new file mode 100644 index 00000000..59b6da4c --- /dev/null +++ b/tony-core/src/main/java/com/linkedin/tony/rpc/HeartbeatRequest.java @@ -0,0 +1,10 @@ +/** + * Copyright 2018 LinkedIn Corporation. All rights reserved. Licensed under the BSD-2 Clause license. + * See LICENSE in the project root for license information. + */ +package com.linkedin.tony.rpc; + +public interface HeartbeatRequest { + String getTaskId(); + void setTaskId(String taskId); +} diff --git a/tony-core/src/main/java/com/linkedin/tony/rpc/HeartbeatResponse.java b/tony-core/src/main/java/com/linkedin/tony/rpc/HeartbeatResponse.java new file mode 100644 index 00000000..88caf92c --- /dev/null +++ b/tony-core/src/main/java/com/linkedin/tony/rpc/HeartbeatResponse.java @@ -0,0 +1,8 @@ +/** + * Copyright 2018 LinkedIn Corporation. All rights reserved. Licensed under the BSD-2 Clause license. + * See LICENSE in the project root for license information. + */ +package com.linkedin.tony.rpc; + +public interface HeartbeatResponse { +} diff --git a/tony-core/src/main/java/com/linkedin/tony/rpc/RegisterExecutionResultRequest.java b/tony-core/src/main/java/com/linkedin/tony/rpc/RegisterExecutionResultRequest.java new file mode 100644 index 00000000..cca19da4 --- /dev/null +++ b/tony-core/src/main/java/com/linkedin/tony/rpc/RegisterExecutionResultRequest.java @@ -0,0 +1,18 @@ +/** + * Copyright 2018 LinkedIn Corporation. All rights reserved. Licensed under the BSD-2 Clause license. + * See LICENSE in the project root for license information. + */ +package com.linkedin.tony.rpc; + + +public interface RegisterExecutionResultRequest { + + int getExitCode(); + void setExitCode(int exitCode); + String getJobName(); + void setJobName(String jobName); + String getJobIndex(); + void setJobIndex(String jobIndex); + String getSessionId(); + void setSessionId(String sessionId); +} diff --git a/tony-core/src/main/java/com/linkedin/tony/rpc/RegisterExecutionResultResponse.java b/tony-core/src/main/java/com/linkedin/tony/rpc/RegisterExecutionResultResponse.java new file mode 100644 index 00000000..a9c19c53 --- /dev/null +++ b/tony-core/src/main/java/com/linkedin/tony/rpc/RegisterExecutionResultResponse.java @@ -0,0 +1,11 @@ +/** + * Copyright 2018 LinkedIn Corporation. All rights reserved. Licensed under the BSD-2 Clause license. + * See LICENSE in the project root for license information. + */ +package com.linkedin.tony.rpc; + + +public interface RegisterExecutionResultResponse { + String getMessage(); + void setMessage(String message); +} diff --git a/tony-core/src/main/java/com/linkedin/tony/rpc/RegisterTensorBoardUrlRequest.java b/tony-core/src/main/java/com/linkedin/tony/rpc/RegisterTensorBoardUrlRequest.java new file mode 100644 index 00000000..755ded5e --- /dev/null +++ b/tony-core/src/main/java/com/linkedin/tony/rpc/RegisterTensorBoardUrlRequest.java @@ -0,0 +1,11 @@ +/** + * Copyright 2018 LinkedIn Corporation. All rights reserved. Licensed under the BSD-2 Clause license. + * See LICENSE in the project root for license information. + */ +package com.linkedin.tony.rpc; + + +public interface RegisterTensorBoardUrlRequest { + String getSpec(); + void setSpec(String spec); +} diff --git a/tony-core/src/main/java/com/linkedin/tony/rpc/RegisterTensorBoardUrlResponse.java b/tony-core/src/main/java/com/linkedin/tony/rpc/RegisterTensorBoardUrlResponse.java new file mode 100644 index 00000000..f5b9a7a3 --- /dev/null +++ b/tony-core/src/main/java/com/linkedin/tony/rpc/RegisterTensorBoardUrlResponse.java @@ -0,0 +1,11 @@ +/** + * Copyright 2018 LinkedIn Corporation. All rights reserved. Licensed under the BSD-2 Clause license. + * See LICENSE in the project root for license information. + */ +package com.linkedin.tony.rpc; + + +public interface RegisterTensorBoardUrlResponse { + String getSpec(); + void setSpec(String spec); +} diff --git a/tony-core/src/main/java/com/linkedin/tony/rpc/RegisterWorkerSpecRequest.java b/tony-core/src/main/java/com/linkedin/tony/rpc/RegisterWorkerSpecRequest.java new file mode 100644 index 00000000..b120f70c --- /dev/null +++ b/tony-core/src/main/java/com/linkedin/tony/rpc/RegisterWorkerSpecRequest.java @@ -0,0 +1,13 @@ +/** + * Copyright 2018 LinkedIn Corporation. All rights reserved. Licensed under the BSD-2 Clause license. + * See LICENSE in the project root for license information. + */ +package com.linkedin.tony.rpc; + + +public interface RegisterWorkerSpecRequest { + String getWorker(); + String getSpec(); + void setWorker(String worker); + void setSpec(String spec); +} \ No newline at end of file diff --git a/tony-core/src/main/java/com/linkedin/tony/rpc/RegisterWorkerSpecResponse.java b/tony-core/src/main/java/com/linkedin/tony/rpc/RegisterWorkerSpecResponse.java new file mode 100644 index 00000000..f498604f --- /dev/null +++ b/tony-core/src/main/java/com/linkedin/tony/rpc/RegisterWorkerSpecResponse.java @@ -0,0 +1,11 @@ +/** + * Copyright 2018 LinkedIn Corporation. All rights reserved. Licensed under the BSD-2 Clause license. + * See LICENSE in the project root for license information. + */ +package com.linkedin.tony.rpc; + + +public interface RegisterWorkerSpecResponse { + String getSpec(); + void setSpec(String spec); +} diff --git a/tony-core/src/main/java/com/linkedin/tony/rpc/TaskUrl.java b/tony-core/src/main/java/com/linkedin/tony/rpc/TaskUrl.java new file mode 100644 index 00000000..812f3790 --- /dev/null +++ b/tony-core/src/main/java/com/linkedin/tony/rpc/TaskUrl.java @@ -0,0 +1,41 @@ +/** + * Copyright 2018 LinkedIn Corporation. All rights reserved. Licensed under the BSD-2 Clause license. + * See LICENSE in the project root for license information. + */ +package com.linkedin.tony.rpc; + + +/** + * Contains the name, index, and URL for a task. + */ +public class TaskUrl implements Comparable { + private final String name; // The name (worker or ps) of the task + private final String index; // The index of the task + private final String url; // The URL where the logs for the task can be found + + public TaskUrl(String name, String index, String url) { + this.name = name; + this.index = index; + this.url = url; + } + + public String getName() { + return name; + } + + public String getIndex() { + return index; + } + + public String getUrl() { + return url; + } + + @Override + public int compareTo(TaskUrl other) { + if (!this.name.equals(other.name)) { + return this.name.compareTo(other.name); + } + return Integer.valueOf(this.index).compareTo(Integer.valueOf(other.index)); + } +} diff --git a/tony-core/src/main/java/com/linkedin/tony/rpc/TensorFlowCluster.java b/tony-core/src/main/java/com/linkedin/tony/rpc/TensorFlowCluster.java new file mode 100644 index 00000000..83a03e62 --- /dev/null +++ b/tony-core/src/main/java/com/linkedin/tony/rpc/TensorFlowCluster.java @@ -0,0 +1,36 @@ +/** + * Copyright 2018 LinkedIn Corporation. All rights reserved. Licensed under the BSD-2 Clause license. + * See LICENSE in the project root for license information. + */ +package com.linkedin.tony.rpc; + +import org.apache.hadoop.ipc.VersionedProtocol; +import org.apache.hadoop.yarn.exceptions.YarnException; +import org.apache.hadoop.yarn.security.client.ClientToAMTokenSelector; +import org.apache.hadoop.security.token.TokenInfo; +import org.apache.hadoop.ipc.ProtocolInfo; + +import java.io.IOException; + +@TokenInfo(ClientToAMTokenSelector.class) +@ProtocolInfo( + protocolName = "com.linkedin.tony.rpc.TensorFlowCluster", + protocolVersion = 1) +public interface TensorFlowCluster extends VersionedProtocol { + long versionID = 1L; + + GetTaskUrlsResponse getTaskUrls(GetTaskUrlsRequest request) throws IOException, YarnException; + + GetClusterSpecResponse getClusterSpec(GetClusterSpecRequest request) + throws YarnException, IOException; + + RegisterWorkerSpecResponse registerWorkerSpec(RegisterWorkerSpecRequest request) + throws YarnException, IOException; + RegisterTensorBoardUrlResponse registerTensorBoardUrl(RegisterTensorBoardUrlRequest request) + throws Exception; + RegisterExecutionResultResponse registerExecutionResult(RegisterExecutionResultRequest request) throws Exception; + Empty finishApplication(Empty request) throws YarnException, IOException; + + HeartbeatResponse taskExecutorHeartbeat(HeartbeatRequest request) throws YarnException, IOException; + +} diff --git a/tony-core/src/main/java/com/linkedin/tony/rpc/TensorFlowClusterPB.java b/tony-core/src/main/java/com/linkedin/tony/rpc/TensorFlowClusterPB.java new file mode 100644 index 00000000..327d0091 --- /dev/null +++ b/tony-core/src/main/java/com/linkedin/tony/rpc/TensorFlowClusterPB.java @@ -0,0 +1,14 @@ +/** + * Copyright 2018 LinkedIn Corporation. All rights reserved. Licensed under the BSD-2 Clause license. + * See LICENSE in the project root for license information. + */ +package com.linkedin.tony.rpc; + +import org.apache.hadoop.ipc.ProtocolInfo; +import com.linkedin.tony.rpc.proto.TensorFlowCluster.TensorFlowClusterService; + +@ProtocolInfo( + protocolName = "com.linkedin.tony.rpc.TensorFlowCluster", + protocolVersion = 1) +public interface TensorFlowClusterPB extends TensorFlowClusterService.BlockingInterface { +} diff --git a/tony-core/src/main/java/com/linkedin/tony/rpc/impl/ApplicationRpcClient.java b/tony-core/src/main/java/com/linkedin/tony/rpc/impl/ApplicationRpcClient.java new file mode 100644 index 00000000..2f2d27db --- /dev/null +++ b/tony-core/src/main/java/com/linkedin/tony/rpc/impl/ApplicationRpcClient.java @@ -0,0 +1,162 @@ +/** + * Copyright 2018 LinkedIn Corporation. All rights reserved. Licensed under the BSD-2 Clause license. + * See LICENSE in the project root for license information. + */ +package com.linkedin.tony.rpc.impl; + + +import com.linkedin.tony.rpc.Empty; +import com.linkedin.tony.rpc.GetClusterSpecRequest; +import com.linkedin.tony.rpc.GetClusterSpecResponse; +import com.linkedin.tony.rpc.GetTaskUrlsRequest; +import com.linkedin.tony.rpc.GetTaskUrlsResponse; +import com.linkedin.tony.rpc.HeartbeatRequest; +import com.linkedin.tony.rpc.RegisterExecutionResultRequest; +import com.linkedin.tony.rpc.RegisterExecutionResultResponse; +import com.linkedin.tony.rpc.RegisterTensorBoardUrlRequest; +import com.linkedin.tony.rpc.RegisterTensorBoardUrlResponse; +import com.linkedin.tony.rpc.RegisterWorkerSpecRequest; +import com.linkedin.tony.rpc.RegisterWorkerSpecResponse; +import com.linkedin.tony.rpc.ApplicationRpc; +import com.linkedin.tony.rpc.TensorFlowCluster; +import com.linkedin.tony.rpc.TaskUrl; +import java.io.IOException; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.net.InetSocketAddress; +import java.security.PrivilegedAction; +import java.util.Set; +import org.apache.hadoop.conf.Configuration; +import org.apache.hadoop.io.retry.RetryPolicy; +import org.apache.hadoop.io.retry.RetryProxy; +import org.apache.hadoop.security.UserGroupInformation; +import org.apache.hadoop.yarn.client.RMProxy; +import org.apache.hadoop.yarn.client.api.YarnClient; +import org.apache.hadoop.yarn.ipc.YarnRPC; +import org.apache.hadoop.yarn.exceptions.YarnException; +import org.apache.hadoop.yarn.factories.RecordFactory; +import org.apache.hadoop.yarn.factory.providers.RecordFactoryProvider; + + +public class ApplicationRpcClient implements ApplicationRpc { + private RecordFactory recordFactory = RecordFactoryProvider.getRecordFactory(null); + private TensorFlowCluster tensorflow; + private static ApplicationRpcClient instance = null; + private static int port = 0; + private static String address = ""; + + public static ApplicationRpcClient getInstance(String serverAddress, int serverPort) { + if (null == instance) { + instance = new ApplicationRpcClient(serverAddress, serverPort, null); + } + return instance; + } + + public static ApplicationRpcClient getInstance(String serverAddress, int serverPort, Configuration conf) { + if (null == instance || !serverAddress.equals(address) || serverPort != port) { + instance = new ApplicationRpcClient(serverAddress, serverPort, conf); + address = serverAddress; + port = serverPort; + } + return instance; + } + + private ApplicationRpcClient(String serverAddress, int serverPort, Configuration conf) { + InetSocketAddress address = new InetSocketAddress(serverAddress, serverPort); + YarnRPC rpc; + if (conf != null) { + rpc = YarnRPC.create(conf); + } else { + conf = new Configuration(); + rpc = YarnRPC.create(conf); + } + + UserGroupInformation ugi; + try { + ugi = UserGroupInformation.getCurrentUser(); + } catch (IOException e) { + throw new RuntimeException(e); + } + RetryPolicy retryPolicy; + try { + // Hadoop 2.9+ method + retryPolicy = RMProxy.createRetryPolicy(conf, false); + } catch (NoSuchMethodError nsme) { + // Hadoop 2.7 method + try { + Method method = RMProxy.class.getMethod("createRetryPolicy", Configuration.class); + retryPolicy = (RetryPolicy) method.invoke(null, conf); + } catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) { + throw new RuntimeException(e); + } + } + this.tensorflow = getProxy(conf, rpc, ugi, address, TensorFlowCluster.class, retryPolicy); + } + + private static T getProxy(final Configuration conf, final YarnRPC rpc, final UserGroupInformation user, + final InetSocketAddress serverAddress, final Class protocol, RetryPolicy retryPolicy) { + YarnClient client = YarnClient.createYarnClient(); + client.init(conf); + client.start(); + + T proxy = user.doAs((PrivilegedAction) () -> (T) rpc.getProxy(protocol, serverAddress, conf)); + return (T) RetryProxy.create(protocol, proxy, retryPolicy); + } + + @Override + public Set getTaskUrls() throws IOException, YarnException { + GetTaskUrlsResponse response = + tensorflow.getTaskUrls(recordFactory.newRecordInstance(GetTaskUrlsRequest.class)); + return response.getTaskUrls(); + } + + @Override + public String getClusterSpec() throws IOException, YarnException { + GetClusterSpecResponse response = + tensorflow.getClusterSpec(recordFactory.newRecordInstance(GetClusterSpecRequest.class)); + return response.getClusterSpec(); + } + + @Override + public String registerWorkerSpec(String worker, String spec) throws IOException, YarnException { + RegisterWorkerSpecRequest request = recordFactory.newRecordInstance(RegisterWorkerSpecRequest.class); + request.setWorker(worker); + request.setSpec(spec); + RegisterWorkerSpecResponse response = tensorflow.registerWorkerSpec(request); + return response.getSpec(); + } + + @Override + public String registerTensorBoardUrl(String spec) throws Exception { + RegisterTensorBoardUrlRequest request = recordFactory.newRecordInstance(RegisterTensorBoardUrlRequest.class); + request.setSpec(spec); + RegisterTensorBoardUrlResponse response = tensorflow.registerTensorBoardUrl(request); + return response.getSpec(); + } + + @Override + public String registerExecutionResult(int exitCode, String jobName, String jobIndex, String sessionId) throws Exception { + RegisterExecutionResultRequest request = recordFactory.newRecordInstance(RegisterExecutionResultRequest.class); + request.setExitCode(exitCode); + request.setJobName(jobName); + request.setJobIndex(jobIndex); + request.setSessionId(sessionId); + RegisterExecutionResultResponse response = tensorflow.registerExecutionResult(request); + return response.getMessage(); + } + + @Override + public void finishApplication() throws YarnException, IOException { + Empty request = recordFactory.newRecordInstance(Empty.class); + tensorflow.finishApplication(request); + } + + @Override + public void taskExecutorHeartbeat(String taskId) throws YarnException, IOException { + HeartbeatRequest request = recordFactory.newRecordInstance(HeartbeatRequest.class); + request.setTaskId(taskId); + tensorflow.taskExecutorHeartbeat(request); + } + + public void reset() { } +} diff --git a/tony-core/src/main/java/com/linkedin/tony/rpc/impl/pb/EmptyPBImpl.java b/tony-core/src/main/java/com/linkedin/tony/rpc/impl/pb/EmptyPBImpl.java new file mode 100644 index 00000000..9c0ea0fd --- /dev/null +++ b/tony-core/src/main/java/com/linkedin/tony/rpc/impl/pb/EmptyPBImpl.java @@ -0,0 +1,51 @@ +/** + * Copyright 2018 LinkedIn Corporation. All rights reserved. Licensed under the BSD-2 Clause license. + * See LICENSE in the project root for license information. + */ +package com.linkedin.tony.rpc.impl.pb; + +import com.linkedin.tony.rpc.Empty; +import com.linkedin.tony.rpc.proto.YarnTensorFlowClusterProtos.EmptyProto; + + +public class EmptyPBImpl implements Empty { + private EmptyProto proto = EmptyProto.getDefaultInstance(); + private EmptyProto.Builder builder = null; + private boolean viaProto = false; + + private boolean rebuild = false; + + public EmptyPBImpl() { + builder = EmptyProto.newBuilder(); + } + + public EmptyPBImpl(EmptyProto proto) { + this.proto = proto; + viaProto = true; + } + + private void mergeLocalToProto() { + if (viaProto) { + maybeInitBuilder(); + } + proto = builder.build(); + rebuild = false; + viaProto = true; + } + + public EmptyProto getProto() { + if (rebuild) { + mergeLocalToProto(); + } + proto = viaProto ? proto : builder.build(); + viaProto = true; + return proto; + } + + private void maybeInitBuilder() { + if (viaProto || builder == null) { + builder = EmptyProto.newBuilder(proto); + } + viaProto = false; + } +} diff --git a/tony-core/src/main/java/com/linkedin/tony/rpc/impl/pb/GetClusterSpecRequestPBImpl.java b/tony-core/src/main/java/com/linkedin/tony/rpc/impl/pb/GetClusterSpecRequestPBImpl.java new file mode 100644 index 00000000..a4b926f9 --- /dev/null +++ b/tony-core/src/main/java/com/linkedin/tony/rpc/impl/pb/GetClusterSpecRequestPBImpl.java @@ -0,0 +1,51 @@ +/** + * Copyright 2018 LinkedIn Corporation. All rights reserved. Licensed under the BSD-2 Clause license. + * See LICENSE in the project root for license information. + */ +package com.linkedin.tony.rpc.impl.pb; + + +import com.linkedin.tony.rpc.GetClusterSpecRequest; +import com.linkedin.tony.rpc.proto.YarnTensorFlowClusterProtos.GetClusterSpecRequestProto; + +public class GetClusterSpecRequestPBImpl implements GetClusterSpecRequest { + private GetClusterSpecRequestProto proto = GetClusterSpecRequestProto.getDefaultInstance(); + private GetClusterSpecRequestProto.Builder builder = null; + private boolean viaProto = false; + + private boolean rebuild = false; + + public GetClusterSpecRequestPBImpl() { + builder = GetClusterSpecRequestProto.newBuilder(); + } + + public GetClusterSpecRequestPBImpl(GetClusterSpecRequestProto proto) { + this.proto = proto; + viaProto = true; + } + + private void mergeLocalToProto() { + if (viaProto) { + maybeInitBuilder(); + } + proto = builder.build(); + rebuild = false; + viaProto = true; + } + + public GetClusterSpecRequestProto getProto() { + if (rebuild) { + mergeLocalToProto(); + } + proto = viaProto ? proto : builder.build(); + viaProto = true; + return proto; + } + + private void maybeInitBuilder() { + if (viaProto || builder == null) { + builder = GetClusterSpecRequestProto.newBuilder(proto); + } + viaProto = false; + } +} diff --git a/tony-core/src/main/java/com/linkedin/tony/rpc/impl/pb/GetClusterSpecResponsePBImpl.java b/tony-core/src/main/java/com/linkedin/tony/rpc/impl/pb/GetClusterSpecResponsePBImpl.java new file mode 100644 index 00000000..1dd63e1e --- /dev/null +++ b/tony-core/src/main/java/com/linkedin/tony/rpc/impl/pb/GetClusterSpecResponsePBImpl.java @@ -0,0 +1,77 @@ +/** + * Copyright 2018 LinkedIn Corporation. All rights reserved. Licensed under the BSD-2 Clause license. + * See LICENSE in the project root for license information. + */ +package com.linkedin.tony.rpc.impl.pb; + +import com.linkedin.tony.rpc.GetClusterSpecResponse; +import com.linkedin.tony.rpc.proto.YarnTensorFlowClusterProtos.GetClusterSpecResponseProto; +import com.linkedin.tony.rpc.proto.YarnTensorFlowClusterProtos.GetClusterSpecResponseProtoOrBuilder; + +public class GetClusterSpecResponsePBImpl implements GetClusterSpecResponse { + GetClusterSpecResponseProto proto = GetClusterSpecResponseProto.getDefaultInstance(); + GetClusterSpecResponseProto.Builder builder = null; + private boolean viaProto = false; + + private String clusterSpec = null; + + public GetClusterSpecResponsePBImpl() { + builder = GetClusterSpecResponseProto.newBuilder(); + } + + public GetClusterSpecResponsePBImpl(GetClusterSpecResponseProto proto) { + this.proto = proto; + viaProto = true; + } + + public GetClusterSpecResponseProto getProto() { + mergeLocalToProto(); + proto = viaProto ? proto : builder.build(); + viaProto = true; + return proto; + } + + private void mergeLocalToProto() { + if (viaProto) { + maybeInitBuilder(); + } + mergeLocalToBuilder(); + proto = builder.build(); + viaProto = true; + } + + private void mergeLocalToBuilder() { + if (this.clusterSpec != null) { + builder.setClusterSpec(this.clusterSpec); + } + } + + private void maybeInitBuilder() { + if (viaProto || builder == null) { + builder = GetClusterSpecResponseProto.newBuilder(proto); + } + viaProto = false; + } + + @Override + public String getClusterSpec() { + GetClusterSpecResponseProtoOrBuilder p = viaProto ? proto : builder; + if (this.clusterSpec != null) { + return this.clusterSpec; + } + if (!p.hasClusterSpec()) { + return null; + } + this.clusterSpec = p.getClusterSpec(); + return this.clusterSpec; + } + + @Override + public void setClusterSpec(String clusterSpec) { + maybeInitBuilder(); + if (clusterSpec == null) { + builder.clearClusterSpec(); + } + this.clusterSpec = clusterSpec; + } +} diff --git a/tony-core/src/main/java/com/linkedin/tony/rpc/impl/pb/GetTaskUrlsRequestPBImpl.java b/tony-core/src/main/java/com/linkedin/tony/rpc/impl/pb/GetTaskUrlsRequestPBImpl.java new file mode 100644 index 00000000..7dc846e0 --- /dev/null +++ b/tony-core/src/main/java/com/linkedin/tony/rpc/impl/pb/GetTaskUrlsRequestPBImpl.java @@ -0,0 +1,52 @@ +/** + * Copyright 2018 LinkedIn Corporation. All rights reserved. Licensed under the BSD-2 Clause license. + * See LICENSE in the project root for license information. + */ +package com.linkedin.tony.rpc.impl.pb; + + +import com.linkedin.tony.rpc.GetTaskUrlsRequest; +import com.linkedin.tony.rpc.proto.YarnTensorFlowClusterProtos.GetTaskUrlsRequestProto; + + +public class GetTaskUrlsRequestPBImpl implements GetTaskUrlsRequest { + private GetTaskUrlsRequestProto proto = GetTaskUrlsRequestProto.getDefaultInstance(); + private GetTaskUrlsRequestProto.Builder builder = null; + private boolean viaProto = false; + + private boolean rebuild = false; + + public GetTaskUrlsRequestPBImpl() { + builder = GetTaskUrlsRequestProto.newBuilder(); + } + + public GetTaskUrlsRequestPBImpl(GetTaskUrlsRequestProto proto) { + this.proto = proto; + viaProto = true; + } + + private void mergeLocalToProto() { + if (viaProto) { + maybeInitBuilder(); + } + proto = builder.build(); + rebuild = false; + viaProto = true; + } + + public GetTaskUrlsRequestProto getProto() { + if (rebuild) { + mergeLocalToProto(); + } + proto = viaProto ? proto : builder.build(); + viaProto = true; + return proto; + } + + private void maybeInitBuilder() { + if (viaProto || builder == null) { + builder = GetTaskUrlsRequestProto.newBuilder(proto); + } + viaProto = false; + } +} diff --git a/tony-core/src/main/java/com/linkedin/tony/rpc/impl/pb/GetTaskUrlsResponsePBImpl.java b/tony-core/src/main/java/com/linkedin/tony/rpc/impl/pb/GetTaskUrlsResponsePBImpl.java new file mode 100644 index 00000000..18643bea --- /dev/null +++ b/tony-core/src/main/java/com/linkedin/tony/rpc/impl/pb/GetTaskUrlsResponsePBImpl.java @@ -0,0 +1,71 @@ +/** + * Copyright 2018 LinkedIn Corporation. All rights reserved. Licensed under the BSD-2 Clause license. + * See LICENSE in the project root for license information. + */ +package com.linkedin.tony.rpc.impl.pb; + +import com.linkedin.tony.ProtoUtils; +import com.linkedin.tony.rpc.GetTaskUrlsResponse; +import com.linkedin.tony.rpc.TaskUrl; +import com.linkedin.tony.rpc.proto.YarnTensorFlowClusterProtos.GetTaskUrlsResponseProto; +import com.linkedin.tony.rpc.proto.YarnTensorFlowClusterProtos.GetTaskUrlsResponseProtoOrBuilder; +import java.util.Set; +import java.util.stream.Collectors; + + +public class GetTaskUrlsResponsePBImpl implements GetTaskUrlsResponse { + GetTaskUrlsResponseProto proto = GetTaskUrlsResponseProto.getDefaultInstance(); + GetTaskUrlsResponseProto.Builder builder = null; + private boolean viaProto = false; + + private Set _taskUrls = null; + + public GetTaskUrlsResponsePBImpl() { + builder = GetTaskUrlsResponseProto.newBuilder(); + } + + public GetTaskUrlsResponsePBImpl(GetTaskUrlsResponseProto proto) { + this.proto = proto; + viaProto = true; + } + + public GetTaskUrlsResponseProto getProto() { + mergeLocalToProto(); + proto = viaProto ? proto : builder.build(); + viaProto = true; + return proto; + } + + private void mergeLocalToProto() { + if (viaProto) { + maybeInitBuilder(); + } + proto = builder.build(); + viaProto = true; + } + + private void maybeInitBuilder() { + if (viaProto || builder == null) { + builder = GetTaskUrlsResponseProto.newBuilder(proto); + } + viaProto = false; + } + + @Override + public Set getTaskUrls() { + GetTaskUrlsResponseProtoOrBuilder p = viaProto ? proto : builder; + if (this._taskUrls != null) { + return this._taskUrls; + } + return p.getTaskUrlsList().stream().map(ProtoUtils::taskUrlProtoToTaskUrl).collect(Collectors.toSet()); + } + + @Override + public void setTaskUrls(Set taskUrls) { + maybeInitBuilder(); + this._taskUrls = taskUrls; + builder.clearTaskUrls(); + builder.addAllTaskUrls(taskUrls.stream().map(ProtoUtils::taskUrlToTaskUrlProto) + .collect(Collectors.toList())); + } +} diff --git a/tony-core/src/main/java/com/linkedin/tony/rpc/impl/pb/HeartbeatRequestPBImpl.java b/tony-core/src/main/java/com/linkedin/tony/rpc/impl/pb/HeartbeatRequestPBImpl.java new file mode 100644 index 00000000..0485ba04 --- /dev/null +++ b/tony-core/src/main/java/com/linkedin/tony/rpc/impl/pb/HeartbeatRequestPBImpl.java @@ -0,0 +1,78 @@ +/** + * Copyright 2018 LinkedIn Corporation. All rights reserved. Licensed under the BSD-2 Clause license. + * See LICENSE in the project root for license information. + */ +package com.linkedin.tony.rpc.impl.pb; + +import com.linkedin.tony.rpc.HeartbeatRequest; +import com.linkedin.tony.rpc.proto.YarnTensorFlowClusterProtos.HeartbeatRequestProto; +import com.linkedin.tony.rpc.proto.YarnTensorFlowClusterProtos.HeartbeatRequestProtoOrBuilder; + + +public class HeartbeatRequestPBImpl implements HeartbeatRequest { + HeartbeatRequestProto proto = HeartbeatRequestProto.getDefaultInstance(); + HeartbeatRequestProto.Builder builder = null; + private boolean viaProto = false; + + private String taskId = null; + + public HeartbeatRequestPBImpl() { + builder = HeartbeatRequestProto.newBuilder(); + } + + public HeartbeatRequestPBImpl(HeartbeatRequestProto proto) { + this.proto = proto; + viaProto = true; + } + + public HeartbeatRequestProto getProto() { + mergeLocalToProto(); + proto = viaProto ? proto : builder.build(); + viaProto = true; + return proto; + } + + private void mergeLocalToProto() { + if (viaProto) { + maybeInitBuilder(); + } + mergeLocalToBuilder(); + proto = builder.build(); + viaProto = true; + } + + private void mergeLocalToBuilder() { + if (this.taskId != null) { + builder.setTaskId(this.taskId); + } + } + + private void maybeInitBuilder() { + if (viaProto || builder == null) { + builder = HeartbeatRequestProto.newBuilder(proto); + } + viaProto = false; + } + + @Override + public String getTaskId() { + HeartbeatRequestProtoOrBuilder p = viaProto ? proto : builder; + if (this.taskId != null) { + return this.taskId; + } + if (!p.hasTaskId()) { + return null; + } + this.taskId = p.getTaskId(); + return this.taskId; + } + + @Override + public void setTaskId(String taskId) { + maybeInitBuilder(); + if (taskId == null) { + builder.clearTaskId(); + } + this.taskId = taskId; + } +} diff --git a/tony-core/src/main/java/com/linkedin/tony/rpc/impl/pb/HeartbeatResponsePBImpl.java b/tony-core/src/main/java/com/linkedin/tony/rpc/impl/pb/HeartbeatResponsePBImpl.java new file mode 100644 index 00000000..7623b32a --- /dev/null +++ b/tony-core/src/main/java/com/linkedin/tony/rpc/impl/pb/HeartbeatResponsePBImpl.java @@ -0,0 +1,51 @@ +/** + * Copyright 2018 LinkedIn Corporation. All rights reserved. Licensed under the BSD-2 Clause license. + * See LICENSE in the project root for license information. + */ +package com.linkedin.tony.rpc.impl.pb; + +import com.linkedin.tony.rpc.HeartbeatResponse; +import com.linkedin.tony.rpc.proto.YarnTensorFlowClusterProtos.HeartbeatResponseProto; + + +public class HeartbeatResponsePBImpl implements HeartbeatResponse { + private HeartbeatResponseProto proto = HeartbeatResponseProto.getDefaultInstance(); + private HeartbeatResponseProto.Builder builder = null; + private boolean viaProto = false; + + private boolean rebuild = false; + + public HeartbeatResponsePBImpl() { + builder = HeartbeatResponseProto.newBuilder(); + } + + public HeartbeatResponsePBImpl(HeartbeatResponseProto proto) { + this.proto = proto; + viaProto = true; + } + + private void mergeLocalToProto() { + if (viaProto) { + maybeInitBuilder(); + } + proto = builder.build(); + rebuild = false; + viaProto = true; + } + + public HeartbeatResponseProto getProto() { + if (rebuild) { + mergeLocalToProto(); + } + proto = viaProto ? proto : builder.build(); + viaProto = true; + return proto; + } + + private void maybeInitBuilder() { + if (viaProto || builder == null) { + builder = HeartbeatResponseProto.newBuilder(proto); + } + viaProto = false; + } +} diff --git a/tony-core/src/main/java/com/linkedin/tony/rpc/impl/pb/RegisterExecutionResultRequestPBImpl.java b/tony-core/src/main/java/com/linkedin/tony/rpc/impl/pb/RegisterExecutionResultRequestPBImpl.java new file mode 100644 index 00000000..e6a28166 --- /dev/null +++ b/tony-core/src/main/java/com/linkedin/tony/rpc/impl/pb/RegisterExecutionResultRequestPBImpl.java @@ -0,0 +1,142 @@ +/** + * Copyright 2018 LinkedIn Corporation. All rights reserved. Licensed under the BSD-2 Clause license. + * See LICENSE in the project root for license information. + */ +package com.linkedin.tony.rpc.impl.pb; + +import com.linkedin.tony.rpc.RegisterExecutionResultRequest; +import com.linkedin.tony.rpc.proto.YarnTensorFlowClusterProtos; +import com.linkedin.tony.rpc.proto.YarnTensorFlowClusterProtos.RegisterExecutionResultRequestProto; + + +public class RegisterExecutionResultRequestPBImpl implements RegisterExecutionResultRequest { + private RegisterExecutionResultRequestProto proto = RegisterExecutionResultRequestProto.getDefaultInstance(); + private RegisterExecutionResultRequestProto.Builder builder = null; + private boolean viaProto = false; + private String jobName = null; + private String jobIndex = null; + private String sessionId = null; + + public RegisterExecutionResultRequestPBImpl() { + builder = RegisterExecutionResultRequestProto.newBuilder(); + } + + public RegisterExecutionResultRequestPBImpl(RegisterExecutionResultRequestProto proto) { + this.proto = proto; + viaProto = true; + } + + private void mergeLocalToProto() { + if (viaProto) { + maybeInitBuilder(); + } + mergeLocalToBuilder(); + proto = builder.build(); + viaProto = true; + } + + private void mergeLocalToBuilder() { + if (this.jobName != null) { + builder.setJobName(this.jobName); + } + if (this.jobIndex != null) { + builder.setJobIndex(this.jobIndex); + } + if (this.sessionId != null) { + builder.setSessionId(this.sessionId); + } + } + + public RegisterExecutionResultRequestProto getProto() { + mergeLocalToProto(); + proto = viaProto ? proto : builder.build(); + viaProto = true; + return proto; + } + + private void maybeInitBuilder() { + if (viaProto || builder == null) { + builder = RegisterExecutionResultRequestProto.newBuilder(proto); + } + viaProto = false; + } + + @Override + public int getExitCode() { + YarnTensorFlowClusterProtos.RegisterExecutionResultRequestProtoOrBuilder p = viaProto ? proto : builder; + return p.getExitCode(); + } + + @Override + public void setExitCode(int exitCode) { + maybeInitBuilder(); + builder.setExitCode(exitCode); + } + + + @Override + public String getJobName() { + YarnTensorFlowClusterProtos.RegisterExecutionResultRequestProtoOrBuilder p = viaProto ? proto : builder; + if (this.jobName != null) { + return this.jobName; + } + if (!p.hasJobName()) { + return null; + } + this.jobName = p.getJobName(); + return this.jobName; + } + + @Override + public void setJobName(String jobName) { + maybeInitBuilder(); + if (jobName == null) { + builder.clearJobName(); + } + this.jobName = jobName; + } + + @Override + public String getJobIndex() { + YarnTensorFlowClusterProtos.RegisterExecutionResultRequestProtoOrBuilder p = viaProto ? proto : builder; + if (this.jobIndex != null) { + return this.jobIndex; + } + if (!p.hasJobIndex()) { + return null; + } + this.jobIndex = p.getJobIndex(); + return this.jobIndex; + } + + @Override + public void setJobIndex(String jobIndex) { + maybeInitBuilder(); + if (jobIndex == null) { + builder.clearJobIndex(); + } + this.jobIndex = jobIndex; + } + + @Override + public String getSessionId() { + YarnTensorFlowClusterProtos.RegisterExecutionResultRequestProtoOrBuilder p = viaProto ? proto : builder; + if (this.sessionId != null) { + return this.sessionId; + } + if (!p.hasSessionId()) { + return null; + } + this.sessionId = p.getSessionId(); + return this.sessionId; + } + + @Override + public void setSessionId(String sessionId) { + maybeInitBuilder(); + if (sessionId == null) { + builder.clearSessionId(); + } + this.sessionId = sessionId; + } +} diff --git a/tony-core/src/main/java/com/linkedin/tony/rpc/impl/pb/RegisterExecutionResultResponsePBImpl.java b/tony-core/src/main/java/com/linkedin/tony/rpc/impl/pb/RegisterExecutionResultResponsePBImpl.java new file mode 100644 index 00000000..38762cac --- /dev/null +++ b/tony-core/src/main/java/com/linkedin/tony/rpc/impl/pb/RegisterExecutionResultResponsePBImpl.java @@ -0,0 +1,77 @@ +/** + * Copyright 2018 LinkedIn Corporation. All rights reserved. Licensed under the BSD-2 Clause license. + * See LICENSE in the project root for license information. + */ +package com.linkedin.tony.rpc.impl.pb; + +import com.linkedin.tony.rpc.RegisterExecutionResultResponse; +import com.linkedin.tony.rpc.proto.YarnTensorFlowClusterProtos; +import com.linkedin.tony.rpc.proto.YarnTensorFlowClusterProtos.RegisterExecutionResultResponseProto; + + +public class RegisterExecutionResultResponsePBImpl implements RegisterExecutionResultResponse { + private RegisterExecutionResultResponseProto proto = RegisterExecutionResultResponseProto.getDefaultInstance(); + private RegisterExecutionResultResponseProto.Builder builder = null; + private boolean viaProto = false; + + private String message = null; + + public RegisterExecutionResultResponsePBImpl() { + builder = RegisterExecutionResultResponseProto.newBuilder(); + } + + public RegisterExecutionResultResponsePBImpl(RegisterExecutionResultResponseProto proto) { + this.proto = proto; + viaProto = true; + } + + private void mergeLocalToProto() { + if (viaProto) { + maybeInitBuilder(); + } + mergeLocalToBuilder(); + proto = builder.build(); + viaProto = true; + } + + private void mergeLocalToBuilder() { + if (this.message != null) { + builder.setMessage(this.message); + } + } + + public RegisterExecutionResultResponseProto getProto() { + mergeLocalToProto(); + proto = viaProto ? proto : builder.build(); + viaProto = true; + return proto; + } + + private void maybeInitBuilder() { + if (viaProto || builder == null) { + builder = RegisterExecutionResultResponseProto.newBuilder(proto); + } + viaProto = false; + } + @Override + public String getMessage() { + YarnTensorFlowClusterProtos.RegisterExecutionResultResponseProtoOrBuilder p = viaProto ? proto : builder; + if (this.message != null) { + return this.message; + } + if (!p.hasMessage()) { + return null; + } + this.message = p.getMessage(); + return this.message; + } + + @Override + public void setMessage(String message) { + maybeInitBuilder(); + if (message == null) { + builder.clearMessage(); + } + this.message = message; + } +} diff --git a/tony-core/src/main/java/com/linkedin/tony/rpc/impl/pb/RegisterTensorBoardUrlRequestPBImpl.java b/tony-core/src/main/java/com/linkedin/tony/rpc/impl/pb/RegisterTensorBoardUrlRequestPBImpl.java new file mode 100644 index 00000000..9e6d93d0 --- /dev/null +++ b/tony-core/src/main/java/com/linkedin/tony/rpc/impl/pb/RegisterTensorBoardUrlRequestPBImpl.java @@ -0,0 +1,77 @@ +/** + * Copyright 2018 LinkedIn Corporation. All rights reserved. Licensed under the BSD-2 Clause license. + * See LICENSE in the project root for license information. + */ +package com.linkedin.tony.rpc.impl.pb; + +import com.linkedin.tony.rpc.RegisterTensorBoardUrlRequest; +import com.linkedin.tony.rpc.proto.YarnTensorFlowClusterProtos; +import com.linkedin.tony.rpc.proto.YarnTensorFlowClusterProtos.RegisterTensorBoardUrlRequestProto; + + +public class RegisterTensorBoardUrlRequestPBImpl implements RegisterTensorBoardUrlRequest { + private RegisterTensorBoardUrlRequestProto proto = RegisterTensorBoardUrlRequestProto.getDefaultInstance(); + private RegisterTensorBoardUrlRequestProto.Builder builder = null; + private boolean viaProto = false; + private String spec = null; + + public RegisterTensorBoardUrlRequestPBImpl() { + builder = RegisterTensorBoardUrlRequestProto.newBuilder(); + } + + public RegisterTensorBoardUrlRequestPBImpl(RegisterTensorBoardUrlRequestProto proto) { + this.proto = proto; + viaProto = true; + } + + private void mergeLocalToProto() { + if (viaProto) { + maybeInitBuilder(); + } + mergeLocalToBuilder(); + proto = builder.build(); + viaProto = true; + } + + private void mergeLocalToBuilder() { + if (this.spec != null) { + builder.setSpec(this.spec); + } + } + + public RegisterTensorBoardUrlRequestProto getProto() { + mergeLocalToProto(); + proto = viaProto ? proto : builder.build(); + viaProto = true; + return proto; + } + + private void maybeInitBuilder() { + if (viaProto || builder == null) { + builder = RegisterTensorBoardUrlRequestProto.newBuilder(proto); + } + viaProto = false; + } + + @Override + public String getSpec() { + YarnTensorFlowClusterProtos.RegisterTensorBoardUrlRequestProtoOrBuilder p = viaProto ? proto : builder; + if (this.spec != null) { + return this.spec; + } + if (!p.hasSpec()) { + return null; + } + this.spec = p.getSpec(); + return this.spec; + } + + @Override + public void setSpec(String spec) { + maybeInitBuilder(); + if (spec == null) { + builder.clearSpec(); + } + this.spec = spec; + } +} diff --git a/tony-core/src/main/java/com/linkedin/tony/rpc/impl/pb/RegisterTensorBoardUrlResponsePBImpl.java b/tony-core/src/main/java/com/linkedin/tony/rpc/impl/pb/RegisterTensorBoardUrlResponsePBImpl.java new file mode 100644 index 00000000..48802ceb --- /dev/null +++ b/tony-core/src/main/java/com/linkedin/tony/rpc/impl/pb/RegisterTensorBoardUrlResponsePBImpl.java @@ -0,0 +1,77 @@ +/** + * Copyright 2018 LinkedIn Corporation. All rights reserved. Licensed under the BSD-2 Clause license. + * See LICENSE in the project root for license information. + */ +package com.linkedin.tony.rpc.impl.pb; + +import com.linkedin.tony.rpc.RegisterTensorBoardUrlResponse; +import com.linkedin.tony.rpc.proto.YarnTensorFlowClusterProtos; +import com.linkedin.tony.rpc.proto.YarnTensorFlowClusterProtos.RegisterTensorBoardUrlResponseProto; + + +public class RegisterTensorBoardUrlResponsePBImpl implements RegisterTensorBoardUrlResponse { + private RegisterTensorBoardUrlResponseProto proto = RegisterTensorBoardUrlResponseProto.getDefaultInstance(); + private RegisterTensorBoardUrlResponseProto.Builder builder = null; + private boolean viaProto = false; + + private String spec = null; + + public RegisterTensorBoardUrlResponsePBImpl() { + builder = RegisterTensorBoardUrlResponseProto.newBuilder(); + } + + public RegisterTensorBoardUrlResponsePBImpl(RegisterTensorBoardUrlResponseProto proto) { + this.proto = proto; + viaProto = true; + } + + private void mergeLocalToProto() { + if (viaProto) { + maybeInitBuilder(); + } + mergeLocalToBuilder(); + proto = builder.build(); + viaProto = true; + } + + private void mergeLocalToBuilder() { + if (this.spec != null) { + builder.setSpec(this.spec); + } + } + + public RegisterTensorBoardUrlResponseProto getProto() { + mergeLocalToProto(); + proto = viaProto ? proto : builder.build(); + viaProto = true; + return proto; + } + + private void maybeInitBuilder() { + if (viaProto || builder == null) { + builder = RegisterTensorBoardUrlResponseProto.newBuilder(proto); + } + viaProto = false; + } + @Override + public String getSpec() { + YarnTensorFlowClusterProtos.RegisterTensorBoardUrlResponseProtoOrBuilder p = viaProto ? proto : builder; + if (this.spec != null) { + return this.spec; + } + if (!p.hasSpec()) { + return null; + } + this.spec = p.getSpec(); + return this.spec; + } + + @Override + public void setSpec(String spec) { + maybeInitBuilder(); + if (spec == null) { + builder.clearSpec(); + } + this.spec = spec; + } +} diff --git a/tony-core/src/main/java/com/linkedin/tony/rpc/impl/pb/RegisterWorkerSpecRequestPBImpl.java b/tony-core/src/main/java/com/linkedin/tony/rpc/impl/pb/RegisterWorkerSpecRequestPBImpl.java new file mode 100644 index 00000000..f427498c --- /dev/null +++ b/tony-core/src/main/java/com/linkedin/tony/rpc/impl/pb/RegisterWorkerSpecRequestPBImpl.java @@ -0,0 +1,104 @@ +/** + * Copyright 2018 LinkedIn Corporation. All rights reserved. Licensed under the BSD-2 Clause license. + * See LICENSE in the project root for license information. + */ +package com.linkedin.tony.rpc.impl.pb; + +import com.linkedin.tony.rpc.RegisterWorkerSpecRequest; +import com.linkedin.tony.rpc.proto.YarnTensorFlowClusterProtos.RegisterWorkerSpecRequestProto; +import com.linkedin.tony.rpc.proto.YarnTensorFlowClusterProtos.RegisterWorkerSpecRequestProtoOrBuilder; + + +public class RegisterWorkerSpecRequestPBImpl implements RegisterWorkerSpecRequest { + private RegisterWorkerSpecRequestProto proto = RegisterWorkerSpecRequestProto.getDefaultInstance(); + private RegisterWorkerSpecRequestProto.Builder builder = null; + private boolean viaProto = false; + + private String worker = null; + private String spec = null; + + public RegisterWorkerSpecRequestPBImpl() { + builder = RegisterWorkerSpecRequestProto.newBuilder(); + } + + public RegisterWorkerSpecRequestPBImpl(RegisterWorkerSpecRequestProto proto) { + this.proto = proto; + viaProto = true; + } + + private void mergeLocalToProto() { + if (viaProto) { + maybeInitBuilder(); + } + mergeLocalToBuilder(); + proto = builder.build(); + viaProto = true; + } + + private void mergeLocalToBuilder() { + if (this.worker != null) { + builder.setWorker(this.worker); + } + if (this.spec != null) { + builder.setSpec(this.spec); + } + } + + public RegisterWorkerSpecRequestProto getProto() { + mergeLocalToProto(); + proto = viaProto ? proto : builder.build(); + viaProto = true; + return proto; + } + + private void maybeInitBuilder() { + if (viaProto || builder == null) { + builder = RegisterWorkerSpecRequestProto.newBuilder(proto); + } + viaProto = false; + } + + @Override + public String getWorker() { + RegisterWorkerSpecRequestProtoOrBuilder p = viaProto ? proto : builder; + if (this.worker != null) { + return this.worker; + } + if (!p.hasWorker()) { + return null; + } + this.worker = p.getWorker(); + return this.worker; + } + + @Override + public void setWorker(String worker) { + maybeInitBuilder(); + if (worker == null) { + builder.clearWorker(); + } + this.worker = worker; + } + + @Override + public String getSpec() { + RegisterWorkerSpecRequestProtoOrBuilder p = viaProto ? proto : builder; + if (this.spec != null) { + return this.spec; + } + if (!p.hasSpec()) { + return null; + } + this.spec = p.getSpec(); + return this.spec; + } + + @Override + public void setSpec(String spec) { + maybeInitBuilder(); + if (spec == null) { + builder.clearSpec(); + } + this.spec = spec; + } +} diff --git a/tony-core/src/main/java/com/linkedin/tony/rpc/impl/pb/RegisterWorkerSpecResponsePBImpl.java b/tony-core/src/main/java/com/linkedin/tony/rpc/impl/pb/RegisterWorkerSpecResponsePBImpl.java new file mode 100644 index 00000000..fac2d794 --- /dev/null +++ b/tony-core/src/main/java/com/linkedin/tony/rpc/impl/pb/RegisterWorkerSpecResponsePBImpl.java @@ -0,0 +1,77 @@ +/** + * Copyright 2018 LinkedIn Corporation. All rights reserved. Licensed under the BSD-2 Clause license. + * See LICENSE in the project root for license information. + */ +package com.linkedin.tony.rpc.impl.pb; + + +import com.linkedin.tony.rpc.RegisterWorkerSpecResponse; +import com.linkedin.tony.rpc.proto.YarnTensorFlowClusterProtos.RegisterWorkerSpecResponseProto; +import com.linkedin.tony.rpc.proto.YarnTensorFlowClusterProtos.RegisterWorkerSpecResponseProtoOrBuilder; + +public class RegisterWorkerSpecResponsePBImpl implements RegisterWorkerSpecResponse { + private RegisterWorkerSpecResponseProto proto = RegisterWorkerSpecResponseProto.getDefaultInstance(); + private RegisterWorkerSpecResponseProto.Builder builder = null; + private boolean viaProto = false; + + private String spec = null; + + public RegisterWorkerSpecResponsePBImpl() { + builder = RegisterWorkerSpecResponseProto.newBuilder(); + } + + public RegisterWorkerSpecResponsePBImpl(RegisterWorkerSpecResponseProto proto) { + this.proto = proto; + viaProto = true; + } + + private void mergeLocalToProto() { + if (viaProto) { + maybeInitBuilder(); + } + mergeLocalToBuilder(); + proto = builder.build(); + viaProto = true; + } + + private void mergeLocalToBuilder() { + if (this.spec != null) { + builder.setSpec(this.spec); + } + } + + public RegisterWorkerSpecResponseProto getProto() { + mergeLocalToProto(); + proto = viaProto ? proto : builder.build(); + viaProto = true; + return proto; + } + + private void maybeInitBuilder() { + if (viaProto || builder == null) { + builder = RegisterWorkerSpecResponseProto.newBuilder(proto); + } + viaProto = false; + } + @Override + public String getSpec() { + RegisterWorkerSpecResponseProtoOrBuilder p = viaProto ? proto : builder; + if (this.spec != null) { + return this.spec; + } + if (!p.hasSpec()) { + return null; + } + this.spec = p.getSpec(); + return this.spec; + } + + @Override + public void setSpec(String spec) { + maybeInitBuilder(); + if (spec == null) { + builder.clearSpec(); + } + this.spec = spec; + } +} diff --git a/tony-core/src/main/java/com/linkedin/tony/rpc/impl/pb/client/TensorFlowClusterPBClientImpl.java b/tony-core/src/main/java/com/linkedin/tony/rpc/impl/pb/client/TensorFlowClusterPBClientImpl.java new file mode 100644 index 00000000..3a76b98a --- /dev/null +++ b/tony-core/src/main/java/com/linkedin/tony/rpc/impl/pb/client/TensorFlowClusterPBClientImpl.java @@ -0,0 +1,159 @@ +/** + * Copyright 2018 LinkedIn Corporation. All rights reserved. Licensed under the BSD-2 Clause license. + * See LICENSE in the project root for license information. + */ + +package com.linkedin.tony.rpc.impl.pb.client; + +import com.google.protobuf.ServiceException; +import com.linkedin.tony.rpc.Empty; +import com.linkedin.tony.rpc.GetClusterSpecRequest; +import com.linkedin.tony.rpc.GetClusterSpecResponse; +import com.linkedin.tony.rpc.GetTaskUrlsRequest; +import com.linkedin.tony.rpc.GetTaskUrlsResponse; +import com.linkedin.tony.rpc.HeartbeatRequest; +import com.linkedin.tony.rpc.HeartbeatResponse; +import com.linkedin.tony.rpc.RegisterExecutionResultRequest; +import com.linkedin.tony.rpc.RegisterExecutionResultResponse; +import com.linkedin.tony.rpc.RegisterTensorBoardUrlRequest; +import com.linkedin.tony.rpc.RegisterTensorBoardUrlResponse; +import com.linkedin.tony.rpc.RegisterWorkerSpecRequest; +import com.linkedin.tony.rpc.RegisterWorkerSpecResponse; +import com.linkedin.tony.rpc.TensorFlowCluster; +import com.linkedin.tony.rpc.TensorFlowClusterPB; +import com.linkedin.tony.rpc.impl.pb.EmptyPBImpl; +import com.linkedin.tony.rpc.impl.pb.GetClusterSpecRequestPBImpl; +import com.linkedin.tony.rpc.impl.pb.GetClusterSpecResponsePBImpl; +import com.linkedin.tony.rpc.impl.pb.GetTaskUrlsRequestPBImpl; +import com.linkedin.tony.rpc.impl.pb.GetTaskUrlsResponsePBImpl; +import com.linkedin.tony.rpc.impl.pb.HeartbeatRequestPBImpl; +import com.linkedin.tony.rpc.impl.pb.HeartbeatResponsePBImpl; +import com.linkedin.tony.rpc.impl.pb.RegisterExecutionResultRequestPBImpl; +import com.linkedin.tony.rpc.impl.pb.RegisterExecutionResultResponsePBImpl; +import com.linkedin.tony.rpc.impl.pb.RegisterTensorBoardUrlRequestPBImpl; +import com.linkedin.tony.rpc.impl.pb.RegisterTensorBoardUrlResponsePBImpl; +import com.linkedin.tony.rpc.impl.pb.RegisterWorkerSpecRequestPBImpl; +import com.linkedin.tony.rpc.impl.pb.RegisterWorkerSpecResponsePBImpl; +import com.linkedin.tony.rpc.proto.YarnTensorFlowClusterProtos; +import com.linkedin.tony.rpc.proto.YarnTensorFlowClusterProtos.GetClusterSpecRequestProto; +import com.linkedin.tony.rpc.proto.YarnTensorFlowClusterProtos.GetTaskUrlsRequestProto; +import com.linkedin.tony.rpc.proto.YarnTensorFlowClusterProtos.RegisterWorkerSpecRequestProto; +import com.linkedin.tony.rpc.proto.YarnTensorFlowClusterProtos.HeartbeatRequestProto; +import java.io.Closeable; +import java.io.IOException; +import java.net.InetSocketAddress; +import org.apache.hadoop.conf.Configuration; +import org.apache.hadoop.ipc.ProtobufRpcEngine; +import org.apache.hadoop.ipc.ProtocolSignature; +import org.apache.hadoop.ipc.RPC; +import org.apache.hadoop.yarn.exceptions.YarnException; +import org.apache.hadoop.yarn.ipc.RPCUtil; + + +/** + * This class is instantiated using reflection by YARN's RecordFactoryPBImpl + */ +public class TensorFlowClusterPBClientImpl implements TensorFlowCluster, Closeable { + private TensorFlowClusterPB proxy; + + public TensorFlowClusterPBClientImpl(long clientVersion, InetSocketAddress addr, Configuration conf) throws IOException { + RPC.setProtocolEngine(conf, TensorFlowClusterPB.class, ProtobufRpcEngine.class); + proxy = RPC.getProxy(TensorFlowClusterPB.class, clientVersion, addr, conf); + } + + @Override + public void close() { + if (this.proxy != null) { + RPC.stopProxy(this.proxy); + } + } + + @Override + public GetTaskUrlsResponse getTaskUrls(GetTaskUrlsRequest request) throws IOException, YarnException { + GetTaskUrlsRequestProto requestProto = ((GetTaskUrlsRequestPBImpl) request).getProto(); + try { + return new GetTaskUrlsResponsePBImpl(proxy.getTaskUrls(null, requestProto)); + } catch (ServiceException e) { + RPCUtil.unwrapAndThrowException(e); + return null; + } + } + + @Override + public GetClusterSpecResponse getClusterSpec(GetClusterSpecRequest request) throws YarnException, IOException { + GetClusterSpecRequestProto requestProto = ((GetClusterSpecRequestPBImpl) request).getProto(); + try { + return new GetClusterSpecResponsePBImpl(proxy.getClusterSpec(null, requestProto)); + } catch (ServiceException e) { + RPCUtil.unwrapAndThrowException(e); + return null; + } + } + + @Override + public RegisterWorkerSpecResponse registerWorkerSpec(RegisterWorkerSpecRequest request) throws YarnException, IOException { + RegisterWorkerSpecRequestProto requestProto = ((RegisterWorkerSpecRequestPBImpl) request).getProto(); + try { + return new RegisterWorkerSpecResponsePBImpl(proxy.registerWorkerSpec(null, requestProto)); + } catch (ServiceException e) { + RPCUtil.unwrapAndThrowException(e); + return null; + } + } + + @Override + public RegisterTensorBoardUrlResponse registerTensorBoardUrl(RegisterTensorBoardUrlRequest request) throws YarnException, IOException { + YarnTensorFlowClusterProtos.RegisterTensorBoardUrlRequestProto requestProto = ((RegisterTensorBoardUrlRequestPBImpl) request).getProto(); + try { + return new RegisterTensorBoardUrlResponsePBImpl(proxy.registerTensorBoardUrl(null, requestProto)); + } catch (ServiceException e) { + RPCUtil.unwrapAndThrowException(e); + return null; + } + } + + @Override + public RegisterExecutionResultResponse registerExecutionResult(RegisterExecutionResultRequest request) throws YarnException, IOException { + YarnTensorFlowClusterProtos.RegisterExecutionResultRequestProto requestProto = ((RegisterExecutionResultRequestPBImpl) request).getProto(); + try { + return new RegisterExecutionResultResponsePBImpl(proxy.registerExecutionResult(null, requestProto)); + } catch (ServiceException e) { + RPCUtil.unwrapAndThrowException(e); + return null; + } + } + + @Override + public Empty finishApplication(Empty request) throws YarnException, IOException { + YarnTensorFlowClusterProtos.EmptyProto requestProto = ((EmptyPBImpl) request).getProto(); + try { + return new EmptyPBImpl(proxy.finishApplication(null, requestProto)); + } catch (ServiceException e) { + RPCUtil.unwrapAndThrowException(e); + return null; + } + } + + @Override + public HeartbeatResponse taskExecutorHeartbeat(HeartbeatRequest request) throws YarnException, IOException { + HeartbeatRequestProto requestProto = ((HeartbeatRequestPBImpl) request).getProto(); + try { + return new HeartbeatResponsePBImpl(proxy.taskExecutorHeartbeat(null, requestProto)); + } catch (ServiceException e) { + RPCUtil.unwrapAndThrowException(e); + return null; + } + } + + @Override + public long getProtocolVersion(String protocol, long version) { + return TensorFlowCluster.versionID; + } + + @Override + public ProtocolSignature getProtocolSignature(String protocol, + long clientVersion, int clientMethodsHash) throws IOException { + return ProtocolSignature.getProtocolSignature(this, + protocol, clientVersion, clientMethodsHash); + } +} \ No newline at end of file diff --git a/tony-core/src/main/java/com/linkedin/tony/rpc/impl/pb/service/TensorFlowClusterPBServiceImpl.java b/tony-core/src/main/java/com/linkedin/tony/rpc/impl/pb/service/TensorFlowClusterPBServiceImpl.java new file mode 100644 index 00000000..e8db3688 --- /dev/null +++ b/tony-core/src/main/java/com/linkedin/tony/rpc/impl/pb/service/TensorFlowClusterPBServiceImpl.java @@ -0,0 +1,139 @@ +/** + * Copyright 2018 LinkedIn Corporation. All rights reserved. Licensed under the BSD-2 Clause license. + * See LICENSE in the project root for license information. + */ +package com.linkedin.tony.rpc.impl.pb.service; + +import com.google.protobuf.RpcController; +import com.google.protobuf.ServiceException; +import com.linkedin.tony.rpc.Empty; +import com.linkedin.tony.rpc.GetClusterSpecResponse; +import com.linkedin.tony.rpc.GetTaskUrlsResponse; +import com.linkedin.tony.rpc.HeartbeatResponse; +import com.linkedin.tony.rpc.RegisterExecutionResultResponse; +import com.linkedin.tony.rpc.RegisterTensorBoardUrlResponse; +import com.linkedin.tony.rpc.RegisterWorkerSpecResponse; +import com.linkedin.tony.rpc.TensorFlowCluster; +import com.linkedin.tony.rpc.TensorFlowClusterPB; +import com.linkedin.tony.rpc.impl.pb.EmptyPBImpl; +import com.linkedin.tony.rpc.impl.pb.GetClusterSpecRequestPBImpl; +import com.linkedin.tony.rpc.impl.pb.GetClusterSpecResponsePBImpl; +import com.linkedin.tony.rpc.impl.pb.GetTaskUrlsRequestPBImpl; +import com.linkedin.tony.rpc.impl.pb.GetTaskUrlsResponsePBImpl; +import com.linkedin.tony.rpc.impl.pb.HeartbeatRequestPBImpl; +import com.linkedin.tony.rpc.impl.pb.HeartbeatResponsePBImpl; +import com.linkedin.tony.rpc.impl.pb.RegisterExecutionResultRequestPBImpl; +import com.linkedin.tony.rpc.impl.pb.RegisterExecutionResultResponsePBImpl; +import com.linkedin.tony.rpc.impl.pb.RegisterTensorBoardUrlRequestPBImpl; +import com.linkedin.tony.rpc.impl.pb.RegisterTensorBoardUrlResponsePBImpl; +import com.linkedin.tony.rpc.impl.pb.RegisterWorkerSpecRequestPBImpl; +import com.linkedin.tony.rpc.impl.pb.RegisterWorkerSpecResponsePBImpl; +import com.linkedin.tony.rpc.proto.YarnTensorFlowClusterProtos; +import com.linkedin.tony.rpc.proto.YarnTensorFlowClusterProtos.EmptyProto; +import com.linkedin.tony.rpc.proto.YarnTensorFlowClusterProtos.GetClusterSpecRequestProto; +import com.linkedin.tony.rpc.proto.YarnTensorFlowClusterProtos.GetClusterSpecResponseProto; +import com.linkedin.tony.rpc.proto.YarnTensorFlowClusterProtos.GetTaskUrlsRequestProto; +import com.linkedin.tony.rpc.proto.YarnTensorFlowClusterProtos.GetTaskUrlsResponseProto; +import com.linkedin.tony.rpc.proto.YarnTensorFlowClusterProtos.RegisterExecutionResultRequestProto; +import com.linkedin.tony.rpc.proto.YarnTensorFlowClusterProtos.RegisterExecutionResultResponseProto; +import com.linkedin.tony.rpc.proto.YarnTensorFlowClusterProtos.RegisterTensorBoardUrlRequestProto; +import com.linkedin.tony.rpc.proto.YarnTensorFlowClusterProtos.RegisterTensorBoardUrlResponseProto; +import com.linkedin.tony.rpc.proto.YarnTensorFlowClusterProtos.RegisterWorkerSpecRequestProto; +import com.linkedin.tony.rpc.proto.YarnTensorFlowClusterProtos.RegisterWorkerSpecResponseProto; +import org.apache.hadoop.yarn.exceptions.YarnException; + +import java.io.IOException; + +public class TensorFlowClusterPBServiceImpl implements TensorFlowClusterPB { + private TensorFlowCluster real; + + public TensorFlowClusterPBServiceImpl(TensorFlowCluster impl) { + this.real = impl; + } + + @Override + public GetTaskUrlsResponseProto getTaskUrls(RpcController controller, + GetTaskUrlsRequestProto proto) throws ServiceException { + GetTaskUrlsRequestPBImpl request = new GetTaskUrlsRequestPBImpl(proto); + try { + GetTaskUrlsResponse response = real.getTaskUrls(request); + return ((GetTaskUrlsResponsePBImpl) response).getProto(); + } catch (YarnException | IOException e) { + throw new ServiceException(e); + } + } + + @Override + public GetClusterSpecResponseProto getClusterSpec(RpcController controller, + GetClusterSpecRequestProto proto) throws ServiceException { + GetClusterSpecRequestPBImpl request = new GetClusterSpecRequestPBImpl(proto); + try { + GetClusterSpecResponse response = real.getClusterSpec(request); + return ((GetClusterSpecResponsePBImpl) response).getProto(); + } catch (YarnException | IOException e) { + throw new ServiceException(e); + } + } + + @Override + public RegisterWorkerSpecResponseProto registerWorkerSpec(RpcController controller, + RegisterWorkerSpecRequestProto proto) throws ServiceException { + RegisterWorkerSpecRequestPBImpl request = new RegisterWorkerSpecRequestPBImpl(proto); + try { + RegisterWorkerSpecResponse response = real.registerWorkerSpec(request); + return ((RegisterWorkerSpecResponsePBImpl) response).getProto(); + } catch (YarnException | IOException e) { + throw new ServiceException(e); + } + } + + @Override + public RegisterTensorBoardUrlResponseProto registerTensorBoardUrl( + RpcController controller, RegisterTensorBoardUrlRequestProto proto) + throws ServiceException { + RegisterTensorBoardUrlRequestPBImpl request = new RegisterTensorBoardUrlRequestPBImpl(proto); + try { + RegisterTensorBoardUrlResponse response = real.registerTensorBoardUrl(request); + return ((RegisterTensorBoardUrlResponsePBImpl) response).getProto(); + } catch (Exception e) { + throw new ServiceException(e); + } + } + + @Override + public RegisterExecutionResultResponseProto registerExecutionResult( + RpcController controller, RegisterExecutionResultRequestProto proto) + throws ServiceException { + RegisterExecutionResultRequestPBImpl request = new RegisterExecutionResultRequestPBImpl(proto); + try { + RegisterExecutionResultResponse response = real.registerExecutionResult(request); + return ((RegisterExecutionResultResponsePBImpl) response).getProto(); + } catch (Exception e) { + throw new ServiceException(e); + } + } + + @Override + public EmptyProto finishApplication(RpcController controller, EmptyProto proto) + throws ServiceException { + EmptyPBImpl request = new EmptyPBImpl(proto); + try { + Empty response = real.finishApplication(request); + return ((EmptyPBImpl) response).getProto(); + } catch (Exception e) { + throw new ServiceException(e); + } + } + + @Override + public YarnTensorFlowClusterProtos.HeartbeatResponseProto taskExecutorHeartbeat(RpcController controller, + YarnTensorFlowClusterProtos.HeartbeatRequestProto proto) throws ServiceException { + HeartbeatRequestPBImpl request = new HeartbeatRequestPBImpl(proto); + try { + HeartbeatResponse response = real.taskExecutorHeartbeat(request); + return ((HeartbeatResponsePBImpl) response).getProto(); + } catch (Exception e) { + throw new ServiceException(e); + } + } +} diff --git a/tony-core/src/main/java/com/linkedin/tony/tensorflow/TensorFlowContainerRequest.java b/tony-core/src/main/java/com/linkedin/tony/tensorflow/TensorFlowContainerRequest.java new file mode 100644 index 00000000..84924881 --- /dev/null +++ b/tony-core/src/main/java/com/linkedin/tony/tensorflow/TensorFlowContainerRequest.java @@ -0,0 +1,70 @@ +/** + * Copyright 2018 LinkedIn Corporation. All rights reserved. Licensed under the BSD-2 Clause license. + * See LICENSE in the project root for license information. + */ +package com.linkedin.tony.tensorflow; + +import org.apache.hadoop.yarn.api.records.Resource; +import org.apache.hadoop.yarn.api.records.ResourceInformation; +import org.apache.hadoop.yarn.exceptions.ResourceNotFoundException; + + +public class TensorFlowContainerRequest { + private int virtualCores; + private long memory; + private int priority; + private int gpu; + private String jobName; + + public TensorFlowContainerRequest(String jobName, int virtualCores, long memory, int gpu, int priority) { + this.virtualCores = virtualCores; + this.memory = memory; + this.priority = priority; + this.gpu = gpu; + this.jobName = jobName; + } + + public TensorFlowContainerRequest(TensorFlowContainerRequest that) { + this.virtualCores = that.virtualCores; + this.memory = that.memory; + this.gpu = that.gpu; + this.priority = that.priority; + this.jobName = that.jobName; + } + + public int getVirtualCores() { + return virtualCores; + } + + public long getMemory() { + return memory; + } + + public int getGPU() { + return gpu; + } + + public int getPriority() { + return priority; + } + + public String getJobName() { + return jobName; + } + + public boolean matchesResourceRequest(Resource resource) { + int resourceMem = (int) resource.getMemorySize(); + int resourceVCore = resource.getVirtualCores(); + + if (this.memory != resourceMem || this.virtualCores != resourceVCore) { + return false; + } + + try { + long numGpus = resource.getResourceValue(ResourceInformation.GPU_URI); + return this.gpu == numGpus; + } catch (ResourceNotFoundException e) { + return true; + } + } +} diff --git a/tony-core/src/main/java/com/linkedin/tony/tensorflow/TensorFlowSession.java b/tony-core/src/main/java/com/linkedin/tony/tensorflow/TensorFlowSession.java new file mode 100644 index 00000000..80c20dea --- /dev/null +++ b/tony-core/src/main/java/com/linkedin/tony/tensorflow/TensorFlowSession.java @@ -0,0 +1,545 @@ +/** + * Copyright 2018 LinkedIn Corporation. All rights reserved. Licensed under the BSD-2 Clause license. + * See LICENSE in the project root for license information. + */ +package com.linkedin.tony.tensorflow; + +import com.google.common.base.Preconditions; +import com.google.common.collect.ImmutableMap; +import com.linkedin.tony.Constants; +import com.linkedin.tony.Utils; +import com.linkedin.tony.rpc.TaskUrl; +import java.net.URI; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.apache.hadoop.conf.Configuration; +import org.apache.hadoop.fs.FileStatus; +import org.apache.hadoop.fs.FileSystem; +import org.apache.hadoop.fs.Path; +import org.apache.hadoop.yarn.api.ApplicationConstants; +import org.apache.hadoop.yarn.api.records.Container; +import org.apache.hadoop.yarn.api.records.ContainerId; +import org.apache.hadoop.yarn.api.records.FinalApplicationStatus; +import org.apache.hadoop.yarn.api.records.LocalResource; +import org.apache.hadoop.yarn.api.records.LocalResourceType; +import org.apache.hadoop.yarn.api.records.LocalResourceVisibility; +import org.apache.hadoop.yarn.api.records.Resource; +import org.apache.hadoop.yarn.conf.YarnConfiguration; +import org.apache.hadoop.yarn.util.ConverterUtils; + +import static com.linkedin.tony.Constants.*; + + +/** + * Represents a Tensorflow session. + */ +public class TensorFlowSession { + private static final Log LOG = LogFactory.getLog(TensorFlowSession.class); + + private String taskCmd; + + private String venv; + private String amAddress; + private int numWorkers; + private int numPs; + + // sessionId to distinguish different sessions. Currently used to distinguish + // failed session and new session. + public int sessionId = 0; + + // A map from task name to an array of TFTasks with that name. + private Map jobTasks = new ConcurrentHashMap<>(); + + private FinalApplicationStatus sessionFinalStatus = FinalApplicationStatus.UNDEFINED; + private String sessionFinalMessage = null; + private TensorFlowContainerRequest psContainerRequest; + private Map shellEnv; + private String jvmArgs; + private TensorFlowContainerRequest workerContainerRequest; + private Map jobTypeToRequestMap; + + public enum TaskType { + TASK_TYPE_CHIEF, TASK_TYPE_PARAMETER_SERVER, TASK_TYPE_OTHERS + } + + public String getTaskCommand() { + StringBuilder cmd = new StringBuilder(); + cmd.append("$JAVA_HOME/bin/java ") + .append(jvmArgs) + .append(" com.linkedin.tony.TaskExecutor ") + .append(" --am_address ") + .append(amAddress) + .append(" --task_command ") + .append(taskCmd); + if (venv != null) { + cmd.append(" --venv "); + cmd.append(venv); + } + for (Map.Entry entry : shellEnv.entrySet()) { + cmd.append(" --shell_env "); + cmd.append(entry.getKey()); + cmd.append("="); + cmd.append(entry.getValue()); + } + return cmd.toString(); + } + + private Map containerIdMap = new HashMap<>(); + + public TensorFlowSession() { + } + + private TensorFlowSession(Builder builder) { + this.taskCmd = builder.taskCmd; + this.venv = builder.venv; + this.amAddress = builder.amAddress; + this.psContainerRequest = builder.psContainerRequest; + this.workerContainerRequest = builder.workerContainerRequest; + this.shellEnv = builder.shellEnv; + this.jvmArgs = builder.jvmArgs; + + this.numWorkers = builder.numWorkers; + this.numPs = builder.numPs; + TFTask[] workerTasks = new TFTask[this.numWorkers]; + jobTasks.put(WORKER_JOB_NAME, workerTasks); + + + TFTask[] psTasks = new TFTask[this.numPs]; + jobTasks.put(PS_JOB_NAME, psTasks); + + this.jobTypeToRequestMap = ImmutableMap.of("ps", psContainerRequest, "worker", workerContainerRequest); + } + + public Map getTFTasks() { + return this.jobTasks; + } + + public void setResources(Configuration yarnConf, + Configuration hdfsConf, + Map localResources, + Map shellEnv, + String hdfsClasspathDir) { + + Map env = System.getenv(); + String zipPath = env.get(Constants.TF_ZIP_PREFIX + Constants.PATH_SUFFIX); + long zipTimestamp = Long.valueOf(env.get(Constants.TF_ZIP_PREFIX + Constants.TIMESTAMP_SUFFIX)); + long zipLength = Long.valueOf(env.get(Constants.TF_ZIP_PREFIX + Constants.LENGTH_SUFFIX)); + + LocalResource zipResource = + LocalResource.newInstance(ConverterUtils.getYarnUrlFromURI(URI.create(zipPath)), + LocalResourceType.ARCHIVE, LocalResourceVisibility.PRIVATE, + zipLength, zipTimestamp); + localResources.put(Constants.TF_ZIP_NAME, zipResource); + + String tonyConfPath = env.get(Constants.TONY_CONF_PREFIX + Constants.PATH_SUFFIX); + long tonyConfTimestamp = Long.valueOf(env.get(Constants.TONY_CONF_PREFIX + Constants.TIMESTAMP_SUFFIX)); + long tonyConfLength = Long.valueOf(env.get(Constants.TONY_CONF_PREFIX + Constants.LENGTH_SUFFIX)); + + LocalResource tonyConfResource = + LocalResource.newInstance(ConverterUtils.getYarnUrlFromURI(URI.create(tonyConfPath)), + LocalResourceType.FILE, LocalResourceVisibility.PRIVATE, + tonyConfLength, tonyConfTimestamp); + localResources.put(Constants.TONY_XML, tonyConfResource); + + try { + if (hdfsClasspathDir != null) { + FileSystem fs = FileSystem.get(new URI(hdfsClasspathDir), hdfsConf); + FileStatus[] ls = fs.listStatus(new Path(hdfsClasspathDir)); + for (FileStatus jar : ls) { + LocalResource resource = + LocalResource.newInstance(ConverterUtils.getYarnUrlFromURI(URI.create(jar.getPath().toString())), + LocalResourceType.FILE, LocalResourceVisibility.PRIVATE, + jar.getLen(), jar.getModificationTime()); + + localResources.put(jar.getPath().getName(), resource); + } + } + } catch (Exception e) { + throw new RuntimeException(e); + } + + StringBuilder classPathEnv = new StringBuilder(ApplicationConstants.Environment.CLASSPATH.$$()) + .append(ApplicationConstants.CLASS_PATH_SEPARATOR).append("./*"); + for (String c : yarnConf.getStrings( + YarnConfiguration.YARN_APPLICATION_CLASSPATH, + YarnConfiguration.DEFAULT_YARN_CROSS_PLATFORM_APPLICATION_CLASSPATH)) { + classPathEnv.append(ApplicationConstants.CLASS_PATH_SEPARATOR); + classPathEnv.append(c.trim()); + } + shellEnv.put("CLASSPATH", classPathEnv.toString()); + } + + public synchronized List getContainersRequests() { + List requests = new ArrayList<>(); + for (Map.Entry entry : jobTasks.entrySet()) { + TFTask[] tasks = entry.getValue(); + for (TFTask task : tasks) { + if (task == null) { + requests.add(createContainerRequestForType(entry.getKey())); + } + } + } + return requests; + } + + public TensorFlowContainerRequest createContainerRequestForType(String jobType) { + switch (jobType) { + case PS_JOB_NAME: + return new TensorFlowContainerRequest(psContainerRequest); + case WORKER_JOB_NAME: + return new TensorFlowContainerRequest(workerContainerRequest); + default: + throw new IllegalArgumentException("Invalid job type: " + jobType); + } + } + + public boolean allTasksScheduled() { + for (TFTask[] tasks : jobTasks.values()) { + for (TFTask task : tasks) { + if (task == null) { + return false; + } + } + } + + return true; + } + + // Get a task that hasn't been scheduled. + public synchronized TFTask getRemainingTask(Resource resource) { + for (Map.Entry entry : jobTasks.entrySet()) { + String jobName = entry.getKey(); + if (!jobTypeToRequestMap.get(jobName).matchesResourceRequest(resource)) { + continue; + } + TFTask[] tasks = entry.getValue(); + for (int i = 0; i < tasks.length; i++) { + if (tasks[i] == null) { + tasks[i] = new TFTask(jobName, String.valueOf(i)); + return tasks[i]; + } + } + } + return null; + } + + public Map> getClusterSpec() { + Map> map = new HashMap<>(); + + for (Map.Entry entry : jobTasks.entrySet()) { + String jobName = entry.getKey(); + TFTask[] tasks = entry.getValue(); + + List builder = new ArrayList<>(); + for (TFTask task : tasks) { + if (task == null) { + continue; + } + + String hostPort = task.getHostPort(); + builder.add(hostPort); + } + map.put(jobName, builder); + } + + return map; + } + + /** + * Refresh task status on each TaskExecutor registers its exit code with AM. + */ + public void onTaskCompleted(String jobName, String jobIndex, int exitCode) { + TFTask task = getTask(jobName, jobIndex); + Preconditions.checkNotNull(task); + TaskType taskType = getTaskType(task); + task.setExitStatus(exitCode); + switch (taskType) { + case TASK_TYPE_CHIEF: + case TASK_TYPE_PARAMETER_SERVER: + case TASK_TYPE_OTHERS: + // On worker failure, set job to fail. + if (exitCode != 0) { + setFinalStatus(FinalApplicationStatus.FAILED, "Exit status: " + exitCode); + } + break; + default: + break; + } + } + + /** + * Update the status of a session and set exit code if a session is completed. + */ + public void updateSessionStatus() { + int failureCount = 0; + for (Map.Entry entry : jobTasks.entrySet()) { + String jobName = entry.getKey(); + TFTask[] tasks = entry.getValue(); + + if (jobName.equals(PS_JOB_NAME)) { + // ignore PS job + continue; + } + + for (TFTask task : tasks) { + if (task == null) { + String msg = "Job is null, this should not happen."; + LOG.error(msg); + setFinalStatus(FinalApplicationStatus.FAILED, msg); + return; + } + boolean isCompleted = task.isCompleted(); + if (!isCompleted) { + String msg = "Job " + task.jobName + " at index: " + task.jobIndex + " haven't finished yet."; + LOG.error(msg); + setFinalStatus(FinalApplicationStatus.FAILED, msg); + return; + } + + int exitStatus = task.getExitStatus(); + if (exitStatus != 0) { + failureCount++; + } + } + } + + if (failureCount > 0) { + setFinalStatus(FinalApplicationStatus.FAILED, + "At least one job task exited with non-zero status, failedCnt=" + + failureCount); + } else { + LOG.info("Session completed with no job failures, setting final status SUCCEEDED."); + setFinalStatus(FinalApplicationStatus.SUCCEEDED, null); + } + } + + public String getFinalMessage() { + return sessionFinalMessage; + } + + public FinalApplicationStatus getFinalStatus() { + return sessionFinalStatus; + } + + public void setFinalStatus(FinalApplicationStatus status, String message) { + sessionFinalStatus = status; + sessionFinalMessage = message; + } + + private TaskType getTaskType(TFTask task) { + TaskType type; + String jobName = task.getJobName(); + if (jobName.equals(PS_JOB_NAME)) { + type = TaskType.TASK_TYPE_PARAMETER_SERVER; + } else { + type = TaskType.TASK_TYPE_OTHERS; + } + return type; + } + + private TFTask getTask(String jobName, String jobIndex) { + for (Map.Entry entry : jobTasks.entrySet()) { + TFTask[] tasks = entry.getValue(); + for (TFTask task : tasks) { + String job = task.getJobName(); + String index = task.getJobIndex(); + if (job.equals(jobName) && index.equals(jobIndex)) { + return task; + } + } + } + return null; + } + + public TFTask getTask(ContainerId containerId) { + return containerIdMap.get(containerId); + } + + /** + * Builder to compose the TensorFlowSession class. + */ + public static class Builder { + private String taskCmd; + private int numWorkers; + private int numPs; + private String venv; + private Map shellEnv; + private String amAddress; + private TensorFlowContainerRequest psContainerRequest; + private TensorFlowContainerRequest workerContainerRequest; + private String jvmArgs; + + public TensorFlowSession build() { + return new TensorFlowSession(this); + } + + public Builder setNumWorkers(int numWorkers) { + this.numWorkers = numWorkers; + return this; + } + + public Builder setNumPs(int numPs) { + this.numPs = numPs; + return this; + } + + public Builder setTaskCmd(String taskCmd) { + this.taskCmd = taskCmd; + return this; + } + + public Builder setVenv(String venv) { + this.venv = venv; + return this; + } + + public Builder setShellEnv(Map shellEnv) { + this.shellEnv = shellEnv; + return this; + } + + public Builder setAMAddress(String amAddress) { + this.amAddress = amAddress; + return this; + } + + public Builder setTaskExecutorJVMArgs(String jvmArgs) { + this.jvmArgs = jvmArgs; + return this; + } + + public Builder setPsContainerRequest(TensorFlowContainerRequest psRequest) { + this.psContainerRequest = psRequest; + return this; + } + + public Builder setWorkerContainerRequest(TensorFlowContainerRequest workerRequest) { + this.workerContainerRequest = workerRequest; + return this; + } + } + + /** + * A TFTask represents a task job executed in the workers. + */ + public class TFTask { + private final String jobName; + private final String jobIndex; + private String host; + private int port = -1; + + /** + * The container the task is running in. Set once a container has been allocated for the task. + */ + private Container container; + + int exitStatus = -1; + + /** + * Set to true when exit status is set. + */ + boolean completed = false; + + public String getJobName() { + return jobName; + } + + public String getJobIndex() { + return jobIndex; + } + + public String getHost() { + return host; + } + + public Container getContainer() { + return container; + } + + public void setContainer(Container container) { + this.container = container; + } + + public boolean isCompleted() { + return completed; + } + + String getHostPort() { + return String.format("%s:%d", host, port < 0 ? 0 : port); + } + + public void setHostPort(String hostPort) { + this.host = hostPort.split(":")[0]; + this.port = Integer.parseInt(hostPort.split(":")[1]); + } + + int getExitStatus() { + return exitStatus; + } + + void setExitStatus(int status) { + this.completed = true; + this.exitStatus = status; + } + + /** + * Returns a {@link TaskUrl} containing the HTTP URL for the task. + */ + public TaskUrl getTaskUrl() { + if (container == null) { + return null; + } + return new TaskUrl(jobName, jobIndex, Utils.constructContainerUrl(container)); + } + + TFTask(String jobName, String jobIndex) { + this.jobName = jobName; + this.jobIndex = jobIndex; + } + + public void addContainer(Container container) { + setContainer(container); + containerIdMap.put(container.getId(), this); + } + + /** + * Combination of jobName and Index. + * @return Id + */ + public String getId() { + return this.jobName + ":" + this.jobIndex; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + TFTask tfTask = (TFTask) o; + return Objects.equals(jobName, tfTask.jobName) && Objects.equals(jobIndex, tfTask.jobIndex); + } + + @Override + public int hashCode() { + return Objects.hash(jobName, jobIndex); + } + } + + public TFTask getTask(String taskId) { + try { + String[] tSplit = taskId.split(":"); + return jobTasks.get(tSplit[0])[Integer.parseInt(tSplit[1])]; + } catch (Exception e) { + return null; + } + } +} diff --git a/tony-core/src/main/proto/tensorflow_cluster_service_protos.proto b/tony-core/src/main/proto/tensorflow_cluster_service_protos.proto new file mode 100644 index 00000000..2478471e --- /dev/null +++ b/tony-core/src/main/proto/tensorflow_cluster_service_protos.proto @@ -0,0 +1,19 @@ +/** + * Copyright 2018 LinkedIn Corporation. All rights reserved. Licensed under the BSD-2 Clause license. + * See LICENSE in the project root for license information. + */ +option java_package = "com.linkedin.tony.rpc.proto"; +option java_outer_classname = "TensorFlowCluster"; + +option java_generic_services = true; +import "yarn_tensorflow_cluster_protos.proto"; + +service TensorFlowClusterService { + rpc getTaskUrls (GetTaskUrlsRequestProto) returns (GetTaskUrlsResponseProto); + rpc getClusterSpec (GetClusterSpecRequestProto) returns (GetClusterSpecResponseProto); + rpc registerWorkerSpec (RegisterWorkerSpecRequestProto) returns (RegisterWorkerSpecResponseProto); + rpc registerTensorBoardUrl (RegisterTensorBoardUrlRequestProto) returns (RegisterTensorBoardUrlResponseProto); + rpc registerExecutionResult(RegisterExecutionResultRequestProto) returns (RegisterExecutionResultResponseProto); + rpc finishApplication (EmptyProto) returns (EmptyProto); // Signals a AM that it can exit now. + rpc taskExecutorHeartbeat (HeartbeatRequestProto) returns (HeartbeatResponseProto); // To be used only by the Task Executor +} diff --git a/tony-core/src/main/proto/yarn_tensorflow_cluster_protos.proto b/tony-core/src/main/proto/yarn_tensorflow_cluster_protos.proto new file mode 100644 index 00000000..eb568d93 --- /dev/null +++ b/tony-core/src/main/proto/yarn_tensorflow_cluster_protos.proto @@ -0,0 +1,64 @@ +/** + * Copyright 2018 LinkedIn Corporation. All rights reserved. Licensed under the BSD-2 Clause license. + * See LICENSE in the project root for license information. + */ +option java_package = "com.linkedin.tony.rpc.proto"; +option java_outer_classname = "YarnTensorFlowClusterProtos"; + +message GetTaskUrlsRequestProto { +} + +message GetTaskUrlsResponseProto { + message TaskUrlProto { + required string name = 1; + required string index = 2; + required string url = 3; + } + repeated TaskUrlProto task_urls = 1; +} + +message GetClusterSpecRequestProto { +} + +message GetClusterSpecResponseProto { + required string cluster_spec = 1; +} + +message RegisterWorkerSpecRequestProto { + optional string worker = 1; + optional string spec = 2; +} + +message RegisterWorkerSpecResponseProto { + optional string spec = 1; +} + +message RegisterTensorBoardUrlRequestProto { + optional string spec = 1; +} + +message RegisterTensorBoardUrlResponseProto { + optional string spec = 1; +} + +message RegisterExecutionResultRequestProto { + required int32 exitCode = 1; + required string jobName = 2; + required string jobIndex = 3; + required string sessionId = 4; +} + +message RegisterExecutionResultResponseProto { + required string message = 1; +} + +message EmptyProto { +} + +message HeartbeatRequestProto { + required string taskId = 1; +} + +message HeartbeatResponseProto { + // nothing for the time-being, but we can include commands later +} \ No newline at end of file diff --git a/tony-core/src/main/resources/META-INF/services/org.apache.hadoop.security.SecurityInfo b/tony-core/src/main/resources/META-INF/services/org.apache.hadoop.security.SecurityInfo new file mode 100644 index 00000000..bd23220e --- /dev/null +++ b/tony-core/src/main/resources/META-INF/services/org.apache.hadoop.security.SecurityInfo @@ -0,0 +1,3 @@ +# Copyright 2018 LinkedIn Corporation. All rights reserved. Licensed under the BSD-2 Clause license. +# See LICENSE in the project root for license information. +com.linkedin.tony.TFClientSecurityInfo diff --git a/tony-core/src/main/resources/log4j.properties b/tony-core/src/main/resources/log4j.properties new file mode 100644 index 00000000..1da82301 --- /dev/null +++ b/tony-core/src/main/resources/log4j.properties @@ -0,0 +1,8 @@ +# Root logger option +log4j.rootLogger=INFO, stdout + +# Direct log messages to stdout +log4j.appender.stdout=org.apache.log4j.ConsoleAppender +log4j.appender.stdout.Target=System.out +log4j.appender.stdout.layout=org.apache.log4j.PatternLayout +log4j.appender.stdout.layout.ConversionPattern=%d{yyyy-MM-dd HH:mm:ss} %-5p %c{1}:%L - %m%n diff --git a/tony-core/src/main/resources/tony-default.xml b/tony-core/src/main/resources/tony-default.xml new file mode 100644 index 00000000..50edcf49 --- /dev/null +++ b/tony-core/src/main/resources/tony-default.xml @@ -0,0 +1,180 @@ + + + + + + + Namenode URIs to get delegation tokens from. + tony.other.namenodes + + + + + Default queue to submit to YARN. + tony.yarn.queue + default + + + + Name of your YARN application. + tony.application.name + TensorFlowApplication + + + + YARN partition which this application should run in. + tony.application.node-label + + + + Whether this is single node training or not. + tony.application.single-node + false + + + + Whether the AM should invoke the user's python script or not. + tony.application.enable-preprocess + false + + + + Max runtime of the application before killing it, in milliseconds. + tony.application.timeout + 0 + + + + + JVM opts for each TaskExecutor. + tony.task.executor.jvm.opts + -Xmx1536m + + + + Timeout, in seconds, for AM to resubmit unregistered tasks (or fail if no retries configured). + tony.task.registration-timeout-sec + 300 + + + + How many times we should resubmit unregistered tasks after the timeout interval. + tony.task.registration-retry-count + 0 + + + + Frequency, in milliseconds, for which TaskExecutors should heartbeat with AM. + tony.task.heartbeat-interval + 1000 + + + + How many missed heartbeats before declaring a TaskExecutor dead. + tony.task.max-missed-heartbeats + 25 + + + + + How many times a failed AM should retry. + tony.am.retry-count + 0 + + + + AM memory size, requested as a string (e.g. '2g' or '2048m'). + tony.am.memory + 2g + + + + Number of AM vcores to use. + tony.am.vcores + 1 + + + + Number of AM GPUs to use. (In general, should only be applicable in single node mode.) + tony.am.gpus + 0 + + + + + Parameter server memory size, requested as a string (e.g. '2g' or '2048m'). + tony.ps.memory + 2g + + + + Number of vcores per parameter server. + tony.ps.vcores + 1 + + + + Number of parameter servers to request. + tony.ps.instances + 1 + + + + + Timeout, in milliseconds for the user's python processes before forcibly killing them. + tony.worker.timeout + 0 + + + + Worker memory size, requested as a string (e.g. '2g' or '2048m'). + tony.worker.memory + 2g + + + + Number of vcores per worker. + tony.worker.vcores + 1 + + + + Number of GPUs per worker. + tony.worker.gpus + 0 + + + + Number of workers to request. + tony.worker.instances + 1 + + + + + + Whether this application is running in a Kerberized grid. Enabling this will fetch tokens from the cluster as + well as between the client and AM. + + tony.application.insecure-mode + false + + + + + Path to HDFS configuration, to be passed as an environment variable to the python training scripts. + + tony.application.hdfs-conf-path + + + + + Path to YARN configuration, to be passed as an environment variable to the python training scripts. + + tony.application.yarn-conf-path + + + diff --git a/tony-core/src/test/java/com/linkedin/tony/TestReader.java b/tony-core/src/test/java/com/linkedin/tony/TestReader.java new file mode 100644 index 00000000..ecf56c25 --- /dev/null +++ b/tony-core/src/test/java/com/linkedin/tony/TestReader.java @@ -0,0 +1,244 @@ +/** + * Copyright 2018 LinkedIn Corporation. All rights reserved. Licensed under the BSD-2 Clause license. + * See LICENSE in the project root for license information. + */ +package com.linkedin.tony; + +import com.linkedin.tony.io.HdfsAvroFileSplitReader; +import java.io.File; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Random; +import org.apache.avro.Schema; +import org.apache.avro.Schema.Field; +import org.apache.avro.file.DataFileWriter; +import org.apache.avro.generic.GenericData; +import org.apache.avro.generic.GenericDatumReader; +import org.apache.avro.generic.GenericDatumWriter; +import org.apache.avro.generic.GenericRecord; +import org.apache.avro.io.BinaryDecoder; +import org.apache.avro.io.DecoderFactory; +import org.apache.hadoop.conf.Configuration; +import org.testng.annotations.Test; + +import static org.testng.Assert.assertEquals; + + +public class TestReader { + private static final int NUM_RECORD = 100000; + + /** + * Tests HdfsAvroFileSplitReader calculating offset correctly. Specifically + * given a specific total length, a max id, HdfsAvroFileSplitReader can + * get the correct byte range for each of the id. "correct" here means the + * byte range are non-overlapping and covers the entire range. + */ + @Test + public void testOffsetCalculation() { + for (int t = 0; t < 1000; t++) { + Random ran = new Random(); + long totalLen = Math.abs(ran.nextLong()) % 100000 + 10000; + int totalIdx = ran.nextInt(20) + 10; // make sure this is not 0 + + long next_start = 0; + + for (int i = 0; i < totalIdx; i++) { + long start = HdfsAvroFileSplitReader.computeReadSplitStart( + totalLen, i, totalIdx); + assertEquals(next_start, start); + long length = HdfsAvroFileSplitReader + .computeReadSplitLength(totalLen, i, totalIdx); + next_start = start + length; + } + assertEquals(totalLen, next_start); + } + } + + /** + * Tests HdfsAvroFileSplitReader can read multiple avro files correctly. + */ + @Test + public void testReader() throws IOException, InterruptedException { + Configuration conf = new Configuration(); + String path0 = "testReader0.avro"; + String path1 = "testReader1.avro"; + String path2 = "testReader2.avro"; + Files.deleteIfExists(Paths.get(path0)); + Files.deleteIfExists(Paths.get(path1)); + Files.deleteIfExists(Paths.get(path2)); + try { + Schema schema = generateTestSchema(); + List all_records = new ArrayList<>(); + all_records.addAll(generateTestAvro(path0, schema)); + all_records.addAll(generateTestAvro(path1, schema)); + all_records.addAll(generateTestAvro(path2, schema)); + // NOTE This will not use HDFS, this generates a RawLocalFileSystem + // instance, we will only be testing with local file system in this unit + // test. + HdfsAvroFileSplitReader reader = + new HdfsAvroFileSplitReader(conf, Arrays.asList(path0, path1, path2), + 0, 1); + + // check schema can be correctly read + String schemaJson = reader.getSchemaJson(); + Schema readSchema = new Schema.Parser().parse(schemaJson); + assertEquals(schema, readSchema); + + readAndCheck(reader, readSchema, all_records); + assertEquals(all_records.size(), 0); + reader.close(); + } finally { + Files.deleteIfExists(Paths.get(path0)); + Files.deleteIfExists(Paths.get(path1)); + Files.deleteIfExists(Paths.get(path2)); + } + } + + /** + * Tests HdfsAvroFileSplitReader can read avro files split correctly. + * Specifically test several readers reading several files. + */ + @Test + public void testReaderPartialRead() throws IOException, InterruptedException { + Configuration conf = new Configuration(); + String path0 = "testReader0.avro"; + String path1 = "testReader1.avro"; + String path2 = "testReader2.avro"; + Files.deleteIfExists(Paths.get(path0)); + Files.deleteIfExists(Paths.get(path1)); + Files.deleteIfExists(Paths.get(path2)); + try { + Schema schema = generateTestSchema(); + + List records0 = generateTestAvro(path0, schema); + List records1 = generateTestAvro(path1, schema); + List records2 = generateTestAvro(path2, schema); + + List all_records = new ArrayList<>(); + all_records.addAll(records0); + all_records.addAll(records1); + all_records.addAll(records2); + + // NOTE here we have 3 avro files, and a total of 3 splits. + // but it does not necessarily mean each reader will be processing + // exactly one file, because the files are different, and a small + // difference could cause the avro file sync point to be far off. + HdfsAvroFileSplitReader reader0 = + new HdfsAvroFileSplitReader(conf, Arrays.asList(path0, path1, path2), + 0, 3); + // wait a bit for the thread to start and load the schema + Thread.sleep(100); + String schemaJson = reader0.getSchemaJson(); + Schema readSchema = new Schema.Parser().parse(schemaJson); + assertEquals(schema, readSchema); + // this call below will remove some entries from all_records. + readAndCheck(reader0, readSchema, all_records); + reader0.close(); + + HdfsAvroFileSplitReader reader1 = + new HdfsAvroFileSplitReader(conf, Arrays.asList(path0, path1, path2), + 1, 3); + // wait a bit for the thread to start and load the schema + Thread.sleep(100); + schemaJson = reader1.getSchemaJson(); + readSchema = new Schema.Parser().parse(schemaJson); + assertEquals(schema, readSchema); + readAndCheck(reader1, readSchema, all_records); + reader1.close(); + + HdfsAvroFileSplitReader reader2 = + new HdfsAvroFileSplitReader(conf, Arrays.asList(path0, path1, path2), + 2, 3); + // wait a bit for the thread to start and load the schema + Thread.sleep(100); + schemaJson = reader2.getSchemaJson(); + readSchema = new Schema.Parser().parse(schemaJson); + assertEquals(schema, readSchema); + readAndCheck(reader2, readSchema, all_records); + reader2.close(); + + // after all three readers, all records should be removed. + assertEquals(all_records.size(), 0); + } finally { + Files.deleteIfExists(Paths.get(path0)); + Files.deleteIfExists(Paths.get(path1)); + Files.deleteIfExists(Paths.get(path2)); + } + } + + private void readAndCheck(HdfsAvroFileSplitReader reader, Schema readSchema, + List all_records) throws IOException, InterruptedException { + while (reader.hasNext()) { + // an arbitrary chosen batch size, to capture the more likely case + // that last batch will be smaller + List records = reader.nextBatchBytes(900); + + for (ByteBuffer recordBytes : records) { + byte[] buffer = recordBytes.array(); + BinaryDecoder decoder = + DecoderFactory.get().binaryDecoder(buffer, null); + GenericDatumReader datumReader = + new GenericDatumReader<>(readSchema); + GenericRecord record = datumReader.read(null, decoder); + String name = record.get("name").toString(); + int age = (Integer) record.get("age"); + + GenericData.Record expected = all_records.remove(0); + + assertEquals(name, expected.get("name")); + assertEquals(age, expected.get("age")); + } + } + } + + private Schema generateTestSchema() { + List fields = new ArrayList<>(); + fields.add(new Field("name", Schema.create(Schema.Type.STRING), + null, "default")); + fields.add(new Field("age", Schema.create(Schema.Type.INT), + null, -1)); + + Schema schema = Schema.createRecord("Person", null, null, false); + schema.setFields(fields); + + return schema; + } + + private List generateTestAvro( + String path, Schema schema) throws IOException { + + Random ran = new Random(); + File file = new File(path); + + List all_records = new ArrayList<>(); + + GenericDatumWriter datum = + new GenericDatumWriter<>(schema); + DataFileWriter writer = + new DataFileWriter<>(datum); + + writer.create(schema, file); + + for (int i = 0; i < NUM_RECORD; i ++) { + GenericData.Record record = + makeObject(schema, "Person " + i, ran.nextInt(120)); + all_records.add(record); + writer.append(record); + } + writer.close(); + return all_records; + } + + private static GenericData.Record makeObject(Schema schema, String name, + int age) { + GenericData.Record record = new GenericData.Record(schema); + record.put("name", name); + record.put("age", age); + return(record); + } +} diff --git a/tony-core/src/test/java/com/linkedin/tony/TestTaskExecutor.java b/tony-core/src/test/java/com/linkedin/tony/TestTaskExecutor.java new file mode 100644 index 00000000..b6378227 --- /dev/null +++ b/tony-core/src/test/java/com/linkedin/tony/TestTaskExecutor.java @@ -0,0 +1,50 @@ +/** + * Copyright 2018 LinkedIn Corporation. All rights reserved. Licensed under the BSD-2 Clause license. + * See LICENSE in the project root for license information. + */ +package com.linkedin.tony; + +import org.apache.hadoop.conf.Configuration; +import org.testng.annotations.BeforeTest; +import org.testng.annotations.Test; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import static org.testng.Assert.assertEquals; + + +public class TestTaskExecutor { + private String[] args; + + @BeforeTest + public void setup() { + List listArgs = new ArrayList<>(); + listArgs.add("-am_address"); + listArgs.add("localhost:1234"); + listArgs.add("-task_command"); + listArgs.add("'sleep 5'"); + listArgs.add("-venv"); + listArgs.add("venv.zip"); + args = listArgs.toArray(new String[listArgs.size()]); + } + + @Test + public void testTaskExecutorConf() throws Exception { + TaskExecutor taskExecutor = new TaskExecutor(); + Configuration tonyConf = new Configuration(false); + tonyConf.setInt(TonyConfigurationKeys.TASK_HEARTBEAT_INTERVAL_MS, 2000); + File confFile = new File(System.getProperty("user.dir"), Constants.TONY_XML); + try (OutputStream os = new FileOutputStream(confFile)) { + tonyConf.writeXml(os); + } + taskExecutor.init(args); + assertEquals(2000, taskExecutor.tonyConf.getInt(TonyConfigurationKeys.TASK_HEARTBEAT_INTERVAL_MS, + TonyConfigurationKeys.DEFAULT_TASK_HEARTBEAT_INTERVAL_MS)); + confFile.delete(); + } +} diff --git a/tony-core/src/test/java/com/linkedin/tony/TestTensorFlowContainerRequest.java b/tony-core/src/test/java/com/linkedin/tony/TestTensorFlowContainerRequest.java new file mode 100644 index 00000000..f10b77f9 --- /dev/null +++ b/tony-core/src/test/java/com/linkedin/tony/TestTensorFlowContainerRequest.java @@ -0,0 +1,28 @@ +/** + * Copyright 2018 LinkedIn Corporation. All rights reserved. Licensed under the BSD-2 Clause license. + * See LICENSE in the project root for license information. + */ +package com.linkedin.tony; + +import com.linkedin.tony.tensorflow.TensorFlowContainerRequest; +import org.apache.hadoop.yarn.api.records.Resource; +import org.apache.hadoop.yarn.api.records.ResourceInformation; +import org.testng.Assert; +import org.testng.annotations.Test; + + +public class TestTensorFlowContainerRequest { + @Test + public void testMatchesResourceRequest() { + // Container request for 1024 MB, 1 core, 1 GPU + TensorFlowContainerRequest containerRequest = new TensorFlowContainerRequest("worker", 1, 1024, 1, 0); + + // Resource request for 1024 MB, 1 core, should not match (does not include GPUs) + Resource resource = Resource.newInstance(1024, 1); + Assert.assertFalse(containerRequest.matchesResourceRequest(resource)); + + // Now add 1 GPU, should match + resource.setResourceValue(ResourceInformation.GPU_URI, 1); + Assert.assertTrue(containerRequest.matchesResourceRequest(resource)); + } +} diff --git a/tony-core/src/test/java/com/linkedin/tony/TestTonyApplicationMaster.java b/tony-core/src/test/java/com/linkedin/tony/TestTonyApplicationMaster.java new file mode 100644 index 00000000..7efd1a83 --- /dev/null +++ b/tony-core/src/test/java/com/linkedin/tony/TestTonyApplicationMaster.java @@ -0,0 +1,35 @@ +/** + * Copyright 2018 LinkedIn Corporation. All rights reserved. Licensed under the BSD-2 Clause license. + * See LICENSE in the project root for license information. + */ +package com.linkedin.tony; + +import org.testng.Assert; +import org.testng.annotations.Test; + + +public class TestTonyApplicationMaster { + @Test + public void testBuildBaseTaskCommand() { + // null venv zip + String actual = TonyApplicationMaster.buildBaseTaskCommand(null, "/export/apps/python/2.7/bin/python2.7", + "src/main/python/my_awesome_script.py", "--input_dir hdfs://default/foo/bar"); + String expected = "/export/apps/python/2.7/bin/python2.7 " + Constants.TF_ZIP_NAME + + "/src/main/python/my_awesome_script.py --input_dir hdfs://default/foo/bar"; + Assert.assertEquals(actual, expected); + + // venv zip is set, but should be ignored since pythonBinaryPath is absolute + actual = TonyApplicationMaster.buildBaseTaskCommand("my_venv.zip", "/export/apps/python/2.7/bin/python2.7", + "src/main/python/my_awesome_script.py", "--input_dir hdfs://default/foo/bar"); + expected = "/export/apps/python/2.7/bin/python2.7 " + Constants.TF_ZIP_NAME + + "/src/main/python/my_awesome_script.py --input_dir hdfs://default/foo/bar"; + Assert.assertEquals(actual, expected); + + // pythonBinaryPath is relative, so should be appended to "venv" + actual = TonyApplicationMaster.buildBaseTaskCommand("my_venv.zip", "Python/bin/python", + "src/main/python/my_awesome_script.py", "--input_dir hdfs://default/foo/bar"); + expected = Constants.PYTHON_VENV_DIR + "/Python/bin/python " + Constants.TF_ZIP_NAME + + "/src/main/python/my_awesome_script.py --input_dir hdfs://default/foo/bar"; + Assert.assertEquals(actual, expected); + } +} diff --git a/tony-core/src/test/java/com/linkedin/tony/TestTonyClient.java b/tony-core/src/test/java/com/linkedin/tony/TestTonyClient.java new file mode 100644 index 00000000..02963a1f --- /dev/null +++ b/tony-core/src/test/java/com/linkedin/tony/TestTonyClient.java @@ -0,0 +1,54 @@ +/** + * Copyright 2018 LinkedIn Corporation. All rights reserved. Licensed under the BSD-2 Clause license. + * See LICENSE in the project root for license information. + */ +package com.linkedin.tony; + +import java.io.File; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.apache.hadoop.io.DataOutputBuffer; +import org.apache.hadoop.security.Credentials; +import org.apache.hadoop.yarn.api.records.ApplicationId; +import org.apache.hadoop.yarn.api.records.ContainerLaunchContext; +import org.testng.Assert; +import org.testng.annotations.BeforeClass; +import org.testng.annotations.Test; + +public class TestTonyClient { + + TonyClient client; + + @BeforeClass + public void setup() { + client = new TonyClient(); + } + + @Test + public void testCreateAMContainerSpec() throws Exception { + File zipFile = new File(System.getProperty("user.dir") + "/tf_archive.zip"); + zipFile.createNewFile(); + zipFile.deleteOnExit(); + File tonyConfFile = new File(System.getProperty("user.dir") + + File.separator + Constants.TONY_FINAL_XML); + tonyConfFile.createNewFile(); + tonyConfFile.deleteOnExit(); + Map shellEnv = new HashMap<>(); + client.createYarnClient(); + ApplicationId appId = ApplicationId.newInstance(1, 1); + ContainerLaunchContext clc = client.createAMContainerSpec(appId, "tfTest", + 8192, null, null, null, null, getTokens(), null); + List cmds = clc.getCommands(); + Assert.assertTrue(cmds.get(0).contains("-Xmx6553m")); + } + + private ByteBuffer getTokens() throws IOException { + Credentials creds = new Credentials(); + DataOutputBuffer buffer = new DataOutputBuffer(); + creds.writeTokenStorageToStream(buffer); + return ByteBuffer.wrap(buffer.getData(), 0, buffer.getLength()); + } +} diff --git a/tony-core/src/test/java/com/linkedin/tony/TestTonyConfigurationFields.java b/tony-core/src/test/java/com/linkedin/tony/TestTonyConfigurationFields.java new file mode 100644 index 00000000..8da54928 --- /dev/null +++ b/tony-core/src/test/java/com/linkedin/tony/TestTonyConfigurationFields.java @@ -0,0 +1,53 @@ +/** + * Copyright 2018 LinkedIn Corporation. All rights reserved. Licensed under the BSD-2 Clause license. + * See LICENSE in the project root for license information. + */ +package com.linkedin.tony; + +import java.io.File; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.apache.hadoop.conf.TestConfigurationFieldsBase; +import org.apache.hadoop.io.DataOutputBuffer; +import org.apache.hadoop.security.Credentials; +import org.apache.hadoop.yarn.api.records.ApplicationId; +import org.apache.hadoop.yarn.api.records.ContainerLaunchContext; +import org.testng.Assert; +import org.testng.annotations.BeforeTest; +import org.testng.annotations.Test; + +public class TestTonyConfigurationFields extends TestConfigurationFieldsBase { + + @Override + public void initializeMemberVariables() { + xmlFilename = new String(Constants.TONY_DEFAULT_XML); + configurationClasses = new Class[] { TonyConfigurationKeys.class }; + + // Set error modes + errorIfMissingConfigProps = true; + errorIfMissingXmlProps = true; + } + + @BeforeTest + public void setupTestConfigurationFields() throws Exception { + super.setupTestConfigurationFields(); + } + + @Test + public void testCompareConfigurationClassAgainstXml() { + super.testCompareConfigurationClassAgainstXml(); + } + + @Test + public void testCompareXmlAgainstConfigurationClass() { + super.testCompareXmlAgainstConfigurationClass(); + } + + @Test + public void testXmlAgainstDefaultValuesInConfigurationClass() { + super.testXmlAgainstDefaultValuesInConfigurationClass(); + } +} diff --git a/tony-core/src/test/java/com/linkedin/tony/TestTonyE2E.java b/tony-core/src/test/java/com/linkedin/tony/TestTonyE2E.java new file mode 100644 index 00000000..26f4596f --- /dev/null +++ b/tony-core/src/test/java/com/linkedin/tony/TestTonyE2E.java @@ -0,0 +1,225 @@ +/** + * Copyright 2018 LinkedIn Corporation. All rights reserved. Licensed under the BSD-2 Clause license. + * See LICENSE in the project root for license information. + */ +package com.linkedin.tony; + +import com.linkedin.minitony.cluster.HDFSUtils; +import com.linkedin.minitony.cluster.MiniCluster; +import com.linkedin.minitony.cluster.MiniTonyUtils; +import java.nio.file.Files; +import org.apache.hadoop.conf.Configuration; +import org.apache.hadoop.fs.FileSystem; +import org.apache.hadoop.fs.Path; +import org.testng.Assert; +import org.testng.annotations.AfterClass; +import org.testng.annotations.BeforeClass; +import org.testng.annotations.Test; + + +/** + * Before running these tests in your IDE, you should run {@code ligradle :tony:setupHdfsLib} first. If you make any + * changes to {@code src/main/java} code, you'll need to run the above command again. + */ +public class TestTonyE2E { + + private MiniCluster cluster; + private String yarnConf; + private String hdfsConf; + + @BeforeClass + public void setup() throws Exception { + // Set up mini cluster. + cluster = new MiniCluster(3); + cluster.start(); + yarnConf = Files.createTempFile("yarn", ".xml").toString(); + hdfsConf = Files.createTempFile("hdfs", ".xml").toString(); + MiniTonyUtils.saveConfigToFile(cluster.getYarnConf(), yarnConf); + MiniTonyUtils.saveConfigToFile(cluster.getHdfsConf(), hdfsConf); + FileSystem fs = FileSystem.get(cluster.getHdfsConf()); + // This is the path we gonna store required libraries in the local HDFS. + Path cachedLibPath = new Path("/yarn/libs"); + if (fs.exists(cachedLibPath)) { + fs.delete(cachedLibPath, true); + } + fs.mkdirs(cachedLibPath); + HDFSUtils.copyDirectoryFilesToFolder(fs, "tony-core/out/libs", "/yarn/libs"); + HDFSUtils.copyDirectoryFilesToFolder(fs, "tony-core/src/test/resources/log4j.properties", "/yarn/libs"); + } + + @AfterClass + public void tearDown() { + cluster.stop(); + } + + @Test + public void testSingleNodeTrainingShouldPass() { + Configuration conf = new Configuration(false); + conf.setBoolean(TonyConfigurationKeys.IS_SINGLE_NODE, true); + conf.setBoolean(TonyConfigurationKeys.IS_INSECURE_MODE, true); + conf.set(TonyConfigurationKeys.HDFS_CONF_LOCATION, hdfsConf); + conf.set(TonyConfigurationKeys.YARN_CONF_LOCATION, yarnConf); + int exitCode = TonyClient.start(new String[] { + "--src_dir", "tony-core/src/test/resources/", + "--executes", "tony-core/src/test/resources/exit_0_check_env.py", + "--hdfs_classpath", "/yarn/libs", + "--python_binary_path", "python", + "--shell_env", "ENV_CHECK=ENV_CHECK", + "--container_env", Constants.SKIP_HADOOP_PATH + "=true" + }, conf); + Assert.assertEquals(exitCode, 0); + } + + @Test + public void testPSWorkerTrainingShouldFailMissedHeartbeat() { + Configuration conf = new Configuration(false); + conf.setBoolean(TonyConfigurationKeys.IS_INSECURE_MODE, true); + conf.set(TonyConfigurationKeys.HDFS_CONF_LOCATION, hdfsConf); + conf.set(TonyConfigurationKeys.YARN_CONF_LOCATION, yarnConf); + conf.setInt(TonyConfigurationKeys.TASK_MAX_MISSED_HEARTBEATS, 2); + int exitCode = TonyClient.start(new String[]{ + "--src_dir", "tony-core/src/test/resources/", + "--executes", "tony-core/src/test/resources/exit_0_check_env.py", + "--hdfs_classpath", "/yarn/libs", + "--python_binary_path", "python", + "--container_env", Constants.SKIP_HADOOP_PATH + "=true", + "--container_env", Constants.TEST_TASK_EXECUTOR_NUM_HB_MISS + "=5" + }, conf); + Assert.assertNotEquals(exitCode, 0); + } + + @Test + public void testPSSkewedWorkerTrainingShouldPass() { + Configuration conf = new Configuration(false); + conf.setBoolean(TonyConfigurationKeys.IS_INSECURE_MODE, true); + conf.set(TonyConfigurationKeys.HDFS_CONF_LOCATION, hdfsConf); + conf.set(TonyConfigurationKeys.YARN_CONF_LOCATION, yarnConf); + conf.setInt(TonyConfigurationKeys.WORKER_INSTANCES, 2); + int exitCode = TonyClient.start(new String[]{ + "--src_dir", "tony-core/src/test/resources/", + "--executes", "tony-core/src/test/resources/exit_0_check_env.py", + "--hdfs_classpath", "/yarn/libs", + "--python_binary_path", "python", + "--shell_env", "ENV_CHECK=ENV_CHECK", + "--container_env", Constants.SKIP_HADOOP_PATH + "=true", + "--container_env", Constants.TEST_TASK_EXECUTOR_SKEW+ "=worker#0#30000" + }, conf); + Assert.assertEquals(exitCode, 0); + } + + @Test + public void testPSWorkerTrainingShouldPass() { + Configuration conf = new Configuration(false); + conf.setBoolean(TonyConfigurationKeys.IS_INSECURE_MODE, true); + conf.set(TonyConfigurationKeys.HDFS_CONF_LOCATION, hdfsConf); + conf.set(TonyConfigurationKeys.YARN_CONF_LOCATION, yarnConf); + int exitCode = TonyClient.start(new String[]{ + "--src_dir", "tony-core/src/test/resources/", + "--executes", "tony-core/src/test/resources/exit_0_check_env.py", + "--hdfs_classpath", "/yarn/libs", + "--python_binary_path", "python", + "--shell_env", "ENV_CHECK=ENV_CHECK", + "--container_env", Constants.SKIP_HADOOP_PATH + "=true" + }, conf); + Assert.assertEquals(exitCode, 0); + } + + @Test + public void testPSWorkerTrainingShouldFail() { + Configuration conf = new Configuration(false); + conf.setBoolean(TonyConfigurationKeys.IS_INSECURE_MODE, true); + conf.set(TonyConfigurationKeys.HDFS_CONF_LOCATION, hdfsConf); + conf.set(TonyConfigurationKeys.YARN_CONF_LOCATION, yarnConf); + int exitCode = TonyClient.start(new String[]{ + "--src_dir", "tony-core/src/test/resources/", + "--executes", "tony-core/src/test/resources/exit_1.py", + "--hdfs_classpath", "/yarn/libs", + "--python_binary_path", "python", + "--container_env", Constants.SKIP_HADOOP_PATH + "=true" + }, conf); + Assert.assertEquals(exitCode, -1); + } + + @Test + public void testSingleNodeTrainingShouldFail() { + Configuration conf = new Configuration(false); + conf.setBoolean(TonyConfigurationKeys.IS_SINGLE_NODE, true); + conf.setBoolean(TonyConfigurationKeys.IS_INSECURE_MODE, true); + conf.set(TonyConfigurationKeys.HDFS_CONF_LOCATION, hdfsConf); + conf.set(TonyConfigurationKeys.YARN_CONF_LOCATION, yarnConf); + int exitCode = TonyClient.start(new String[]{ + "--src_dir", "tony-core/src/test/resources/", + "--executes", "tony-core/src/test/resources/exit_1.py", + "--hdfs_classpath", "/yarn/libs", + "--python_binary_path", "python", + "--container_env", Constants.SKIP_HADOOP_PATH + "=true" + }, conf); + Assert.assertEquals(exitCode, -1); + } + + @Test + public void testAMCrashTonyShouldFail() { + Configuration conf = new Configuration(false); + conf.setBoolean(TonyConfigurationKeys.IS_SINGLE_NODE, true); + conf.setBoolean(TonyConfigurationKeys.IS_INSECURE_MODE, true); + conf.set(TonyConfigurationKeys.HDFS_CONF_LOCATION, hdfsConf); + conf.set(TonyConfigurationKeys.YARN_CONF_LOCATION, yarnConf); + int exitCode = TonyClient.start(new String[]{ + "--src_dir", "tony-core/src/test/resources/", + "--executes", "tony-core/src/test/resources/exit_0.py", + "--hdfs_classpath", "/yarn/libs", + "--python_binary_path", "python", + "--container_env", Constants.TEST_AM_CRASH + "=true", + "--container_env", Constants.SKIP_HADOOP_PATH + "=true" + }, conf); + Assert.assertEquals(exitCode, -1); + } + + /** + * The ps and workers should hang on the first attempt, and after 20 seconds, the AM should release the containers and + * reschedule them in new containers. + */ + @Test(enabled = false) + public void testAMRequestsNewContainersAfterTimeout() { + Configuration conf = new Configuration(false); + conf.setBoolean(TonyConfigurationKeys.IS_INSECURE_MODE, true); + conf.set(TonyConfigurationKeys.HDFS_CONF_LOCATION, hdfsConf); + conf.set(TonyConfigurationKeys.YARN_CONF_LOCATION, yarnConf); + conf.setInt(TonyConfigurationKeys.PS_INSTANCES, 2); + conf.setInt(TonyConfigurationKeys.WORKER_INSTANCES, 2); + conf.setInt(TonyConfigurationKeys.TASK_REGISTRATION_TIMEOUT_SEC, 20); + conf.setInt(TonyConfigurationKeys.TASK_REGISTRATION_RETRY_COUNT, 1); + int exitCode = TonyClient.start(new String[]{ + "--src_dir", "tony-core/src/test/resources/", + "--executes", "tony-core/src/test/resources/exit_0.py", + "--hdfs_classpath", "/yarn/libs", + "--python_binary_path", "python", + "--container_env", Constants.TEST_TASK_EXECUTOR_HANG + "=true", + "--container_env", Constants.SKIP_HADOOP_PATH + "=true" + }, conf); + Assert.assertEquals(exitCode, 0); + } + + /** + * Tests that the AM will stop the job after a timeout period if the tasks have not registered yet (which suggests + * they are stuck). + */ + @Test + public void testAMStopsJobAfterTimeout() { + Configuration conf = new Configuration(false); + conf.setBoolean(TonyConfigurationKeys.IS_INSECURE_MODE, true); + conf.set(TonyConfigurationKeys.HDFS_CONF_LOCATION, hdfsConf); + conf.set(TonyConfigurationKeys.YARN_CONF_LOCATION, yarnConf); + conf.setInt(TonyConfigurationKeys.TASK_REGISTRATION_TIMEOUT_SEC, 5); + conf.setInt(TonyConfigurationKeys.TASK_REGISTRATION_RETRY_COUNT, 0); + int exitCode = TonyClient.start(new String[]{ + "--src_dir", "tony-core/src/test/resources/", + "--executes", "tony-core/src/test/resources/exit_0.py", + "--hdfs_classpath", "/yarn/libs", + "--python_binary_path", "python", + "--container_env", Constants.TEST_TASK_EXECUTOR_HANG + "=true", + "--container_env", Constants.SKIP_HADOOP_PATH + "=true" + }, conf); + Assert.assertEquals(exitCode, -1); + } +} diff --git a/tony-core/src/test/java/com/linkedin/tony/TestUtils.java b/tony-core/src/test/java/com/linkedin/tony/TestUtils.java new file mode 100644 index 00000000..c76d8c11 --- /dev/null +++ b/tony-core/src/test/java/com/linkedin/tony/TestUtils.java @@ -0,0 +1,45 @@ +/** + * Copyright 2018 LinkedIn Corporation. All rights reserved. Licensed under the BSD-2 Clause license. + * See LICENSE in the project root for license information. + */ +package com.linkedin.tony; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import org.testng.annotations.Test; + +import static org.testng.Assert.*; + + +public class TestUtils { + @Test + public void testParseMemoryString() { + assertEquals(Utils.parseMemoryString("2g"), "2048"); + assertEquals(Utils.parseMemoryString("2M"), "2"); + assertEquals(Utils.parseMemoryString("3"), "3"); + } + + @Test + public void testPoll() { + assertTrue(Utils.poll(() -> true, 1, 1)); + assertFalse(Utils.poll(() -> false, 1, 1)); + } + + @Test + public void testUnzipArchive() { + ClassLoader classLoader = getClass().getClassLoader(); + File file = new File(classLoader.getResource("test.zip").getFile()); + try { + Utils.unzipArchive(file.getPath(), "venv/"); + Path unzippedFilePath = Paths.get("venv/123.xml"); + assertTrue(Files.exists(unzippedFilePath)); + Files.deleteIfExists(Paths.get("venv/123.xml")); + Files.deleteIfExists(Paths.get("venv/")); + } catch (IOException e) { + fail(e.toString()); + } + + } +} diff --git a/tony-core/src/test/resources/exit_0.py b/tony-core/src/test/resources/exit_0.py new file mode 100644 index 00000000..b14420d6 --- /dev/null +++ b/tony-core/src/test/resources/exit_0.py @@ -0,0 +1,13 @@ +""" +Copyright 2018 LinkedIn Corporation. All rights reserved. Licensed under the BSD-2 Clause license. +See LICENSE in the project root for license information. +""" +import time + + +def return_zero(): + time.sleep(1) + return 0 + + +exit(return_zero()) diff --git a/tony-core/src/test/resources/exit_0_check_env.py b/tony-core/src/test/resources/exit_0_check_env.py new file mode 100644 index 00000000..629e5eb6 --- /dev/null +++ b/tony-core/src/test/resources/exit_0_check_env.py @@ -0,0 +1,24 @@ +""" +Copyright 2018 LinkedIn Corporation. All rights reserved. Licensed under the BSD-2 Clause license. +See LICENSE in the project root for license information. +""" +import time +import logging +import os +import sys + +time.sleep(1) + +# Set up logging. +log_root = logging.getLogger() +log_root.setLevel(logging.DEBUG) +ch = logging.StreamHandler(sys.stdout) +ch.setLevel(logging.DEBUG) +log_root.addHandler(ch) + +if os.environ['ENV_CHECK'] == 'ENV_CHECK': + logging.info('Found ENV_CHECK environment variable.') + exit(0) +else: + logging.error('Failed to find ENV_CHECK environment variable') + exit(1) diff --git a/tony-core/src/test/resources/exit_1.py b/tony-core/src/test/resources/exit_1.py new file mode 100644 index 00000000..b316a5b2 --- /dev/null +++ b/tony-core/src/test/resources/exit_1.py @@ -0,0 +1,13 @@ +""" +Copyright 2018 LinkedIn Corporation. All rights reserved. Licensed under the BSD-2 Clause license. +See LICENSE in the project root for license information. +""" +import time + + +def return_1(): + time.sleep(1) + return 1 + + +exit(return_1()) diff --git a/tony-core/src/test/resources/log4j.properties b/tony-core/src/test/resources/log4j.properties new file mode 100644 index 00000000..eb933b4f --- /dev/null +++ b/tony-core/src/test/resources/log4j.properties @@ -0,0 +1,8 @@ +# Root logger option +log4j.rootLogger=OFF +log4j.logger.com.linkedin.tony=INFO, stdout +# Direct log messages to stdout +log4j.appender.stdout=org.apache.log4j.ConsoleAppender +log4j.appender.stdout.Target=System.out +log4j.appender.stdout.layout=org.apache.log4j.PatternLayout +log4j.appender.stdout.layout.ConversionPattern=%d{yyyy-MM-dd HH:mm:ss} %-5p %c{1}:%L - %m%n diff --git a/tony-core/src/test/resources/resource-types.xml b/tony-core/src/test/resources/resource-types.xml new file mode 100644 index 00000000..745ce8eb --- /dev/null +++ b/tony-core/src/test/resources/resource-types.xml @@ -0,0 +1,12 @@ + + + + yarn.resource-types + yarn.io/gpu + + \ No newline at end of file diff --git a/tony-mini/build.gradle b/tony-mini/build.gradle new file mode 100644 index 00000000..e329deeb --- /dev/null +++ b/tony-mini/build.gradle @@ -0,0 +1,20 @@ +apply plugin: 'java' + +dependencies { + compile deps.external.avro + compile deps.external.guava + compile deps.external.jackson_databind + compile deps.external.jackson_dataformat_yaml + compile deps.external.objenesis + compile deps.external.py4j + compile deps.external.sshd + compile deps.hadoop.common + compile deps.hadoop.common_test + compile deps.hadoop.hdfs + compile deps.hadoop.hdfs_client + compile deps.hadoop.hdfs_test + compile deps.hadoop.yarn_api + compile deps.hadoop.yarn_client + compile deps.hadoop.yarn_common + compile deps.hadoop.yarn_server_test +} diff --git a/tony-mini/src/main/java/com/linkedin/minitony/cluster/HDFSUtils.java b/tony-mini/src/main/java/com/linkedin/minitony/cluster/HDFSUtils.java new file mode 100644 index 00000000..3efb0a4a --- /dev/null +++ b/tony-mini/src/main/java/com/linkedin/minitony/cluster/HDFSUtils.java @@ -0,0 +1,41 @@ +/** + * Copyright 2018 LinkedIn Corporation. All rights reserved. Licensed under the BSD-2 Clause license. + * See LICENSE in the project root for license information. + */ + +package com.linkedin.minitony.cluster; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Paths; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.apache.hadoop.fs.FileSystem; +import org.apache.hadoop.fs.Path; + + +public class HDFSUtils { + private static final Log LOG = LogFactory.getLog(HDFSUtils.class); + + /** + * Copy files under src directory recursively to dst folder. + * @param fs a hadoop file system reference + * @param src the directory under which you want to copy files from (local disk) + * @param dst the destination directory. (hdfs) + * @throws IOException exception when copy files. + */ + public static void copyDirectoryFilesToFolder(FileSystem fs, String src, String dst) throws IOException { + Files.walk(Paths.get(src)) + .filter(Files::isRegularFile) + .forEach(file -> { + Path jar = new Path(file.toString()); + try { + fs.copyFromLocalFile(jar, new Path(dst)); + } catch (IOException e) { + LOG.error("Failed to copy directory from: " + src + " to: " + dst + " ", e); + } + }); + } + + private HDFSUtils() { } +} \ No newline at end of file diff --git a/tony-mini/src/main/java/com/linkedin/minitony/cluster/MiniCluster.java b/tony-mini/src/main/java/com/linkedin/minitony/cluster/MiniCluster.java new file mode 100644 index 00000000..481cb376 --- /dev/null +++ b/tony-mini/src/main/java/com/linkedin/minitony/cluster/MiniCluster.java @@ -0,0 +1,82 @@ +/** + * Copyright 2018 LinkedIn Corporation. All rights reserved. Licensed under the BSD-2 Clause license. + * See LICENSE in the project root for license information. + */ +package com.linkedin.minitony.cluster; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.apache.hadoop.conf.Configuration; +import org.apache.hadoop.hdfs.DFSConfigKeys; +import org.apache.hadoop.hdfs.HdfsConfiguration; +import org.apache.hadoop.hdfs.MiniDFSCluster; +import org.apache.hadoop.yarn.conf.YarnConfiguration; +import org.apache.hadoop.yarn.server.MiniYARNCluster; +import org.apache.hadoop.yarn.server.resourcemanager.scheduler.ResourceScheduler; +import org.apache.hadoop.yarn.server.resourcemanager.scheduler.fifo.FifoScheduler; + + +/** + * MiniCluster is used to spin off a Mini Hadoop cluster. This can be used independently + * inside TonY itself for integration testing. + */ +public class MiniCluster { + private static final Log LOG = LogFactory.getLog(MiniCluster.class); + private MiniDFSCluster dfsCluster; + private MiniYARNCluster yarnCluster; + + private static final short REPLICATION = 1; + private static final int BLOCKSIZE = 1048576; + private Configuration yarnClusterConf; + private Configuration hdfsClusterConf; + private int numNodeManagers; + + /** + * Instantiate a MiniCluster instance. + * @param numNodeManagers the number of nodes inside mini cluster. + */ + public MiniCluster(int numNodeManagers) { + this.numNodeManagers = numNodeManagers; + } + + public void start() throws Exception { + YarnConfiguration yarnConf = new YarnConfiguration(); + yarnConf.setInt(YarnConfiguration.RM_SCHEDULER_MINIMUM_ALLOCATION_MB, 256); + yarnConf.setClass(YarnConfiguration.RM_SCHEDULER, + FifoScheduler.class, ResourceScheduler.class); + HdfsConfiguration hdfsConf = new HdfsConfiguration(); + hdfsConf.setLong(DFSConfigKeys.DFS_BLOCK_SIZE_KEY, BLOCKSIZE); + yarnCluster = new MiniYARNCluster("MiniTonY", numNodeManagers, 1, 1); + dfsCluster = new MiniDFSCluster.Builder(hdfsConf).numDataNodes(1).numDataNodes(REPLICATION).build(); + yarnCluster.init(yarnConf); + yarnCluster.start(); + dfsCluster.waitActive(); + yarnClusterConf = yarnCluster.getConfig(); + hdfsClusterConf = dfsCluster.getNameNode().getConf(); + yarnClusterConf.setBoolean("ipc.client.fallback-to-simple-auth-allowed", true); + hdfsClusterConf.setBoolean("ipc.client.fallback-to-simple-auth-allowed", true); + } + + public void stop() { + yarnCluster.stop(); + dfsCluster.shutdown(); + } + + public Configuration getYarnConf() { + return yarnClusterConf; + } + + public Configuration getHdfsConf() { + return hdfsClusterConf; + } + + public static void main(String[] args) { + try { + MiniCluster cluster = new MiniCluster(2); + cluster.start(); + cluster.stop(); + } catch (Exception e) { + LOG.error(e); + } + } +} diff --git a/tony-mini/src/main/java/com/linkedin/minitony/cluster/MiniTonyUtils.java b/tony-mini/src/main/java/com/linkedin/minitony/cluster/MiniTonyUtils.java new file mode 100644 index 00000000..3c781788 --- /dev/null +++ b/tony-mini/src/main/java/com/linkedin/minitony/cluster/MiniTonyUtils.java @@ -0,0 +1,27 @@ +/** + * Copyright 2018 LinkedIn Corporation. All rights reserved. Licensed under the BSD-2 Clause license. + * See LICENSE in the project root for license information. + */ +package com.linkedin.minitony.cluster; + +import java.io.IOException; +import java.io.PrintWriter; +import org.apache.hadoop.conf.Configuration; + + +public class MiniTonyUtils { + + /** + * Write a Hadoop configuration to file. + * @param conf the configuration object. + * @param filePath the filePath we are writing the configuration to. + * @throws IOException IO exception during writing files. + */ + public static void saveConfigToFile(Configuration conf, String filePath) throws IOException { + PrintWriter yarnWriter = new PrintWriter(filePath, "UTF-8"); + conf.writeXml(yarnWriter); + } + + private MiniTonyUtils() { } + +}