Commit 00d5d880 authored by Khalique Ahmed's avatar Khalique Ahmed
Browse files

Merge branch 'develop' of https://github.com/ROCmSoftwarePlatform/AMDMIGraphX into mi100_opts

parents 00d90ca8 f60c3815
......@@ -5,8 +5,6 @@ CheckOptions:
value: '::std::async;::std::launder;::std::remove;::std::remove_if;::std::unique;::std::unique_ptr::release;::std::basic_string::empty;::std::vector::empty;::std::find;::std::find_if;::std::find_if_not;::std::all_of;::std::any_of;::std::none_of;::std::count;::std::count_if;::std::mismatch;::std::find_end;::std::find_first_of;::std::adjacent_find;::std::search;::std::search_n;::std::nth_element;::std::lower_bound;::std::upper_bound;::std::binary_search;::std::equal_range;::std::max;::std::max_element;::std::min;::std::min_element;::std::minmax;::std::minmax_element;::std::equal;::std::lexicographical_compare;::std::accumulate;::std::inner_product'
- key: cppcoreguidelines-macro-usage.AllowedRegexp
value: 'DEBUG|FALLTHROUGH|STRINGIZE|_HAS_|_THROW|_REQUIRES|_DECLARE_|_VISIT_|_REGISTER_|_GENERATE_|_DETAIL_|_TIDY_|_MANAGE_PTR|_MATCHER|DEVICE_SHARED'
- key: cppcoreguidelines-narrowing-conversions.WarnOnFloatingPointNarrowingConversion
value: 0
- key: modernize-loop-convert.MinConfidence
value: risky
- key: modernize-loop-convert.NamingStyle
......@@ -111,7 +109,7 @@ CheckOptions:
value: CamelCase
- key: readability-identifier-naming.TypeAliasCase
value: lower_case
# - key: readability-identifier-naming.MacroDefinitionCase
# value: UPPER_CASE
# - key: readability-identifier-naming.MacroDefinitionPrefix
# value: MIGRAPHX_
- key: readability-identifier-naming.MacroDefinitionCase
value: UPPER_CASE
- key: readability-identifier-naming.MacroDefinitionPrefix
value: MIGRAPHX_
......@@ -151,6 +151,9 @@ jobs:
- debug
- release
- codecov
exclude:
- os: ubuntu-16.04
configuration: debug
steps:
- uses: actions/checkout@v2
......
......@@ -36,7 +36,7 @@ find_package(nlohmann_json 3.8.0 REQUIRED)
include(ROCMSetupVersion)
rocm_setup_version(VERSION 1.1)
rocm_setup_version(VERSION 1.3)
set(MIGRAPHX_SO_VERSION ${PROJECT_VERSION_MAJOR}.${PROJECT_VERSION_MINOR})
option( BUILD_SHARED_LIBS "Build as a shared library" ON )
......@@ -148,6 +148,7 @@ rocm_enable_clang_tidy(
-*-avoid-c-arrays
-*-explicit-constructor
-*-magic-numbers
-*-narrowing-conversions
-*-non-private-member-variables-in-classes
-*-use-auto
-*-use-emplace
......
......@@ -6,7 +6,7 @@ ARG PREFIX=/usr/local
RUN dpkg --add-architecture i386
# Add rocm repository
RUN sh -c 'echo deb [arch=amd64 trusted=yes] http://repo.radeon.com/rocm/apt/4.1/ xenial main > /etc/apt/sources.list.d/rocm.list'
RUN sh -c 'echo deb [arch=amd64 trusted=yes] http://repo.radeon.com/rocm/apt/4.2/ xenial main > /etc/apt/sources.list.d/rocm.list'
# Install dependencies
RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --allow-unauthenticated \
......
......@@ -2,27 +2,36 @@ if(COMMAND find_python)
return()
endif()
macro(py_exec)
execute_process(${ARGN} RESULT_VARIABLE RESULT)
if(NOT RESULT EQUAL 0)
message(FATAL_ERROR "Process failed: ${ARGN}")
endif()
endmacro()
set(PYBIND11_NOPYTHON On)
find_package(pybind11 REQUIRED)
macro(find_python version)
find_program(PYTHON_CONFIG_${version} python${version}-config)
if(EXISTS ${PYTHON_CONFIG_${version}})
execute_process(COMMAND ${PYTHON_CONFIG_${version}} --includes OUTPUT_VARIABLE _python_include_args)
py_exec(COMMAND ${PYTHON_CONFIG_${version}} --includes OUTPUT_VARIABLE _python_include_args)
separate_arguments(_python_includes UNIX_COMMAND "${_python_include_args}")
string(REPLACE "-I" "" _python_includes "${_python_includes}")
add_library(python${version}::headers INTERFACE IMPORTED GLOBAL)
set_target_properties(python${version}::headers PROPERTIES
INTERFACE_INCLUDE_DIRECTORIES "${_python_includes}"
)
execute_process(COMMAND ${PYTHON_CONFIG_${version}} --prefix OUTPUT_VARIABLE _python_prefix)
py_exec(COMMAND ${PYTHON_CONFIG_${version}} --prefix OUTPUT_VARIABLE _python_prefix)
string(STRIP "${_python_prefix}" _python_prefix)
set(PYTHON_${version}_EXECUTABLE "${_python_prefix}/bin/python${version}" CACHE PATH "")
endif()
endmacro()
function(py_extension name version)
set(_python_module_extension)
execute_process(COMMAND ${PYTHON_CONFIG_${version}} --extension-suffix OUTPUT_VARIABLE _python_module_extension)
set(_python_module_extension ".so")
if(version VERSION_GREATER_EQUAL 3.0)
py_exec(COMMAND ${PYTHON_CONFIG_${version}} --extension-suffix OUTPUT_VARIABLE _python_module_extension)
string(STRIP "${_python_module_extension}" _python_module_extension)
endif()
set_target_properties(${name} PROPERTIES PREFIX "" SUFFIX "${_python_module_extension}")
endfunction()
function(py_add_module NAME)
......
......@@ -13,3 +13,5 @@ This directory contains examples of common use cases for MIGraphX.
- [Python Resnet50 Inference](./python_api_inference)
- [Python BERT SQuAD Inference](./python_bert_squad_example)
- [Python Super Resolution](./python_super_resolution)
- [Python NFNet Inference](./python_nfnet_inference)
......@@ -118,7 +118,7 @@ This directory contains everything that is needed to perform inference on an MNI
```
$ mkdir build
$ cd build
$ cmake ..
$ CXX=/opt/rocm/llvm/bin/clang++ cmake ..
$ make
```
There will now be an executable named `mnist_inference` in the `build` directory. This can be run with or without options. Executing without any options will produce the following output:
......
......@@ -122,10 +122,8 @@ int main(int argc, char** argv)
migraphx::program_parameters prog_params;
auto param_shapes = prog.get_parameter_shapes();
for(auto&& name : param_shapes.names())
{
prog_params.add(name, migraphx::argument(param_shapes[name], digit.data()));
}
auto input = param_shapes.names().front();
prog_params.add(input, migraphx::argument(param_shapes[input], digit.data()));
std::cout << "Model evaluating input..." << std::endl;
auto start = std::chrono::high_resolution_clock::now();
......
# NFNet Inference with MIGraphX
## NFNet
NFNet: Normalizer-Free Nets. An image recognition model that can be trained without batch normalization layers. It instead uses gradient clipping algorithm to provide same affects of BatchNorm.
<ins>**Summary:**</ins>
- SOTA on ImageNet (86.5% top-1 w/o extra data)
- Up to 8.7x faster to train than EfficientNets to a given accuracy
- Normalizer-free (no BatchNorm)
**Paper**: https://arxiv.org/pdf/2102.06171.pdf
**Colab notebook**: https://github.com/deepmind/deepmind-research/tree/master/nfnets
### Why not batch norm?
Batch normalization has three significant practical disadvantages:
1. It is an expensive computational primitive, which incurs memory overhead and significantly increases the time required to evaluate the gradient in some networks.
2. It introduces a discrepancy between the behavior of the model during training and at inference time, introducing hidden hyper-parameters that have to be tuned.
3. Last and most important point, batch normalization breaks the independence between training examples in the minibatch (batch size matters with batch norm, distributed training becomes extremely cumbersome).
Instead:
- Authors provide Adaptive Gradient Clipping (AGC), which clips gradients based on the unit-wise ratio of gradient norms to parameter norms, and they demonstrate that AGC allows them to train normalizer-free networks with larger batch sizes and stronger data augmentations.
- They design a family of Normalizer-Free ResNets, called NFNets, which set new state-of-the-art validation accuracies on ImageNet for a range of training latencies. Their NFNet-F1 model achieves similar accuracy to EfficientNet-B7 while being 8.7× faster to train, and their largest model sets a new overall state of the art without extra data of 86.5% top-1 accuracy.
- They show that NFNets achieve substantially higher validation accuracies than batch-normalized networks when fine-tuning on ImageNet after pre-training on a large private dataset of 300 million labelled images. Their best model achieves 89.2% top-1 accuracy after fine-tuning.
## Inference with MIGraphX using NFNet ONNX Model
There is no ONNX model released for NFNet, as of June 2021, however a PyTorch model is available at:
https://github.com/rwightman/pytorch-image-models.
We provide an in-house produced and optimized ONNX model, which can be parsed and compiled using MIGraphX for AMD GPUs. The ONNX model file can be fetched using the Jupyter notebook we provide.
### Requirements:
1) AMD GPU system with ROCm installed.
2) Jupyter notebook library.
### How to use NFNet for image recognition:
Please utilize the notebook example provided:
1) Install jupyter notebook to your environment if not already installed:
```
https://jupyter.org/install
```
2) Connect to your jupyter server and utilize `nfnet_inference.ipynb` notebook file.
### How to compare MIGraphX to ONNX Runtime for NFNet ONNX model:
First install requirements:
```
pip3 install -r requirements_nfnet.txt
```
On your terminal, invoke:
```
python3 ort_comparison.py
````
{
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# NFNet Inference with AMD MIGraphX\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"Normalizer-Free ResNet is a new residual convolutional network providing new state-of-the-art Top-1 accuracy of 86.5% at ImageNet dataset. The most important feature of the model is removing batch normalization. Instead of batch normalization, it uses adaptive gradient clipping to provide same regularization effect of BatchNorm. <br> Details of this network: https://arxiv.org/abs/2102.06171\n",
"\n",
"In this notebook, we are showing: <br>\n",
"- How to optimize NFNet ONNX model with AMD MIGraphX.\n",
"- How to run inference on AMD GPU with the optimized ONNX model.\n",
"\n",
"The NFNet utilized in this example is the smallest NFNet version, F0: 71.5M parameters (83.6% top-1 accuracy on ImageNet)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Requirements"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"!apt-get update\n",
"!apt-get install ffmpeg libsm6 libxext6 -y "
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"!pip3 install --upgrade pip\n",
"!pip3 install -r requirements_nfnet.txt"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"import numpy as np\n",
"import cv2\n",
"import json\n",
"from PIL import Image\n",
"import time\n",
"from os import path "
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"### Importing AMD MIGraphX Python Module"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"import migraphx"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"### Create NFNet ONNX file\n",
"Following repository provides functionality to create NFNet ONNX file from PyTorch model."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"!wget -nc https://www.dropbox.com/s/u4ga8zyxtppfzxc/dm_nfnet_f0.onnx"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"### Load ImageNet labels"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"with open('../python_api_inference/imagenet_simple_labels.json') as json_data:\n",
" labels = json.load(json_data)"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"## Load ONNX model using MIGraphX"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"model = migraphx.parse_onnx(\"dm_nfnet_f0.onnx\")\n",
"model.compile(migraphx.get_target(\"gpu\"))\n",
"\n",
"print(model.get_parameter_names())\n",
"print(model.get_parameter_shapes())\n",
"print(model.get_output_shapes())"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"### Functions for image processing"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"def make_nxn(image, n):\n",
" height, width = image.shape[:2] \n",
" if height > width:\n",
" dif = height - width\n",
" bar = dif // 2 \n",
" square = image[(bar + (dif % 2)):(height - bar),:]\n",
" return cv2.resize(square, (n, n))\n",
" elif width > height:\n",
" dif = width - height\n",
" bar = dif // 2\n",
" square = image[:,(bar + (dif % 2)):(width - bar)]\n",
" return cv2.resize(square, (n, n))\n",
" else:\n",
" return cv2.resize(image, (n, n))\n",
" \n",
"def preprocess(img_data):\n",
" mean_vec = np.array([0.485, 0.456, 0.406])\n",
" stddev_vec = np.array([0.229, 0.224, 0.225])\n",
" norm_img_data = np.zeros(img_data.shape).astype('float32')\n",
" for i in range(img_data.shape[0]): \n",
" norm_img_data[i,:,:] = (img_data[i,:,:]/255 - mean_vec[i]) / stddev_vec[i]\n",
" return norm_img_data\n",
"\n",
"def input_process(frame, dim):\n",
" # Crop and resize original image\n",
" cropped = make_nxn(frame, dim)\n",
" # Convert from HWC to CHW\n",
" chw = cropped.transpose(2,0,1)\n",
" # Apply normalization\n",
" pp = preprocess(chw)\n",
" # Add singleton dimension (CHW to NCHW)\n",
" data = np.expand_dims(pp.astype('float32'),0)\n",
" return data"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"### Download example image"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# Fetch example image: traffic light\n",
"!wget -nc http://farm5.static.flickr.com/4072/4462811418_8bc2bd42ca_z_d.jpg -O traffic_light.jpg\n",
"# Read the image\n",
"im = cv2.imread('traffic_light.jpg')"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# Process the read image to conform input requirements\n",
"data_input = input_process(im, 192)\n",
"\n",
"# Run the model\n",
"start = time.time()\n",
"results = model.run({'inputs':data_input}) # Your first inference would take longer than the following ones.\n",
"print(f\"Time inference took: {100*(time.time() - start):.2f}ms\")\n",
"# Extract the index of the top prediction\n",
"res_npa = np.array(results[0])\n",
"print(f\"\\nResult: {labels[np.argmax(res_npa)]}\")"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# Run the model again, first one would take long\n",
"start = time.time()\n",
"results = model.run({'inputs':data_input}) # Your first inference would take longer than the following ones.\n",
"print(f\"Time inference took: {100*(time.time() - start):.2f}ms\")\n",
"# Extract the index of the top prediction\n",
"res_npa = np.array(results[0])\n",
"print(f\"\\nResult: {labels[np.argmax(res_npa)]}\")"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.6.9"
}
},
"nbformat": 4,
"nbformat_minor": 4
}
import numpy
import onnxruntime as rt
sess = rt.InferenceSession("dm_nfnet_f0.onnx")
input_name = sess.get_inputs()[0].name
print("input name", input_name)
input_shape = sess.get_inputs()[0].shape
print("input shape", input_shape)
input_type = sess.get_inputs()[0].type
print("input type", input_type)
output_name = sess.get_outputs()[0].name
print("output name", output_name)
output_shape = sess.get_outputs()[0].shape
print("output shape", output_shape)
output_type = sess.get_outputs()[0].type
print("output type", output_type)
x = numpy.random.random((1, 3, 192, 192))
x = x.astype(numpy.float32)
import migraphx
model = migraphx.parse_onnx("dm_nfnet_f0.onnx")
model.compile(migraphx.get_target("gpu"))
print(model.get_parameter_names())
print(model.get_parameter_shapes())
print(model.get_output_shapes())
result_migraphx = model.run({"inputs": x})
result_ort = sess.run([output_name], {input_name: x})
result_migraphx = result_migraphx[0].tolist()
for i in range(10):
x = numpy.random.random((1, 3, 192, 192))
x = x.astype(numpy.float32)
result_migraphx = model.run({"inputs": x})
result_ort = sess.run([output_name], {input_name: x})
try:
numpy.testing.assert_allclose(result_migraphx[0].tolist(),
result_ort[0][0],
rtol=1e-02)
print(f"Test #{i} completed.")
except AssertionError as e:
print(e)
opencv-python
onnxruntime
\ No newline at end of file
......@@ -6,7 +6,7 @@ ARG PREFIX=/usr/local
RUN dpkg --add-architecture i386
# Add rocm repository
RUN sh -c 'echo deb [arch=amd64 trusted=yes] http://repo.radeon.com/rocm/apt/4.1/ xenial main > /etc/apt/sources.list.d/rocm.list'
RUN sh -c 'echo deb [arch=amd64 trusted=yes] http://repo.radeon.com/rocm/apt/4.2/ xenial main > /etc/apt/sources.list.d/rocm.list'
# Install dependencies
RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --allow-unauthenticated \
......
......@@ -23,12 +23,14 @@ add_library(migraphx
eliminate_data_type.cpp
eliminate_identity.cpp
eliminate_pad.cpp
insert_pad.cpp
file_buffer.cpp
rewrite_batchnorm.cpp
rewrite_rnn.cpp
rewrite_pooling.cpp
env.cpp
generate.cpp
inline_module.cpp
instruction.cpp
load_save.cpp
make_op.cpp
......@@ -99,6 +101,7 @@ register_migraphx_ops(
flatten
floor
gather
get_tuple_elem
greater
gru
identity
......@@ -135,6 +138,7 @@ register_migraphx_ops(
reduce_sum
relu
reshape
reverse
rnn
rnn_last_cell_output
rnn_last_hs_output
......
......@@ -32,7 +32,7 @@ std::vector<char> src_compiler::compile(const std::vector<src_file>& srcs) const
}
}
params += " -o" + out;
params += " -o " + out;
td.execute(compiler, params);
......
......@@ -5,76 +5,86 @@
#include <migraphx/op/im2col.hpp>
#include <migraphx/op/pooling.hpp>
#include <migraphx/op/pad.hpp>
#include <migraphx/make_op.hpp>
#include <migraphx/iterator_for.hpp>
#include <migraphx/stringutils.hpp>
namespace migraphx {
inline namespace MIGRAPHX_INLINE_NS {
void eliminate_pad::apply(module& p) const
{
for(auto ins : iterator_for(p))
{
const std::string& op_name = ins->name();
if(op_name != "convolution" and op_name != "im2col" and op_name != "pooling")
continue;
auto input = ins->inputs().front();
if(input->name() != "pad")
continue;
if(op_name == "convolution" or op_name == "im2col")
update_op(input, ins, p);
else if(op_name == "pooling")
update_pooling(input, ins, p);
}
}
void eliminate_pad::update_op(const instruction_ref& input,
const instruction_ref& ins,
module& p) const
static void update_op(const instruction_ref& input, const instruction_ref& ins, module& m)
{
auto pad_op = any_cast<op::pad>(input->get_operator());
if(!pad_op.symmetric())
return;
auto kdims = input->get_shape().lens().size() - 2;
auto kdims_it = pad_op.pads.begin() + 2;
std::vector<size_t> new_pads(kdims_it, kdims_it + kdims);
std::vector<size_t> pads_l(kdims_it, kdims_it + kdims);
std::vector<size_t> pads_r(kdims_it + kdims + 2, pad_op.pads.end());
auto op = ins->get_operator();
op.from_value({{"padding", new_pads}});
std::vector<size_t> padding(kdims * 2, 0);
std::transform(
pads_l.begin(), pads_l.end(), padding.begin(), padding.begin(), std::plus<size_t>());
std::transform(pads_r.begin(),
pads_r.end(),
padding.begin() + kdims,
padding.begin() + kdims,
std::plus<size_t>());
op.from_value({{"padding", padding}});
std::vector<instruction_ref> new_inputs{ins->inputs()};
new_inputs.front() = input->inputs().front();
p.replace_instruction(ins, op, new_inputs);
m.replace_instruction(ins, op, new_inputs);
}
void eliminate_pad::update_pooling(const instruction_ref& input,
const instruction_ref& ins,
module& p) const
static void update_pooling(const instruction_ref& input, const instruction_ref& ins, module& m)
{
auto pad_op = any_cast<op::pad>(input->get_operator());
if(!pad_op.symmetric())
return;
auto kdims = input->get_shape().lens().size() - 2;
auto kdims_it = pad_op.pads.begin() + 2;
std::vector<size_t> new_pads(kdims_it, kdims_it + kdims);
auto op = any_cast<op::pooling>(ins->get_operator());
if(op.mode == "average")
{
return;
}
auto pad_op = any_cast<op::pad>(input->get_operator());
auto kdims = input->get_shape().lens().size() - 2;
auto kdims_it = pad_op.pads.begin() + 2;
std::vector<size_t> pads_l(kdims_it, kdims_it + kdims);
std::vector<size_t> pads_r(kdims_it + kdims + 2, pad_op.pads.end());
op.padding = new_pads;
std::transform(
pads_l.begin(), pads_l.end(), op.padding.begin(), op.padding.begin(), std::plus<size_t>());
std::transform(pads_r.begin(),
pads_r.end(),
op.padding.begin() + kdims,
op.padding.begin() + kdims,
std::plus<size_t>());
std::vector<instruction_ref> new_inputs{ins->inputs()};
new_inputs.front() = input->inputs().front();
p.replace_instruction(ins, op, new_inputs);
m.replace_instruction(ins, op, new_inputs);
}
void eliminate_pad::apply(module& m) const
{
for(auto ins : iterator_for(m))
{
const std::string& op_name = ins->name();
if(op_name != "convolution" and op_name != "im2col" and op_name != "pooling")
continue;
auto input = ins->inputs().front();
if(input->name() != "pad")
continue;
if(op_name == "convolution" or op_name == "im2col")
update_op(input, ins, m);
else if(op_name == "pooling")
update_pooling(input, ins, m);
}
}
} // namespace MIGRAPHX_INLINE_NS
......
......@@ -158,6 +158,13 @@ struct check_shapes
return *this;
}
const check_shapes& tuple_type() const
{
if(!this->all_of([](const shape& s) { return s.type() == shape::tuple_type; }))
MIGRAPHX_THROW(prefix() + "Shapes are not tuple!");
return *this;
}
const check_shapes& not_transposed() const
{
if(!this->all_of([](const shape& s) { return not s.transposed(); }))
......
File mode changed from 100644 to 100755
......@@ -20,10 +20,7 @@ struct eliminate_pad
{
std::string name() const { return "eliminate_pad"; }
void apply(module& p) const;
void update_op(const instruction_ref& input, const instruction_ref& ins, module& p) const;
void update_pooling(const instruction_ref& input, const instruction_ref& ins, module& p) const;
void apply(module& m) const;
};
} // namespace MIGRAPHX_INLINE_NS
......
File mode changed from 100644 to 100755
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment