From d5b16ccd1112f087ffc76bf3008c90b7c0eff848 Mon Sep 17 00:00:00 2001 From: Devin Robison Date: Tue, 7 Feb 2023 16:26:22 -0700 Subject: [PATCH 001/157] Start sketching out control messages, add temporary fix for PreallocateStage missing symbols --- external/morpheus-visualizations | 2 +- external/utilities | 2 +- morpheus/_lib/cmake/libraries/morpheus.cmake | 3 +- .../include/morpheus/messages/control.hpp | 57 +++++++++++++++ .../include/morpheus/stages/preallocate.hpp | 66 ++++++++++++++++-- morpheus/_lib/src/messages/control.cpp | 44 ++++++++++++ morpheus/_lib/src/python_modules/messages.cpp | 7 ++ morpheus/_lib/src/stages/preallocate.cpp | 69 ++++++++++--------- 8 files changed, 208 insertions(+), 42 deletions(-) create mode 100644 morpheus/_lib/include/morpheus/messages/control.hpp create mode 100644 morpheus/_lib/src/messages/control.cpp diff --git a/external/morpheus-visualizations b/external/morpheus-visualizations index 145069979b..27efc4fd1c 160000 --- a/external/morpheus-visualizations +++ b/external/morpheus-visualizations @@ -1 +1 @@ -Subproject commit 145069979b10c90f12116f1984a124b02be664e9 +Subproject commit 27efc4fd1c984332920db2a2d6ab1f84d3cb55cd diff --git a/external/utilities b/external/utilities index 6e1d4e62e8..1df6920352 160000 --- a/external/utilities +++ b/external/utilities @@ -1 +1 @@ -Subproject commit 6e1d4e62e8ad36a3ff45652f4d1aa03810de3751 +Subproject commit 1df6920352b1e76b1af075fc5ecf358c523c4221 diff --git a/morpheus/_lib/cmake/libraries/morpheus.cmake b/morpheus/_lib/cmake/libraries/morpheus.cmake index 0b6b7c7b59..de0d40e7c2 100644 --- a/morpheus/_lib/cmake/libraries/morpheus.cmake +++ b/morpheus/_lib/cmake/libraries/morpheus.cmake @@ -18,6 +18,7 @@ add_library(morpheus # Keep these sorted! ${MORPHEUS_LIB_ROOT}/src/io/deserializers.cpp ${MORPHEUS_LIB_ROOT}/src/io/serializers.cpp + ${MORPHEUS_LIB_ROOT}/src/messages/control.cpp ${MORPHEUS_LIB_ROOT}/src/messages/memory/inference_memory.cpp ${MORPHEUS_LIB_ROOT}/src/messages/memory/inference_memory_fil.cpp ${MORPHEUS_LIB_ROOT}/src/messages/memory/inference_memory_nlp.cpp @@ -35,10 +36,10 @@ add_library(morpheus ${MORPHEUS_LIB_ROOT}/src/objects/fiber_queue.cpp ${MORPHEUS_LIB_ROOT}/src/objects/file_types.cpp ${MORPHEUS_LIB_ROOT}/src/objects/mutable_table_ctx_mgr.cpp - ${MORPHEUS_LIB_ROOT}/src/objects/wrapped_tensor.cpp ${MORPHEUS_LIB_ROOT}/src/objects/python_data_table.cpp ${MORPHEUS_LIB_ROOT}/src/objects/rmm_tensor.cpp ${MORPHEUS_LIB_ROOT}/src/objects/tensor.cpp + ${MORPHEUS_LIB_ROOT}/src/objects/wrapped_tensor.cpp ${MORPHEUS_LIB_ROOT}/src/stages/add_classification.cpp ${MORPHEUS_LIB_ROOT}/src/stages/add_scores.cpp ${MORPHEUS_LIB_ROOT}/src/stages/deserialize.cpp diff --git a/morpheus/_lib/include/morpheus/messages/control.hpp b/morpheus/_lib/include/morpheus/messages/control.hpp new file mode 100644 index 0000000000..fd3891fe18 --- /dev/null +++ b/morpheus/_lib/include/morpheus/messages/control.hpp @@ -0,0 +1,57 @@ +/** + * SPDX-FileCopyrightText: Copyright (c) 2021-2023, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include + +#include + +namespace morpheus { +#pragma GCC visibility push(default) + +class ControlMessage +{ + public: + enum class ControlMessageType + { + noop, + custom, + raw, + load + }; + + ControlMessage() = default; + ControlMessage(const nlohmann::json& message); + + ControlMessageType type() const; + + const nlohmann::json& message() const; + + private: + ControlMessageType m_type = ControlMessageType::noop; + nlohmann::json m_message; +}; + +struct ControlMessageProxy +{ + static std::shared_ptr create(pybind11::dict& message); +}; + +#pragma GCC visibility pop +} // namespace morpheus \ No newline at end of file diff --git a/morpheus/_lib/include/morpheus/stages/preallocate.hpp b/morpheus/_lib/include/morpheus/stages/preallocate.hpp index f52568fdff..1f37717b2b 100644 --- a/morpheus/_lib/include/morpheus/stages/preallocate.hpp +++ b/morpheus/_lib/include/morpheus/stages/preallocate.hpp @@ -36,6 +36,29 @@ namespace morpheus { #pragma GCC visibility push(default) +namespace { +/** + * @brief Performs preallocation to the underlying dataframe. These functions ensure that the MutableTableInfo object + * has gone out of scope and thus releasing the mutex prior to the stage calling `on_next` which may block. + * + * @param msg + * @param columns + */ +//@{ +void preallocate(std::shared_ptr msg, + const std::vector>& columns) +{ + auto table = msg->get_mutable_info(); + table.insert_missing_columns(columns); +} + +void preallocate(std::shared_ptr msg, + const std::vector>& columns) +{ + preallocate(msg->meta, columns); +} +//@} +} // namespace /****** Component public implementations *******************/ /****** PreallocateStage ********************************/ /* Preallocates new columns into the underlying dataframe. This stage supports both MessageMeta & subclasses of @@ -75,12 +98,45 @@ struct PreallocateStageInterfaceProxy std::vector> needed_columns); }; -// Explicit instantiations -template class PreallocateStage; -template class PreallocateStage; +template +PreallocateStage::PreallocateStage(const std::vector>& needed_columns) : + base_t(base_t::op_factory_from_sub_fn(build_operator())) +{ + for (const auto& col : needed_columns) + { + m_needed_columns.emplace_back(std::make_tuple<>(std::get<0>(col), DType(std::get<1>(col)))); + } +} + +template +typename PreallocateStage::subscribe_fn_t PreallocateStage::build_operator() +{ + return [this](rxcpp::observable input, rxcpp::subscriber output) { + return input.subscribe(rxcpp::make_observer( + [this, &output](sink_type_t x) { + // Since the msg was just emitted from the source we shouldn't have any trouble acquiring the mutex. + preallocate(x, m_needed_columns); + output.on_next(std::move(x)); + }, + [&](std::exception_ptr error_ptr) { output.on_error(error_ptr); }, + [&]() { output.on_completed(); })); + }; +} -template struct PreallocateStageInterfaceProxy; -template struct PreallocateStageInterfaceProxy; +template +std::shared_ptr>> PreallocateStageInterfaceProxy::init( + mrc::segment::Builder& builder, + const std::string& name, + std::vector> needed_columns) +{ + return builder.construct_object>(name, needed_columns); +} +// Explicit instantiations +// template class __attribute__((visibility("default"))) PreallocateStage; +// template class __attribute__((visibility("default"))) PreallocateStage; +// +// template struct __attribute__((visibility("default"))) PreallocateStageInterfaceProxy; +// template struct __attribute__((visibility("default"))) PreallocateStageInterfaceProxy; #pragma GCC visibility pop } // namespace morpheus diff --git a/morpheus/_lib/src/messages/control.cpp b/morpheus/_lib/src/messages/control.cpp new file mode 100644 index 0000000000..415f01ae09 --- /dev/null +++ b/morpheus/_lib/src/messages/control.cpp @@ -0,0 +1,44 @@ +/** + * SPDX-FileCopyrightText: Copyright (c) 2021-2023, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "morpheus/messages/control.hpp" + +#include +#include + +namespace py = pybind11; + +namespace morpheus { + +ControlMessage::ControlMessage(const nlohmann::json& message) : m_message(message) {} + +ControlMessage::ControlMessageType ControlMessage::type() const +{ + return m_type; +} + +const nlohmann::json& ControlMessage::message() const +{ + return m_message; +} + +std::shared_ptr ControlMessageProxy::create(py::dict& message) +{ + return std::make_shared(mrc::pymrc::cast_from_pyobject(message)); +} + +} // namespace morpheus diff --git a/morpheus/_lib/src/python_modules/messages.cpp b/morpheus/_lib/src/python_modules/messages.cpp index 68446efb2d..adec2f2252 100644 --- a/morpheus/_lib/src/python_modules/messages.cpp +++ b/morpheus/_lib/src/python_modules/messages.cpp @@ -15,6 +15,7 @@ * limitations under the License. */ +#include "morpheus/messages/control.hpp" #include "morpheus/messages/memory/inference_memory.hpp" #include "morpheus/messages/memory/inference_memory_fil.hpp" #include "morpheus/messages/memory/inference_memory_nlp.hpp" @@ -111,6 +112,12 @@ PYBIND11_MODULE(messages, m) mrc::edge::EdgeConnector, std::shared_ptr>::register_converter(); + py::class_>(m, "ControlMessage") + .def(py::init<>()) + .def(py::init(py::overload_cast(&ControlMessageProxy::create)), py::return_value_policy::move) + .def("message", &ControlMessage::message) + .def("type", &ControlMessage::type); + // Context manager for Mutable Dataframes. Attempting to use it outside of a with block will raise an exception py::class_>(m, "MutableTableCtxMgr") .def("__enter__", &MutableTableCtxMgr::enter, py::return_value_policy::reference) diff --git a/morpheus/_lib/src/stages/preallocate.cpp b/morpheus/_lib/src/stages/preallocate.cpp index 6ca3434655..d76d23f1b8 100644 --- a/morpheus/_lib/src/stages/preallocate.cpp +++ b/morpheus/_lib/src/stages/preallocate.cpp @@ -47,38 +47,39 @@ void preallocate(std::shared_ptr msg, namespace morpheus { -template -PreallocateStage::PreallocateStage(const std::vector>& needed_columns) : - base_t(base_t::op_factory_from_sub_fn(build_operator())) -{ - for (const auto& col : needed_columns) - { - m_needed_columns.emplace_back(std::make_tuple<>(std::get<0>(col), DType(std::get<1>(col)))); - } -} - -template -typename PreallocateStage::subscribe_fn_t PreallocateStage::build_operator() -{ - return [this](rxcpp::observable input, rxcpp::subscriber output) { - return input.subscribe(rxcpp::make_observer( - [this, &output](sink_type_t x) { - // Since the msg was just emitted from the source we shouldn't have any trouble acquiring the mutex. - preallocate(x, m_needed_columns); - output.on_next(std::move(x)); - }, - [&](std::exception_ptr error_ptr) { output.on_error(error_ptr); }, - [&]() { output.on_completed(); })); - }; -} - -template -std::shared_ptr>> PreallocateStageInterfaceProxy::init( - mrc::segment::Builder& builder, - const std::string& name, - std::vector> needed_columns) -{ - return builder.construct_object>(name, needed_columns); -} - +//TODO(devin) temporary fix for missing symbols issue +//template +//PreallocateStage::PreallocateStage(const std::vector>& needed_columns) : +// base_t(base_t::op_factory_from_sub_fn(build_operator())) +//{ +// for (const auto& col : needed_columns) +// { +// m_needed_columns.emplace_back(std::make_tuple<>(std::get<0>(col), DType(std::get<1>(col)))); +// } +//} +// +//template +//typename PreallocateStage::subscribe_fn_t PreallocateStage::build_operator() +//{ +// return [this](rxcpp::observable input, rxcpp::subscriber output) { +// return input.subscribe(rxcpp::make_observer( +// [this, &output](sink_type_t x) { +// // Since the msg was just emitted from the source we shouldn't have any trouble acquiring the mutex. +// preallocate(x, m_needed_columns); +// output.on_next(std::move(x)); +// }, +// [&](std::exception_ptr error_ptr) { output.on_error(error_ptr); }, +// [&]() { output.on_completed(); })); +// }; +//} +// +//template +//std::shared_ptr>> PreallocateStageInterfaceProxy::init( +// mrc::segment::Builder& builder, +// const std::string& name, +// std::vector> needed_columns) +//{ +// return builder.construct_object>(name, needed_columns); +//} +// } // namespace morpheus From 4c630a6bcb8724c4400fef33e2b7869afc10d428 Mon Sep 17 00:00:00 2001 From: Devin Robison Date: Wed, 8 Feb 2023 15:00:14 -0700 Subject: [PATCH 002/157] Bug fixes, and control message PoC impl + cpp/py unit tests --- morpheus/_lib/cmake/libraries/morpheus.cmake | 1 - .../include/morpheus/messages/control.hpp | 22 ++++- .../include/morpheus/stages/preallocate.hpp | 9 +- morpheus/_lib/src/messages/control.cpp | 23 ++++- morpheus/_lib/src/python_modules/messages.cpp | 11 ++- morpheus/_lib/src/stages/preallocate.cpp | 85 ------------------- morpheus/_lib/tests/CMakeLists.txt | 1 + .../tests/messages/test_control_message.cpp | 55 ++++++++++++ .../_lib/tests/messages/test_messages.hpp | 32 +++++++ morpheus/_lib/tests/test_multi_slices.cpp | 4 +- morpheus/messages/__init__.py | 2 + morpheus/messages/message_control.py | 24 ++++++ tests/messages/test_control_message.py | 27 ++++++ 13 files changed, 196 insertions(+), 100 deletions(-) delete mode 100644 morpheus/_lib/src/stages/preallocate.cpp create mode 100644 morpheus/_lib/tests/messages/test_control_message.cpp create mode 100644 morpheus/_lib/tests/messages/test_messages.hpp create mode 100644 morpheus/messages/message_control.py create mode 100644 tests/messages/test_control_message.py diff --git a/morpheus/_lib/cmake/libraries/morpheus.cmake b/morpheus/_lib/cmake/libraries/morpheus.cmake index de0d40e7c2..4ca88cd181 100644 --- a/morpheus/_lib/cmake/libraries/morpheus.cmake +++ b/morpheus/_lib/cmake/libraries/morpheus.cmake @@ -46,7 +46,6 @@ add_library(morpheus ${MORPHEUS_LIB_ROOT}/src/stages/file_source.cpp ${MORPHEUS_LIB_ROOT}/src/stages/filter_detection.cpp ${MORPHEUS_LIB_ROOT}/src/stages/kafka_source.cpp - ${MORPHEUS_LIB_ROOT}/src/stages/preallocate.cpp ${MORPHEUS_LIB_ROOT}/src/stages/preprocess_fil.cpp ${MORPHEUS_LIB_ROOT}/src/stages/preprocess_nlp.cpp ${MORPHEUS_LIB_ROOT}/src/stages/serialize.cpp diff --git a/morpheus/_lib/include/morpheus/messages/control.hpp b/morpheus/_lib/include/morpheus/messages/control.hpp index fd3891fe18..06501152be 100644 --- a/morpheus/_lib/include/morpheus/messages/control.hpp +++ b/morpheus/_lib/include/morpheus/messages/control.hpp @@ -39,8 +39,25 @@ class ControlMessage ControlMessage() = default; ControlMessage(const nlohmann::json& message); - ControlMessageType type() const; + /** + * @brief Get the message type + * @return + */ + ControlMessageType message_type() const; + // TODO(Devin) + //void message_type(ControlMessageType type); + + /** + * @brief Set the message object + * @param message + */ + void message(const nlohmann::json& message); + + /** + * + * @return + */ const nlohmann::json& message() const; private: @@ -51,6 +68,9 @@ class ControlMessage struct ControlMessageProxy { static std::shared_ptr create(pybind11::dict& message); + + static pybind11::dict message(ControlMessage& self); + static void message(ControlMessage& self, pybind11::dict& message); }; #pragma GCC visibility pop diff --git a/morpheus/_lib/include/morpheus/stages/preallocate.hpp b/morpheus/_lib/include/morpheus/stages/preallocate.hpp index 1f37717b2b..75c08dd63b 100644 --- a/morpheus/_lib/include/morpheus/stages/preallocate.hpp +++ b/morpheus/_lib/include/morpheus/stages/preallocate.hpp @@ -57,8 +57,8 @@ void preallocate(std::shared_ptr msg, { preallocate(msg->meta, columns); } -//@} } // namespace + /****** Component public implementations *******************/ /****** PreallocateStage ********************************/ /* Preallocates new columns into the underlying dataframe. This stage supports both MessageMeta & subclasses of @@ -131,12 +131,5 @@ std::shared_ptr>> PreallocateSta { return builder.construct_object>(name, needed_columns); } -// Explicit instantiations -// template class __attribute__((visibility("default"))) PreallocateStage; -// template class __attribute__((visibility("default"))) PreallocateStage; -// -// template struct __attribute__((visibility("default"))) PreallocateStageInterfaceProxy; -// template struct __attribute__((visibility("default"))) PreallocateStageInterfaceProxy; - #pragma GCC visibility pop } // namespace morpheus diff --git a/morpheus/_lib/src/messages/control.cpp b/morpheus/_lib/src/messages/control.cpp index 415f01ae09..e4d8f3aa1b 100644 --- a/morpheus/_lib/src/messages/control.cpp +++ b/morpheus/_lib/src/messages/control.cpp @@ -26,7 +26,7 @@ namespace morpheus { ControlMessage::ControlMessage(const nlohmann::json& message) : m_message(message) {} -ControlMessage::ControlMessageType ControlMessage::type() const +ControlMessage::ControlMessageType ControlMessage::message_type() const { return m_type; } @@ -36,9 +36,28 @@ const nlohmann::json& ControlMessage::message() const return m_message; } +void ControlMessage::message(const nlohmann::json& message) +{ + m_message = message; +} + +/*** Proxy Implementations ***/ + std::shared_ptr ControlMessageProxy::create(py::dict& message) { return std::make_shared(mrc::pymrc::cast_from_pyobject(message)); } -} // namespace morpheus +py::dict ControlMessageProxy::message(ControlMessage& self) +{ + auto dict = mrc::pymrc::cast_from_json(self.message()); + + return dict; +} + +void ControlMessageProxy::message(ControlMessage& self, py::dict& message) +{ + self.message(mrc::pymrc::cast_from_pyobject(message)); +} + +} // namespace morpheus \ No newline at end of file diff --git a/morpheus/_lib/src/python_modules/messages.cpp b/morpheus/_lib/src/python_modules/messages.cpp index adec2f2252..c2cfb8ba95 100644 --- a/morpheus/_lib/src/python_modules/messages.cpp +++ b/morpheus/_lib/src/python_modules/messages.cpp @@ -79,6 +79,7 @@ PYBIND11_MODULE(messages, m) // Allows python objects to keep DataTable objects alive py::class_>(m, "DataTable"); + mrc::pymrc::PortBuilderUtil::register_port_util>(); mrc::pymrc::PortBuilderUtil::register_port_util>(); mrc::pymrc::PortBuilderUtil::register_port_util>(); mrc::pymrc::PortBuilderUtil::register_port_util>(); @@ -115,8 +116,14 @@ PYBIND11_MODULE(messages, m) py::class_>(m, "ControlMessage") .def(py::init<>()) .def(py::init(py::overload_cast(&ControlMessageProxy::create)), py::return_value_policy::move) - .def("message", &ControlMessage::message) - .def("type", &ControlMessage::type); + .def("message", + pybind11::overload_cast(&ControlMessageProxy::message), + py::return_value_policy::reference_internal) + .def("message", + pybind11::overload_cast(&ControlMessageProxy::message), + py::arg("message"), + py::return_value_policy::reference_internal) + .def("type", &ControlMessage::message_type); // Context manager for Mutable Dataframes. Attempting to use it outside of a with block will raise an exception py::class_>(m, "MutableTableCtxMgr") diff --git a/morpheus/_lib/src/stages/preallocate.cpp b/morpheus/_lib/src/stages/preallocate.cpp deleted file mode 100644 index d76d23f1b8..0000000000 --- a/morpheus/_lib/src/stages/preallocate.cpp +++ /dev/null @@ -1,85 +0,0 @@ -/** - * SPDX-FileCopyrightText: Copyright (c) 2022-2023, NVIDIA CORPORATION & AFFILIATES. All rights reserved. - * SPDX-License-Identifier: Apache-2.0 - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#include "morpheus/stages/preallocate.hpp" - -#include "morpheus/objects/table_info.hpp" // for TableInfo - -#include - -namespace { -/** - * @brief Performs preallocation to the underlying dataframe. These functions ensure that the MutableTableInfo object - * has gone out of scope and thus releasing the mutex prior to the stage calling `on_next` which may block. - * - * @param msg - * @param columns - */ -//@{ -void preallocate(std::shared_ptr msg, - const std::vector>& columns) -{ - auto table = msg->get_mutable_info(); - table.insert_missing_columns(columns); -} - -void preallocate(std::shared_ptr msg, - const std::vector>& columns) -{ - preallocate(msg->meta, columns); -} -//@} -} // namespace - -namespace morpheus { - -//TODO(devin) temporary fix for missing symbols issue -//template -//PreallocateStage::PreallocateStage(const std::vector>& needed_columns) : -// base_t(base_t::op_factory_from_sub_fn(build_operator())) -//{ -// for (const auto& col : needed_columns) -// { -// m_needed_columns.emplace_back(std::make_tuple<>(std::get<0>(col), DType(std::get<1>(col)))); -// } -//} -// -//template -//typename PreallocateStage::subscribe_fn_t PreallocateStage::build_operator() -//{ -// return [this](rxcpp::observable input, rxcpp::subscriber output) { -// return input.subscribe(rxcpp::make_observer( -// [this, &output](sink_type_t x) { -// // Since the msg was just emitted from the source we shouldn't have any trouble acquiring the mutex. -// preallocate(x, m_needed_columns); -// output.on_next(std::move(x)); -// }, -// [&](std::exception_ptr error_ptr) { output.on_error(error_ptr); }, -// [&]() { output.on_completed(); })); -// }; -//} -// -//template -//std::shared_ptr>> PreallocateStageInterfaceProxy::init( -// mrc::segment::Builder& builder, -// const std::string& name, -// std::vector> needed_columns) -//{ -// return builder.construct_object>(name, needed_columns); -//} -// -} // namespace morpheus diff --git a/morpheus/_lib/tests/CMakeLists.txt b/morpheus/_lib/tests/CMakeLists.txt index 9467f6cffa..90b3776354 100644 --- a/morpheus/_lib/tests/CMakeLists.txt +++ b/morpheus/_lib/tests/CMakeLists.txt @@ -18,6 +18,7 @@ list(APPEND CMAKE_MESSAGE_CONTEXT "tests") # Keep all source files sorted add_executable(test_libmorpheus # test_cuda.cu + messages/test_control_message.cpp test_main.cpp test_matx_util.cpp test_morpheus.cpp diff --git a/morpheus/_lib/tests/messages/test_control_message.cpp b/morpheus/_lib/tests/messages/test_control_message.cpp new file mode 100644 index 0000000000..e0d3d65303 --- /dev/null +++ b/morpheus/_lib/tests/messages/test_control_message.cpp @@ -0,0 +1,55 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2022-2023, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "nlohmann/json.hpp" +#include "test_messages.hpp" + +#include "morpheus/messages/control.hpp" + +using namespace morpheus; +using namespace morpheus::test; + +TEST_F(TestControlMessage, InitializationTest) +{ + auto msg_one = ControlMessage(); + + ASSERT_EQ(msg_one.message_type(), ControlMessage::ControlMessageType::noop); + + auto config = nlohmann::json(); + config["some_value"] = "42"; + + auto msg_two = ControlMessage(config); + + ASSERT_EQ(msg_two.message_type(), ControlMessage::ControlMessageType::noop); + ASSERT_EQ(msg_two.message().contains("some_value"), true); + ASSERT_EQ(msg_two.message()["some_value"], "42"); +} + +TEST_F(TestControlMessage, SetMessageTest) +{ + auto msg = ControlMessage(); + + ASSERT_EQ(msg.message().contains("some_value"), false); + + auto config = nlohmann::json(); + config["some_value"] = "42"; + + msg.message(config); + + ASSERT_EQ(msg.message().contains("some_value"), true); + ASSERT_EQ(msg.message()["some_value"], "42"); +} \ No newline at end of file diff --git a/morpheus/_lib/tests/messages/test_messages.hpp b/morpheus/_lib/tests/messages/test_messages.hpp new file mode 100644 index 0000000000..c10096d75d --- /dev/null +++ b/morpheus/_lib/tests/messages/test_messages.hpp @@ -0,0 +1,32 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2022-2023, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include "../test_morpheus.hpp" // IWYU pragma: associated + +namespace morpheus::test { +class TestMessages : public ::testing::Test +{ + protected: + void SetUp() override {} + + void TearDown() override {} +}; + +using TestControlMessage = TestMessages; +} // namespace morpheus::test \ No newline at end of file diff --git a/morpheus/_lib/tests/test_multi_slices.cpp b/morpheus/_lib/tests/test_multi_slices.cpp index eefa329d66..4d5d4f9140 100644 --- a/morpheus/_lib/tests/test_multi_slices.cpp +++ b/morpheus/_lib/tests/test_multi_slices.cpp @@ -46,6 +46,7 @@ #include using namespace morpheus; +using namespace morpheus::test; namespace py = pybind11; namespace { @@ -62,7 +63,8 @@ TEST_CLASS(MultiSlices); TEST_F(TestMultiSlices, Ranges) { - std::filesystem::path morpheus_root{std::getenv("MORPHEUS_ROOT")}; + std::filesystem::path morpheus_root = get_morpheus_root(); + auto input_file = morpheus_root / "tests/tests_data/filter_probs.csv"; auto table_m = load_table_from_file(input_file); diff --git a/morpheus/messages/__init__.py b/morpheus/messages/__init__.py index 58d7c1c85f..53380be2e7 100644 --- a/morpheus/messages/__init__.py +++ b/morpheus/messages/__init__.py @@ -21,6 +21,7 @@ from morpheus.messages.message_base import MessageBase from morpheus.messages.message_meta import MessageMeta from morpheus.messages.message_meta import UserMessageMeta +from morpheus.messages.message_control import MessageControl from morpheus.messages.multi_message import MultiMessage from morpheus.messages.multi_ae_message import MultiAEMessage from morpheus.messages.multi_inference_message import InferenceMemory @@ -43,6 +44,7 @@ "InferenceMemoryFIL", "InferenceMemoryNLP", "MessageBase", + "MessageControl", "MessageMeta", "MultiAEMessage", "MultiInferenceFILMessage", diff --git a/morpheus/messages/message_control.py b/morpheus/messages/message_control.py new file mode 100644 index 0000000000..e888e99c26 --- /dev/null +++ b/morpheus/messages/message_control.py @@ -0,0 +1,24 @@ +# Copyright (c) 2021-2023, NVIDIA CORPORATION. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import threading +import warnings + +import morpheus._lib.messages as _messages +from morpheus.messages.message_base import MessageBase + + +class MessageControl(MessageBase, cpp_class=_messages.ControlMessage): + def __init__(self): + super().__init__() diff --git a/tests/messages/test_control_message.py b/tests/messages/test_control_message.py new file mode 100644 index 0000000000..8a316d1ffd --- /dev/null +++ b/tests/messages/test_control_message.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python +# SPDX-FileCopyrightText: Copyright (c) 2022-2023, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +import morpheus.messages as messages +import morpheus._lib.messages as _messages + + +def test_control_message_init(): + raw_control_message_one = _messages.ControlMessage() + raw_control_message_two = _messages.ControlMessage({"test": "test"}) + + control_message_one = messages.MessageControl() + control_message_two = messages.MessageControl({"test": "test"}) From 7eb09a5a47b2306b15d581e351e7469e0b250ef4 Mon Sep 17 00:00:00 2001 From: Devin Robison Date: Wed, 8 Feb 2023 15:59:25 -0700 Subject: [PATCH 003/157] Bring naming changes in line with other messages, add more python unit tests --- .../include/morpheus/messages/control.hpp | 14 ++++---- morpheus/_lib/src/messages/control.cpp | 16 ++++----- morpheus/_lib/src/python_modules/messages.cpp | 10 +++--- .../tests/messages/test_control_message.cpp | 10 +++--- morpheus/messages/message_control.py | 5 +-- tests/messages/test_control_message.py | 35 +++++++++++++++++-- 6 files changed, 59 insertions(+), 31 deletions(-) diff --git a/morpheus/_lib/include/morpheus/messages/control.hpp b/morpheus/_lib/include/morpheus/messages/control.hpp index 06501152be..f029fe9c4a 100644 --- a/morpheus/_lib/include/morpheus/messages/control.hpp +++ b/morpheus/_lib/include/morpheus/messages/control.hpp @@ -25,7 +25,7 @@ namespace morpheus { #pragma GCC visibility push(default) -class ControlMessage +class MessageControl { public: enum class ControlMessageType @@ -36,8 +36,8 @@ class ControlMessage load }; - ControlMessage() = default; - ControlMessage(const nlohmann::json& message); + MessageControl() = default; + MessageControl(const nlohmann::json& message); /** * @brief Get the message type @@ -45,7 +45,7 @@ class ControlMessage */ ControlMessageType message_type() const; - // TODO(Devin) + // TODO(Devin) : May or may not use enums for message types //void message_type(ControlMessageType type); /** @@ -67,10 +67,10 @@ class ControlMessage struct ControlMessageProxy { - static std::shared_ptr create(pybind11::dict& message); + static std::shared_ptr create(pybind11::dict& message); - static pybind11::dict message(ControlMessage& self); - static void message(ControlMessage& self, pybind11::dict& message); + static pybind11::dict message(MessageControl& self); + static void message(MessageControl& self, pybind11::dict& message); }; #pragma GCC visibility pop diff --git a/morpheus/_lib/src/messages/control.cpp b/morpheus/_lib/src/messages/control.cpp index e4d8f3aa1b..2a65c5cafd 100644 --- a/morpheus/_lib/src/messages/control.cpp +++ b/morpheus/_lib/src/messages/control.cpp @@ -24,38 +24,38 @@ namespace py = pybind11; namespace morpheus { -ControlMessage::ControlMessage(const nlohmann::json& message) : m_message(message) {} +MessageControl::MessageControl(const nlohmann::json& message) : m_message(message) {} -ControlMessage::ControlMessageType ControlMessage::message_type() const +MessageControl::ControlMessageType MessageControl::message_type() const { return m_type; } -const nlohmann::json& ControlMessage::message() const +const nlohmann::json& MessageControl::message() const { return m_message; } -void ControlMessage::message(const nlohmann::json& message) +void MessageControl::message(const nlohmann::json& message) { m_message = message; } /*** Proxy Implementations ***/ -std::shared_ptr ControlMessageProxy::create(py::dict& message) +std::shared_ptr ControlMessageProxy::create(py::dict& message) { - return std::make_shared(mrc::pymrc::cast_from_pyobject(message)); + return std::make_shared(mrc::pymrc::cast_from_pyobject(message)); } -py::dict ControlMessageProxy::message(ControlMessage& self) +py::dict ControlMessageProxy::message(MessageControl& self) { auto dict = mrc::pymrc::cast_from_json(self.message()); return dict; } -void ControlMessageProxy::message(ControlMessage& self, py::dict& message) +void ControlMessageProxy::message(MessageControl& self, py::dict& message) { self.message(mrc::pymrc::cast_from_pyobject(message)); } diff --git a/morpheus/_lib/src/python_modules/messages.cpp b/morpheus/_lib/src/python_modules/messages.cpp index c2cfb8ba95..efbc2140d8 100644 --- a/morpheus/_lib/src/python_modules/messages.cpp +++ b/morpheus/_lib/src/python_modules/messages.cpp @@ -79,7 +79,7 @@ PYBIND11_MODULE(messages, m) // Allows python objects to keep DataTable objects alive py::class_>(m, "DataTable"); - mrc::pymrc::PortBuilderUtil::register_port_util>(); + mrc::pymrc::PortBuilderUtil::register_port_util>(); mrc::pymrc::PortBuilderUtil::register_port_util>(); mrc::pymrc::PortBuilderUtil::register_port_util>(); mrc::pymrc::PortBuilderUtil::register_port_util>(); @@ -113,17 +113,17 @@ PYBIND11_MODULE(messages, m) mrc::edge::EdgeConnector, std::shared_ptr>::register_converter(); - py::class_>(m, "ControlMessage") + py::class_>(m, "MessageControl") .def(py::init<>()) .def(py::init(py::overload_cast(&ControlMessageProxy::create)), py::return_value_policy::move) .def("message", - pybind11::overload_cast(&ControlMessageProxy::message), + pybind11::overload_cast(&ControlMessageProxy::message), py::return_value_policy::reference_internal) .def("message", - pybind11::overload_cast(&ControlMessageProxy::message), + pybind11::overload_cast(&ControlMessageProxy::message), py::arg("message"), py::return_value_policy::reference_internal) - .def("type", &ControlMessage::message_type); + .def("type", &MessageControl::message_type); // Context manager for Mutable Dataframes. Attempting to use it outside of a with block will raise an exception py::class_>(m, "MutableTableCtxMgr") diff --git a/morpheus/_lib/tests/messages/test_control_message.cpp b/morpheus/_lib/tests/messages/test_control_message.cpp index e0d3d65303..b345f72687 100644 --- a/morpheus/_lib/tests/messages/test_control_message.cpp +++ b/morpheus/_lib/tests/messages/test_control_message.cpp @@ -25,23 +25,23 @@ using namespace morpheus::test; TEST_F(TestControlMessage, InitializationTest) { - auto msg_one = ControlMessage(); + auto msg_one = MessageControl(); - ASSERT_EQ(msg_one.message_type(), ControlMessage::ControlMessageType::noop); + ASSERT_EQ(msg_one.message_type(), MessageControl::ControlMessageType::noop); auto config = nlohmann::json(); config["some_value"] = "42"; - auto msg_two = ControlMessage(config); + auto msg_two = MessageControl(config); - ASSERT_EQ(msg_two.message_type(), ControlMessage::ControlMessageType::noop); + ASSERT_EQ(msg_two.message_type(), MessageControl::ControlMessageType::noop); ASSERT_EQ(msg_two.message().contains("some_value"), true); ASSERT_EQ(msg_two.message()["some_value"], "42"); } TEST_F(TestControlMessage, SetMessageTest) { - auto msg = ControlMessage(); + auto msg = MessageControl(); ASSERT_EQ(msg.message().contains("some_value"), false); diff --git a/morpheus/messages/message_control.py b/morpheus/messages/message_control.py index e888e99c26..33b2e8c025 100644 --- a/morpheus/messages/message_control.py +++ b/morpheus/messages/message_control.py @@ -12,13 +12,10 @@ # See the License for the specific language governing permissions and # limitations under the License. -import threading -import warnings - import morpheus._lib.messages as _messages from morpheus.messages.message_base import MessageBase -class MessageControl(MessageBase, cpp_class=_messages.ControlMessage): +class MessageControl(MessageBase, cpp_class=_messages.MessageControl): def __init__(self): super().__init__() diff --git a/tests/messages/test_control_message.py b/tests/messages/test_control_message.py index 8a316d1ffd..b7ef49bb93 100644 --- a/tests/messages/test_control_message.py +++ b/tests/messages/test_control_message.py @@ -20,8 +20,39 @@ def test_control_message_init(): - raw_control_message_one = _messages.ControlMessage() - raw_control_message_two = _messages.ControlMessage({"test": "test"}) + raw_control_message_one = _messages.MessageControl() + raw_control_message_two = _messages.MessageControl({"test": "test"}) control_message_one = messages.MessageControl() control_message_two = messages.MessageControl({"test": "test"}) + + +def test_control_message_get(): + raw_control_message = _messages.MessageControl({"test": "test_rcm"}) + control_message = messages.MessageControl({"test": "test_cm"}) + + assert "test" in raw_control_message.message() + assert raw_control_message.message()["test"] == "test_rcm" + + assert "test" in control_message.message() + assert control_message.message()["test"] == "test_cm" + + +def test_control_message_set(): + raw_control_message = _messages.MessageControl() + control_message = messages.MessageControl() + + raw_control_message.message({"test": "test_rcm"}) + control_message.message({"test": "test_cm"}) + + assert "test" in raw_control_message.message() + assert raw_control_message.message()["test"] == "test_rcm" + + assert "test" in control_message.message() + assert control_message.message()["test"] == "test_cm" + + +if (__name__ == "__main__"): + test_control_message_init() + test_control_message_get() + test_control_message_set() From 5db3a4f42639266915a2615cd04c022ed03f0568 Mon Sep 17 00:00:00 2001 From: Devin Robison Date: Thu, 9 Feb 2023 13:16:58 -0700 Subject: [PATCH 004/157] Checkpoint --- morpheus/_lib/cmake/libraries/morpheus.cmake | 2 + .../_lib/include/morpheus/io/data_loader.hpp | 52 ++++++++++++++++ .../include/morpheus/messages/control.hpp | 18 ------ .../morpheus/modules/data_loader_module.hpp | 43 +++++++++++++ morpheus/_lib/src/io/data_loader.cpp | 60 +++++++++++++++++++ .../_lib/src/modules/data_loader_module.cpp | 57 ++++++++++++++++++ morpheus/_lib/src/python_modules/messages.cpp | 3 +- 7 files changed, 215 insertions(+), 20 deletions(-) create mode 100644 morpheus/_lib/include/morpheus/io/data_loader.hpp create mode 100644 morpheus/_lib/include/morpheus/modules/data_loader_module.hpp create mode 100644 morpheus/_lib/src/io/data_loader.cpp create mode 100644 morpheus/_lib/src/modules/data_loader_module.cpp diff --git a/morpheus/_lib/cmake/libraries/morpheus.cmake b/morpheus/_lib/cmake/libraries/morpheus.cmake index 4ca88cd181..47f40b025f 100644 --- a/morpheus/_lib/cmake/libraries/morpheus.cmake +++ b/morpheus/_lib/cmake/libraries/morpheus.cmake @@ -16,6 +16,7 @@ message(STATUS "Adding library: morpheus") add_library(morpheus # Keep these sorted! + ${MORPHEUS_LIB_ROOT}/src/io/data_loader.cpp ${MORPHEUS_LIB_ROOT}/src/io/deserializers.cpp ${MORPHEUS_LIB_ROOT}/src/io/serializers.cpp ${MORPHEUS_LIB_ROOT}/src/messages/control.cpp @@ -33,6 +34,7 @@ add_library(morpheus ${MORPHEUS_LIB_ROOT}/src/messages/multi_response.cpp ${MORPHEUS_LIB_ROOT}/src/messages/multi_response_probs.cpp ${MORPHEUS_LIB_ROOT}/src/messages/multi_tensor.cpp + ${MORPHEUS_LIB_ROOT}/src/modules/data_loader_module.cpp ${MORPHEUS_LIB_ROOT}/src/objects/fiber_queue.cpp ${MORPHEUS_LIB_ROOT}/src/objects/file_types.cpp ${MORPHEUS_LIB_ROOT}/src/objects/mutable_table_ctx_mgr.cpp diff --git a/morpheus/_lib/include/morpheus/io/data_loader.hpp b/morpheus/_lib/include/morpheus/io/data_loader.hpp new file mode 100644 index 0000000000..8563b559ba --- /dev/null +++ b/morpheus/_lib/include/morpheus/io/data_loader.hpp @@ -0,0 +1,52 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2022-2023, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include + +namespace morpheus { + +class MessageControl; +class MessageMeta; + +class Loader +{ + public: + virtual ~Loader() = default; + + virtual std::shared_ptr load_data(const MessageControl& message) = 0; +}; + +class DataLoader +{ + public: + DataLoader() = default; + ~DataLoader() = default; + + // Probably a MessageMeta? + std::shared_ptr load(const MessageControl& control_message); + + void register_loader(const std::string& loader_id, std::unique_ptr loader); + + void remove_loader(const std::string& loader_id); + + private: + std::map> m_loaders; +}; +} // namespace morpheus \ No newline at end of file diff --git a/morpheus/_lib/include/morpheus/messages/control.hpp b/morpheus/_lib/include/morpheus/messages/control.hpp index f029fe9c4a..2be2947818 100644 --- a/morpheus/_lib/include/morpheus/messages/control.hpp +++ b/morpheus/_lib/include/morpheus/messages/control.hpp @@ -28,26 +28,9 @@ namespace morpheus { class MessageControl { public: - enum class ControlMessageType - { - noop, - custom, - raw, - load - }; - MessageControl() = default; MessageControl(const nlohmann::json& message); - /** - * @brief Get the message type - * @return - */ - ControlMessageType message_type() const; - - // TODO(Devin) : May or may not use enums for message types - //void message_type(ControlMessageType type); - /** * @brief Set the message object * @param message @@ -61,7 +44,6 @@ class MessageControl const nlohmann::json& message() const; private: - ControlMessageType m_type = ControlMessageType::noop; nlohmann::json m_message; }; diff --git a/morpheus/_lib/include/morpheus/modules/data_loader_module.hpp b/morpheus/_lib/include/morpheus/modules/data_loader_module.hpp new file mode 100644 index 0000000000..0a1dea963e --- /dev/null +++ b/morpheus/_lib/include/morpheus/modules/data_loader_module.hpp @@ -0,0 +1,43 @@ +/** + * SPDX-FileCopyrightText: Copyright (c) 2021-2023, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include "morpheus/io/data_loader.hpp" + +#include +#include + +namespace morpheus { +class DataLoaderModule : public mrc::modules::SegmentModule, public mrc::modules::PersistentModule +{ + using type_t = DataLoaderModule; + + public: + DataLoaderModule(std::string module_name); + DataLoaderModule(std::string module_name, nlohmann::json config); + + bool m_was_configured{false}; + + protected: + void initialize(mrc::segment::Builder& builder) override; + std::string module_type_name() const override; + + private: + DataLoader m_data_loader; +}; +} // namespace morpheus \ No newline at end of file diff --git a/morpheus/_lib/src/io/data_loader.cpp b/morpheus/_lib/src/io/data_loader.cpp new file mode 100644 index 0000000000..83ffce2960 --- /dev/null +++ b/morpheus/_lib/src/io/data_loader.cpp @@ -0,0 +1,60 @@ +/** + * SPDX-FileCopyrightText: Copyright (c) 2021-2023, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "morpheus/io/data_loader.hpp" + +#include "morpheus/messages/control.hpp" + +namespace morpheus { + +std::shared_ptr DataLoader::load(const MessageControl& control_message) +{ + auto payload = control_message.message(); + + if (payload.contains("loader_id")) + { + auto loader_id = payload["loader_id"].get(); + auto loader = m_loaders.find(loader_id); + if (loader != m_loaders.end()) + { + return loader->second->load_data(control_message); + } + } + + throw std::runtime_error("No loader registered for message: " + control_message.message().dump()); +} + +void DataLoader::register_loader(const std::string& loader_id, std::unique_ptr loader) +{ + if (m_loaders.find(loader_id) != m_loaders.end()) + { + throw std::runtime_error("Loader already registered with id: " + loader_id); + } + + m_loaders[loader_id] = std::move(loader); +} + +void DataLoader::remove_loader(const std::string& loader_id) +{ + if (m_loaders.find(loader_id) == m_loaders.end()) + { + throw std::runtime_error("Loader not registered with id: " + loader_id); + } + + m_loaders.erase(loader_id); +} +} // namespace morpheus \ No newline at end of file diff --git a/morpheus/_lib/src/modules/data_loader_module.cpp b/morpheus/_lib/src/modules/data_loader_module.cpp new file mode 100644 index 0000000000..6b86ccfa02 --- /dev/null +++ b/morpheus/_lib/src/modules/data_loader_module.cpp @@ -0,0 +1,57 @@ +/** + * SPDX-FileCopyrightText: Copyright (c) 2021-2023, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "morpheus/modules/data_loader_module.hpp" + +#include "morpheus/messages/meta.hpp" + +#include +#include +#include +#include + +#include + +namespace morpheus { +using namespace mrc::modules; + +DataLoaderModule::DataLoaderModule(std::string module_name) : SegmentModule(module_name) {} + +DataLoaderModule::DataLoaderModule(std::string module_name, nlohmann::json config) : + SegmentModule(std::move(module_name), std::move(config)) +{} + +void DataLoaderModule::initialize(mrc::segment::Builder& builder) +{ + if (config().contains("loaders")) + { + // TODO + } + + auto loader_node = builder.make_node( + "input", + rxcpp::operators::map([this](MessageControl& control_message) { return m_data_loader.load(control_message); })); + + register_input_port("input", loader_node); + register_output_port("output", loader_node); +} + +std::string DataLoaderModule::module_type_name() const +{ + return std::string(::mrc::boost_type_name()); +} +} // namespace morpheus \ No newline at end of file diff --git a/morpheus/_lib/src/python_modules/messages.cpp b/morpheus/_lib/src/python_modules/messages.cpp index efbc2140d8..fe0bb0bafc 100644 --- a/morpheus/_lib/src/python_modules/messages.cpp +++ b/morpheus/_lib/src/python_modules/messages.cpp @@ -122,8 +122,7 @@ PYBIND11_MODULE(messages, m) .def("message", pybind11::overload_cast(&ControlMessageProxy::message), py::arg("message"), - py::return_value_policy::reference_internal) - .def("type", &MessageControl::message_type); + py::return_value_policy::reference_internal); // Context manager for Mutable Dataframes. Attempting to use it outside of a with block will raise an exception py::class_>(m, "MutableTableCtxMgr") From 02a38c3f19e19cab002feddc7d636978f2e5257b Mon Sep 17 00:00:00 2001 From: Devin Robison Date: Thu, 9 Feb 2023 17:03:42 -0700 Subject: [PATCH 005/157] DataLoader is working in basic principal, need to move edge converters for MessageControl into their own module --- .../_lib/include/morpheus/io/data_loader.hpp | 8 +- .../include/morpheus/messages/control.hpp | 2 +- .../morpheus/modules/data_loader_module.hpp | 6 +- morpheus/_lib/src/io/data_loader.cpp | 7 +- morpheus/_lib/src/messages/control.cpp | 7 +- .../_lib/src/modules/data_loader_module.cpp | 14 ++- morpheus/_lib/src/python_modules/messages.cpp | 8 ++ morpheus/_lib/src/python_modules/stages.cpp | 10 ++ .../tests/messages/test_control_message.cpp | 3 - tests/modules/test_morpheus_modules.py | 101 ++++++++++++++++++ 10 files changed, 143 insertions(+), 23 deletions(-) create mode 100644 tests/modules/test_morpheus_modules.py diff --git a/morpheus/_lib/include/morpheus/io/data_loader.hpp b/morpheus/_lib/include/morpheus/io/data_loader.hpp index 8563b559ba..5ddfd2963f 100644 --- a/morpheus/_lib/include/morpheus/io/data_loader.hpp +++ b/morpheus/_lib/include/morpheus/io/data_loader.hpp @@ -17,20 +17,20 @@ #pragma once +#include "morpheus/messages/control.hpp" +#include "morpheus/messages/meta.hpp" + #include #include namespace morpheus { -class MessageControl; -class MessageMeta; - class Loader { public: virtual ~Loader() = default; - virtual std::shared_ptr load_data(const MessageControl& message) = 0; + virtual std::shared_ptr load(const MessageControl& message) = 0; }; class DataLoader diff --git a/morpheus/_lib/include/morpheus/messages/control.hpp b/morpheus/_lib/include/morpheus/messages/control.hpp index 2be2947818..94c38a75c9 100644 --- a/morpheus/_lib/include/morpheus/messages/control.hpp +++ b/morpheus/_lib/include/morpheus/messages/control.hpp @@ -56,4 +56,4 @@ struct ControlMessageProxy }; #pragma GCC visibility pop -} // namespace morpheus \ No newline at end of file +} // namespace morpheus diff --git a/morpheus/_lib/include/morpheus/modules/data_loader_module.hpp b/morpheus/_lib/include/morpheus/modules/data_loader_module.hpp index 0a1dea963e..e5bc4147f6 100644 --- a/morpheus/_lib/include/morpheus/modules/data_loader_module.hpp +++ b/morpheus/_lib/include/morpheus/modules/data_loader_module.hpp @@ -21,18 +21,19 @@ #include #include +#include namespace morpheus { +#pragma GCC visibility push(default) class DataLoaderModule : public mrc::modules::SegmentModule, public mrc::modules::PersistentModule { using type_t = DataLoaderModule; public: + virtual ~DataLoaderModule() = default; DataLoaderModule(std::string module_name); DataLoaderModule(std::string module_name, nlohmann::json config); - bool m_was_configured{false}; - protected: void initialize(mrc::segment::Builder& builder) override; std::string module_type_name() const override; @@ -40,4 +41,5 @@ class DataLoaderModule : public mrc::modules::SegmentModule, public mrc::modules private: DataLoader m_data_loader; }; +#pragma GCC visibility pop } // namespace morpheus \ No newline at end of file diff --git a/morpheus/_lib/src/io/data_loader.cpp b/morpheus/_lib/src/io/data_loader.cpp index 83ffce2960..d32f08cd06 100644 --- a/morpheus/_lib/src/io/data_loader.cpp +++ b/morpheus/_lib/src/io/data_loader.cpp @@ -31,10 +31,13 @@ std::shared_ptr DataLoader::load(const MessageControl& control_mess auto loader = m_loaders.find(loader_id); if (loader != m_loaders.end()) { - return loader->second->load_data(control_message); + return loader->second->load(control_message); } } + // TODO(Devin): Testing. Remove this. + return std::shared_ptr(nullptr); + throw std::runtime_error("No loader registered for message: " + control_message.message().dump()); } @@ -57,4 +60,4 @@ void DataLoader::remove_loader(const std::string& loader_id) m_loaders.erase(loader_id); } -} // namespace morpheus \ No newline at end of file +} // namespace morpheus diff --git a/morpheus/_lib/src/messages/control.cpp b/morpheus/_lib/src/messages/control.cpp index 2a65c5cafd..f349ad79d7 100644 --- a/morpheus/_lib/src/messages/control.cpp +++ b/morpheus/_lib/src/messages/control.cpp @@ -26,11 +26,6 @@ namespace morpheus { MessageControl::MessageControl(const nlohmann::json& message) : m_message(message) {} -MessageControl::ControlMessageType MessageControl::message_type() const -{ - return m_type; -} - const nlohmann::json& MessageControl::message() const { return m_message; @@ -60,4 +55,4 @@ void ControlMessageProxy::message(MessageControl& self, py::dict& message) self.message(mrc::pymrc::cast_from_pyobject(message)); } -} // namespace morpheus \ No newline at end of file +} // namespace morpheus diff --git a/morpheus/_lib/src/modules/data_loader_module.cpp b/morpheus/_lib/src/modules/data_loader_module.cpp index 6b86ccfa02..415cb45140 100644 --- a/morpheus/_lib/src/modules/data_loader_module.cpp +++ b/morpheus/_lib/src/modules/data_loader_module.cpp @@ -24,11 +24,14 @@ #include #include +#include + #include -namespace morpheus { using namespace mrc::modules; +namespace morpheus { + DataLoaderModule::DataLoaderModule(std::string module_name) : SegmentModule(module_name) {} DataLoaderModule::DataLoaderModule(std::string module_name, nlohmann::json config) : @@ -42,9 +45,10 @@ void DataLoaderModule::initialize(mrc::segment::Builder& builder) // TODO } - auto loader_node = builder.make_node( - "input", - rxcpp::operators::map([this](MessageControl& control_message) { return m_data_loader.load(control_message); })); + auto loader_node = builder.make_node, std::shared_ptr>( + "input", rxcpp::operators::map([this](std::shared_ptr control_message) { + return m_data_loader.load(*control_message); + })); register_input_port("input", loader_node); register_output_port("output", loader_node); @@ -54,4 +58,4 @@ std::string DataLoaderModule::module_type_name() const { return std::string(::mrc::boost_type_name()); } -} // namespace morpheus \ No newline at end of file +} // namespace morpheus diff --git a/morpheus/_lib/src/python_modules/messages.cpp b/morpheus/_lib/src/python_modules/messages.cpp index fe0bb0bafc..b7d7b588db 100644 --- a/morpheus/_lib/src/python_modules/messages.cpp +++ b/morpheus/_lib/src/python_modules/messages.cpp @@ -88,6 +88,14 @@ PYBIND11_MODULE(messages, m) mrc::pymrc::PortBuilderUtil::register_port_util>(); mrc::pymrc::PortBuilderUtil::register_port_util>(); + mrc::edge::EdgeConnector, + mrc::pymrc::PyObjectHolder>::register_converter(); + mrc::edge::EdgeConnector>::register_converter(); + + mrc::edge::EdgeConnector, mrc::pymrc::PyObjectHolder>::register_converter(); + mrc::edge::EdgeConnector>::register_converter(); + // EdgeConnectors for derived classes of MultiMessage to MultiMessage mrc::edge::EdgeConnector, std::shared_ptr>::register_converter(); diff --git a/morpheus/_lib/src/python_modules/stages.cpp b/morpheus/_lib/src/python_modules/stages.cpp index 521b3cea54..601b1d9205 100644 --- a/morpheus/_lib/src/python_modules/stages.cpp +++ b/morpheus/_lib/src/python_modules/stages.cpp @@ -15,8 +15,10 @@ * limitations under the License. */ +#include "morpheus/messages/control.hpp" #include "morpheus/messages/meta.hpp" #include "morpheus/messages/multi.hpp" +#include "morpheus/modules/data_loader_module.hpp" #include "morpheus/stages/add_classification.hpp" #include "morpheus/stages/add_scores.hpp" #include "morpheus/stages/deserialize.hpp" @@ -31,7 +33,9 @@ #include "morpheus/stages/write_to_file.hpp" #include "morpheus/utilities/cudf_util.hpp" +#include #include +#include #include // for multiple_inheritance #include // for arg, init, class_, module_, str_attr_accessor, PYBIND11_MODULE, pybind11 #include // for dict, sequence @@ -59,6 +63,12 @@ PYBIND11_MODULE(stages, m) mrc::pymrc::from_import(m, "morpheus._lib.common", "FilterSource"); + // TODO(Devin): Move to its own 'modules' module, shouldn't be with stages + const std::vector MRCModuleVersion{mrc_VERSION_MAJOR, mrc_VERSION_MINOR, mrc_VERSION_PATCH}; + using namespace mrc::modules; + mrc::modules::ModelRegistryUtil::create_registered_module( + "DataLoader", "morpheus", MRCModuleVersion); + py::class_, mrc::segment::ObjectProperties, std::shared_ptr>>( diff --git a/morpheus/_lib/tests/messages/test_control_message.cpp b/morpheus/_lib/tests/messages/test_control_message.cpp index b345f72687..79a9e3a5f1 100644 --- a/morpheus/_lib/tests/messages/test_control_message.cpp +++ b/morpheus/_lib/tests/messages/test_control_message.cpp @@ -27,14 +27,11 @@ TEST_F(TestControlMessage, InitializationTest) { auto msg_one = MessageControl(); - ASSERT_EQ(msg_one.message_type(), MessageControl::ControlMessageType::noop); - auto config = nlohmann::json(); config["some_value"] = "42"; auto msg_two = MessageControl(config); - ASSERT_EQ(msg_two.message_type(), MessageControl::ControlMessageType::noop); ASSERT_EQ(msg_two.message().contains("some_value"), true); ASSERT_EQ(msg_two.message()["some_value"], "42"); } diff --git a/tests/modules/test_morpheus_modules.py b/tests/modules/test_morpheus_modules.py new file mode 100644 index 0000000000..e3193e3cc5 --- /dev/null +++ b/tests/modules/test_morpheus_modules.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python +# SPDX-FileCopyrightText: Copyright (c) 2022-2023, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import time + +import mrc +import morpheus._lib.stages # TODO: for DataLoader +import morpheus._lib.messages as _messages +import morpheus.messages as messages + + +def test_contains_namespace(): + registry = mrc.ModuleRegistry + + assert registry.contains_namespace("morpheus") + + +def test_is_version_compatible(): + registry = mrc.ModuleRegistry + + release_version = [int(x) for x in mrc.__version__.split(".")] + old_release_version = [22, 10, 0] + no_version_patch = [22, 10] + no_version_minor_and_patch = [22] + + assert registry.is_version_compatible(release_version) + assert registry.is_version_compatible(old_release_version) is not True + assert registry.is_version_compatible(no_version_patch) is not True + assert registry.is_version_compatible(no_version_minor_and_patch) is not True + + +def test_get_module(): + registry = mrc.ModuleRegistry + + fn_constructor = registry.get_module_constructor("DataLoader", "morpheus") + assert fn_constructor is not None + + config = {} + module_instance = fn_constructor("ModuleDataLoaderTest", config) + + +def test_init_module(): + def init_wrapper(builder: mrc.Builder): + def gen_data(): + for i in range(10): + yield messages.MessageControl() + + def on_next(data): + pass + + def on_error(): + pass + + def on_complete(): + pass + + registry = mrc.ModuleRegistry + + fn_constructor = registry.get_module_constructor("DataLoader", "morpheus") + assert fn_constructor is not None + + source = builder.make_source("source", gen_data) + + config = {} + data_loader = builder.load_module("DataLoader", "morpheus", "ModuleDataLoaderTest", config) + + sink = builder.make_sink("sink", on_next, on_error, on_complete) + + builder.make_edge(source, data_loader.input_port("input")) + builder.make_edge(data_loader.output_port("output"), sink) + + pipeline = mrc.Pipeline() + pipeline.make_segment("main", init_wrapper) + + options = mrc.Options() + options.topology.user_cpuset = "0-1" + + executor = mrc.Executor(options) + executor.register_pipeline(pipeline) + executor.start() + executor.join() + + +if (__name__ == "__main__"): + test_contains_namespace() + test_is_version_compatible() + test_get_module() + test_init_module() From 1b7053fb42a7b101c13198d0072da94ac8525a74 Mon Sep 17 00:00:00 2001 From: Devin Robison Date: Thu, 9 Feb 2023 17:37:54 -0700 Subject: [PATCH 006/157] Add DataLoaderModule and morpheus module integration, fixed all module versioning --- CMakeLists.txt | 2 +- morpheus/_lib/CMakeLists.txt | 3 + morpheus/_lib/__init__.py | 1 + .../_lib/cmake/python_modules/modules.cmake | 34 +++++++++++ morpheus/_lib/src/python_modules/common.cpp | 27 +++++---- morpheus/_lib/src/python_modules/messages.cpp | 56 ++++++++++--------- morpheus/_lib/src/python_modules/modules.cpp | 48 ++++++++++++++++ morpheus/_lib/src/python_modules/stages.cpp | 50 +++++++---------- morpheus/modules/__init__.py | 2 + tests/modules/test_morpheus_modules.py | 3 +- 10 files changed, 153 insertions(+), 73 deletions(-) create mode 100644 morpheus/_lib/cmake/python_modules/modules.cmake create mode 100644 morpheus/_lib/src/python_modules/modules.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 14d5c9f62d..895a81b6ba 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -96,7 +96,7 @@ project(morpheus VERSION 23.01.00 LANGUAGES C CXX CUDA) -rapids_cmake_write_version_file(${CMAKE_BINARY_DIR}/autogenerated/include/mrc/version.hpp) +rapids_cmake_write_version_file(${CMAKE_BINARY_DIR}/autogenerated/include/morpheus/version.hpp) # Ccache configuration include(environment/init_ccache) diff --git a/morpheus/_lib/CMakeLists.txt b/morpheus/_lib/CMakeLists.txt index 7deb0557d0..721472b115 100644 --- a/morpheus/_lib/CMakeLists.txt +++ b/morpheus/_lib/CMakeLists.txt @@ -39,6 +39,9 @@ include(cmake/python_modules/stages.cmake) #----------morpheus._lib.messages--------- include(cmake/python_modules/messages.cmake) +#----------morpheus._lib.modules--------- +include(cmake/python_modules/modules.cmake) + #----------morpheus._lib.common--------- include(cmake/python_modules/common.cmake) diff --git a/morpheus/_lib/__init__.py b/morpheus/_lib/__init__.py index 06137372ca..b41ee7cf96 100644 --- a/morpheus/_lib/__init__.py +++ b/morpheus/_lib/__init__.py @@ -14,3 +14,4 @@ from . import common from . import messages from . import stages +from . import modules diff --git a/morpheus/_lib/cmake/python_modules/modules.cmake b/morpheus/_lib/cmake/python_modules/modules.cmake new file mode 100644 index 0000000000..14d55f65d0 --- /dev/null +++ b/morpheus/_lib/cmake/python_modules/modules.cmake @@ -0,0 +1,34 @@ +# ============================================================================= +# Copyright (c) 2020-2023, NVIDIA CORPORATION. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +# in compliance with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under the License +# is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +# or implied. See the License for the specific language governing permissions and limitations under +# the License. +# ============================================================================= + +morpheus_utils_add_pybind11_module( + modules + MODULE_ROOT + "${MORPHEUS_LIB_ROOT}" + SOURCE_FILES + "${MORPHEUS_LIB_ROOT}/src/python_modules/modules.cpp" + INCLUDE_DIRS + "${MORPHEUS_LIB_ROOT}/include" + LINK_TARGETS + morpheus + mrc::pymrc + OUTPUT_TARGET + modules_target + INSTALL_DEST + ${MORPHEUS_LIB_INSTALL_DIR} +) + +if(MORPHEUS_PYTHON_INPLACE_BUILD) + morpheus_utils_inplace_build_copy(${modules_target} ${MORPHEUS_LIB_ROOT}) +endif() diff --git a/morpheus/_lib/src/python_modules/common.cpp b/morpheus/_lib/src/python_modules/common.cpp index 4ec80f1d04..44649fb499 100644 --- a/morpheus/_lib/src/python_modules/common.cpp +++ b/morpheus/_lib/src/python_modules/common.cpp @@ -22,7 +22,9 @@ #include "morpheus/objects/tensor_object.hpp" // for TensorObject #include "morpheus/objects/wrapped_tensor.hpp" #include "morpheus/utilities/cudf_util.hpp" +#include "morpheus/version.hpp" +#include #include #include @@ -30,9 +32,9 @@ namespace morpheus { namespace py = pybind11; -PYBIND11_MODULE(common, m) +PYBIND11_MODULE(common, _module) { - m.doc() = R"pbdoc( + _module.doc() = R"pbdoc( ----------------------- .. currentmodule:: morpheus.common .. autosummary:: @@ -42,16 +44,16 @@ PYBIND11_MODULE(common, m) // Load the cudf helpers load_cudf_helpers(); - py::class_(m, "Tensor") + py::class_(_module, "Tensor") .def_property_readonly("__cuda_array_interface__", &TensorObjectInterfaceProxy::cuda_array_interface); - py::class_>(m, "FiberQueue") + py::class_>(_module, "FiberQueue") .def(py::init<>(&FiberQueueInterfaceProxy::init), py::arg("max_size")) .def("get", &FiberQueueInterfaceProxy::get, py::arg("block") = true, py::arg("timeout") = 0.0) .def("put", &FiberQueueInterfaceProxy::put, py::arg("item"), py::arg("block") = true, py::arg("timeout") = 0.0) .def("close", &FiberQueueInterfaceProxy::close); - py::enum_(m, "TypeId", "Supported Morpheus types") + py::enum_(_module, "TypeId", "Supported Morpheus types") .value("EMPTY", TypeId::EMPTY) .value("INT8", TypeId::INT8) .value("INT16", TypeId::INT16) @@ -66,9 +68,9 @@ PYBIND11_MODULE(common, m) .value("BOOL8", TypeId::BOOL8) .value("STRING", TypeId::STRING); - m.def("tyepid_to_numpy_str", [](TypeId tid) { return DType(tid).type_str(); }); + _module.def("tyepid_to_numpy_str", [](TypeId tid) { return DType(tid).type_str(); }); - py::enum_(m, + py::enum_(_module, "FileTypes", "The type of files that the `FileSourceStage` can read and `WriteToFileStage` can write. Use " "'auto' to determine from the file extension.") @@ -76,18 +78,15 @@ PYBIND11_MODULE(common, m) .value("JSON", FileTypes::JSON) .value("CSV", FileTypes::CSV); - m.def("determine_file_type", &FileTypesInterfaceProxy::determine_file_type); + _module.def("determine_file_type", &FileTypesInterfaceProxy::determine_file_type); py::enum_( - m, "FilterSource", "Enum to indicate which source the FilterDetectionsStage should operate on.") + _module, "FilterSource", "Enum to indicate which source the FilterDetectionsStage should operate on.") .value("Auto", FilterSource::Auto) .value("TENSOR", FilterSource::TENSOR) .value("DATAFRAME", FilterSource::DATAFRAME); -#ifdef VERSION_INFO - m.attr("__version__") = MACRO_STRINGIFY(VERSION_INFO); -#else - m.attr("__version__") = "dev"; -#endif + _module.attr("__version__") = + MRC_CONCAT_STR(morpheus_VERSION_MAJOR << "." << morpheus_VERSION_MINOR << "." << morpheus_VERSION_PATCH); } } // namespace morpheus diff --git a/morpheus/_lib/src/python_modules/messages.cpp b/morpheus/_lib/src/python_modules/messages.cpp index b7d7b588db..d802adb3cd 100644 --- a/morpheus/_lib/src/python_modules/messages.cpp +++ b/morpheus/_lib/src/python_modules/messages.cpp @@ -32,10 +32,12 @@ #include "morpheus/objects/data_table.hpp" #include "morpheus/objects/mutable_table_ctx_mgr.hpp" #include "morpheus/utilities/cudf_util.hpp" +#include "morpheus/version.hpp" #include // for Status #include #include +#include #include // IWYU pragma: keep #include #include @@ -56,10 +58,9 @@ namespace morpheus { namespace fs = std::filesystem; namespace py = pybind11; -// Define the pybind11 module m, as 'pipeline'. -PYBIND11_MODULE(messages, m) +PYBIND11_MODULE(messages, _module) { - m.doc() = R"pbdoc( + _module.doc() = R"pbdoc( ----------------------- .. currentmodule:: morpheus.messages .. autosummary:: @@ -70,14 +71,14 @@ PYBIND11_MODULE(messages, m) // Load the cudf helpers load_cudf_helpers(); - mrc::pymrc::import(m, "cupy"); - mrc::pymrc::import(m, "morpheus._lib.common"); + mrc::pymrc::import(_module, "cupy"); + mrc::pymrc::import(_module, "morpheus._lib.common"); // Required for SegmentObject - mrc::pymrc::import(m, "mrc.core.node"); + mrc::pymrc::import(_module, "mrc.core.node"); // Allows python objects to keep DataTable objects alive - py::class_>(m, "DataTable"); + py::class_>(_module, "DataTable"); mrc::pymrc::PortBuilderUtil::register_port_util>(); mrc::pymrc::PortBuilderUtil::register_port_util>(); @@ -88,6 +89,7 @@ PYBIND11_MODULE(messages, m) mrc::pymrc::PortBuilderUtil::register_port_util>(); mrc::pymrc::PortBuilderUtil::register_port_util>(); + // EdgeConnectors for converting between PyObjectHolders and various Message types mrc::edge::EdgeConnector, mrc::pymrc::PyObjectHolder>::register_converter(); mrc::edge::EdgeConnector, std::shared_ptr>::register_converter(); - py::class_>(m, "MessageControl") + py::class_>(_module, "MessageControl") .def(py::init<>()) .def(py::init(py::overload_cast(&ControlMessageProxy::create)), py::return_value_policy::move) .def("message", @@ -133,7 +135,7 @@ PYBIND11_MODULE(messages, m) py::return_value_policy::reference_internal); // Context manager for Mutable Dataframes. Attempting to use it outside of a with block will raise an exception - py::class_>(m, "MutableTableCtxMgr") + py::class_>(_module, "MutableTableCtxMgr") .def("__enter__", &MutableTableCtxMgr::enter, py::return_value_policy::reference) .def("__exit__", &MutableTableCtxMgr::exit) .def("__getattr__", &MutableTableCtxMgr::throw_usage_error) @@ -141,7 +143,7 @@ PYBIND11_MODULE(messages, m) .def("__setattr__", &MutableTableCtxMgr::throw_usage_error) .def("__setitem__", &MutableTableCtxMgr::throw_usage_error); - py::class_>(m, "MessageMeta") + py::class_>(_module, "MessageMeta") .def(py::init<>(&MessageMetaInterfaceProxy::init_python), py::arg("df")) .def_property_readonly("count", &MessageMetaInterfaceProxy::count) .def_property_readonly("df", &MessageMetaInterfaceProxy::df_property, py::return_value_policy::move) @@ -149,7 +151,7 @@ PYBIND11_MODULE(messages, m) .def("mutable_dataframe", &MessageMetaInterfaceProxy::mutable_dataframe, py::return_value_policy::move) .def_static("make_from_file", &MessageMetaInterfaceProxy::init_cpp); - py::class_>(m, "MultiMessage") + py::class_>(_module, "MultiMessage") .def(py::init<>(&MultiMessageInterfaceProxy::init), py::arg("meta"), py::arg("mess_offset"), @@ -176,10 +178,10 @@ PYBIND11_MODULE(messages, m) py::return_value_policy::move) .def("get_meta_list", &MultiMessageInterfaceProxy::get_meta_list, py::return_value_policy::move); - py::class_>(m, "InferenceMemory") + py::class_>(_module, "InferenceMemory") .def_property_readonly("count", &InferenceMemoryInterfaceProxy::get_count); - py::class_>(m, "InferenceMemoryNLP") + py::class_>(_module, "InferenceMemoryNLP") .def(py::init<>(&InferenceMemoryNLPInterfaceProxy::init), py::arg("count"), py::arg("input_ids"), @@ -195,7 +197,7 @@ PYBIND11_MODULE(messages, m) .def_property( "seq_ids", &InferenceMemoryNLPInterfaceProxy::get_seq_ids, &InferenceMemoryNLPInterfaceProxy::set_seq_ids); - py::class_>(m, "InferenceMemoryFIL") + py::class_>(_module, "InferenceMemoryFIL") .def(py::init<>(&InferenceMemoryFILInterfaceProxy::init), py::arg("count"), py::arg("input__0"), @@ -208,7 +210,8 @@ PYBIND11_MODULE(messages, m) .def_property( "seq_ids", &InferenceMemoryFILInterfaceProxy::get_seq_ids, &InferenceMemoryFILInterfaceProxy::set_seq_ids); - py::class_>(m, "MultiInferenceMessage") + py::class_>(_module, + "MultiInferenceMessage") .def(py::init<>(&MultiInferenceMessageInterfaceProxy::init), py::arg("meta"), py::arg("mess_offset"), @@ -223,7 +226,7 @@ PYBIND11_MODULE(messages, m) .def("get_slice", &MultiInferenceMessageInterfaceProxy::get_slice, py::return_value_policy::reference_internal); py::class_>( - m, "MultiInferenceNLPMessage") + _module, "MultiInferenceNLPMessage") .def(py::init<>(&MultiInferenceNLPMessageInterfaceProxy::init), py::arg("meta"), py::arg("mess_offset"), @@ -239,7 +242,7 @@ PYBIND11_MODULE(messages, m) .def_property_readonly("seq_ids", &MultiInferenceNLPMessageInterfaceProxy::seq_ids); py::class_>( - m, "MultiInferenceFILMessage") + _module, "MultiInferenceFILMessage") .def(py::init<>(&MultiInferenceFILMessageInterfaceProxy::init), py::arg("meta"), py::arg("mess_offset"), @@ -251,23 +254,25 @@ PYBIND11_MODULE(messages, m) .def_property_readonly("offset", &MultiInferenceFILMessageInterfaceProxy::offset) .def_property_readonly("count", &MultiInferenceFILMessageInterfaceProxy::count); - py::class_>(m, "TensorMemory") + py::class_>(_module, "TensorMemory") .def_readonly("count", &TensorMemory::count); - py::class_>(m, "ResponseMemory") + py::class_>(_module, "ResponseMemory") .def_readonly("count", &ResponseMemory::count) .def("get_output", &ResponseMemoryInterfaceProxy::get_output, py::return_value_policy::reference_internal) .def("get_output_tensor", &ResponseMemoryInterfaceProxy::get_output_tensor, py::return_value_policy::reference_internal); - py::class_>(m, "ResponseMemoryProbs") + py::class_>(_module, + "ResponseMemoryProbs") .def(py::init<>(&ResponseMemoryProbsInterfaceProxy::init), py::arg("count"), py::arg("probs")) .def_property_readonly("count", &ResponseMemoryProbsInterfaceProxy::count) .def_property( "probs", &ResponseMemoryProbsInterfaceProxy::get_probs, &ResponseMemoryProbsInterfaceProxy::set_probs); - py::class_>(m, "MultiResponseMessage") + py::class_>(_module, + "MultiResponseMessage") .def(py::init<>(&MultiResponseMessageInterfaceProxy::init), py::arg("meta"), py::arg("mess_offset"), @@ -281,7 +286,7 @@ PYBIND11_MODULE(messages, m) .def("get_output", &MultiResponseMessageInterfaceProxy::get_output); py::class_>( - m, "MultiResponseProbsMessage") + _module, "MultiResponseProbsMessage") .def(py::init<>(&MultiResponseProbsMessageInterfaceProxy::init), py::arg("meta"), py::arg("mess_offset"), @@ -294,10 +299,7 @@ PYBIND11_MODULE(messages, m) .def_property_readonly("count", &MultiResponseProbsMessageInterfaceProxy::count) .def_property_readonly("probs", &MultiResponseProbsMessageInterfaceProxy::probs); -#ifdef VERSION_INFO - m.attr("__version__") = MACRO_STRINGIFY(VERSION_INFO); -#else - m.attr("__version__") = "dev"; -#endif + _module.attr("__version__") = + MRC_CONCAT_STR(morpheus_VERSION_MAJOR << "." << morpheus_VERSION_MINOR << "." << morpheus_VERSION_PATCH); } } // namespace morpheus diff --git a/morpheus/_lib/src/python_modules/modules.cpp b/morpheus/_lib/src/python_modules/modules.cpp new file mode 100644 index 0000000000..983e112086 --- /dev/null +++ b/morpheus/_lib/src/python_modules/modules.cpp @@ -0,0 +1,48 @@ +/** + * SPDX-FileCopyrightText: Copyright (c) 2021-2023, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "morpheus/modules/data_loader_module.hpp" +#include "morpheus/version.hpp" + +#include +#include +#include +#include // for arg, init, class_, module_, str_attr_accessor, PYBIND11_MODULE, pybind11 + +namespace morpheus { +namespace py = pybind11; + +PYBIND11_MODULE(modules, _module) +{ + _module.doc() = R"pbdoc( + ----------------------- + .. currentmodule:: morpheus.modules + .. autosummary:: + :toctree: _generate + + )pbdoc"; + + // TODO(Devin): Move to its own 'modules' module, shouldn't be with stages + const std::vector MRCModuleVersion{mrc_VERSION_MAJOR, mrc_VERSION_MINOR, mrc_VERSION_PATCH}; + using namespace mrc::modules; + mrc::modules::ModelRegistryUtil::create_registered_module( + "DataLoader", "morpheus", MRCModuleVersion); + + _module.attr("__version__") = + MRC_CONCAT_STR(morpheus_VERSION_MAJOR << "." << morpheus_VERSION_MINOR << "." << morpheus_VERSION_PATCH); +} +} // namespace morpheus diff --git a/morpheus/_lib/src/python_modules/stages.cpp b/morpheus/_lib/src/python_modules/stages.cpp index 601b1d9205..67b71f3b2a 100644 --- a/morpheus/_lib/src/python_modules/stages.cpp +++ b/morpheus/_lib/src/python_modules/stages.cpp @@ -15,10 +15,8 @@ * limitations under the License. */ -#include "morpheus/messages/control.hpp" #include "morpheus/messages/meta.hpp" #include "morpheus/messages/multi.hpp" -#include "morpheus/modules/data_loader_module.hpp" #include "morpheus/stages/add_classification.hpp" #include "morpheus/stages/add_scores.hpp" #include "morpheus/stages/deserialize.hpp" @@ -32,7 +30,9 @@ #include "morpheus/stages/triton_inference.hpp" #include "morpheus/stages/write_to_file.hpp" #include "morpheus/utilities/cudf_util.hpp" +#include "morpheus/version.hpp" +#include #include #include #include @@ -47,10 +47,9 @@ namespace morpheus { namespace py = pybind11; -// Define the pybind11 module m, as 'pipeline'. -PYBIND11_MODULE(stages, m) +PYBIND11_MODULE(stages, _module) { - m.doc() = R"pbdoc( + _module.doc() = R"pbdoc( ----------------------- .. currentmodule:: morpheus.stages .. autosummary:: @@ -61,18 +60,12 @@ PYBIND11_MODULE(stages, m) // Load the cudf helpers load_cudf_helpers(); - mrc::pymrc::from_import(m, "morpheus._lib.common", "FilterSource"); - - // TODO(Devin): Move to its own 'modules' module, shouldn't be with stages - const std::vector MRCModuleVersion{mrc_VERSION_MAJOR, mrc_VERSION_MINOR, mrc_VERSION_PATCH}; - using namespace mrc::modules; - mrc::modules::ModelRegistryUtil::create_registered_module( - "DataLoader", "morpheus", MRCModuleVersion); + mrc::pymrc::from_import(_module, "morpheus._lib.common", "FilterSource"); py::class_, mrc::segment::ObjectProperties, std::shared_ptr>>( - m, "AddClassificationsStage", py::multiple_inheritance()) + _module, "AddClassificationsStage", py::multiple_inheritance()) .def(py::init<>(&AddClassificationStageInterfaceProxy::init), py::arg("builder"), py::arg("name"), @@ -82,7 +75,7 @@ PYBIND11_MODULE(stages, m) py::class_, mrc::segment::ObjectProperties, - std::shared_ptr>>(m, "AddScoresStage", py::multiple_inheritance()) + std::shared_ptr>>(_module, "AddScoresStage", py::multiple_inheritance()) .def(py::init<>(&AddScoresStageInterfaceProxy::init), py::arg("builder"), py::arg("name"), @@ -92,7 +85,7 @@ PYBIND11_MODULE(stages, m) py::class_, mrc::segment::ObjectProperties, std::shared_ptr>>( - m, "DeserializeStage", py::multiple_inheritance()) + _module, "DeserializeStage", py::multiple_inheritance()) .def(py::init<>(&DeserializeStageInterfaceProxy::init), py::arg("builder"), py::arg("name"), @@ -100,7 +93,7 @@ PYBIND11_MODULE(stages, m) py::class_, mrc::segment::ObjectProperties, - std::shared_ptr>>(m, "FileSourceStage", py::multiple_inheritance()) + std::shared_ptr>>(_module, "FileSourceStage", py::multiple_inheritance()) .def(py::init<>(&FileSourceStageInterfaceProxy::init), py::arg("builder"), py::arg("name"), @@ -110,7 +103,7 @@ PYBIND11_MODULE(stages, m) py::class_, mrc::segment::ObjectProperties, std::shared_ptr>>( - m, "FilterDetectionsStage", py::multiple_inheritance()) + _module, "FilterDetectionsStage", py::multiple_inheritance()) .def(py::init<>(&FilterDetectionStageInterfaceProxy::init), py::arg("builder"), py::arg("name"), @@ -122,7 +115,7 @@ PYBIND11_MODULE(stages, m) py::class_, mrc::segment::ObjectProperties, std::shared_ptr>>( - m, "InferenceClientStage", py::multiple_inheritance()) + _module, "InferenceClientStage", py::multiple_inheritance()) .def(py::init<>(&InferenceClientStageInterfaceProxy::init), py::arg("builder"), py::arg("name"), @@ -136,7 +129,7 @@ PYBIND11_MODULE(stages, m) py::class_, mrc::segment::ObjectProperties, std::shared_ptr>>( - m, "KafkaSourceStage", py::multiple_inheritance()) + _module, "KafkaSourceStage", py::multiple_inheritance()) .def(py::init<>(&KafkaSourceStageInterfaceProxy::init), py::arg("builder"), py::arg("name"), @@ -152,7 +145,7 @@ PYBIND11_MODULE(stages, m) py::class_>, mrc::segment::ObjectProperties, std::shared_ptr>>>( - m, "PreallocateMessageMetaStage", py::multiple_inheritance()) + _module, "PreallocateMessageMetaStage", py::multiple_inheritance()) .def(py::init<>(&PreallocateStageInterfaceProxy::init), py::arg("builder"), py::arg("name"), @@ -161,7 +154,7 @@ PYBIND11_MODULE(stages, m) py::class_>, mrc::segment::ObjectProperties, std::shared_ptr>>>( - m, "PreallocateMultiMessageStage", py::multiple_inheritance()) + _module, "PreallocateMultiMessageStage", py::multiple_inheritance()) .def(py::init<>(&PreallocateStageInterfaceProxy::init), py::arg("builder"), py::arg("name"), @@ -170,7 +163,7 @@ PYBIND11_MODULE(stages, m) py::class_, mrc::segment::ObjectProperties, std::shared_ptr>>( - m, "PreprocessFILStage", py::multiple_inheritance()) + _module, "PreprocessFILStage", py::multiple_inheritance()) .def(py::init<>(&PreprocessFILStageInterfaceProxy::init), py::arg("builder"), py::arg("name"), @@ -179,7 +172,7 @@ PYBIND11_MODULE(stages, m) py::class_, mrc::segment::ObjectProperties, std::shared_ptr>>( - m, "PreprocessNLPStage", py::multiple_inheritance()) + _module, "PreprocessNLPStage", py::multiple_inheritance()) .def(py::init<>(&PreprocessNLPStageInterfaceProxy::init), py::arg("builder"), py::arg("name"), @@ -193,7 +186,7 @@ PYBIND11_MODULE(stages, m) py::class_, mrc::segment::ObjectProperties, - std::shared_ptr>>(m, "SerializeStage", py::multiple_inheritance()) + std::shared_ptr>>(_module, "SerializeStage", py::multiple_inheritance()) .def(py::init<>(&SerializeStageInterfaceProxy::init), py::arg("builder"), py::arg("name"), @@ -204,7 +197,7 @@ PYBIND11_MODULE(stages, m) py::class_, mrc::segment::ObjectProperties, std::shared_ptr>>( - m, "WriteToFileStage", py::multiple_inheritance()) + _module, "WriteToFileStage", py::multiple_inheritance()) .def(py::init<>(&WriteToFileStageInterfaceProxy::init), py::arg("builder"), py::arg("name"), @@ -214,10 +207,7 @@ PYBIND11_MODULE(stages, m) py::arg("include_index_col") = true, py::arg("flush") = false); -#ifdef VERSION_INFO - m.attr("__version__") = MACRO_STRINGIFY(VERSION_INFO); -#else - m.attr("__version__") = "dev"; -#endif + _module.attr("__version__") = MRC_CONCAT_STR(morpheus_VERSION_MAJOR << "." << morpheus_VERSION_MINOR << "." + << morpheus_VERSION_PATCH); } } // namespace morpheus diff --git a/morpheus/modules/__init__.py b/morpheus/modules/__init__.py index 081b2ae826..339e06d1dd 100644 --- a/morpheus/modules/__init__.py +++ b/morpheus/modules/__init__.py @@ -11,3 +11,5 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. + +import morpheus._lib.modules \ No newline at end of file diff --git a/tests/modules/test_morpheus_modules.py b/tests/modules/test_morpheus_modules.py index e3193e3cc5..8e2279d3c7 100644 --- a/tests/modules/test_morpheus_modules.py +++ b/tests/modules/test_morpheus_modules.py @@ -17,8 +17,9 @@ import time import mrc -import morpheus._lib.stages # TODO: for DataLoader + import morpheus._lib.messages as _messages +import morpheus.modules # Used to load and register morpheus modules import morpheus.messages as messages From 9f8889bdd643c96ce9d90b0556505913f0ae8f3e Mon Sep 17 00:00:00 2001 From: Devin Robison Date: Thu, 9 Feb 2023 18:41:33 -0700 Subject: [PATCH 007/157] Something is broken with the data loader test -- why is there a segfault in map.find ? --- .../_lib/include/morpheus/io/data_loader.hpp | 10 ++--- .../include/morpheus/io/loaders/payload.hpp | 37 +++++++++++++++++++ .../include/morpheus/messages/control.hpp | 8 +++- .../morpheus/modules/data_loader_module.hpp | 3 +- morpheus/_lib/src/io/data_loader.cpp | 30 ++++++++++----- morpheus/_lib/src/messages/control.cpp | 15 ++++++++ .../_lib/src/modules/data_loader_module.cpp | 13 ++++++- tests/modules/test_morpheus_modules.py | 3 +- 8 files changed, 99 insertions(+), 20 deletions(-) create mode 100644 morpheus/_lib/include/morpheus/io/loaders/payload.hpp diff --git a/morpheus/_lib/include/morpheus/io/data_loader.hpp b/morpheus/_lib/include/morpheus/io/data_loader.hpp index 5ddfd2963f..25ec93239a 100644 --- a/morpheus/_lib/include/morpheus/io/data_loader.hpp +++ b/morpheus/_lib/include/morpheus/io/data_loader.hpp @@ -30,23 +30,23 @@ class Loader public: virtual ~Loader() = default; - virtual std::shared_ptr load(const MessageControl& message) = 0; + virtual std::shared_ptr load(MessageControl& message) = 0; }; class DataLoader { public: - DataLoader() = default; + DataLoader(); ~DataLoader() = default; // Probably a MessageMeta? - std::shared_ptr load(const MessageControl& control_message); + std::shared_ptr load(MessageControl& control_message); - void register_loader(const std::string& loader_id, std::unique_ptr loader); + void register_loader(const std::string& loader_id, std::shared_ptr loader); void remove_loader(const std::string& loader_id); private: - std::map> m_loaders; + std::map> m_loaders{}; }; } // namespace morpheus \ No newline at end of file diff --git a/morpheus/_lib/include/morpheus/io/loaders/payload.hpp b/morpheus/_lib/include/morpheus/io/loaders/payload.hpp new file mode 100644 index 0000000000..bca49c39e4 --- /dev/null +++ b/morpheus/_lib/include/morpheus/io/loaders/payload.hpp @@ -0,0 +1,37 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2022-2023, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include "morpheus/io/data_loader.hpp" + +namespace morpheus { +/** + * @brief Very simple raw data loader that takes payload data on the control message and returns it + * + */ +class PayloadDataLoader : public Loader +{ + public: + PayloadDataLoader() = default; + ~PayloadDataLoader() = default; + + std::shared_ptr load(MessageControl& message) override{ + return std::move(message.payload()); + }; +}; +} // namespace morpheus \ No newline at end of file diff --git a/morpheus/_lib/include/morpheus/messages/control.hpp b/morpheus/_lib/include/morpheus/messages/control.hpp index 94c38a75c9..717489b08f 100644 --- a/morpheus/_lib/include/morpheus/messages/control.hpp +++ b/morpheus/_lib/include/morpheus/messages/control.hpp @@ -23,6 +23,7 @@ #include namespace morpheus { +class MessageMeta; #pragma GCC visibility push(default) class MessageControl @@ -43,8 +44,13 @@ class MessageControl */ const nlohmann::json& message() const; + void payload(const std::shared_ptr& payload); + + std::shared_ptr payload(); + private: - nlohmann::json m_message; + std::shared_ptr m_data{}; + nlohmann::json m_message{}; }; struct ControlMessageProxy diff --git a/morpheus/_lib/include/morpheus/modules/data_loader_module.hpp b/morpheus/_lib/include/morpheus/modules/data_loader_module.hpp index e5bc4147f6..904a777a8d 100644 --- a/morpheus/_lib/include/morpheus/modules/data_loader_module.hpp +++ b/morpheus/_lib/include/morpheus/modules/data_loader_module.hpp @@ -30,7 +30,6 @@ class DataLoaderModule : public mrc::modules::SegmentModule, public mrc::modules using type_t = DataLoaderModule; public: - virtual ~DataLoaderModule() = default; DataLoaderModule(std::string module_name); DataLoaderModule(std::string module_name, nlohmann::json config); @@ -39,7 +38,7 @@ class DataLoaderModule : public mrc::modules::SegmentModule, public mrc::modules std::string module_type_name() const override; private: - DataLoader m_data_loader; + DataLoader m_data_loader{}; }; #pragma GCC visibility pop } // namespace morpheus \ No newline at end of file diff --git a/morpheus/_lib/src/io/data_loader.cpp b/morpheus/_lib/src/io/data_loader.cpp index d32f08cd06..5f569e33ba 100644 --- a/morpheus/_lib/src/io/data_loader.cpp +++ b/morpheus/_lib/src/io/data_loader.cpp @@ -15,39 +15,49 @@ * limitations under the License. */ -#include "morpheus/io/data_loader.hpp" - +#include "morpheus/io/loaders/payload.hpp" #include "morpheus/messages/control.hpp" +#include +#include + namespace morpheus { -std::shared_ptr DataLoader::load(const MessageControl& control_message) +DataLoader::DataLoader() : m_loaders{} +{ + register_loader("payload", std::move(std::make_shared())); +} + +std::shared_ptr DataLoader::load(MessageControl& control_message) { auto payload = control_message.message(); if (payload.contains("loader_id")) { - auto loader_id = payload["loader_id"].get(); - auto loader = m_loaders.find(loader_id); + // TODO + std::cerr << "Looking for loader: " << payload["loader_id"] << std::endl << std::flush; + std::string loader_id = payload["loader_id"]; + auto loader = m_loaders.find(loader_id); + std::cerr << "Found a loader: " << payload["loader_id"] << std::endl << std::flush; if (loader != m_loaders.end()) { + std::cerr << "Found loader: " << loader_id << std::endl << std::flush; return loader->second->load(control_message); } } - // TODO(Devin): Testing. Remove this. - return std::shared_ptr(nullptr); - throw std::runtime_error("No loader registered for message: " + control_message.message().dump()); } -void DataLoader::register_loader(const std::string& loader_id, std::unique_ptr loader) +void DataLoader::register_loader(const std::string& loader_id, std::shared_ptr loader) { if (m_loaders.find(loader_id) != m_loaders.end()) { throw std::runtime_error("Loader already registered with id: " + loader_id); } + VLOG(2) << "Registering data loader: " << loader_id << std::endl; + m_loaders[loader_id] = std::move(loader); } @@ -58,6 +68,8 @@ void DataLoader::remove_loader(const std::string& loader_id) throw std::runtime_error("Loader not registered with id: " + loader_id); } + VLOG(2) << "Removing data loader: " << loader_id << std::endl; + m_loaders.erase(loader_id); } } // namespace morpheus diff --git a/morpheus/_lib/src/messages/control.cpp b/morpheus/_lib/src/messages/control.cpp index f349ad79d7..a3959744cd 100644 --- a/morpheus/_lib/src/messages/control.cpp +++ b/morpheus/_lib/src/messages/control.cpp @@ -36,6 +36,21 @@ void MessageControl::message(const nlohmann::json& message) m_message = message; } +std::shared_ptr MessageControl::payload() +{ + // TODO(Devin): do something else + auto temp = std::move(m_data); + m_data = nullptr; + + return temp; +} + +void MessageControl::payload(const std::shared_ptr& payload) +{ + // TODO(Devin): can we just overwrite? + m_data = payload; +} + /*** Proxy Implementations ***/ std::shared_ptr ControlMessageProxy::create(py::dict& message) diff --git a/morpheus/_lib/src/modules/data_loader_module.cpp b/morpheus/_lib/src/modules/data_loader_module.cpp index 415cb45140..ef4a250c99 100644 --- a/morpheus/_lib/src/modules/data_loader_module.cpp +++ b/morpheus/_lib/src/modules/data_loader_module.cpp @@ -17,18 +17,19 @@ #include "morpheus/modules/data_loader_module.hpp" +#include "morpheus/io/loaders/payload.hpp" #include "morpheus/messages/meta.hpp" #include #include #include #include - #include #include using namespace mrc::modules; +using nlohmann::json; namespace morpheus { @@ -40,9 +41,17 @@ DataLoaderModule::DataLoaderModule(std::string module_name, nlohmann::json confi void DataLoaderModule::initialize(mrc::segment::Builder& builder) { + // TODO(Devin): Modularize loader lookups, and standardize this a bit more if (config().contains("loaders")) { - // TODO + auto loader_list = config()["loaders"]; + for (json::iterator it = loader_list.begin(); it != loader_list.end(); ++it) + { + if (*it == "payload") + { + m_data_loader.register_loader("payload", std::make_unique()); + } + } } auto loader_node = builder.make_node, std::shared_ptr>( diff --git a/tests/modules/test_morpheus_modules.py b/tests/modules/test_morpheus_modules.py index 8e2279d3c7..d559346b84 100644 --- a/tests/modules/test_morpheus_modules.py +++ b/tests/modules/test_morpheus_modules.py @@ -56,8 +56,9 @@ def test_get_module(): def test_init_module(): def init_wrapper(builder: mrc.Builder): def gen_data(): + config = {"loader_id": "payload"} for i in range(10): - yield messages.MessageControl() + yield messages.MessageControl(config) def on_next(data): pass From 026d263bc48547d3d5e7f9e144d1199ce6c1f1a3 Mon Sep 17 00:00:00 2001 From: Devin Robison Date: Fri, 10 Feb 2023 15:18:35 -0700 Subject: [PATCH 008/157] Add c++ side tests for data loader module, fix some bugs related to using a varaible with the name 'module' --- morpheus/_lib/src/io/data_loader.cpp | 5 +-- morpheus/_lib/src/python_modules/messages.cpp | 44 +++++++++---------- morpheus/_lib/tests/CMakeLists.txt | 1 + 3 files changed, 24 insertions(+), 26 deletions(-) diff --git a/morpheus/_lib/src/io/data_loader.cpp b/morpheus/_lib/src/io/data_loader.cpp index 5f569e33ba..2f315497a4 100644 --- a/morpheus/_lib/src/io/data_loader.cpp +++ b/morpheus/_lib/src/io/data_loader.cpp @@ -25,7 +25,7 @@ namespace morpheus { DataLoader::DataLoader() : m_loaders{} { - register_loader("payload", std::move(std::make_shared())); + register_loader("payload", std::make_shared()); } std::shared_ptr DataLoader::load(MessageControl& control_message) @@ -35,13 +35,10 @@ std::shared_ptr DataLoader::load(MessageControl& control_message) if (payload.contains("loader_id")) { // TODO - std::cerr << "Looking for loader: " << payload["loader_id"] << std::endl << std::flush; std::string loader_id = payload["loader_id"]; auto loader = m_loaders.find(loader_id); - std::cerr << "Found a loader: " << payload["loader_id"] << std::endl << std::flush; if (loader != m_loaders.end()) { - std::cerr << "Found loader: " << loader_id << std::endl << std::flush; return loader->second->load(control_message); } } diff --git a/morpheus/_lib/src/python_modules/messages.cpp b/morpheus/_lib/src/python_modules/messages.cpp index d802adb3cd..aac9b5505f 100644 --- a/morpheus/_lib/src/python_modules/messages.cpp +++ b/morpheus/_lib/src/python_modules/messages.cpp @@ -58,9 +58,9 @@ namespace morpheus { namespace fs = std::filesystem; namespace py = pybind11; -PYBIND11_MODULE(messages, _module) +PYBIND11_MODULE(messages, py_mod) { - _module.doc() = R"pbdoc( + py_mod.doc() = R"pbdoc( ----------------------- .. currentmodule:: morpheus.messages .. autosummary:: @@ -71,14 +71,14 @@ PYBIND11_MODULE(messages, _module) // Load the cudf helpers load_cudf_helpers(); - mrc::pymrc::import(_module, "cupy"); - mrc::pymrc::import(_module, "morpheus._lib.common"); + mrc::pymrc::import(py_mod, "cupy"); + mrc::pymrc::import(py_mod, "morpheus._lib.common"); // Required for SegmentObject - mrc::pymrc::import(_module, "mrc.core.node"); + mrc::pymrc::import(py_mod, "mrc.core.node"); // Allows python objects to keep DataTable objects alive - py::class_>(_module, "DataTable"); + py::class_>(py_mod, "DataTable"); mrc::pymrc::PortBuilderUtil::register_port_util>(); mrc::pymrc::PortBuilderUtil::register_port_util>(); @@ -123,7 +123,7 @@ PYBIND11_MODULE(messages, _module) mrc::edge::EdgeConnector, std::shared_ptr>::register_converter(); - py::class_>(_module, "MessageControl") + py::class_>(py_mod, "MessageControl") .def(py::init<>()) .def(py::init(py::overload_cast(&ControlMessageProxy::create)), py::return_value_policy::move) .def("message", @@ -135,7 +135,7 @@ PYBIND11_MODULE(messages, _module) py::return_value_policy::reference_internal); // Context manager for Mutable Dataframes. Attempting to use it outside of a with block will raise an exception - py::class_>(_module, "MutableTableCtxMgr") + py::class_>(py_mod, "MutableTableCtxMgr") .def("__enter__", &MutableTableCtxMgr::enter, py::return_value_policy::reference) .def("__exit__", &MutableTableCtxMgr::exit) .def("__getattr__", &MutableTableCtxMgr::throw_usage_error) @@ -143,7 +143,7 @@ PYBIND11_MODULE(messages, _module) .def("__setattr__", &MutableTableCtxMgr::throw_usage_error) .def("__setitem__", &MutableTableCtxMgr::throw_usage_error); - py::class_>(_module, "MessageMeta") + py::class_>(py_mod, "MessageMeta") .def(py::init<>(&MessageMetaInterfaceProxy::init_python), py::arg("df")) .def_property_readonly("count", &MessageMetaInterfaceProxy::count) .def_property_readonly("df", &MessageMetaInterfaceProxy::df_property, py::return_value_policy::move) @@ -151,7 +151,7 @@ PYBIND11_MODULE(messages, _module) .def("mutable_dataframe", &MessageMetaInterfaceProxy::mutable_dataframe, py::return_value_policy::move) .def_static("make_from_file", &MessageMetaInterfaceProxy::init_cpp); - py::class_>(_module, "MultiMessage") + py::class_>(py_mod, "MultiMessage") .def(py::init<>(&MultiMessageInterfaceProxy::init), py::arg("meta"), py::arg("mess_offset"), @@ -178,10 +178,10 @@ PYBIND11_MODULE(messages, _module) py::return_value_policy::move) .def("get_meta_list", &MultiMessageInterfaceProxy::get_meta_list, py::return_value_policy::move); - py::class_>(_module, "InferenceMemory") + py::class_>(py_mod, "InferenceMemory") .def_property_readonly("count", &InferenceMemoryInterfaceProxy::get_count); - py::class_>(_module, "InferenceMemoryNLP") + py::class_>(py_mod, "InferenceMemoryNLP") .def(py::init<>(&InferenceMemoryNLPInterfaceProxy::init), py::arg("count"), py::arg("input_ids"), @@ -197,7 +197,7 @@ PYBIND11_MODULE(messages, _module) .def_property( "seq_ids", &InferenceMemoryNLPInterfaceProxy::get_seq_ids, &InferenceMemoryNLPInterfaceProxy::set_seq_ids); - py::class_>(_module, "InferenceMemoryFIL") + py::class_>(py_mod, "InferenceMemoryFIL") .def(py::init<>(&InferenceMemoryFILInterfaceProxy::init), py::arg("count"), py::arg("input__0"), @@ -210,7 +210,7 @@ PYBIND11_MODULE(messages, _module) .def_property( "seq_ids", &InferenceMemoryFILInterfaceProxy::get_seq_ids, &InferenceMemoryFILInterfaceProxy::set_seq_ids); - py::class_>(_module, + py::class_>(py_mod, "MultiInferenceMessage") .def(py::init<>(&MultiInferenceMessageInterfaceProxy::init), py::arg("meta"), @@ -226,7 +226,7 @@ PYBIND11_MODULE(messages, _module) .def("get_slice", &MultiInferenceMessageInterfaceProxy::get_slice, py::return_value_policy::reference_internal); py::class_>( - _module, "MultiInferenceNLPMessage") + py_mod, "MultiInferenceNLPMessage") .def(py::init<>(&MultiInferenceNLPMessageInterfaceProxy::init), py::arg("meta"), py::arg("mess_offset"), @@ -242,7 +242,7 @@ PYBIND11_MODULE(messages, _module) .def_property_readonly("seq_ids", &MultiInferenceNLPMessageInterfaceProxy::seq_ids); py::class_>( - _module, "MultiInferenceFILMessage") + py_mod, "MultiInferenceFILMessage") .def(py::init<>(&MultiInferenceFILMessageInterfaceProxy::init), py::arg("meta"), py::arg("mess_offset"), @@ -254,24 +254,24 @@ PYBIND11_MODULE(messages, _module) .def_property_readonly("offset", &MultiInferenceFILMessageInterfaceProxy::offset) .def_property_readonly("count", &MultiInferenceFILMessageInterfaceProxy::count); - py::class_>(_module, "TensorMemory") + py::class_>(py_mod, "TensorMemory") .def_readonly("count", &TensorMemory::count); - py::class_>(_module, "ResponseMemory") + py::class_>(py_mod, "ResponseMemory") .def_readonly("count", &ResponseMemory::count) .def("get_output", &ResponseMemoryInterfaceProxy::get_output, py::return_value_policy::reference_internal) .def("get_output_tensor", &ResponseMemoryInterfaceProxy::get_output_tensor, py::return_value_policy::reference_internal); - py::class_>(_module, + py::class_>(py_mod, "ResponseMemoryProbs") .def(py::init<>(&ResponseMemoryProbsInterfaceProxy::init), py::arg("count"), py::arg("probs")) .def_property_readonly("count", &ResponseMemoryProbsInterfaceProxy::count) .def_property( "probs", &ResponseMemoryProbsInterfaceProxy::get_probs, &ResponseMemoryProbsInterfaceProxy::set_probs); - py::class_>(_module, + py::class_>(py_mod, "MultiResponseMessage") .def(py::init<>(&MultiResponseMessageInterfaceProxy::init), py::arg("meta"), @@ -286,7 +286,7 @@ PYBIND11_MODULE(messages, _module) .def("get_output", &MultiResponseMessageInterfaceProxy::get_output); py::class_>( - _module, "MultiResponseProbsMessage") + py_mod, "MultiResponseProbsMessage") .def(py::init<>(&MultiResponseProbsMessageInterfaceProxy::init), py::arg("meta"), py::arg("mess_offset"), @@ -299,7 +299,7 @@ PYBIND11_MODULE(messages, _module) .def_property_readonly("count", &MultiResponseProbsMessageInterfaceProxy::count) .def_property_readonly("probs", &MultiResponseProbsMessageInterfaceProxy::probs); - _module.attr("__version__") = + py_mod.attr("__version__") = MRC_CONCAT_STR(morpheus_VERSION_MAJOR << "." << morpheus_VERSION_MINOR << "." << morpheus_VERSION_PATCH); } } // namespace morpheus diff --git a/morpheus/_lib/tests/CMakeLists.txt b/morpheus/_lib/tests/CMakeLists.txt index 90b3776354..b0d13aac0a 100644 --- a/morpheus/_lib/tests/CMakeLists.txt +++ b/morpheus/_lib/tests/CMakeLists.txt @@ -19,6 +19,7 @@ list(APPEND CMAKE_MESSAGE_CONTEXT "tests") add_executable(test_libmorpheus # test_cuda.cu messages/test_control_message.cpp + modules/test_data_loader.cpp test_main.cpp test_matx_util.cpp test_morpheus.cpp From a982c99bb010ccb5cf8c30eb1882504f5203b3fa Mon Sep 17 00:00:00 2001 From: Devin Robison Date: Fri, 10 Feb 2023 16:24:35 -0700 Subject: [PATCH 009/157] Add loader sketches, more unit tests --- morpheus/_lib/cmake/libraries/morpheus.cmake | 4 + .../_lib/include/morpheus/io/loaders/all.hpp | 23 ++ .../_lib/include/morpheus/io/loaders/file.hpp | 35 +++ .../_lib/include/morpheus/io/loaders/grpc.hpp | 35 +++ .../include/morpheus/io/loaders/payload.hpp | 6 +- .../_lib/include/morpheus/io/loaders/rest.hpp | 35 +++ .../morpheus/modules/data_loader_module.hpp | 2 +- morpheus/_lib/src/io/data_loader.cpp | 8 +- morpheus/_lib/src/io/loaders/file.cpp | 30 +++ morpheus/_lib/src/io/loaders/grpc.cpp | 30 +++ morpheus/_lib/src/io/loaders/payload.cpp | 28 ++ morpheus/_lib/src/io/loaders/rest.cpp | 30 +++ .../_lib/src/modules/data_loader_module.cpp | 20 +- morpheus/_lib/tests/CMakeLists.txt | 2 +- morpheus/_lib/tests/io/test_data_loader.cpp | 42 +++ morpheus/_lib/tests/io/test_io.hpp | 32 +++ .../tests/modules/test_data_loader_module.cpp | 245 ++++++++++++++++++ morpheus/_lib/tests/modules/test_modules.hpp | 32 +++ 18 files changed, 625 insertions(+), 14 deletions(-) create mode 100644 morpheus/_lib/include/morpheus/io/loaders/all.hpp create mode 100644 morpheus/_lib/include/morpheus/io/loaders/file.hpp create mode 100644 morpheus/_lib/include/morpheus/io/loaders/grpc.hpp create mode 100644 morpheus/_lib/include/morpheus/io/loaders/rest.hpp create mode 100644 morpheus/_lib/src/io/loaders/file.cpp create mode 100644 morpheus/_lib/src/io/loaders/grpc.cpp create mode 100644 morpheus/_lib/src/io/loaders/payload.cpp create mode 100644 morpheus/_lib/src/io/loaders/rest.cpp create mode 100644 morpheus/_lib/tests/io/test_data_loader.cpp create mode 100644 morpheus/_lib/tests/io/test_io.hpp create mode 100644 morpheus/_lib/tests/modules/test_data_loader_module.cpp create mode 100644 morpheus/_lib/tests/modules/test_modules.hpp diff --git a/morpheus/_lib/cmake/libraries/morpheus.cmake b/morpheus/_lib/cmake/libraries/morpheus.cmake index 47f40b025f..0b361f95fd 100644 --- a/morpheus/_lib/cmake/libraries/morpheus.cmake +++ b/morpheus/_lib/cmake/libraries/morpheus.cmake @@ -18,6 +18,10 @@ add_library(morpheus # Keep these sorted! ${MORPHEUS_LIB_ROOT}/src/io/data_loader.cpp ${MORPHEUS_LIB_ROOT}/src/io/deserializers.cpp + ${MORPHEUS_LIB_ROOT}/src/io/loaders/file.cpp + ${MORPHEUS_LIB_ROOT}/src/io/loaders/grpc.cpp + ${MORPHEUS_LIB_ROOT}/src/io/loaders/payload.cpp + ${MORPHEUS_LIB_ROOT}/src/io/loaders/rest.cpp ${MORPHEUS_LIB_ROOT}/src/io/serializers.cpp ${MORPHEUS_LIB_ROOT}/src/messages/control.cpp ${MORPHEUS_LIB_ROOT}/src/messages/memory/inference_memory.cpp diff --git a/morpheus/_lib/include/morpheus/io/loaders/all.hpp b/morpheus/_lib/include/morpheus/io/loaders/all.hpp new file mode 100644 index 0000000000..a5fc39bdee --- /dev/null +++ b/morpheus/_lib/include/morpheus/io/loaders/all.hpp @@ -0,0 +1,23 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2022-2023, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include "file.hpp" +#include "grpc.hpp" +#include "payload.hpp" +#include "rest.hpp" diff --git a/morpheus/_lib/include/morpheus/io/loaders/file.hpp b/morpheus/_lib/include/morpheus/io/loaders/file.hpp new file mode 100644 index 0000000000..868644d506 --- /dev/null +++ b/morpheus/_lib/include/morpheus/io/loaders/file.hpp @@ -0,0 +1,35 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2022-2023, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include "morpheus/io/data_loader.hpp" + +namespace morpheus { +/** + * @brief Very simple raw data loader that takes payload data on the control message and returns it + * + */ +class FileDataLoader : public Loader +{ + public: + FileDataLoader() = default; + ~FileDataLoader() = default; + + std::shared_ptr load(MessageControl& message) override; +}; +} // namespace morpheus \ No newline at end of file diff --git a/morpheus/_lib/include/morpheus/io/loaders/grpc.hpp b/morpheus/_lib/include/morpheus/io/loaders/grpc.hpp new file mode 100644 index 0000000000..072bed1c3d --- /dev/null +++ b/morpheus/_lib/include/morpheus/io/loaders/grpc.hpp @@ -0,0 +1,35 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2022-2023, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include "morpheus/io/data_loader.hpp" + +namespace morpheus { +/** + * @brief Very simple raw data loader that takes payload data on the control message and returns it + * + */ +class GRPCDataLoader : public Loader +{ + public: + GRPCDataLoader() = default; + ~GRPCDataLoader() = default; + + std::shared_ptr load(MessageControl& message) override; +}; +} // namespace morpheus \ No newline at end of file diff --git a/morpheus/_lib/include/morpheus/io/loaders/payload.hpp b/morpheus/_lib/include/morpheus/io/loaders/payload.hpp index bca49c39e4..7920df405d 100644 --- a/morpheus/_lib/include/morpheus/io/loaders/payload.hpp +++ b/morpheus/_lib/include/morpheus/io/loaders/payload.hpp @@ -19,6 +19,8 @@ #include "morpheus/io/data_loader.hpp" +#include + namespace morpheus { /** * @brief Very simple raw data loader that takes payload data on the control message and returns it @@ -30,8 +32,6 @@ class PayloadDataLoader : public Loader PayloadDataLoader() = default; ~PayloadDataLoader() = default; - std::shared_ptr load(MessageControl& message) override{ - return std::move(message.payload()); - }; + std::shared_ptr load(MessageControl& control_message) override; }; } // namespace morpheus \ No newline at end of file diff --git a/morpheus/_lib/include/morpheus/io/loaders/rest.hpp b/morpheus/_lib/include/morpheus/io/loaders/rest.hpp new file mode 100644 index 0000000000..d7288ac917 --- /dev/null +++ b/morpheus/_lib/include/morpheus/io/loaders/rest.hpp @@ -0,0 +1,35 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2022-2023, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include "morpheus/io/data_loader.hpp" + +namespace morpheus { +/** + * @brief Very simple raw data loader that takes payload data on the control message and returns it + * + */ +class RESTDataLoader : public Loader +{ + public: + RESTDataLoader() = default; + ~RESTDataLoader() = default; + + std::shared_ptr load(MessageControl& message) override; +}; +} // namespace morpheus \ No newline at end of file diff --git a/morpheus/_lib/include/morpheus/modules/data_loader_module.hpp b/morpheus/_lib/include/morpheus/modules/data_loader_module.hpp index 904a777a8d..af5a109aab 100644 --- a/morpheus/_lib/include/morpheus/modules/data_loader_module.hpp +++ b/morpheus/_lib/include/morpheus/modules/data_loader_module.hpp @@ -31,7 +31,7 @@ class DataLoaderModule : public mrc::modules::SegmentModule, public mrc::modules public: DataLoaderModule(std::string module_name); - DataLoaderModule(std::string module_name, nlohmann::json config); + DataLoaderModule(std::string module_name, nlohmann::json _config); protected: void initialize(mrc::segment::Builder& builder) override; diff --git a/morpheus/_lib/src/io/data_loader.cpp b/morpheus/_lib/src/io/data_loader.cpp index 2f315497a4..9678380e91 100644 --- a/morpheus/_lib/src/io/data_loader.cpp +++ b/morpheus/_lib/src/io/data_loader.cpp @@ -15,7 +15,8 @@ * limitations under the License. */ -#include "morpheus/io/loaders/payload.hpp" +#include "morpheus/io/data_loader.hpp" + #include "morpheus/messages/control.hpp" #include @@ -23,10 +24,7 @@ namespace morpheus { -DataLoader::DataLoader() : m_loaders{} -{ - register_loader("payload", std::make_shared()); -} +DataLoader::DataLoader() : m_loaders{} {} std::shared_ptr DataLoader::load(MessageControl& control_message) { diff --git a/morpheus/_lib/src/io/loaders/file.cpp b/morpheus/_lib/src/io/loaders/file.cpp new file mode 100644 index 0000000000..e95991b30d --- /dev/null +++ b/morpheus/_lib/src/io/loaders/file.cpp @@ -0,0 +1,30 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2022-2023, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "morpheus/io/loaders/file.hpp" + +#include + +namespace morpheus { +std::shared_ptr FileDataLoader::load(MessageControl& message) +{ + VLOG(30) << "Called FileDataLoader::load()"; + + // TODO(Devin): Implement this + return std::move(message.payload()); +} +} // namespace morpheus \ No newline at end of file diff --git a/morpheus/_lib/src/io/loaders/grpc.cpp b/morpheus/_lib/src/io/loaders/grpc.cpp new file mode 100644 index 0000000000..7d2bbb2595 --- /dev/null +++ b/morpheus/_lib/src/io/loaders/grpc.cpp @@ -0,0 +1,30 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2022-2023, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "morpheus/io/loaders/grpc.hpp" + +#include + +namespace morpheus { +std::shared_ptr GRPCDataLoader::load(MessageControl& message) +{ + VLOG(30) << "Called GRPCDataLoader::load()"; + + // TODO(Devin): Implement this + return std::move(message.payload()); +} +} // namespace morpheus \ No newline at end of file diff --git a/morpheus/_lib/src/io/loaders/payload.cpp b/morpheus/_lib/src/io/loaders/payload.cpp new file mode 100644 index 0000000000..0425d16e8d --- /dev/null +++ b/morpheus/_lib/src/io/loaders/payload.cpp @@ -0,0 +1,28 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2022-2023, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "morpheus/io/loaders/payload.hpp" + +#include + +namespace morpheus { +std::shared_ptr PayloadDataLoader::load(MessageControl& message) +{ + VLOG(30) << "Called PayloadDataLoader::load()"; + return std::move(message.payload()); +} +} // namespace morpheus \ No newline at end of file diff --git a/morpheus/_lib/src/io/loaders/rest.cpp b/morpheus/_lib/src/io/loaders/rest.cpp new file mode 100644 index 0000000000..58b19092e4 --- /dev/null +++ b/morpheus/_lib/src/io/loaders/rest.cpp @@ -0,0 +1,30 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2022-2023, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "morpheus/io/loaders/rest.hpp" + +#include + +namespace morpheus { +std::shared_ptr RESTDataLoader::load(MessageControl& message) +{ + VLOG(30) << "Called RESTDataLoader::load()"; + + // TODO(Devin): Implement this + return std::move(message.payload()); +} +} // namespace morpheus \ No newline at end of file diff --git a/morpheus/_lib/src/modules/data_loader_module.cpp b/morpheus/_lib/src/modules/data_loader_module.cpp index ef4a250c99..66f798955b 100644 --- a/morpheus/_lib/src/modules/data_loader_module.cpp +++ b/morpheus/_lib/src/modules/data_loader_module.cpp @@ -17,7 +17,7 @@ #include "morpheus/modules/data_loader_module.hpp" -#include "morpheus/io/loaders/payload.hpp" +#include "morpheus/io/loaders/all.hpp" #include "morpheus/messages/meta.hpp" #include @@ -35,8 +35,8 @@ namespace morpheus { DataLoaderModule::DataLoaderModule(std::string module_name) : SegmentModule(module_name) {} -DataLoaderModule::DataLoaderModule(std::string module_name, nlohmann::json config) : - SegmentModule(std::move(module_name), std::move(config)) +DataLoaderModule::DataLoaderModule(std::string module_name, nlohmann::json _config) : + SegmentModule(std::move(module_name), std::move(_config)) {} void DataLoaderModule::initialize(mrc::segment::Builder& builder) @@ -47,10 +47,22 @@ void DataLoaderModule::initialize(mrc::segment::Builder& builder) auto loader_list = config()["loaders"]; for (json::iterator it = loader_list.begin(); it != loader_list.end(); ++it) { - if (*it == "payload") + if (*it == "file") + { + m_data_loader.register_loader("file", std::make_unique()); + } + else if (*it == "grpc") + { + m_data_loader.register_loader("grpc", std::make_unique()); + } + else if (*it == "payload") { m_data_loader.register_loader("payload", std::make_unique()); } + else if (*it == "rest") + { + m_data_loader.register_loader("rest", std::make_unique()); + } } } diff --git a/morpheus/_lib/tests/CMakeLists.txt b/morpheus/_lib/tests/CMakeLists.txt index b0d13aac0a..131f8296c3 100644 --- a/morpheus/_lib/tests/CMakeLists.txt +++ b/morpheus/_lib/tests/CMakeLists.txt @@ -19,7 +19,7 @@ list(APPEND CMAKE_MESSAGE_CONTEXT "tests") add_executable(test_libmorpheus # test_cuda.cu messages/test_control_message.cpp - modules/test_data_loader.cpp + modules/test_data_loader_module.cpp test_main.cpp test_matx_util.cpp test_morpheus.cpp diff --git a/morpheus/_lib/tests/io/test_data_loader.cpp b/morpheus/_lib/tests/io/test_data_loader.cpp new file mode 100644 index 0000000000..f26cb9d272 --- /dev/null +++ b/morpheus/_lib/tests/io/test_data_loader.cpp @@ -0,0 +1,42 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2022-2023, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "test_io.hpp" + +#include "morpheus/messages/control.hpp" +#include "morpheus/messages/meta.hpp" +#include "morpheus/modules/data_loader_module.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace morpheus; +using namespace morpheus::test; + + +TEST_F(TestDataLoader, DataLoaderModuleInitializationTest) { + +} \ No newline at end of file diff --git a/morpheus/_lib/tests/io/test_io.hpp b/morpheus/_lib/tests/io/test_io.hpp new file mode 100644 index 0000000000..239d0526a8 --- /dev/null +++ b/morpheus/_lib/tests/io/test_io.hpp @@ -0,0 +1,32 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2022-2023, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include "../test_morpheus.hpp" // IWYU pragma: associated + +namespace morpheus::test { +class TestIO : public ::testing::Test +{ + protected: + void SetUp() override {} + + void TearDown() override {} +}; + +using TestDataLoader = TestIO; +} // namespace morpheus::test \ No newline at end of file diff --git a/morpheus/_lib/tests/modules/test_data_loader_module.cpp b/morpheus/_lib/tests/modules/test_data_loader_module.cpp new file mode 100644 index 0000000000..c673049ad1 --- /dev/null +++ b/morpheus/_lib/tests/modules/test_data_loader_module.cpp @@ -0,0 +1,245 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2022-2023, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "test_modules.hpp" + +#include "morpheus/messages/control.hpp" +#include "morpheus/messages/meta.hpp" +#include "morpheus/modules/data_loader_module.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace morpheus; +using namespace morpheus::test; + +TEST_F(TestDataLoaderModule, EndToEndFileDataLoaderTest) +{ + using namespace mrc::modules; + using namespace mrc; + + using sp_msg_meta_t = std::shared_ptr; + using sp_msg_ctrl_t = std::shared_ptr; + + auto init_wrapper = [](segment::Builder& builder) { + nlohmann::json config; + config["loaders"] = {"file", "grpc", "payload", "rest"}; + auto data_loader_module = builder.make_module("DataLoaderTest", config); + + auto source = builder.make_source("source", [](rxcpp::subscriber& sub) { + if (sub.is_subscribed()) + { + for (int i = 0; i < 10; i++) + { + nlohmann::json config; + config["loader_id"] = "file"; + sub.on_next(std::make_shared(config)); + } + } + + sub.on_completed(); + }); + + std::size_t x; + builder.make_edge(source, data_loader_module->input_port("input")); + auto sink = builder.make_sink("sink", [&x](sp_msg_meta_t input) { + x++; + VLOG(10) << "Received message"; + }); + + builder.make_edge(data_loader_module->output_port("output"), sink); + }; + + std::unique_ptr m_pipeline; + m_pipeline = pipeline::make_pipeline(); + + m_pipeline->make_segment("main", init_wrapper); + + auto options = std::make_shared(); + options->topology().user_cpuset("0-1"); + options->topology().restrict_gpus(true); + + Executor executor(options); + executor.register_pipeline(std::move(m_pipeline)); + executor.start(); + executor.join(); +} + +TEST_F(TestDataLoaderModule, EndToEndGRPCDataLoaderTest) +{ + using namespace mrc::modules; + using namespace mrc; + + using sp_msg_meta_t = std::shared_ptr; + using sp_msg_ctrl_t = std::shared_ptr; + + auto init_wrapper = [](segment::Builder& builder) { + nlohmann::json config; + config["loaders"] = {"file", "grpc", "payload", "rest"}; + auto data_loader_module = builder.make_module("DataLoaderTest", config); + + auto source = builder.make_source("source", [](rxcpp::subscriber& sub) { + if (sub.is_subscribed()) + { + for (int i = 0; i < 10; i++) + { + nlohmann::json config; + config["loader_id"] = "grpc"; + sub.on_next(std::make_shared(config)); + } + } + + sub.on_completed(); + }); + + std::size_t x; + builder.make_edge(source, data_loader_module->input_port("input")); + auto sink = builder.make_sink("sink", [&x](sp_msg_meta_t input) { + x++; + VLOG(10) << "Received message"; + }); + + builder.make_edge(data_loader_module->output_port("output"), sink); + }; + + std::unique_ptr m_pipeline; + m_pipeline = pipeline::make_pipeline(); + + m_pipeline->make_segment("main", init_wrapper); + + auto options = std::make_shared(); + options->topology().user_cpuset("0-1"); + options->topology().restrict_gpus(true); + + Executor executor(options); + executor.register_pipeline(std::move(m_pipeline)); + executor.start(); + executor.join(); +} + +TEST_F(TestDataLoaderModule, EndToEndPayloadDataLoaderTest) +{ + using namespace mrc::modules; + using namespace mrc; + + using sp_msg_meta_t = std::shared_ptr; + using sp_msg_ctrl_t = std::shared_ptr; + + auto init_wrapper = [](segment::Builder& builder) { + nlohmann::json config; + config["loaders"] = {"file", "grpc", "payload", "rest"}; + auto data_loader_module = builder.make_module("DataLoaderTest", config); + + auto source = builder.make_source("source", [](rxcpp::subscriber& sub) { + if (sub.is_subscribed()) + { + for (int i = 0; i < 10; i++) + { + nlohmann::json config; + config["loader_id"] = "payload"; + sub.on_next(std::make_shared(config)); + } + } + + sub.on_completed(); + }); + + std::size_t x; + builder.make_edge(source, data_loader_module->input_port("input")); + auto sink = builder.make_sink("sink", [&x](sp_msg_meta_t input) { + x++; + VLOG(10) << "Received message"; + }); + + builder.make_edge(data_loader_module->output_port("output"), sink); + }; + + std::unique_ptr m_pipeline; + m_pipeline = pipeline::make_pipeline(); + + m_pipeline->make_segment("main", init_wrapper); + + auto options = std::make_shared(); + options->topology().user_cpuset("0-1"); + options->topology().restrict_gpus(true); + + Executor executor(options); + executor.register_pipeline(std::move(m_pipeline)); + executor.start(); + executor.join(); +} + +TEST_F(TestDataLoaderModule, EndToEndRESTDataLoaderTest) +{ + using namespace mrc::modules; + using namespace mrc; + + using sp_msg_meta_t = std::shared_ptr; + using sp_msg_ctrl_t = std::shared_ptr; + + auto init_wrapper = [](segment::Builder& builder) { + nlohmann::json config; + config["loaders"] = {"file", "grpc", "payload", "rest"}; + auto data_loader_module = builder.make_module("DataLoaderTest", config); + + auto source = builder.make_source("source", [](rxcpp::subscriber& sub) { + if (sub.is_subscribed()) + { + for (int i = 0; i < 10; i++) + { + nlohmann::json config; + config["loader_id"] = "rest"; + sub.on_next(std::make_shared(config)); + } + } + + sub.on_completed(); + }); + + std::size_t x; + builder.make_edge(source, data_loader_module->input_port("input")); + auto sink = builder.make_sink("sink", [&x](sp_msg_meta_t input) { + x++; + VLOG(10) << "Received message"; + }); + + builder.make_edge(data_loader_module->output_port("output"), sink); + }; + + std::unique_ptr m_pipeline; + m_pipeline = pipeline::make_pipeline(); + + m_pipeline->make_segment("main", init_wrapper); + + auto options = std::make_shared(); + options->topology().user_cpuset("0-1"); + options->topology().restrict_gpus(true); + + Executor executor(options); + executor.register_pipeline(std::move(m_pipeline)); + executor.start(); + executor.join(); +} \ No newline at end of file diff --git a/morpheus/_lib/tests/modules/test_modules.hpp b/morpheus/_lib/tests/modules/test_modules.hpp new file mode 100644 index 0000000000..7f4b1cbd77 --- /dev/null +++ b/morpheus/_lib/tests/modules/test_modules.hpp @@ -0,0 +1,32 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2022-2023, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include "../test_morpheus.hpp" // IWYU pragma: associated + +namespace morpheus::test { +class TestModules : public ::testing::Test +{ + protected: + void SetUp() override {} + + void TearDown() override {} +}; + +using TestDataLoaderModule = TestModules; +} // namespace morpheus::test \ No newline at end of file From 5e12d99a7de2864f819324914a9df14e73cddeab Mon Sep 17 00:00:00 2001 From: Devin Robison Date: Mon, 13 Feb 2023 17:15:20 -0700 Subject: [PATCH 010/157] Add unit tests, work around interpreter conflicts with cudf --- .../_lib/include/morpheus/io/data_loader.hpp | 6 +- .../_lib/include/morpheus/io/loaders/file.hpp | 3 + .../_lib/include/morpheus/io/loaders/grpc.hpp | 2 + .../include/morpheus/io/loaders/payload.hpp | 2 + .../_lib/include/morpheus/io/loaders/rest.hpp | 2 + .../include/morpheus/messages/control.hpp | 2 +- morpheus/_lib/src/io/data_loader.cpp | 14 +- morpheus/_lib/src/io/loaders/file.cpp | 44 +++++- morpheus/_lib/src/messages/control.cpp | 6 +- morpheus/_lib/tests/CMakeLists.txt | 3 +- morpheus/_lib/tests/io/test_data_loader.cpp | 98 ++++++++++++- morpheus/_lib/tests/io/test_io.hpp | 105 +++++++++++++- .../tests/modules/test_data_loader_module.cpp | 137 ++++++++++-------- morpheus/_lib/tests/test_morpheus.hpp | 10 +- 14 files changed, 348 insertions(+), 86 deletions(-) diff --git a/morpheus/_lib/include/morpheus/io/data_loader.hpp b/morpheus/_lib/include/morpheus/io/data_loader.hpp index 25ec93239a..64dc048540 100644 --- a/morpheus/_lib/include/morpheus/io/data_loader.hpp +++ b/morpheus/_lib/include/morpheus/io/data_loader.hpp @@ -25,6 +25,7 @@ namespace morpheus { +#pragma GCC visibility push(default) class Loader { public: @@ -42,11 +43,12 @@ class DataLoader // Probably a MessageMeta? std::shared_ptr load(MessageControl& control_message); - void register_loader(const std::string& loader_id, std::shared_ptr loader); + void register_loader(const std::string& loader_id, std::shared_ptr loader, bool overwrite = true); - void remove_loader(const std::string& loader_id); + void remove_loader(const std::string& loader_id, bool throw_if_not_found = true); private: std::map> m_loaders{}; }; +#pragma GCC visibility pop } // namespace morpheus \ No newline at end of file diff --git a/morpheus/_lib/include/morpheus/io/loaders/file.hpp b/morpheus/_lib/include/morpheus/io/loaders/file.hpp index 868644d506..69813c50db 100644 --- a/morpheus/_lib/include/morpheus/io/loaders/file.hpp +++ b/morpheus/_lib/include/morpheus/io/loaders/file.hpp @@ -18,8 +18,10 @@ #pragma once #include "morpheus/io/data_loader.hpp" +#include "morpheus/messages/meta.hpp" namespace morpheus { +#pragma GCC visibility push(default) /** * @brief Very simple raw data loader that takes payload data on the control message and returns it * @@ -32,4 +34,5 @@ class FileDataLoader : public Loader std::shared_ptr load(MessageControl& message) override; }; +#pragma GCC visibility pop } // namespace morpheus \ No newline at end of file diff --git a/morpheus/_lib/include/morpheus/io/loaders/grpc.hpp b/morpheus/_lib/include/morpheus/io/loaders/grpc.hpp index 072bed1c3d..8c5cd9c82a 100644 --- a/morpheus/_lib/include/morpheus/io/loaders/grpc.hpp +++ b/morpheus/_lib/include/morpheus/io/loaders/grpc.hpp @@ -20,6 +20,7 @@ #include "morpheus/io/data_loader.hpp" namespace morpheus { +#pragma GCC visibility push(default) /** * @brief Very simple raw data loader that takes payload data on the control message and returns it * @@ -32,4 +33,5 @@ class GRPCDataLoader : public Loader std::shared_ptr load(MessageControl& message) override; }; +#pragma GCC visibility pop } // namespace morpheus \ No newline at end of file diff --git a/morpheus/_lib/include/morpheus/io/loaders/payload.hpp b/morpheus/_lib/include/morpheus/io/loaders/payload.hpp index 7920df405d..693881cd1a 100644 --- a/morpheus/_lib/include/morpheus/io/loaders/payload.hpp +++ b/morpheus/_lib/include/morpheus/io/loaders/payload.hpp @@ -22,6 +22,7 @@ #include namespace morpheus { +#pragma GCC visibility push(default) /** * @brief Very simple raw data loader that takes payload data on the control message and returns it * @@ -34,4 +35,5 @@ class PayloadDataLoader : public Loader std::shared_ptr load(MessageControl& control_message) override; }; +#pragma GCC visibility pop } // namespace morpheus \ No newline at end of file diff --git a/morpheus/_lib/include/morpheus/io/loaders/rest.hpp b/morpheus/_lib/include/morpheus/io/loaders/rest.hpp index d7288ac917..fc16cfdf71 100644 --- a/morpheus/_lib/include/morpheus/io/loaders/rest.hpp +++ b/morpheus/_lib/include/morpheus/io/loaders/rest.hpp @@ -20,6 +20,7 @@ #include "morpheus/io/data_loader.hpp" namespace morpheus { +#pragma GCC visibility push(default) /** * @brief Very simple raw data loader that takes payload data on the control message and returns it * @@ -32,4 +33,5 @@ class RESTDataLoader : public Loader std::shared_ptr load(MessageControl& message) override; }; +#pragma GCC visibility pop } // namespace morpheus \ No newline at end of file diff --git a/morpheus/_lib/include/morpheus/messages/control.hpp b/morpheus/_lib/include/morpheus/messages/control.hpp index 717489b08f..aeba53e6f2 100644 --- a/morpheus/_lib/include/morpheus/messages/control.hpp +++ b/morpheus/_lib/include/morpheus/messages/control.hpp @@ -49,7 +49,7 @@ class MessageControl std::shared_ptr payload(); private: - std::shared_ptr m_data{}; + std::shared_ptr m_payload{nullptr}; nlohmann::json m_message{}; }; diff --git a/morpheus/_lib/src/io/data_loader.cpp b/morpheus/_lib/src/io/data_loader.cpp index 9678380e91..6f0ae63947 100644 --- a/morpheus/_lib/src/io/data_loader.cpp +++ b/morpheus/_lib/src/io/data_loader.cpp @@ -29,7 +29,6 @@ DataLoader::DataLoader() : m_loaders{} {} std::shared_ptr DataLoader::load(MessageControl& control_message) { auto payload = control_message.message(); - if (payload.contains("loader_id")) { // TODO @@ -44,9 +43,9 @@ std::shared_ptr DataLoader::load(MessageControl& control_message) throw std::runtime_error("No loader registered for message: " + control_message.message().dump()); } -void DataLoader::register_loader(const std::string& loader_id, std::shared_ptr loader) +void DataLoader::register_loader(const std::string& loader_id, std::shared_ptr loader, bool overwrite) { - if (m_loaders.find(loader_id) != m_loaders.end()) + if (!overwrite and m_loaders.find(loader_id) != m_loaders.end()) { throw std::runtime_error("Loader already registered with id: " + loader_id); } @@ -56,11 +55,16 @@ void DataLoader::register_loader(const std::string& loader_id, std::shared_ptr +#include +#include +#include +#include + +#include #include +namespace {} + namespace morpheus { std::shared_ptr FileDataLoader::load(MessageControl& message) { VLOG(30) << "Called FileDataLoader::load()"; - // TODO(Devin): Implement this - return std::move(message.payload()); + // TODO(Devin) : error checking + improve robustness + auto filenames = message.message()["files"]; + auto sstream = std::stringstream(); + for (auto& filename : filenames) + { + auto file = std::fstream(filename); + if (!file) + { + throw std::runtime_error("Could not open file: "); + } + + // TODO(Devin) : implement strategies + sstream << file.rdbuf(); + file.close(); + } + + { + pybind11::gil_scoped_acquire gil; + pybind11::module_ mod_cudf; + + auto& cache_handle = mrc::pymrc::PythonObjectCache::get_handle(); + mod_cudf = cache_handle.get_module("cudf"); + + // TODO(Devin) : Do something more efficient + auto py_string = pybind11::str(sstream.str()); + auto py_buffer = pybind11::buffer(pybind11::bytes(py_string)); + auto dataframe = mod_cudf.attr("read_csv")(py_buffer); + + return MessageMeta::create_from_python(std::move(dataframe)); + } } } // namespace morpheus \ No newline at end of file diff --git a/morpheus/_lib/src/messages/control.cpp b/morpheus/_lib/src/messages/control.cpp index a3959744cd..7b98fba7e3 100644 --- a/morpheus/_lib/src/messages/control.cpp +++ b/morpheus/_lib/src/messages/control.cpp @@ -39,8 +39,8 @@ void MessageControl::message(const nlohmann::json& message) std::shared_ptr MessageControl::payload() { // TODO(Devin): do something else - auto temp = std::move(m_data); - m_data = nullptr; + auto temp = std::move(m_payload); + m_payload = nullptr; return temp; } @@ -48,7 +48,7 @@ std::shared_ptr MessageControl::payload() void MessageControl::payload(const std::shared_ptr& payload) { // TODO(Devin): can we just overwrite? - m_data = payload; + m_payload = payload; } /*** Proxy Implementations ***/ diff --git a/morpheus/_lib/tests/CMakeLists.txt b/morpheus/_lib/tests/CMakeLists.txt index 131f8296c3..9c357ce539 100644 --- a/morpheus/_lib/tests/CMakeLists.txt +++ b/morpheus/_lib/tests/CMakeLists.txt @@ -18,8 +18,9 @@ list(APPEND CMAKE_MESSAGE_CONTEXT "tests") # Keep all source files sorted add_executable(test_libmorpheus # test_cuda.cu + io/test_data_loader.cpp messages/test_control_message.cpp - modules/test_data_loader_module.cpp + modules/test_data_loader_module.cpp test_main.cpp test_matx_util.cpp test_morpheus.cpp diff --git a/morpheus/_lib/tests/io/test_data_loader.cpp b/morpheus/_lib/tests/io/test_data_loader.cpp index f26cb9d272..18c7dd27d7 100644 --- a/morpheus/_lib/tests/io/test_data_loader.cpp +++ b/morpheus/_lib/tests/io/test_data_loader.cpp @@ -17,13 +17,12 @@ #include "test_io.hpp" +#include "morpheus/io/data_loader.hpp" +#include "morpheus/io/loaders/all.hpp" #include "morpheus/messages/control.hpp" -#include "morpheus/messages/meta.hpp" -#include "morpheus/modules/data_loader_module.hpp" #include #include -#include #include #include #include @@ -33,10 +32,99 @@ #include #include +#include +#include +#include +#include + +namespace py = pybind11; using namespace morpheus; using namespace morpheus::test; +bool TestIO::m_initialized{false}; + +TEST_F(TestDataLoader, DataLoaderInitializationTest) +{ + auto data_loader = DataLoader(); +} + +TEST_F(TestDataLoader, DataLoaderRegisterLoaderTest) +{ + auto data_loader = DataLoader(); + + nlohmann::json config; + config["loader_id"] = ""; + + std::vector loaders = {"grpc", "payload", "rest"}; + for (auto& loader : loaders) + { + config["loader_id"] = loader; + auto msg = MessageControl(config); + + EXPECT_THROW(data_loader.load(msg), std::runtime_error); + } + + // data_loader.register_loader("file", std::make_unique()); + data_loader.register_loader("grpc", std::make_unique()); + data_loader.register_loader("payload", std::make_unique()); + data_loader.register_loader("rest", std::make_unique()); + + for (auto& loader : loaders) + { + config["loader_id"] = loader; + auto msg = MessageControl(config); + + EXPECT_NO_THROW(data_loader.load(msg)); + } +} + +/** + * @brief Check that we can send a control message, with a raw data payload and load it correctly. + */ +TEST_F(TestDataLoader, PayloadLoaderTest) +{ + auto data_loader = DataLoader(); + data_loader.register_loader("payload", std::make_unique()); + + nlohmann::json config; + config["loader_id"] = "payload"; + + auto msg = MessageControl(config); + + auto mm = create_mock_msg_meta({"col1", "col2", "col3"}, {"int32", "float32", "string"}, 5); + msg.payload(mm); + + auto mm2 = data_loader.load(msg); + EXPECT_EQ(mm, mm2); +} + +/** + * @brief Check that we can send a control message, with a raw data payload and load it correctly. + */ +TEST_F(TestDataLoader, FileLoaderTest) +{ + auto data_loader = DataLoader(); + data_loader.register_loader("file", std::make_unique()); + + auto string_df = create_mock_dataframe({"col1", "col2", "col3"}, {"int32", "float32", "string"}, 5); + + char temp_file[] = "/tmp/morpheus_test_XXXXXXXX"; + int fd = mkstemp(temp_file); + if (fd == -1) + { + GTEST_SKIP() << "Failed to create temporary file, skipping test"; + } + + nlohmann::json config; + config["loader_id"] = "file"; + config["strategy"] = "merge"; + config["files"] = {std::string(temp_file)}; + + auto msg = MessageControl(config); -TEST_F(TestDataLoader, DataLoaderModuleInitializationTest) { + std::fstream data_file(temp_file, std::ios::out | std::ios::binary | std::ios::trunc); + data_file << string_df; + data_file.close(); -} \ No newline at end of file + auto mm2 = data_loader.load(msg); +} diff --git a/morpheus/_lib/tests/io/test_io.hpp b/morpheus/_lib/tests/io/test_io.hpp index 239d0526a8..4ff3801782 100644 --- a/morpheus/_lib/tests/io/test_io.hpp +++ b/morpheus/_lib/tests/io/test_io.hpp @@ -19,14 +19,115 @@ #include "../test_morpheus.hpp" // IWYU pragma: associated +#include "morpheus/messages/meta.hpp" + +#include +#include + +#include +#include +#include +#include +#include + namespace morpheus::test { + +/** + * @brief Test fixture for IO tests + * Note: we don't finalize the interpreter after each test, because cudf doesn't behave well when the interpreter is + * initialized more than once. This means that additional attention is required when adding new tests to this fixture, + * because they will share the same interpreter instance and state. + */ class TestIO : public ::testing::Test { protected: - void SetUp() override {} + void SetUp() override + { + if (!m_initialized) + { + pybind11::initialize_interpreter(); + m_initialized = true; + + auto& cache_handle = mrc::pymrc::PythonObjectCache::get_handle(); + cache_handle.get_module("cudf"); // pre-load cudf + } + } void TearDown() override {} + + private: + static bool m_initialized; }; -using TestDataLoader = TestIO; +std::string accum_merge(std::string lhs, std::string rhs) +{ + if (lhs.empty()) + { + return std::move(rhs); + } + + return std::move(lhs) + "," + std::move(rhs); +} + +std::string create_mock_dataframe(std::vector cols, std::vector dtypes, std::size_t rows) +{ + assert(cols.size() == dtypes.size()); + static std::vector random_strings = {"field1", "test123", "abc", "xyz", "123", "foo", "bar", "baz"}; + + auto sstream = std::stringstream(); + + // Create header + sstream << std::accumulate(cols.begin(), cols.end(), std::string(""), accum_merge); + sstream << std::endl; + + // Populate with random data + std::srand(std::time(nullptr)); + for (std::size_t row = 0; row < rows; ++row) + { + for (std::size_t col = 0; col < cols.size(); ++col) + { + if (dtypes[col] == "int32") + { + sstream << std::rand() % 100 << ","; + } + else if (dtypes[col] == "float32") + { + sstream << std::rand() % 100 << "." << std::rand() % 100 << ","; + } + else if (dtypes[col] == "string") + { + sstream << random_strings[std::rand() % (random_strings.size() - 1)] << ","; + } + else + { + throw std::runtime_error("Unsupported dtype"); + } + } + sstream.seekp(-1, std::ios::cur); // Remove last comma + sstream << std::endl; + } + + return sstream.str(); +} + +std::shared_ptr create_mock_msg_meta(std::vector cols, + std::vector dtypes, + std::size_t rows) +{ + auto string_df = create_mock_dataframe(cols, dtypes, rows); + + pybind11::gil_scoped_acquire gil; + pybind11::module_ mod_cudf; + + auto& cache_handle = mrc::pymrc::PythonObjectCache::get_handle(); + mod_cudf = cache_handle.get_module("cudf"); + + auto py_string = pybind11::str(string_df); + auto py_buffer = pybind11::buffer(pybind11::bytes(py_string)); + auto dataframe = mod_cudf.attr("read_csv")(py_buffer); + + return MessageMeta::create_from_python(std::move(dataframe)); +} + +using TestDataLoader = TestIO; // NOLINT } // namespace morpheus::test \ No newline at end of file diff --git a/morpheus/_lib/tests/modules/test_data_loader_module.cpp b/morpheus/_lib/tests/modules/test_data_loader_module.cpp index c673049ad1..46524e9500 100644 --- a/morpheus/_lib/tests/modules/test_data_loader_module.cpp +++ b/morpheus/_lib/tests/modules/test_data_loader_module.cpp @@ -36,57 +36,60 @@ using namespace morpheus; using namespace morpheus::test; -TEST_F(TestDataLoaderModule, EndToEndFileDataLoaderTest) -{ - using namespace mrc::modules; - using namespace mrc; - - using sp_msg_meta_t = std::shared_ptr; - using sp_msg_ctrl_t = std::shared_ptr; - - auto init_wrapper = [](segment::Builder& builder) { - nlohmann::json config; - config["loaders"] = {"file", "grpc", "payload", "rest"}; - auto data_loader_module = builder.make_module("DataLoaderTest", config); - - auto source = builder.make_source("source", [](rxcpp::subscriber& sub) { - if (sub.is_subscribed()) - { - for (int i = 0; i < 10; i++) - { - nlohmann::json config; - config["loader_id"] = "file"; - sub.on_next(std::make_shared(config)); - } - } - - sub.on_completed(); - }); - - std::size_t x; - builder.make_edge(source, data_loader_module->input_port("input")); - auto sink = builder.make_sink("sink", [&x](sp_msg_meta_t input) { - x++; - VLOG(10) << "Received message"; - }); - - builder.make_edge(data_loader_module->output_port("output"), sink); - }; - - std::unique_ptr m_pipeline; - m_pipeline = pipeline::make_pipeline(); - - m_pipeline->make_segment("main", init_wrapper); - - auto options = std::make_shared(); - options->topology().user_cpuset("0-1"); - options->topology().restrict_gpus(true); - - Executor executor(options); - executor.register_pipeline(std::move(m_pipeline)); - executor.start(); - executor.join(); -} +// TEST_F(TestDataLoaderModule, EndToEndFileDataLoaderTest) +//{ +// using namespace mrc::modules; +// using namespace mrc; +// +// using sp_msg_meta_t = std::shared_ptr; +// using sp_msg_ctrl_t = std::shared_ptr; +// +// std::size_t packet_count{0}; +// +// auto init_wrapper = [&packet_count](segment::Builder& builder) { +// nlohmann::json config; +// config["loaders"] = {"file", "grpc", "payload", "rest"}; +// auto data_loader_module = builder.make_module("DataLoaderTest", config); +// +// auto source = builder.make_source("source", [](rxcpp::subscriber& sub) { +// if (sub.is_subscribed()) +// { +// for (int i = 0; i < 10; i++) +// { +// nlohmann::json config; +// config["loader_id"] = "file"; +// sub.on_next(std::make_shared(config)); +// } +// } +// +// sub.on_completed(); +// }); +// +// builder.make_edge(source, data_loader_module->input_port("input")); +// auto sink = builder.make_sink("sink", [&packet_count](sp_msg_meta_t input) { +// packet_count++; +// VLOG(10) << "Received message"; +// }); +// +// builder.make_edge(data_loader_module->output_port("output"), sink); +// }; +// +// std::unique_ptr m_pipeline; +// m_pipeline = pipeline::make_pipeline(); +// +// m_pipeline->make_segment("main", init_wrapper); +// +// auto options = std::make_shared(); +// options->topology().user_cpuset("0-1"); +// options->topology().restrict_gpus(true); +// +// Executor executor(options); +// executor.register_pipeline(std::move(m_pipeline)); +// executor.start(); +// executor.join(); +// +// EXPECT_EQ(packet_count, 10); +// } TEST_F(TestDataLoaderModule, EndToEndGRPCDataLoaderTest) { @@ -96,7 +99,9 @@ TEST_F(TestDataLoaderModule, EndToEndGRPCDataLoaderTest) using sp_msg_meta_t = std::shared_ptr; using sp_msg_ctrl_t = std::shared_ptr; - auto init_wrapper = [](segment::Builder& builder) { + std::size_t packet_count{0}; + + auto init_wrapper = [&packet_count](segment::Builder& builder) { nlohmann::json config; config["loaders"] = {"file", "grpc", "payload", "rest"}; auto data_loader_module = builder.make_module("DataLoaderTest", config); @@ -115,10 +120,9 @@ TEST_F(TestDataLoaderModule, EndToEndGRPCDataLoaderTest) sub.on_completed(); }); - std::size_t x; builder.make_edge(source, data_loader_module->input_port("input")); - auto sink = builder.make_sink("sink", [&x](sp_msg_meta_t input) { - x++; + auto sink = builder.make_sink("sink", [&packet_count](sp_msg_meta_t input) { + packet_count++; VLOG(10) << "Received message"; }); @@ -138,6 +142,8 @@ TEST_F(TestDataLoaderModule, EndToEndGRPCDataLoaderTest) executor.register_pipeline(std::move(m_pipeline)); executor.start(); executor.join(); + + EXPECT_EQ(packet_count, 10); } TEST_F(TestDataLoaderModule, EndToEndPayloadDataLoaderTest) @@ -148,7 +154,9 @@ TEST_F(TestDataLoaderModule, EndToEndPayloadDataLoaderTest) using sp_msg_meta_t = std::shared_ptr; using sp_msg_ctrl_t = std::shared_ptr; - auto init_wrapper = [](segment::Builder& builder) { + std::size_t packet_count{0}; + + auto init_wrapper = [&packet_count](segment::Builder& builder) { nlohmann::json config; config["loaders"] = {"file", "grpc", "payload", "rest"}; auto data_loader_module = builder.make_module("DataLoaderTest", config); @@ -169,8 +177,8 @@ TEST_F(TestDataLoaderModule, EndToEndPayloadDataLoaderTest) std::size_t x; builder.make_edge(source, data_loader_module->input_port("input")); - auto sink = builder.make_sink("sink", [&x](sp_msg_meta_t input) { - x++; + auto sink = builder.make_sink("sink", [&packet_count](sp_msg_meta_t input) { + packet_count++; VLOG(10) << "Received message"; }); @@ -190,6 +198,8 @@ TEST_F(TestDataLoaderModule, EndToEndPayloadDataLoaderTest) executor.register_pipeline(std::move(m_pipeline)); executor.start(); executor.join(); + + EXPECT_EQ(packet_count, 10); } TEST_F(TestDataLoaderModule, EndToEndRESTDataLoaderTest) @@ -200,7 +210,9 @@ TEST_F(TestDataLoaderModule, EndToEndRESTDataLoaderTest) using sp_msg_meta_t = std::shared_ptr; using sp_msg_ctrl_t = std::shared_ptr; - auto init_wrapper = [](segment::Builder& builder) { + std::size_t packet_count{0}; + + auto init_wrapper = [&packet_count](segment::Builder& builder) { nlohmann::json config; config["loaders"] = {"file", "grpc", "payload", "rest"}; auto data_loader_module = builder.make_module("DataLoaderTest", config); @@ -219,10 +231,9 @@ TEST_F(TestDataLoaderModule, EndToEndRESTDataLoaderTest) sub.on_completed(); }); - std::size_t x; builder.make_edge(source, data_loader_module->input_port("input")); - auto sink = builder.make_sink("sink", [&x](sp_msg_meta_t input) { - x++; + auto sink = builder.make_sink("sink", [&packet_count](sp_msg_meta_t input) { + packet_count++; VLOG(10) << "Received message"; }); @@ -242,4 +253,6 @@ TEST_F(TestDataLoaderModule, EndToEndRESTDataLoaderTest) executor.register_pipeline(std::move(m_pipeline)); executor.start(); executor.join(); + + EXPECT_EQ(packet_count, 10); } \ No newline at end of file diff --git a/morpheus/_lib/tests/test_morpheus.hpp b/morpheus/_lib/tests/test_morpheus.hpp index f31e437f52..e7d7631e07 100644 --- a/morpheus/_lib/tests/test_morpheus.hpp +++ b/morpheus/_lib/tests/test_morpheus.hpp @@ -19,12 +19,16 @@ #include // IWYU pragma: keep #include // IWYU pragma: keep +#include #include -#define TEST_CLASS(name) \ - class Test##name : public ::testing::Test \ - {} + +#define TEST_CLASS(name) \ + class __attribute__((visibility("default"))) Test##name : public ::testing::Test \ + { \ + void SetUp() override {} \ + } namespace morpheus::test { From 1b63cfadeef246c56b5367e7e10d337b969ba303 Mon Sep 17 00:00:00 2001 From: Devin Robison Date: Tue, 14 Feb 2023 17:40:08 -0700 Subject: [PATCH 011/157] Add file loader paths for supported files, fix tests, add python tests --- morpheus/_lib/src/io/data_loader.cpp | 4 +- morpheus/_lib/src/io/loaders/file.cpp | 97 +++++++++--- morpheus/_lib/src/python_modules/messages.cpp | 8 +- morpheus/_lib/tests/io/test_data_loader.cpp | 2 - morpheus/_lib/tests/io/test_io.hpp | 110 +------------- .../tests/modules/test_data_loader_module.cpp | 115 +++++++++------ morpheus/_lib/tests/modules/test_modules.hpp | 9 +- morpheus/_lib/tests/test_main.cpp | 2 - morpheus/_lib/tests/test_morpheus.cpp | 97 ++++++++++++ morpheus/_lib/tests/test_morpheus.hpp | 33 ++++- tests/messages/test_control_message.py | 18 ++- tests/modules/test_morpheus_modules.py | 138 +++++++++++++++++- 12 files changed, 435 insertions(+), 198 deletions(-) diff --git a/morpheus/_lib/src/io/data_loader.cpp b/morpheus/_lib/src/io/data_loader.cpp index 6f0ae63947..fe4940adf9 100644 --- a/morpheus/_lib/src/io/data_loader.cpp +++ b/morpheus/_lib/src/io/data_loader.cpp @@ -36,7 +36,9 @@ std::shared_ptr DataLoader::load(MessageControl& control_message) auto loader = m_loaders.find(loader_id); if (loader != m_loaders.end()) { - return loader->second->load(control_message); + VLOG(5) << "Loading data using loader: " << loader_id + << " for message: " << control_message.message().dump() << std::endl; + return std::move(loader->second->load(control_message)); } } diff --git a/morpheus/_lib/src/io/loaders/file.cpp b/morpheus/_lib/src/io/loaders/file.cpp index d4e70d9cc5..63d9a1a756 100644 --- a/morpheus/_lib/src/io/loaders/file.cpp +++ b/morpheus/_lib/src/io/loaders/file.cpp @@ -20,10 +20,8 @@ #include "morpheus/messages/control.hpp" #include "morpheus/messages/meta.hpp" -#include -#include +#include #include -#include #include #include @@ -34,37 +32,90 @@ namespace {} namespace morpheus { std::shared_ptr FileDataLoader::load(MessageControl& message) { + namespace py = pybind11; VLOG(30) << "Called FileDataLoader::load()"; + // Aggregate dataframes for each file + py::gil_scoped_acquire gil; + py::module_ mod_cudf; + + auto& cache_handle = mrc::pymrc::PythonObjectCache::get_handle(); + mod_cudf = cache_handle.get_module("cudf"); + // TODO(Devin) : error checking + improve robustness - auto filenames = message.message()["files"]; - auto sstream = std::stringstream(); - for (auto& filename : filenames) + auto config = message.message(); + if (!config.contains("files")) { - auto file = std::fstream(filename); - if (!file) - { - throw std::runtime_error("Could not open file: "); - } + throw std::runtime_error("'File Loader' control message specified no files to load"); + } - // TODO(Devin) : implement strategies - sstream << file.rdbuf(); - file.close(); + // TODO(Devin) : Migrate this to use the cudf::io interface + std::string strategy = config.value("strategy", "aggregate"); + if (strategy != "aggregate") + { + throw std::runtime_error("Only 'merge' strategy is currently supported"); } + auto files = config["files"]; + py::object dataframe = py::none(); + for (auto& file : files) { - pybind11::gil_scoped_acquire gil; - pybind11::module_ mod_cudf; + boost::filesystem::path path(file.value("path", "")); + std::string extension = file.value("type", path.extension().string()); + // Remove the leading period + if (!extension.empty() && extension[0] == '.') + { + extension = extension.substr(1); + } + std::transform(extension.begin(), extension.end(), extension.begin(), ::tolower); + + VLOG(5) << "Loading file: " << file.dump(2); - auto& cache_handle = mrc::pymrc::PythonObjectCache::get_handle(); - mod_cudf = cache_handle.get_module("cudf"); + auto current_df = mod_cudf.attr("DataFrame")(); + if (extension == "csv") + { + current_df = mod_cudf.attr("read_csv")(path.string()); + } + else if (extension == "parquet") + { + current_df = mod_cudf.attr("read_parquet")(path.string()); + } + else if (extension == "orc") + { + current_df = mod_cudf.attr("read_orc")(path.string()); + } + else if (extension == "json") + { + current_df = mod_cudf.attr("read_json")(path.string()); + } + else if (extension == "feather") + { + current_df = mod_cudf.attr("read_feather")(path.string()); + } + else if (extension == "hdf") + { + current_df = mod_cudf.attr("read_hdf")(path.string()); + } + else if (extension == "avro") + { + current_df = mod_cudf.attr("read_avro")(path.string()); + } - // TODO(Devin) : Do something more efficient - auto py_string = pybind11::str(sstream.str()); - auto py_buffer = pybind11::buffer(pybind11::bytes(py_string)); - auto dataframe = mod_cudf.attr("read_csv")(py_buffer); + if (dataframe.is_none()) + { + dataframe = current_df; + continue; + } - return MessageMeta::create_from_python(std::move(dataframe)); + if (strategy == "aggregate") + { + py::list args; + args.attr("append")(dataframe); + args.attr("append")(current_df); + dataframe = mod_cudf.attr("concat")(args); + } } + + return MessageMeta::create_from_python(std::move(dataframe)); } } // namespace morpheus \ No newline at end of file diff --git a/morpheus/_lib/src/python_modules/messages.cpp b/morpheus/_lib/src/python_modules/messages.cpp index aac9b5505f..ea276f6514 100644 --- a/morpheus/_lib/src/python_modules/messages.cpp +++ b/morpheus/_lib/src/python_modules/messages.cpp @@ -132,6 +132,11 @@ PYBIND11_MODULE(messages, py_mod) .def("message", pybind11::overload_cast(&ControlMessageProxy::message), py::arg("message"), + py::return_value_policy::reference_internal) + .def( + "payload", pybind11::overload_cast<>(&MessageControl::payload), py::return_value_policy::reference_internal) + .def("payload", + pybind11::overload_cast&>(&MessageControl::payload), py::return_value_policy::reference_internal); // Context manager for Mutable Dataframes. Attempting to use it outside of a with block will raise an exception @@ -264,8 +269,7 @@ PYBIND11_MODULE(messages, py_mod) &ResponseMemoryInterfaceProxy::get_output_tensor, py::return_value_policy::reference_internal); - py::class_>(py_mod, - "ResponseMemoryProbs") + py::class_>(py_mod, "ResponseMemoryProbs") .def(py::init<>(&ResponseMemoryProbsInterfaceProxy::init), py::arg("count"), py::arg("probs")) .def_property_readonly("count", &ResponseMemoryProbsInterfaceProxy::count) .def_property( diff --git a/morpheus/_lib/tests/io/test_data_loader.cpp b/morpheus/_lib/tests/io/test_data_loader.cpp index 18c7dd27d7..29d515cda4 100644 --- a/morpheus/_lib/tests/io/test_data_loader.cpp +++ b/morpheus/_lib/tests/io/test_data_loader.cpp @@ -41,8 +41,6 @@ namespace py = pybind11; using namespace morpheus; using namespace morpheus::test; -bool TestIO::m_initialized{false}; - TEST_F(TestDataLoader, DataLoaderInitializationTest) { auto data_loader = DataLoader(); diff --git a/morpheus/_lib/tests/io/test_io.hpp b/morpheus/_lib/tests/io/test_io.hpp index 4ff3801782..b47b301456 100644 --- a/morpheus/_lib/tests/io/test_io.hpp +++ b/morpheus/_lib/tests/io/test_io.hpp @@ -19,115 +19,7 @@ #include "../test_morpheus.hpp" // IWYU pragma: associated -#include "morpheus/messages/meta.hpp" - -#include -#include - -#include -#include -#include -#include -#include - namespace morpheus::test { -/** - * @brief Test fixture for IO tests - * Note: we don't finalize the interpreter after each test, because cudf doesn't behave well when the interpreter is - * initialized more than once. This means that additional attention is required when adding new tests to this fixture, - * because they will share the same interpreter instance and state. - */ -class TestIO : public ::testing::Test -{ - protected: - void SetUp() override - { - if (!m_initialized) - { - pybind11::initialize_interpreter(); - m_initialized = true; - - auto& cache_handle = mrc::pymrc::PythonObjectCache::get_handle(); - cache_handle.get_module("cudf"); // pre-load cudf - } - } - - void TearDown() override {} - - private: - static bool m_initialized; -}; - -std::string accum_merge(std::string lhs, std::string rhs) -{ - if (lhs.empty()) - { - return std::move(rhs); - } - - return std::move(lhs) + "," + std::move(rhs); -} - -std::string create_mock_dataframe(std::vector cols, std::vector dtypes, std::size_t rows) -{ - assert(cols.size() == dtypes.size()); - static std::vector random_strings = {"field1", "test123", "abc", "xyz", "123", "foo", "bar", "baz"}; - - auto sstream = std::stringstream(); - - // Create header - sstream << std::accumulate(cols.begin(), cols.end(), std::string(""), accum_merge); - sstream << std::endl; - - // Populate with random data - std::srand(std::time(nullptr)); - for (std::size_t row = 0; row < rows; ++row) - { - for (std::size_t col = 0; col < cols.size(); ++col) - { - if (dtypes[col] == "int32") - { - sstream << std::rand() % 100 << ","; - } - else if (dtypes[col] == "float32") - { - sstream << std::rand() % 100 << "." << std::rand() % 100 << ","; - } - else if (dtypes[col] == "string") - { - sstream << random_strings[std::rand() % (random_strings.size() - 1)] << ","; - } - else - { - throw std::runtime_error("Unsupported dtype"); - } - } - sstream.seekp(-1, std::ios::cur); // Remove last comma - sstream << std::endl; - } - - return sstream.str(); -} - -std::shared_ptr create_mock_msg_meta(std::vector cols, - std::vector dtypes, - std::size_t rows) -{ - auto string_df = create_mock_dataframe(cols, dtypes, rows); - - pybind11::gil_scoped_acquire gil; - pybind11::module_ mod_cudf; - - auto& cache_handle = mrc::pymrc::PythonObjectCache::get_handle(); - mod_cudf = cache_handle.get_module("cudf"); - - auto py_string = pybind11::str(string_df); - auto py_buffer = pybind11::buffer(pybind11::bytes(py_string)); - auto dataframe = mod_cudf.attr("read_csv")(py_buffer); - - return MessageMeta::create_from_python(std::move(dataframe)); -} - -using TestDataLoader = TestIO; // NOLINT +using TestDataLoader = TestWithPythonInterpreter; // NOLINT } // namespace morpheus::test \ No newline at end of file diff --git a/morpheus/_lib/tests/modules/test_data_loader_module.cpp b/morpheus/_lib/tests/modules/test_data_loader_module.cpp index 46524e9500..bd5e9e89f8 100644 --- a/morpheus/_lib/tests/modules/test_data_loader_module.cpp +++ b/morpheus/_lib/tests/modules/test_data_loader_module.cpp @@ -33,63 +33,87 @@ #include #include +#include + using namespace morpheus; using namespace morpheus::test; +// TODO(Devin): Can't seem to get this to work, we lock up trying to grab the gil -- something going on with the fiber +// interactions. // TEST_F(TestDataLoaderModule, EndToEndFileDataLoaderTest) //{ -// using namespace mrc::modules; -// using namespace mrc; +// using namespace mrc::modules; +// using namespace mrc; +// +// using sp_msg_meta_t = std::shared_ptr; +// using sp_msg_ctrl_t = std::shared_ptr; +// +// std::size_t packet_count{0}; // -// using sp_msg_meta_t = std::shared_ptr; -// using sp_msg_ctrl_t = std::shared_ptr; +// auto init_wrapper = [&packet_count](segment::Builder& builder) { +// nlohmann::json config; +// config["loaders"] = {"file"}; // -// std::size_t packet_count{0}; +// auto data_loader_module = builder.make_module("DataLoaderTest", config); // -// auto init_wrapper = [&packet_count](segment::Builder& builder) { -// nlohmann::json config; -// config["loaders"] = {"file", "grpc", "payload", "rest"}; -// auto data_loader_module = builder.make_module("DataLoaderTest", config); +// auto source = builder.make_source("source", [](rxcpp::subscriber& sub) { +// std::string string_df = create_mock_dataframe({"col1", "col2", "col3"}, {"int32", "float32", "string"}, +// 5); // -// auto source = builder.make_source("source", [](rxcpp::subscriber& sub) { -// if (sub.is_subscribed()) -// { -// for (int i = 0; i < 10; i++) -// { -// nlohmann::json config; -// config["loader_id"] = "file"; -// sub.on_next(std::make_shared(config)); -// } -// } +// char temp_file[] = "/tmp/morpheus_test_XXXXXXXX"; +// int fd = mkstemp(temp_file); +// if (fd == -1) +// { +// GTEST_SKIP() << "Failed to create temporary file, skipping test"; +// } // -// sub.on_completed(); -// }); +// std::fstream data_file(temp_file, std::ios::out | std::ios::binary | std::ios::trunc); +// data_file << string_df; +// data_file.close(); // -// builder.make_edge(source, data_loader_module->input_port("input")); -// auto sink = builder.make_sink("sink", [&packet_count](sp_msg_meta_t input) { -// packet_count++; -// VLOG(10) << "Received message"; -// }); +// nlohmann::json config; +// config["loader_id"] = "file"; +// config["strategy"] = "merge"; +// config["files"] = {std::string(temp_file)}; +// if (sub.is_subscribed()) +// { +// for (int i = 0; i < 10; i++) +// { +// sub.on_next(std::make_shared(config)); +// } +// } // -// builder.make_edge(data_loader_module->output_port("output"), sink); -// }; +// sub.on_completed(); +// }); // -// std::unique_ptr m_pipeline; -// m_pipeline = pipeline::make_pipeline(); +// builder.make_edge(source, data_loader_module->input_port("input")); +// auto sink = builder.make_sink("sink", [&packet_count](sp_msg_meta_t input) { +// packet_count++; +// VLOG(30) << "Received message"; +// }); // -// m_pipeline->make_segment("main", init_wrapper); +// builder.make_edge(data_loader_module->output_port("output"), sink); +// }; // -// auto options = std::make_shared(); -// options->topology().user_cpuset("0-1"); -// options->topology().restrict_gpus(true); +// std::unique_ptr m_pipeline; +// m_pipeline = pipeline::make_pipeline(); // -// Executor executor(options); -// executor.register_pipeline(std::move(m_pipeline)); -// executor.start(); -// executor.join(); +// m_pipeline->make_segment("main", init_wrapper); // -// EXPECT_EQ(packet_count, 10); -// } +// auto options = std::make_shared(); +// options->topology().user_cpuset("0-1"); +// options->topology().restrict_gpus(true); +// // We're running an interpreter, and accessing python objects from multiple threads, will lock up if we use +// // fibers. +// options->engine_factories().set_default_engine_type(runnable::EngineType::Thread); +// +// Executor executor(options); +// executor.register_pipeline(std::move(m_pipeline)); +// executor.start(); +// executor.join(); +// +// EXPECT_EQ(packet_count, 10); +//} TEST_F(TestDataLoaderModule, EndToEndGRPCDataLoaderTest) { @@ -103,7 +127,7 @@ TEST_F(TestDataLoaderModule, EndToEndGRPCDataLoaderTest) auto init_wrapper = [&packet_count](segment::Builder& builder) { nlohmann::json config; - config["loaders"] = {"file", "grpc", "payload", "rest"}; + config["loaders"] = {"grpc"}; auto data_loader_module = builder.make_module("DataLoaderTest", config); auto source = builder.make_source("source", [](rxcpp::subscriber& sub) { @@ -137,6 +161,7 @@ TEST_F(TestDataLoaderModule, EndToEndGRPCDataLoaderTest) auto options = std::make_shared(); options->topology().user_cpuset("0-1"); options->topology().restrict_gpus(true); + options->engine_factories().set_default_engine_type(runnable::EngineType::Thread); Executor executor(options); executor.register_pipeline(std::move(m_pipeline)); @@ -158,7 +183,7 @@ TEST_F(TestDataLoaderModule, EndToEndPayloadDataLoaderTest) auto init_wrapper = [&packet_count](segment::Builder& builder) { nlohmann::json config; - config["loaders"] = {"file", "grpc", "payload", "rest"}; + config["loaders"] = {"payload"}; auto data_loader_module = builder.make_module("DataLoaderTest", config); auto source = builder.make_source("source", [](rxcpp::subscriber& sub) { @@ -193,6 +218,7 @@ TEST_F(TestDataLoaderModule, EndToEndPayloadDataLoaderTest) auto options = std::make_shared(); options->topology().user_cpuset("0-1"); options->topology().restrict_gpus(true); + options->engine_factories().set_default_engine_type(runnable::EngineType::Thread); Executor executor(options); executor.register_pipeline(std::move(m_pipeline)); @@ -214,7 +240,7 @@ TEST_F(TestDataLoaderModule, EndToEndRESTDataLoaderTest) auto init_wrapper = [&packet_count](segment::Builder& builder) { nlohmann::json config; - config["loaders"] = {"file", "grpc", "payload", "rest"}; + config["loaders"] = {"rest"}; auto data_loader_module = builder.make_module("DataLoaderTest", config); auto source = builder.make_source("source", [](rxcpp::subscriber& sub) { @@ -246,8 +272,9 @@ TEST_F(TestDataLoaderModule, EndToEndRESTDataLoaderTest) m_pipeline->make_segment("main", init_wrapper); auto options = std::make_shared(); - options->topology().user_cpuset("0-1"); + options->topology().user_cpuset("0"); options->topology().restrict_gpus(true); + options->engine_factories().set_default_engine_type(runnable::EngineType::Thread); Executor executor(options); executor.register_pipeline(std::move(m_pipeline)); diff --git a/morpheus/_lib/tests/modules/test_modules.hpp b/morpheus/_lib/tests/modules/test_modules.hpp index 7f4b1cbd77..d7c1eaed39 100644 --- a/morpheus/_lib/tests/modules/test_modules.hpp +++ b/morpheus/_lib/tests/modules/test_modules.hpp @@ -20,13 +20,6 @@ #include "../test_morpheus.hpp" // IWYU pragma: associated namespace morpheus::test { -class TestModules : public ::testing::Test -{ - protected: - void SetUp() override {} - void TearDown() override {} -}; - -using TestDataLoaderModule = TestModules; +using TestDataLoaderModule = TestWithPythonInterpreter; } // namespace morpheus::test \ No newline at end of file diff --git a/morpheus/_lib/tests/test_main.cpp b/morpheus/_lib/tests/test_main.cpp index ea864e51e0..19f2c73c13 100644 --- a/morpheus/_lib/tests/test_main.cpp +++ b/morpheus/_lib/tests/test_main.cpp @@ -16,9 +16,7 @@ */ #include // for ParseCommandLineFlags - #include - #include // IWYU pragma: keep int main(int argc, char** argv) diff --git a/morpheus/_lib/tests/test_morpheus.cpp b/morpheus/_lib/tests/test_morpheus.cpp index 7d6d1ee162..09723019b3 100644 --- a/morpheus/_lib/tests/test_morpheus.cpp +++ b/morpheus/_lib/tests/test_morpheus.cpp @@ -15,11 +15,50 @@ * limitations under the License. */ +#include "test_morpheus.hpp" + +#include "morpheus/messages/meta.hpp" + +#include + #include +#include +#include +#include #include +namespace { +std::string accum_merge(std::string lhs, std::string rhs) +{ + if (lhs.empty()) + { + return std::move(rhs); + } + + return std::move(lhs) + "," + std::move(rhs); +} +} // namespace + namespace morpheus::test { +bool TestWithPythonInterpreter::m_initialized = false; + +void TestWithPythonInterpreter::SetUp() +{ + initialize_interpreter(); +} + +void TestWithPythonInterpreter::TearDown() {} + +void TestWithPythonInterpreter::initialize_interpreter() const +{ + if (!m_initialized) + { + pybind11::initialize_interpreter(); + m_initialized = true; + } +} + std::filesystem::path get_morpheus_root() { auto root = std::getenv("MORPHEUS_ROOT"); @@ -32,4 +71,62 @@ std::filesystem::path get_morpheus_root() return std::filesystem::path{root}; } +std::string create_mock_dataframe(std::vector cols, std::vector dtypes, std::size_t rows) +{ + assert(cols.size() == dtypes.size()); + static std::vector random_strings = {"field1", "test123", "abc", "xyz", "123", "foo", "bar", "baz"}; + + auto sstream = std::stringstream(); + + // Create header + sstream << std::accumulate(cols.begin(), cols.end(), std::string(""), accum_merge); + sstream << std::endl; + + // Populate with random data + std::srand(std::time(nullptr)); + for (std::size_t row = 0; row < rows; ++row) + { + for (std::size_t col = 0; col < cols.size(); ++col) + { + if (dtypes[col] == "int32") + { + sstream << std::rand() % 100 << ","; + } + else if (dtypes[col] == "float32") + { + sstream << std::rand() % 100 << "." << std::rand() % 100 << ","; + } + else if (dtypes[col] == "string") + { + sstream << random_strings[std::rand() % (random_strings.size() - 1)] << ","; + } + else + { + throw std::runtime_error("Unsupported dtype"); + } + } + sstream.seekp(-1, std::ios::cur); // Remove last comma + sstream << std::endl; + } + + return sstream.str(); +} + +std::shared_ptr create_mock_msg_meta(std::vector cols, + std::vector dtypes, + std::size_t rows) +{ + auto string_df = create_mock_dataframe(cols, dtypes, rows); + + pybind11::gil_scoped_acquire gil; + pybind11::module_ mod_cudf; + mod_cudf = pybind11::module_::import("cudf"); + + auto py_string = pybind11::str(string_df); + auto py_buffer = pybind11::buffer(pybind11::bytes(py_string)); + auto dataframe = mod_cudf.attr("read_csv")(py_buffer); + + return MessageMeta::create_from_python(std::move(dataframe)); +} + } // namespace morpheus::test diff --git a/morpheus/_lib/tests/test_morpheus.hpp b/morpheus/_lib/tests/test_morpheus.hpp index e7d7631e07..6098f60840 100644 --- a/morpheus/_lib/tests/test_morpheus.hpp +++ b/morpheus/_lib/tests/test_morpheus.hpp @@ -23,19 +23,50 @@ #include - #define TEST_CLASS(name) \ class __attribute__((visibility("default"))) Test##name : public ::testing::Test \ { \ void SetUp() override {} \ } +namespace morpheus { +class MessageMeta; +} + namespace morpheus::test { +/** + * @brief Test fixture for tests that require a python interpreter. + * Note: we don't finalize the interpreter after each test, because cudf doesn't behave well when the interpreter is + * initialized more than once. This means that additional attention is required when adding new tests to this fixture, + * because they will share the same interpreter instance and state. + * Note: Additionally, creating another interpreter in the same library (lib_testmorpheus.so) will fail; if you must do + * so, create a new library. + */ +class TestWithPythonInterpreter : public ::testing::Test +{ + public: + void initialize_interpreter() const; + + protected: + void SetUp() override; + + void TearDown() override; + + private: + static bool m_initialized; +}; + /** * @brief Gets the `MORPHEUS_ROOT` env variable or throws a runtime_error. * @return std::filesystem::path */ std::filesystem::path get_morpheus_root(); +std::string create_mock_dataframe(std::vector cols, std::vector dtypes, std::size_t rows); + +std::shared_ptr create_mock_msg_meta(std::vector cols, + std::vector dtypes, + std::size_t rows); + } // namespace morpheus::test diff --git a/tests/messages/test_control_message.py b/tests/messages/test_control_message.py index b7ef49bb93..a1e1cb01c7 100644 --- a/tests/messages/test_control_message.py +++ b/tests/messages/test_control_message.py @@ -14,7 +14,7 @@ # See the License for the specific language governing permissions and # limitations under the License. - +import cudf import morpheus.messages as messages import morpheus._lib.messages as _messages @@ -52,6 +52,22 @@ def test_control_message_set(): assert control_message.message()["test"] == "test_cm" +def test_control_message_set_and_get_payload(): + df = cudf.DataFrame({ + 'col1': [1, 2, 3, 4, 5], + 'col2': [1.1, 2.2, 3.3, 4.4, 5.5], + 'col3': ['a', 'b', 'c', 'd', 'e'], + 'col4': [True, False, True, False, True] + }) + msg = _messages.MessageControl() + payload = messages.MessageMeta(df) + msg.payload(payload) + + payload2 = msg.payload() + assert payload2 is not None + assert payload.df == payload2.df + + if (__name__ == "__main__"): test_control_message_init() test_control_message_get() diff --git a/tests/modules/test_morpheus_modules.py b/tests/modules/test_morpheus_modules.py index d559346b84..a21261547a 100644 --- a/tests/modules/test_morpheus_modules.py +++ b/tests/modules/test_morpheus_modules.py @@ -17,6 +17,9 @@ import time import mrc +import cudf +import tempfile +import os import morpheus._lib.messages as _messages import morpheus.modules # Used to load and register morpheus modules @@ -53,16 +56,134 @@ def test_get_module(): module_instance = fn_constructor("ModuleDataLoaderTest", config) -def test_init_module(): +packet_count = 5 +packets_received = 0 + + +def test_payload_loader_module(): def init_wrapper(builder: mrc.Builder): + df = cudf.DataFrame({ + 'col1': [1, 2, 3, 4, 5], + 'col2': [1.1, 2.2, 3.3, 4.4, 5.5], + 'col3': ['a', 'b', 'c', 'd', 'e'], + 'col4': [True, False, True, False, True] + }) + def gen_data(): + global packet_count config = {"loader_id": "payload"} - for i in range(10): - yield messages.MessageControl(config) + + payload = messages.MessageMeta(df) + for i in range(packet_count): + msg = messages.MessageControl(config) + msg.payload(payload) + + yield msg def on_next(data): + global packets_received + packets_received += 1 + assert (data.df == df) + + def on_error(): pass + def on_complete(): + pass + + registry = mrc.ModuleRegistry + + fn_constructor = registry.get_module_constructor("DataLoader", "morpheus") + assert fn_constructor is not None + + source = builder.make_source("source", gen_data) + + config = {"loaders": "payload"} + # This will unpack the config and forward it's payload (MessageMeta) to the sink + data_loader = builder.load_module("DataLoader", "morpheus", "ModuleDataLoaderTest", config) + + sink = builder.make_sink("sink", on_next, on_error, on_complete) + + builder.make_edge(source, data_loader.input_port("input")) + builder.make_edge(data_loader.output_port("output"), sink) + + pipeline = mrc.Pipeline() + pipeline.make_segment("main", init_wrapper) + + options = mrc.Options() + options.topology.user_cpuset = "0-1" + + executor = mrc.Executor(options) + executor.register_pipeline(pipeline) + executor.start() + executor.join() + + assert (packets_received == packet_count) + + +def test_file_loader_module(): + global packets_received + packets_received = 0 + + df = cudf.DataFrame({ + 'col1': [1, 2, 3, 4, 5], + 'col2': [1.1, 2.2, 3.3, 4.4, 5.5], + 'col3': ['a', 'b', 'c', 'd', 'e'], + 'col4': [True, False, True, False, True] + }, columns=['col1', 'col2', 'col3', 'col4']) + + files = [] + file_types = ["csv", "parquet", "orc"] + for ftype in file_types: + _tempfile = tempfile.NamedTemporaryFile(suffix=f".{ftype}", delete=False) + filename = _tempfile.name + + if ftype == "csv": + df.to_csv(filename, index=False) + elif ftype == "parquet": + df.to_parquet(filename) + elif ftype == "orc": + df.to_orc(filename) + + files.append((filename, ftype)) + + def init_wrapper(builder: mrc.Builder): + def gen_data(): + global packet_count + + for f in files: + # Check with the file type + config = { + "loader_id": "file", + "strategy": "aggregate", + "files": [ + { + "path": f[0], + "type": f[1] + } + ] + } + msg = messages.MessageControl(config) + yield msg + + # Make sure we can auto-detect the file type + config = { + "loader_id": "file", + "strategy": "aggregate", + "files": [ + { + "path": f[0], + } + ] + } + msg = messages.MessageControl(config) + yield msg + + def on_next(data): + global packets_received + packets_received += 1 + assert (data.df == df) + def on_error(): pass @@ -76,7 +197,8 @@ def on_complete(): source = builder.make_source("source", gen_data) - config = {} + config = {"loaders": "file"} + # This will unpack the config and forward its payload (MessageMeta) to the sink data_loader = builder.load_module("DataLoader", "morpheus", "ModuleDataLoaderTest", config) sink = builder.make_sink("sink", on_next, on_error, on_complete) @@ -95,9 +217,15 @@ def on_complete(): executor.start() executor.join() + assert (packets_received == len(files) * 2) + + for f in files: + os.remove(f[0]) + if (__name__ == "__main__"): test_contains_namespace() test_is_version_compatible() test_get_module() - test_init_module() + test_payload_loader_module() + test_file_loader_module() From 5aab213517291d7aa890dcc2d3b482cd2865f5bc Mon Sep 17 00:00:00 2001 From: bsuryadevara Date: Tue, 14 Feb 2023 19:28:25 -0600 Subject: [PATCH 012/157] broadcast stage for dfp pipeline --- .../morpheus/dfp/modules/dfp_inf.py | 85 +++++++ .../morpheus/dfp/modules/dfp_preproc.py | 64 ++++++ .../morpheus/dfp/modules/dfp_tra.py | 69 ++++++ .../morpheus/dfp/utils/config_generator.py | 215 +++++++++++++++++- .../morpheus/dfp/utils/derive_args.py | 98 +++++--- .../morpheus/dfp/utils/module_ids.py | 3 + .../morpheus/dfp/utils/schema_utils.py | 20 +- .../morpheus/dfp_azure_modules_inference.py | 4 +- .../morpheus/dfp_azure_modules_pipeline.py | 8 +- .../morpheus/dfp_azure_modules_training.py | 4 +- .../morpheus/dfp_duo_modules_inference.py | 4 +- .../morpheus/dfp_duo_modules_pipeline.py | 4 +- .../morpheus/dfp_duo_modules_training.py | 4 +- .../morpheus/dfp_modules_pipeline.py | 209 +++++++++++++++++ morpheus/stages/general/broadcast_stage.py | 86 +++++++ 15 files changed, 822 insertions(+), 55 deletions(-) create mode 100644 examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inf.py create mode 100644 examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_preproc.py create mode 100644 examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_tra.py create mode 100644 examples/digital_fingerprinting/production/morpheus/dfp_modules_pipeline.py create mode 100644 morpheus/stages/general/broadcast_stage.py diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inf.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inf.py new file mode 100644 index 0000000000..00933f2506 --- /dev/null +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inf.py @@ -0,0 +1,85 @@ +# Copyright (c) 2022-2023, NVIDIA CORPORATION. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import logging + +import dfp.modules.dfp_data_prep # noqa: F401 +import dfp.modules.dfp_inference # noqa: F401 +import dfp.modules.dfp_postprocessing # noqa: F401 +import dfp.modules.dfp_rolling_window # noqa: F401 +import dfp.modules.dfp_split_users # noqa: F401 +import mrc + +import morpheus.modules.filter_detections # noqa: F401 +import morpheus.modules.serialize # noqa: F401 +import morpheus.modules.write_to_file # noqa: F401 +from morpheus.utils.module_ids import FILTER_DETECTIONS +from morpheus.utils.module_ids import MODULE_NAMESPACE +from morpheus.utils.module_ids import SERIALIZE +from morpheus.utils.module_ids import WRITE_TO_FILE +from morpheus.utils.module_utils import get_module_config +from morpheus.utils.module_utils import load_module +from morpheus.utils.module_utils import register_module + +from ..utils.module_ids import DFP_DATA_PREP +from ..utils.module_ids import DFP_INF +from ..utils.module_ids import DFP_INFERENCE +from ..utils.module_ids import DFP_POST_PROCESSING +from ..utils.module_ids import DFP_ROLLING_WINDOW + +logger = logging.getLogger(__name__) + + +@register_module(DFP_INF, MODULE_NAMESPACE) +def dfp_inf(builder: mrc.Builder): + """ + This module function allows for the consolidation of multiple dfp pipeline modules relevent to inference + process into a single module. + + Parameters + ---------- + builder : mrc.Builder + Pipeline budler instance. + """ + + config = get_module_config(DFP_INF, builder) + + dfp_rolling_window_conf = config.get(DFP_ROLLING_WINDOW, None) + dfp_data_prep_conf = config.get(DFP_DATA_PREP, None) + dfp_inference_conf = config.get(DFP_INFERENCE, None) + filter_detections_conf = config.get(FILTER_DETECTIONS, None) + dfp_post_proc_conf = config.get(DFP_POST_PROCESSING, None) + serialize_conf = config.get(SERIALIZE, None) + write_to_file_conf = config.get(WRITE_TO_FILE, None) + + # Load modules + dfp_rolling_window_module = load_module(dfp_rolling_window_conf, builder=builder) + dfp_data_prep_module = load_module(dfp_data_prep_conf, builder=builder) + dfp_inference_module = load_module(dfp_inference_conf, builder=builder) + filter_detections_module = load_module(filter_detections_conf, builder=builder) + dfp_post_proc_module = load_module(dfp_post_proc_conf, builder=builder) + serialize_module = load_module(serialize_conf, builder=builder) + write_to_file_module = load_module(write_to_file_conf, builder=builder) + + # Make an edge between the modules. + builder.make_edge(dfp_rolling_window_module.output_port("output"), dfp_data_prep_module.input_port("input")) + builder.make_edge(dfp_data_prep_module.output_port("output"), dfp_inference_module.input_port("input")) + builder.make_edge(dfp_inference_module.output_port("output"), filter_detections_module.input_port("input")) + builder.make_edge(filter_detections_module.output_port("output"), dfp_post_proc_module.input_port("input")) + builder.make_edge(dfp_post_proc_module.output_port("output"), serialize_module.input_port("input")) + builder.make_edge(serialize_module.output_port("output"), write_to_file_module.input_port("input")) + + # Register input and output port for a module. + builder.register_module_input("input", dfp_rolling_window_module.input_port("input")) + builder.register_module_output("output", write_to_file_module.output_port("output")) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_preproc.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_preproc.py new file mode 100644 index 0000000000..03cd8ab91e --- /dev/null +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_preproc.py @@ -0,0 +1,64 @@ +# Copyright (c) 2022-2023, NVIDIA CORPORATION. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import logging + +import dfp.modules.dfp_split_users # noqa: F401 +import mrc + +import morpheus.modules.file_batcher # noqa: F401 +import morpheus.modules.file_to_df # noqa: F401 +from morpheus.utils.module_ids import FILE_BATCHER +from morpheus.utils.module_ids import FILE_TO_DF +from morpheus.utils.module_ids import MODULE_NAMESPACE +from morpheus.utils.module_utils import get_module_config +from morpheus.utils.module_utils import load_module +from morpheus.utils.module_utils import register_module + +from ..utils.module_ids import DFP_PREPROC +from ..utils.module_ids import DFP_SPLIT_USERS + +logger = logging.getLogger(__name__) + + +@register_module(DFP_PREPROC, MODULE_NAMESPACE) +def dfp_preproc(builder: mrc.Builder): + """ + This module function allows for the consolidation of multiple dfp pipeline modules relevent to inference + process into a single module. + + Parameters + ---------- + builder : mrc.Builder + Pipeline budler instance. + """ + + config = get_module_config(DFP_PREPROC, builder) + + file_batcher_conf = config.get(FILE_BATCHER, None) + file_to_df_conf = config.get(FILE_TO_DF, None) + dfp_split_users_conf = config.get(DFP_SPLIT_USERS, None) + + # Load modules + file_batcher_module = load_module(file_batcher_conf, builder=builder) + file_to_dataframe_module = load_module(file_to_df_conf, builder=builder) + dfp_split_users_modules = load_module(dfp_split_users_conf, builder=builder) + + # Make an edge between the modules. + builder.make_edge(file_batcher_module.output_port("output"), file_to_dataframe_module.input_port("input")) + builder.make_edge(file_to_dataframe_module.output_port("output"), dfp_split_users_modules.input_port("input")) + + # Register input and output port for a module. + builder.register_module_input("input", file_batcher_module.input_port("input")) + builder.register_module_output("output", dfp_split_users_modules.output_port("output")) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_tra.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_tra.py new file mode 100644 index 0000000000..fd3bff30d5 --- /dev/null +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_tra.py @@ -0,0 +1,69 @@ +# Copyright (c) 2022-2023, NVIDIA CORPORATION. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import logging + +import dfp.modules.dfp_data_prep # noqa: F401 +import dfp.modules.dfp_rolling_window # noqa: F401 +import dfp.modules.dfp_training # noqa: F401 +import mrc + +import morpheus.modules.mlflow_model_writer # noqa: F401 +from morpheus.utils.module_ids import MLFLOW_MODEL_WRITER +from morpheus.utils.module_ids import MODULE_NAMESPACE +from morpheus.utils.module_utils import get_module_config +from morpheus.utils.module_utils import load_module +from morpheus.utils.module_utils import register_module + +from ..utils.module_ids import DFP_DATA_PREP +from ..utils.module_ids import DFP_ROLLING_WINDOW +from ..utils.module_ids import DFP_TRA +from ..utils.module_ids import DFP_TRAINING + +logger = logging.getLogger(__name__) + + +@register_module(DFP_TRA, MODULE_NAMESPACE) +def dfp_tra(builder: mrc.Builder): + """ + This module function allows for the consolidation of multiple dfp pipeline modules relevent to training + process into a single module. + + Parameters + ---------- + builder : mrc.Builder + Pipeline budler instance. + """ + + config = get_module_config(DFP_TRA, builder) + + dfp_rolling_window_conf = config.get(DFP_ROLLING_WINDOW, None) + dfp_data_prep_conf = config.get(DFP_DATA_PREP, None) + dfp_training_conf = config.get(DFP_TRAINING, None) + mlflow_model_writer_conf = config.get(MLFLOW_MODEL_WRITER, None) + + # Load modules + dfp_rolling_window_module = load_module(dfp_rolling_window_conf, builder=builder) + dfp_data_prep_module = load_module(dfp_data_prep_conf, builder=builder) + dfp_training_module = load_module(dfp_training_conf, builder=builder) + mlflow_model_writer_module = load_module(mlflow_model_writer_conf, builder=builder) + + # Make an edge between the modules. + builder.make_edge(dfp_rolling_window_module.output_port("output"), dfp_data_prep_module.input_port("input")) + builder.make_edge(dfp_data_prep_module.output_port("output"), dfp_training_module.input_port("input")) + builder.make_edge(dfp_training_module.output_port("output"), mlflow_model_writer_module.input_port("input")) + + # Register input and output port for a module. + builder.register_module_input("input", dfp_rolling_window_module.input_port("input")) + builder.register_module_output("output", mlflow_model_writer_module.output_port("output")) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py b/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py index 42365cabdc..09b13c30fd 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py @@ -17,11 +17,14 @@ from dfp.utils.derive_args import DeriveArgs from dfp.utils.derive_args import pyobj2str from dfp.utils.module_ids import DFP_DATA_PREP +from dfp.utils.module_ids import DFP_INF from dfp.utils.module_ids import DFP_INFERENCE from dfp.utils.module_ids import DFP_INFERENCE_PIPELINE from dfp.utils.module_ids import DFP_POST_PROCESSING +from dfp.utils.module_ids import DFP_PREPROC from dfp.utils.module_ids import DFP_ROLLING_WINDOW from dfp.utils.module_ids import DFP_SPLIT_USERS +from dfp.utils.module_ids import DFP_TRA from dfp.utils.module_ids import DFP_TRAINING from dfp.utils.module_ids import DFP_TRAINING_PIPELINE from dfp.utils.regex_utils import iso_date_regex_pattern @@ -53,6 +56,201 @@ def __init__(self, config: Config, derive_args: DeriveArgs, schema: Schema, enco self._preprocess_schema_str = pyobj2str(schema.preprocess, encoding=encoding) self._input_message_type = pyobj2str(MultiMessage, encoding) + def get_conf(self): + + conf = {} + + conf["preproc"] = self.get_preproc_conf() + + if self._derive_args.is_train_and_infer: + conf["training"] = self.train_conf() + conf["inference"] = self.infer_conf() + elif self._derive_args.is_training: + conf["training"] = self.train_conf() + else: + conf["inference"] = self.infer_conf() + + return conf + + def get_preproc_conf(self): + + module_conf = { + "module_id": DFP_PREPROC, + "module_name": "dfp_preproc", + "namespace": MODULE_NAMESPACE, + FILE_BATCHER: { + "module_id": FILE_BATCHER, + "module_name": "file_batcher", + "namespace": MODULE_NAMESPACE, + "period": "D", + "sampling_rate_s": self._derive_args.sample_rate_s, + "start_time": self._derive_args.time_fields.start_time, + "end_time": self._derive_args.time_fields.end_time, + "iso_date_regex_pattern": iso_date_regex_pattern + }, + FILE_TO_DF: { + "module_id": FILE_TO_DF, + "module_name": "FILE_TO_DF", + "namespace": MODULE_NAMESPACE, + "timestamp_column_name": self._config.ae.timestamp_column_name, + "parser_kwargs": { + "lines": False, "orient": "records" + }, + "cache_dir": self._derive_args.cache_dir, + "filter_null": True, + "file_type": "JSON", + "schema": { + "schema_str": self._source_schema_str, "encoding": self._encoding + } + }, + DFP_SPLIT_USERS: { + "module_id": DFP_SPLIT_USERS, + "module_name": "dfp_split_users", + "namespace": MODULE_NAMESPACE, + "include_generic": self._derive_args.include_generic, + "include_individual": self._derive_args.include_individual, + "skip_users": self._derive_args.skip_users, + "only_users": self._derive_args.only_users, + "timestamp_column_name": self._config.ae.timestamp_column_name, + "userid_column_name": self._config.ae.userid_column_name, + "fallback_username": self._config.ae.fallback_username + } + } + + return module_conf + + def infer_conf(self): + module_conf = { + "module_id": DFP_INF, + "module_name": "dfp_inf", + "namespace": MODULE_NAMESPACE, + DFP_ROLLING_WINDOW: { + "module_id": DFP_ROLLING_WINDOW, + "module_name": "dfp_rolling_window", + "namespace": MODULE_NAMESPACE, + "min_history": 1, + "min_increment": 0, + "max_history": self._derive_args.infer_duration, + "cache_dir": self._derive_args.cache_dir, + "timestamp_column_name": self._config.ae.timestamp_column_name + }, + DFP_DATA_PREP: { + "module_id": DFP_DATA_PREP, + "module_name": "dfp_data_prep", + "namespace": MODULE_NAMESPACE, + "timestamp_column_name": self._config.ae.timestamp_column_name, + "schema": { + "schema_str": self._preprocess_schema_str, "encoding": self._encoding + } + }, + DFP_INFERENCE: { + "module_id": DFP_INFERENCE, + "module_name": "dfp_inference", + "namespace": MODULE_NAMESPACE, + "model_name_formatter": self._derive_args.model_name_formatter, + "fallback_username": self._config.ae.fallback_username, + "timestamp_column_name": self._config.ae.timestamp_column_name + }, + FILTER_DETECTIONS: { + "module_id": FILTER_DETECTIONS, + "module_name": "filter_detections", + "namespace": MODULE_NAMESPACE, + "field_name": "mean_abs_z", + "threshold": 2.0, + "filter_source": "DATAFRAME", + "schema": { + "input_message_type": self._input_message_type, "encoding": self._encoding + } + }, + DFP_POST_PROCESSING: { + "module_id": DFP_POST_PROCESSING, + "module_name": "dfp_post_processing", + "namespace": MODULE_NAMESPACE, + "timestamp_column_name": self._config.ae.timestamp_column_name + }, + SERIALIZE: { + "module_id": SERIALIZE, + "module_name": "serialize", + "namespace": MODULE_NAMESPACE, + "exclude": ['batch_count', 'origin_hash', '_row_hash', '_batch_id'] + }, + WRITE_TO_FILE: { + "module_id": WRITE_TO_FILE, + "module_name": "write_to_file", + "namespace": MODULE_NAMESPACE, + "filename": "dfp_detections_{}.csv".format(self._derive_args.source), + "overwrite": True + } + } + + return module_conf + + def train_conf(self): + module_conf = { + "module_id": DFP_TRA, + "module_name": "dfp_tra", + "namespace": MODULE_NAMESPACE, + DFP_ROLLING_WINDOW: { + "module_id": DFP_ROLLING_WINDOW, + "module_name": "dfp_rolling_window", + "namespace": MODULE_NAMESPACE, + "min_history": 300, + "min_increment": 300, + "max_history": self._derive_args.train_duration, + "cache_dir": self._derive_args.cache_dir, + "timestamp_column_name": self._config.ae.timestamp_column_name + }, + DFP_DATA_PREP: { + "module_id": DFP_DATA_PREP, + "module_name": "dfp_data_prep", + "namespace": MODULE_NAMESPACE, + "timestamp_column_name": self._config.ae.timestamp_column_name, + "schema": { + "schema_str": self._preprocess_schema_str, "encoding": self._encoding + } + }, + DFP_TRAINING: { + "module_id": DFP_TRAINING, + "module_name": "dfp_training", + "namespace": MODULE_NAMESPACE, + "model_kwargs": { + "encoder_layers": [512, 500], # layers of the encoding part + "decoder_layers": [512], # layers of the decoding part + "activation": 'relu', # activation function + "swap_p": 0.2, # noise parameter + "lr": 0.001, # learning rate + "lr_decay": 0.99, # learning decay + "batch_size": 512, + "verbose": False, + "optimizer": 'sgd', # SGD optimizer is selected(Stochastic gradient descent) + "scaler": 'standard', # feature scaling method + "min_cats": 1, # cut off for minority categories + "progress_bar": False, + "device": "cuda" + }, + "feature_columns": self._config.ae.feature_columns, + "epochs": 30, + "validation_size": 0.10 + }, + MLFLOW_MODEL_WRITER: { + "module_id": MLFLOW_MODEL_WRITER, + "module_name": "mlflow_model_writer", + "namespace": MODULE_NAMESPACE, + "model_name_formatter": self._derive_args.model_name_formatter, + "experiment_name_formatter": self._derive_args.experiment_name_formatter, + "timestamp_column_name": self._config.ae.timestamp_column_name, + "conda_env": { + 'channels': ['defaults', 'conda-forge'], + 'dependencies': ['python={}'.format('3.8'), 'pip'], + 'pip': ['mlflow', 'dfencoder'], + 'name': 'mlflow-env' + }, + "databricks_permissions": None + } + } + + return module_conf + def inf_pipe_module_conf(self): module_conf = { @@ -65,8 +263,8 @@ def inf_pipe_module_conf(self): "namespace": MODULE_NAMESPACE, "period": "D", "sampling_rate_s": self._derive_args.sample_rate_s, - "start_time": self._derive_args.start_time, - "end_time": self._derive_args.end_time, + "start_time": self._derive_args.time_fields.start_time, + "end_time": self._derive_args.time_fields.end_time, "iso_date_regex_pattern": iso_date_regex_pattern }, FILE_TO_DF: { @@ -102,7 +300,7 @@ def inf_pipe_module_conf(self): "namespace": MODULE_NAMESPACE, "min_history": 1, "min_increment": 0, - "max_history": self._derive_args.duration, + "max_history": self._derive_args.infer_duration, "cache_dir": self._derive_args.cache_dir, "timestamp_column_name": self._config.ae.timestamp_column_name }, @@ -150,7 +348,7 @@ def inf_pipe_module_conf(self): "module_id": WRITE_TO_FILE, "module_name": "write_to_file", "namespace": MODULE_NAMESPACE, - "filename": "dfp_detections_{}.csv".format(self._derive_args.source), + "filename": "dfp_detections_{}.csv".format(self._derive_args.log_type), "overwrite": True } } @@ -168,8 +366,8 @@ def tra_pipe_module_conf(self): "namespace": MODULE_NAMESPACE, "period": "D", "sampling_rate_s": self._derive_args.sample_rate_s, - "start_time": self._derive_args.start_time, - "end_time": self._derive_args.end_time, + "start_time": self._derive_args.time_fields.start_time, + "end_time": self._derive_args.time_fields.end_time, "iso_date_regex_pattern": iso_date_regex_pattern }, FILE_TO_DF: { @@ -205,7 +403,7 @@ def tra_pipe_module_conf(self): "namespace": MODULE_NAMESPACE, "min_history": 300, "min_increment": 300, - "max_history": self._derive_args.duration, + "max_history": self._derive_args.train_duration, "cache_dir": self._derive_args.cache_dir, "timestamp_column_name": self._config.ae.timestamp_column_name }, @@ -261,7 +459,7 @@ def tra_pipe_module_conf(self): return module_conf -def generate_ae_config(labels_file: str, +def generate_ae_config(log_type: str, userid_column_name: str, timestamp_column_name: str, use_cpp: bool = False, @@ -275,6 +473,7 @@ def generate_ae_config(labels_file: str, config.ae = ConfigAutoEncoder() + labels_file = "data/columns_ae_{}.txt".format(log_type) config.ae.feature_columns = load_labels_file(get_package_relative_file(labels_file)) config.ae.userid_column_name = userid_column_name config.ae.timestamp_column_name = timestamp_column_name diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/utils/derive_args.py b/examples/digital_fingerprinting/production/morpheus/dfp/utils/derive_args.py index 0adc36fc65..485f943d75 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/utils/derive_args.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/utils/derive_args.py @@ -14,6 +14,7 @@ import logging import pickle +from dataclasses import dataclass from datetime import datetime from datetime import timedelta from datetime import timezone @@ -26,36 +27,54 @@ logger = logging.getLogger(__name__) +@dataclass +class TimeFields: + start_time: datetime + end_time: datetime + + class DeriveArgs: def __init__(self, skip_user: str, only_user: str, start_time: str, - duration: str, + infer_duration: str, + train_duration: str, log_level: str, cache_dir: str, sample_rate_s: str, - source: str, + log_type: str, tracking_uri: str, + pipeline_type: str = None, train_users: str = None): self._skip_users = list(skip_user) self._only_users = list(only_user) self._start_time = start_time - self._duration = duration + self._infer_duration = infer_duration + self._train_duration = train_duration self._log_level = log_level self._train_users = train_users self._cache_dir = cache_dir - self._include_generic = None - self._include_individual = None self._initialized = False self._tracking_uri = tracking_uri self._sample_rate_s = sample_rate_s - self._source = source - self._model_name_formatter = "DFP-%s-{user_id}" % (source) - self._experiment_name_formatter = "dfp/%s/training/{reg_model_name}" % (source) - self._is_training = (train_users is not None and train_users != "none") + self._log_type = log_type + self._pipeline_type = pipeline_type + + self._include_generic = None + self._include_individual = None + self._time_fields: TimeFields = None + + self._model_name_formatter = "DFP-%s-{user_id}" % (log_type) + self._experiment_name_formatter = "dfp/%s/training/{reg_model_name}" % (log_type) + + train_flag = (train_users is not None and train_users != "none") + + self._is_training = (train_flag and pipeline_type != "infer") + self._is_train_and_infer = (train_flag and pipeline_type == "train_and_infer") + self._is_inference = not (self._is_training or self._is_train_and_infer) def verify_init(func): @@ -78,32 +97,39 @@ def _configure_logging(self): logger.info("Train generic_user: %s", self._include_generic) logger.info("Skipping users: %s", self._skip_users) logger.info("Start Time: %s", self._start_time) - logger.info("Duration: %s", self._duration) + logger.info("Training duration: %s", self._train_duration) + logger.info("Inference duration: %s", self._infer_duration) logger.info("Cache Dir: %s", self._cache_dir) @property @verify_init - def start_time(self): - return self._start_time + def time_fields(self): + return self._time_fields @property @verify_init - def end_time(self): - return self._end_time + def include_generic(self): + return self._include_generic @property @verify_init - def include_generic(self): - return self._include_generic + def infer_duration(self): + return self._infer_duration @property @verify_init - def include_individual(self): - return self._include_individual + def train_duration(self): + return self._train_duration @property - def duration(self): - return self._duration + @verify_init + def is_train_and_infer(self): + return self._is_train_and_infer + + @property + @verify_init + def include_individual(self): + return self._include_individual @property def sample_rate_s(self): @@ -122,8 +148,8 @@ def cache_dir(self): return self._cache_dir @property - def source(self): - return self._source + def log_type(self): + return self._log_type @property def model_name_formatter(self): @@ -143,16 +169,20 @@ def _set_include_generic(self): def _set_include_individual(self): self._include_individual = self._train_users != "generic" - def _update_start_stop_time(self): - duration = timedelta(seconds=pd.Timedelta(self._duration).total_seconds()) + def _create_time_fields(self, duration) -> TimeFields: + duration = timedelta(seconds=pd.Timedelta(duration).total_seconds()) if self._start_time is None: - self._end_time = datetime.now(tz=timezone.utc) - self._start_time = self._end_time - duration + end_time = datetime.now(tz=timezone.utc) + self._start_time = end_time - duration else: if self._start_time.tzinfo is None: self._start_time = self._start_time.replace(tzinfo=timezone.utc) - self._end_time = self._start_time + duration + end_time = self._start_time + duration + + tf = TimeFields(self._start_time, end_time) + + return tf def _set_mlflow_tracking_uri(self): if self._tracking_uri is None: @@ -161,8 +191,20 @@ def _set_mlflow_tracking_uri(self): mlflow.set_tracking_uri(self._tracking_uri) logger.info("Tracking URI: %s", mlflow.get_tracking_uri()) + def _set_time_fields(self): + if self._is_train_and_infer: + logger.info("Inline training is triggered. Ovverriding 'training_duration' with 'inference_duration'.") + self._train_duration = self._infer_duration + self._time_fields = self._create_time_fields(self._infer_duration) + elif self._is_training: + self._time_fields = self._create_time_fields(self._train_duration) + elif self._is_inference: + self._time_fields = self._create_time_fields(self._infer_duration) + else: + raise Exception("Unable to update time fields.") + def init(self): - self._update_start_stop_time() + self._set_time_fields() self._set_include_generic() self._set_include_individual() self._configure_logging() diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/utils/module_ids.py b/examples/digital_fingerprinting/production/morpheus/dfp/utils/module_ids.py index 6c7b233f3c..3a76a6e324 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/utils/module_ids.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/utils/module_ids.py @@ -22,3 +22,6 @@ DFP_INFERENCE_PIPELINE = "DFPInferencePipeline" DFP_INFERENCE = "DFPInference" DFP_POST_PROCESSING = "DFPPostProcessing" +DFP_PREPROC = "DFPPreproc" +DFP_INF = "DFPInf" +DFP_TRA = "DFPTra" diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/utils/schema_utils.py b/examples/digital_fingerprinting/production/morpheus/dfp/utils/schema_utils.py index 545c7a5861..8edade2028 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/utils/schema_utils.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/utils/schema_utils.py @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -import dataclasses +from dataclasses import dataclass from datetime import datetime from functools import partial @@ -28,7 +28,7 @@ from morpheus.utils.column_info import create_increment_col -@dataclasses.dataclass +@dataclass class Schema: source: DataFrameInputSchema preprocess: DataFrameInputSchema @@ -36,10 +36,20 @@ class Schema: class SchemaBuilder: - def __init__(self, config: Config): + def __init__(self, config: Config, log_type: str): self._config = config + self._log_type = log_type - def build_azure_schema(self) -> Schema: + def build_schema(self): + + if self._log_type == "duo": + return self._build_duo_schema() + elif self._log_type == "azure": + return self._build_azure_schema() + else: + raise Exception("No matching schema found for log type : {}".format(self._log_type)) + + def _build_azure_schema(self) -> Schema: # Specify the column names to ensure all data is uniform source_column_info = [ DateTimeColumn(name=self._config.ae.timestamp_column_name, dtype=datetime, input_name="time"), @@ -93,7 +103,7 @@ def build_azure_schema(self) -> Schema: return schema - def build_duo_schema(self) -> Schema: + def _build_duo_schema(self) -> Schema: # Specify the column names to ensure all data is uniform source_column_info = [ diff --git a/examples/digital_fingerprinting/production/morpheus/dfp_azure_modules_inference.py b/examples/digital_fingerprinting/production/morpheus/dfp_azure_modules_inference.py index 90c5d09b4b..7327d1c238 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp_azure_modules_inference.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp_azure_modules_inference.py @@ -111,12 +111,12 @@ def run_pipeline(skip_user: typing.Tuple[str], derive_args.init() - config: Config = generate_ae_config(labels_file="data/columns_ae_azure.txt", + config: Config = generate_ae_config(log_type="azure", userid_column_name="username", timestamp_column_name="timestamp") schema_builder = SchemaBuilder(config) - schema: Schema = schema_builder.build_azure_schema() + schema: Schema = schema_builder.build_schema() config_generator = ConfigGenerator(config, derive_args, schema) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp_azure_modules_pipeline.py b/examples/digital_fingerprinting/production/morpheus/dfp_azure_modules_pipeline.py index 7855df7eee..4a7b3908be 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp_azure_modules_pipeline.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp_azure_modules_pipeline.py @@ -138,12 +138,12 @@ def run_pipeline(train_users, derive_args.init() - config: Config = generate_ae_config(labels_file="data/columns_ae_azure.txt", + config: Config = generate_ae_config(log_type="azure", userid_column_name="username", timestamp_column_name="timestamp") schema_builder = SchemaBuilder(config) - schema: Schema = schema_builder.build_azure_schema() + schema: Schema = schema_builder.build_schema() encoding = "latin1" @@ -161,8 +161,8 @@ def run_pipeline(train_users, "namespace": MODULE_NAMESPACE, "period": "D", "sampling_rate_s": sample_rate_s, - "start_time": derive_args.start_time, - "end_time": derive_args.end_time, + "start_time": derive_args.time_fields.start_time, + "end_time": derive_args.time_fields.end_time, "iso_date_regex_pattern": iso_date_regex_pattern }, FILE_TO_DF: { diff --git a/examples/digital_fingerprinting/production/morpheus/dfp_azure_modules_training.py b/examples/digital_fingerprinting/production/morpheus/dfp_azure_modules_training.py index a9189895df..993ca3af95 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp_azure_modules_training.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp_azure_modules_training.py @@ -121,12 +121,12 @@ def run_pipeline(train_users, derive_args.init() - config: Config = generate_ae_config(labels_file="data/columns_ae_azure.txt", + config: Config = generate_ae_config(log_type="azure", userid_column_name="username", timestamp_column_name="timestamp") schema_builder = SchemaBuilder(config) - schema: Schema = schema_builder.build_azure_schema() + schema: Schema = schema_builder.build_schema() config_generator = ConfigGenerator(config, derive_args, schema) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp_duo_modules_inference.py b/examples/digital_fingerprinting/production/morpheus/dfp_duo_modules_inference.py index 7c44fd7afd..df5f9730f7 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp_duo_modules_inference.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp_duo_modules_inference.py @@ -112,12 +112,12 @@ def run_pipeline(skip_user: typing.Tuple[str], derive_args.init() - config: Config = generate_ae_config(labels_file="data/columns_ae_duo.txt", + config: Config = generate_ae_config(log_type="duo", userid_column_name="username", timestamp_column_name="timestamp") schema_builder = SchemaBuilder(config) - schema: Schema = schema_builder.build_duo_schema() + schema: Schema = schema_builder.build_schema() config_generator = ConfigGenerator(config, derive_args, schema) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp_duo_modules_pipeline.py b/examples/digital_fingerprinting/production/morpheus/dfp_duo_modules_pipeline.py index 3ef9489907..197a440f71 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp_duo_modules_pipeline.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp_duo_modules_pipeline.py @@ -138,12 +138,12 @@ def run_pipeline(train_users, derive_args.init() - config: Config = generate_ae_config(labels_file="data/columns_ae_duo.txt", + config: Config = generate_ae_config(log_type="duo", userid_column_name="username", timestamp_column_name="timestamp") schema_builder = SchemaBuilder(config) - schema: Schema = schema_builder.build_duo_schema() + schema: Schema = schema_builder.build_schema() encoding = "latin1" diff --git a/examples/digital_fingerprinting/production/morpheus/dfp_duo_modules_training.py b/examples/digital_fingerprinting/production/morpheus/dfp_duo_modules_training.py index d515b6353a..a219657104 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp_duo_modules_training.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp_duo_modules_training.py @@ -121,12 +121,12 @@ def run_pipeline(train_users, derive_args.init() - config: Config = generate_ae_config(labels_file="data/columns_ae_duo.txt", + config: Config = generate_ae_config(log_type="duo", userid_column_name="username", timestamp_column_name="timestamp") schema_builder = SchemaBuilder(config) - schema: Schema = schema_builder.build_duo_schema() + schema: Schema = schema_builder.build_schema() config_generator = ConfigGenerator(config, derive_args, schema) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp_modules_pipeline.py b/examples/digital_fingerprinting/production/morpheus/dfp_modules_pipeline.py new file mode 100644 index 0000000000..b082a58d33 --- /dev/null +++ b/examples/digital_fingerprinting/production/morpheus/dfp_modules_pipeline.py @@ -0,0 +1,209 @@ +# Copyright (c) 2023, NVIDIA CORPORATION. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import logging +import typing +from datetime import datetime + +import click +import dfp.modules.dfp_inf # noqa: F401 +import dfp.modules.dfp_preproc # noqa: F401 +import dfp.modules.dfp_tra # noqa: F401 +from dfp.stages.multi_file_source import MultiFileSource +from dfp.utils.config_generator import ConfigGenerator +from dfp.utils.config_generator import generate_ae_config +from dfp.utils.derive_args import DeriveArgs +from dfp.utils.schema_utils import Schema +from dfp.utils.schema_utils import SchemaBuilder + +from morpheus.cli.utils import get_log_levels +from morpheus.cli.utils import parse_log_level +from morpheus.config import Config +from morpheus.pipeline.pipeline import Pipeline +from morpheus.stages.general.broadcast_stage import BroadcastStage +from morpheus.stages.general.linear_modules_stage import LinearModulesStage +from morpheus.stages.general.monitor_stage import MonitorStage + + +@click.command() +@click.option( + "--log_type", + type=click.Choice(["duo", "azure"], case_sensitive=False), + help=(""), +) +@click.option( + "--pipeline_type", + type=click.Choice(["infer", "train", "train_and_infer"], case_sensitive=False), + help=(""), +) +@click.option( + "--train_users", + type=click.Choice(["all", "generic", "individual", "none"], case_sensitive=False), + help=("Indicates whether or not to train per user or a generic model for all users. " + "Selecting none runs the inference pipeline."), +) +@click.option( + "--skip_user", + multiple=True, + type=str, + help="User IDs to skip. Mutually exclusive with only_user", +) +@click.option( + "--only_user", + multiple=True, + type=str, + help="Only users specified by this option will be included. Mutually exclusive with skip_user", +) +@click.option( + "--start_time", + type=click.DateTime( + formats=['%Y-%m-%d', '%Y-%m-%dT%H:%M:%S', '%Y-%m-%d %H:%M:%S', '%Y-%m-%dT%H:%M:%S%z', '%Y-%m-%d %H:%M:%S%z']), + default=None, + help="The start of the time window, if undefined start_date will be `now()-duration`", +) +@click.option( + "--inference_duration", + type=str, + default="1d", + help="The inference duration to run starting from start_time", +) +@click.option( + "--training_duration", + type=str, + default="60d", + help="The training duration to run starting from start_time", +) +@click.option( + "--cache_dir", + type=str, + default="./.cache/dfp", + show_envvar=True, + help="The location to cache data such as S3 downloads and pre-processed data", +) +@click.option("--log_level", + default=logging.getLevelName(Config().log_level), + type=click.Choice(get_log_levels(), case_sensitive=False), + callback=parse_log_level, + help="Specify the logging level to use.") +@click.option("--sample_rate_s", + type=int, + default=0, + show_envvar=True, + help="Minimum time step, in milliseconds, between object logs.") +@click.option( + "--input_file", + "-f", + type=str, + multiple=True, + help=("List of files to process. Can specify multiple arguments for multiple files. " + "Also accepts glob (*) wildcards and schema prefixes such as `s3://`. " + "For example, to make a local cache of an s3 bucket, use `filecache::s3://mybucket/*`. " + "See fsspec documentation for list of possible options."), +) +@click.option('--tracking_uri', + type=str, + default="http://mlflow:5000", + help=("The MLflow tracking URI to connect to the tracking backend.")) +def run_pipeline(log_type: str, + pipeline_type, + train_users, + skip_user: typing.Tuple[str], + only_user: typing.Tuple[str], + start_time: datetime, + inference_duration: str, + training_duration: str, + cache_dir, + log_level, + sample_rate_s, + **kwargs): + + derive_args = DeriveArgs(skip_user, + only_user, + start_time, + inference_duration, + training_duration, + log_level, + cache_dir, + sample_rate_s, + log_type, + tracking_uri=kwargs["tracking_uri"], + pipeline_type=pipeline_type, + train_users=train_users) + + derive_args.init() + + config: Config = generate_ae_config(log_type, userid_column_name="username", timestamp_column_name="timestamp") + + schema_builder = SchemaBuilder(config, log_type) + schema: Schema = schema_builder.build_schema() + + config_generator = ConfigGenerator(config, derive_args, schema) + + conf = config_generator.get_conf() + + # Create a pipeline object + pipeline = Pipeline(config) + + source_stage = pipeline.add_stage(MultiFileSource(config, filenames=list(kwargs["input_file"]))) + + # Here we add a wrapped module that implements the DFP Inference pipeline + preproc_stage = pipeline.add_stage( + LinearModulesStage(config, conf.get("preproc"), input_port_name="input", output_port_name="output")) + + pipeline.add_edge(source_stage, preproc_stage) + + if "training" in conf and "inference" in conf: + broadcast_stage = pipeline.add_stage(BroadcastStage(config, output_port_count=2)) + + pipeline.add_edge(preproc_stage, broadcast_stage) + + inf_stage = pipeline.add_stage( + LinearModulesStage(config, conf.get("inference"), input_port_name="input", output_port_name="output")) + + tra_stage = pipeline.add_stage( + LinearModulesStage(config, conf.get("training"), input_port_name="input", output_port_name="output")) + + inf_mntr_stage = pipeline.add_stage(MonitorStage(config, description="Inference Pipeline rate", + smoothing=0.001)) + tra_mntr_stage = pipeline.add_stage(MonitorStage(config, description="Training Pipeline rate", smoothing=0.001)) + + pipeline.add_edge(broadcast_stage.output_ports[0], inf_stage) + pipeline.add_edge(broadcast_stage.output_ports[1], tra_stage) + pipeline.add_edge(inf_stage, inf_mntr_stage) + pipeline.add_edge(tra_stage, tra_mntr_stage) + + elif "training" in conf: + + tra_stage = pipeline.add_stage( + LinearModulesStage(config, conf.get("training"), input_port_name="input", output_port_name="output")) + mntr_stage = pipeline.add_stage(MonitorStage(config, description="Training Pipeline rate", smoothing=0.001)) + pipeline.add_edge(preproc_stage, tra_stage) + pipeline.add_edge(tra_stage, mntr_stage) + + elif "inference" in conf: + inf_stage = pipeline.add_stage( + LinearModulesStage(config, conf.get("inference"), input_port_name="input", output_port_name="output")) + mntr_stage = pipeline.add_stage(MonitorStage(config, description="Inference Pipeline rate", smoothing=0.001)) + pipeline.add_edge(preproc_stage, inf_stage) + pipeline.add_edge(inf_stage, mntr_stage) + + else: + raise Exception("Required keys not found in the configuration to trigger the pipeline") + + # Run the pipeline + pipeline.run() + + +if __name__ == "__main__": + run_pipeline(obj={}, auto_envvar_prefix='DFP', show_default=True, prog_name="dfp") diff --git a/morpheus/stages/general/broadcast_stage.py b/morpheus/stages/general/broadcast_stage.py new file mode 100644 index 0000000000..4b8fee0c53 --- /dev/null +++ b/morpheus/stages/general/broadcast_stage.py @@ -0,0 +1,86 @@ +# Copyright (c) 2023, NVIDIA CORPORATION. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import logging +import typing + +import mrc +from mrc.core.node import Broadcast + +from morpheus.config import Config +from morpheus.pipeline.stage import Stage +from morpheus.pipeline.stream_pair import StreamPair + +logger = logging.getLogger(__name__) + + +class BroadcastStage(Stage): + """ + """ + + def __init__(self, c: Config, output_port_count: int = 2): + + super().__init__(c) + + self._create_ports(1, output_port_count) + self._output_port_count = output_port_count + + @property + def name(self) -> str: + return "broadcast" + + def supports_cpp_node(self): + return False + + def input_types(self) -> typing.Tuple: + """ + Returns input type for the current stage. + """ + + return (typing.Any, ) + + def accepted_types(self) -> typing.Tuple: + """ + Accepted input types for this stage are returned. + + Returns + ------- + typing.Tuple + Accepted input types. + + """ + return (typing.Any, ) + + def _build(self, builder: mrc.Builder, in_stream_pairs: typing.List[StreamPair]) -> typing.List[StreamPair]: + + assert len(in_stream_pairs) == 1, "Only 1 input supported" + + in_stream_node = in_stream_pairs[0][0] + output_type = in_stream_pairs[0][1] + + # Create a broadcast node + broadcast_node = Broadcast(builder, "broadcast") + builder.make_edge(in_stream_node, broadcast_node) + + if self._output_port_count <= 0: + raise ValueError("Output port count must be greater than 0") + + out_stream_pairs = [] + + count = 0 + while (count < self._output_port_count): + out_stream_pairs.append((broadcast_node, output_type)) + count += 1 + + return out_stream_pairs From 8ff9ca7d29d1f07f7b645e2666f557786d99ade4 Mon Sep 17 00:00:00 2001 From: bsuryadevara Date: Wed, 15 Feb 2023 16:14:32 -0600 Subject: [PATCH 013/157] broadcast stage for dfp pipeline --- .../dfp/modules/dfp_rolling_window.py | 4 +- .../morpheus/dfp/utils/config_generator.py | 15 +- .../morpheus/dfp/utils/derive_args.py | 72 ++-- .../morpheus/dfp_azure_modules_inference.py | 12 +- .../morpheus/dfp_azure_modules_pipeline.py | 312 ------------------ .../morpheus/dfp_azure_modules_training.py | 8 +- .../morpheus/dfp_duo_modules_inference.py | 12 +- .../morpheus/dfp_duo_modules_pipeline.py | 310 ----------------- .../morpheus/dfp_duo_modules_training.py | 8 +- .../morpheus/dfp_modules_pipeline.py | 41 +-- morpheus/stages/general/broadcast_stage.py | 23 +- tests/test_broadcast_stage.py | 52 +++ 12 files changed, 148 insertions(+), 721 deletions(-) delete mode 100644 examples/digital_fingerprinting/production/morpheus/dfp_azure_modules_pipeline.py delete mode 100644 examples/digital_fingerprinting/production/morpheus/dfp_duo_modules_pipeline.py create mode 100755 tests/test_broadcast_stage.py diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_rolling_window.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_rolling_window.py index 1711dbdc3a..3cbb0e413a 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_rolling_window.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_rolling_window.py @@ -123,8 +123,8 @@ def build_window(message: DFPMessageMeta) -> MultiDFPMessage: # Otherwise return a new message return MultiDFPMessage(meta=DFPMessageMeta(df=train_df, user_id=user_id), - mess_offset=train_offset, - mess_count=found_count) + mess_offset=0, + mess_count=len(train_df)) def on_data(message: DFPMessageMeta): diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py b/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py index 09b13c30fd..7a9bf5ad36 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py @@ -60,7 +60,7 @@ def get_conf(self): conf = {} - conf["preproc"] = self.get_preproc_conf() + conf["preproc"] = self.preproc_conf() if self._derive_args.is_train_and_infer: conf["training"] = self.train_conf() @@ -72,7 +72,7 @@ def get_conf(self): return conf - def get_preproc_conf(self): + def preproc_conf(self): module_conf = { "module_id": DFP_PREPROC, @@ -130,7 +130,7 @@ def infer_conf(self): "namespace": MODULE_NAMESPACE, "min_history": 1, "min_increment": 0, - "max_history": self._derive_args.infer_duration, + "max_history": "1d", "cache_dir": self._derive_args.cache_dir, "timestamp_column_name": self._config.ae.timestamp_column_name }, @@ -178,7 +178,7 @@ def infer_conf(self): "module_id": WRITE_TO_FILE, "module_name": "write_to_file", "namespace": MODULE_NAMESPACE, - "filename": "dfp_detections_{}.csv".format(self._derive_args.source), + "filename": "dfp_detections_{}.csv".format(self._derive_args.log_type), "overwrite": True } } @@ -186,6 +186,7 @@ def infer_conf(self): return module_conf def train_conf(self): + module_conf = { "module_id": DFP_TRA, "module_name": "dfp_tra", @@ -196,7 +197,7 @@ def train_conf(self): "namespace": MODULE_NAMESPACE, "min_history": 300, "min_increment": 300, - "max_history": self._derive_args.train_duration, + "max_history": self._derive_args.duration, "cache_dir": self._derive_args.cache_dir, "timestamp_column_name": self._config.ae.timestamp_column_name }, @@ -300,7 +301,7 @@ def inf_pipe_module_conf(self): "namespace": MODULE_NAMESPACE, "min_history": 1, "min_increment": 0, - "max_history": self._derive_args.infer_duration, + "max_history": "1d", "cache_dir": self._derive_args.cache_dir, "timestamp_column_name": self._config.ae.timestamp_column_name }, @@ -403,7 +404,7 @@ def tra_pipe_module_conf(self): "namespace": MODULE_NAMESPACE, "min_history": 300, "min_increment": 300, - "max_history": self._derive_args.train_duration, + "max_history": self._derive_args.duration, "cache_dir": self._derive_args.cache_dir, "timestamp_column_name": self._config.ae.timestamp_column_name }, diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/utils/derive_args.py b/examples/digital_fingerprinting/production/morpheus/dfp/utils/derive_args.py index 485f943d75..62bbec3695 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/utils/derive_args.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/utils/derive_args.py @@ -39,21 +39,19 @@ def __init__(self, skip_user: str, only_user: str, start_time: str, - infer_duration: str, - train_duration: str, - log_level: str, + log_level: int, cache_dir: str, sample_rate_s: str, + duration: str, log_type: str, tracking_uri: str, - pipeline_type: str = None, + workload_type: str = None, train_users: str = None): self._skip_users = list(skip_user) self._only_users = list(only_user) self._start_time = start_time - self._infer_duration = infer_duration - self._train_duration = train_duration + self._duration = duration self._log_level = log_level self._train_users = train_users self._cache_dir = cache_dir @@ -61,7 +59,7 @@ def __init__(self, self._tracking_uri = tracking_uri self._sample_rate_s = sample_rate_s self._log_type = log_type - self._pipeline_type = pipeline_type + self._workload_type = workload_type self._include_generic = None self._include_individual = None @@ -70,11 +68,12 @@ def __init__(self, self._model_name_formatter = "DFP-%s-{user_id}" % (log_type) self._experiment_name_formatter = "dfp/%s/training/{reg_model_name}" % (log_type) - train_flag = (train_users is not None and train_users != "none") + train_flag = (train_users is not None and train_users) - self._is_training = (train_flag and pipeline_type != "infer") - self._is_train_and_infer = (train_flag and pipeline_type == "train_and_infer") - self._is_inference = not (self._is_training or self._is_train_and_infer) + self._is_training = (train_flag and workload_type != "infer") + self._is_train_and_infer = (train_flag and workload_type == "train_and_infer") + self._is_inference = not (self._is_training or self._is_train_and_infer or workload_type == "train" + or workload_type == "train_and_infer") def verify_init(func): @@ -86,21 +85,9 @@ def wrapper(self, *args, **kwargs): return wrapper def _configure_logging(self): - configure_logging(log_level=self._log_level) logging.getLogger("mlflow").setLevel(self._log_level) - if (len(self._only_users) > 0 and len(self._only_users) > 0): - logging.error("Option --skip_user and --only_user are mutually exclusive. Exiting") - - logger.info("Running training pipeline with the following options: ") - logger.info("Train generic_user: %s", self._include_generic) - logger.info("Skipping users: %s", self._skip_users) - logger.info("Start Time: %s", self._start_time) - logger.info("Training duration: %s", self._train_duration) - logger.info("Inference duration: %s", self._infer_duration) - logger.info("Cache Dir: %s", self._cache_dir) - @property @verify_init def time_fields(self): @@ -112,14 +99,8 @@ def include_generic(self): return self._include_generic @property - @verify_init - def infer_duration(self): - return self._infer_duration - - @property - @verify_init - def train_duration(self): - return self._train_duration + def duration(self): + return self._duration @property @verify_init @@ -159,6 +140,10 @@ def model_name_formatter(self): def is_training(self): return self._is_training + @property + def is_inference(self): + return self._is_inference + @property def experiment_name_formatter(self): return self._experiment_name_formatter @@ -192,25 +177,30 @@ def _set_mlflow_tracking_uri(self): logger.info("Tracking URI: %s", mlflow.get_tracking_uri()) def _set_time_fields(self): - if self._is_train_and_infer: - logger.info("Inline training is triggered. Ovverriding 'training_duration' with 'inference_duration'.") - self._train_duration = self._infer_duration - self._time_fields = self._create_time_fields(self._infer_duration) - elif self._is_training: - self._time_fields = self._create_time_fields(self._train_duration) - elif self._is_inference: - self._time_fields = self._create_time_fields(self._infer_duration) + if self._is_train_and_infer or self._is_training or self._is_inference: + self._time_fields = self._create_time_fields(self._duration) else: - raise Exception("Unable to update time fields.") + raise Exception( + "Invalid arguments, when --workload_type is 'train' or 'train_and_infer' --train_users must be passed.") def init(self): + self._configure_logging() self._set_time_fields() self._set_include_generic() self._set_include_individual() - self._configure_logging() self._set_mlflow_tracking_uri() self._initialized = True + if (len(self._only_users) > 0 and len(self._only_users) > 0): + logging.error("Option --skip_user and --only_user are mutually exclusive. Exiting") + + logger.info("Running training pipeline with the following options: ") + logger.info("Train generic_user: %s", self._include_generic) + logger.info("Skipping users: %s", self._skip_users) + logger.info("Start Time: %s", self._start_time) + logger.info("Duration: %s", self._duration) + logger.info("Cache Dir: %s", self._cache_dir) + def pyobj2str(pyobj, encoding): str_val = str(pickle.dumps(pyobj), encoding=encoding) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp_azure_modules_inference.py b/examples/digital_fingerprinting/production/morpheus/dfp_azure_modules_inference.py index 7327d1c238..8056e9bc3d 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp_azure_modules_inference.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp_azure_modules_inference.py @@ -56,7 +56,7 @@ @click.option( "--duration", type=str, - default="1d", + default="60d", help="The duration to run starting from start_time", ) @click.option( @@ -102,20 +102,20 @@ def run_pipeline(skip_user: typing.Tuple[str], derive_args = DeriveArgs(skip_user, only_user, start_time, - duration, log_level, cache_dir, sample_rate_s, - tracking_uri=kwargs["tracking_uri"], - source="azure") + duration, + log_type="azure", + tracking_uri=kwargs["tracking_uri"]) derive_args.init() - config: Config = generate_ae_config(log_type="azure", + config: Config = generate_ae_config(derive_args.log_type, userid_column_name="username", timestamp_column_name="timestamp") - schema_builder = SchemaBuilder(config) + schema_builder = SchemaBuilder(config, derive_args.log_type) schema: Schema = schema_builder.build_schema() config_generator = ConfigGenerator(config, derive_args, schema) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp_azure_modules_pipeline.py b/examples/digital_fingerprinting/production/morpheus/dfp_azure_modules_pipeline.py deleted file mode 100644 index 4a7b3908be..0000000000 --- a/examples/digital_fingerprinting/production/morpheus/dfp_azure_modules_pipeline.py +++ /dev/null @@ -1,312 +0,0 @@ -# Copyright (c) 2022-2023, NVIDIA CORPORATION. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import logging -import typing -from datetime import datetime - -import click -import dfp.modules.dfp_model_train_deploy # noqa: F401 -import dfp.modules.dfp_preprocessing # noqa: F401 -from dfp.messages.multi_dfp_message import MultiDFPMessage -from dfp.stages.dfp_inference_stage import DFPInferenceStage -from dfp.stages.dfp_postprocessing_stage import DFPPostprocessingStage -from dfp.stages.multi_file_source import MultiFileSource -from dfp.utils.config_generator import generate_ae_config -from dfp.utils.derive_args import DeriveArgs -from dfp.utils.derive_args import pyobj2str -from dfp.utils.module_ids import DFP_DATA_PREP -from dfp.utils.module_ids import DFP_MODEL_TRAIN_DEPLOY -from dfp.utils.module_ids import DFP_PREPROCESSING -from dfp.utils.module_ids import DFP_ROLLING_WINDOW -from dfp.utils.module_ids import DFP_SPLIT_USERS -from dfp.utils.module_ids import DFP_TRAINING -from dfp.utils.regex_utils import iso_date_regex_pattern -from dfp.utils.schema_utils import Schema -from dfp.utils.schema_utils import SchemaBuilder - -from morpheus._lib.common import FilterSource -from morpheus.cli.utils import get_log_levels -from morpheus.cli.utils import parse_log_level -from morpheus.config import Config -from morpheus.pipeline import LinearPipeline -from morpheus.stages.general.linear_modules_stage import LinearModulesStage -from morpheus.stages.general.monitor_stage import MonitorStage -from morpheus.stages.output.write_to_file_stage import WriteToFileStage -from morpheus.stages.postprocess.filter_detections_stage import FilterDetectionsStage -from morpheus.stages.postprocess.serialize_stage import SerializeStage -from morpheus.utils.module_ids import FILE_BATCHER -from morpheus.utils.module_ids import FILE_TO_DF -from morpheus.utils.module_ids import MLFLOW_MODEL_WRITER -from morpheus.utils.module_ids import MODULE_NAMESPACE - - -@click.command() -@click.option( - "--train_users", - type=click.Choice(["all", "generic", "individual", "none"], case_sensitive=False), - help=("Indicates whether or not to train per user or a generic model for all users. " - "Selecting none runs the inference pipeline."), -) -@click.option( - "--skip_user", - multiple=True, - type=str, - help="User IDs to skip. Mutually exclusive with only_user", -) -@click.option( - "--only_user", - multiple=True, - type=str, - help="Only users specified by this option will be included. Mutually exclusive with skip_user", -) -@click.option( - "--start_time", - type=click.DateTime( - formats=['%Y-%m-%d', '%Y-%m-%dT%H:%M:%S', '%Y-%m-%d %H:%M:%S', '%Y-%m-%dT%H:%M:%S%z', '%Y-%m-%d %H:%M:%S%z']), - default=None, - help="The start of the time window, if undefined start_date will be `now()-duration`", -) -@click.option( - "--duration", - type=str, - default="60d", - help="The duration to run starting from start_time", -) -@click.option( - "--cache_dir", - type=str, - default="./.cache/dfp", - show_envvar=True, - help="The location to cache data such as S3 downloads and pre-processed data", -) -@click.option("--log_level", - default=logging.getLevelName(Config().log_level), - type=click.Choice(get_log_levels(), case_sensitive=False), - callback=parse_log_level, - help="Specify the logging level to use.") -@click.option("--sample_rate_s", - type=int, - default=0, - show_envvar=True, - help="Minimum time step, in milliseconds, between object logs.") -@click.option( - "--input_file", - "-f", - type=str, - multiple=True, - help=("List of files to process. Can specify multiple arguments for multiple files. " - "Also accepts glob (*) wildcards and schema prefixes such as `s3://`. " - "For example, to make a local cache of an s3 bucket, use `filecache::s3://mybucket/*`. " - "See fsspec documentation for list of possible options."), -) -@click.option('--tracking_uri', - type=str, - default="http://mlflow:5000", - help=("The MLflow tracking URI to connect to the tracking backend.")) -def run_pipeline(train_users, - skip_user: typing.Tuple[str], - only_user: typing.Tuple[str], - start_time: datetime, - duration, - cache_dir, - log_level, - sample_rate_s, - **kwargs): - - derive_args = DeriveArgs(skip_user, - only_user, - start_time, - duration, - log_level, - cache_dir, - sample_rate_s, - tracking_uri=kwargs["tracking_uri"], - source="azure", - train_users=train_users) - - derive_args.init() - - config: Config = generate_ae_config(log_type="azure", - userid_column_name="username", - timestamp_column_name="timestamp") - - schema_builder = SchemaBuilder(config) - schema: Schema = schema_builder.build_schema() - - encoding = "latin1" - - # Convert schema as a string - source_schema_str = pyobj2str(schema.source, encoding=encoding) - preprocess_schema_str = pyobj2str(schema.preprocess, encoding=encoding) - - preprocessing_module_config = { - "module_id": DFP_PREPROCESSING, - "module_name": "dfp_preprocessing", - "namespace": MODULE_NAMESPACE, - FILE_BATCHER: { - "module_id": FILE_BATCHER, - "module_name": "file_batcher", - "namespace": MODULE_NAMESPACE, - "period": "D", - "sampling_rate_s": sample_rate_s, - "start_time": derive_args.time_fields.start_time, - "end_time": derive_args.time_fields.end_time, - "iso_date_regex_pattern": iso_date_regex_pattern - }, - FILE_TO_DF: { - "module_id": FILE_TO_DF, - "module_name": "FILE_TO_DF", - "namespace": MODULE_NAMESPACE, - "timestamp_column_name": config.ae.timestamp_column_name, - "parser_kwargs": { - "lines": False, "orient": "records" - }, - "cache_dir": cache_dir, - "filter_null": True, - "file_type": "JSON", - "schema": { - "schema_str": source_schema_str, "encoding": encoding - } - }, - DFP_SPLIT_USERS: { - "module_id": DFP_SPLIT_USERS, - "module_name": "dfp_split_users", - "namespace": MODULE_NAMESPACE, - "include_generic": derive_args.include_generic, - "include_individual": derive_args.include_individual, - "skip_users": derive_args.skip_users, - "only_users": derive_args.only_users, - "timestamp_column_name": config.ae.timestamp_column_name, - "userid_column_name": config.ae.userid_column_name, - "fallback_username": config.ae.fallback_username - }, - DFP_ROLLING_WINDOW: { - "module_id": DFP_ROLLING_WINDOW, - "module_name": "dfp_rolling_window", - "namespace": MODULE_NAMESPACE, - "min_history": 300 if derive_args.is_training else 1, - "min_increment": 300 if derive_args.is_training else 0, - "max_history": "60d" if derive_args.is_training else "1d", - "cache_dir": cache_dir, - "timestamp_column_name": config.ae.timestamp_column_name - }, - DFP_DATA_PREP: { - "module_id": DFP_DATA_PREP, - "module_name": "dfp_data_prep", - "namespace": MODULE_NAMESPACE, - "timestamp_column_name": config.ae.timestamp_column_name, - "schema": { - "schema_str": preprocess_schema_str, "encoding": encoding - } - } - } - - # Create a linear pipeline object - pipeline = LinearPipeline(config) - - pipeline.set_source(MultiFileSource(config, filenames=list(kwargs["input_file"]))) - - # Here we add a wrapped module that implements the full DFPPreprocessing pipeline. - pipeline.add_stage( - LinearModulesStage(config, - preprocessing_module_config, - input_port_name="input", - output_port_name="output", - output_type=MultiDFPMessage)) - - pipeline.add_stage(MonitorStage(config, description="Preprocessing Module rate", smoothing=0.001)) - - if (derive_args.is_training): - - # Module configuration - training_module_config = { - "module_id": DFP_MODEL_TRAIN_DEPLOY, - "module_name": "dfp_model_train_deploy", - "namespace": MODULE_NAMESPACE, - DFP_TRAINING: { - "module_id": DFP_TRAINING, - "module_name": "dfp_training", - "namespace": MODULE_NAMESPACE, - "model_kwargs": { - "encoder_layers": [512, 500], # layers of the encoding part - "decoder_layers": [512], # layers of the decoding part - "activation": 'relu', # activation function - "swap_p": 0.2, # noise parameter - "lr": 0.001, # learning rate - "lr_decay": 0.99, # learning decay - "batch_size": 512, - "verbose": False, - "optimizer": 'sgd', # SGD optimizer is selected(Stochastic gradient descent) - "scaler": 'standard', # feature scaling method - "min_cats": 1, # cut off for minority categories - "progress_bar": False, - "device": "cuda" - }, - "feature_columns": config.ae.feature_columns, - "epochs": 30, - "validation_size": 0.10 - }, - MLFLOW_MODEL_WRITER: { - "module_id": MLFLOW_MODEL_WRITER, - "module_name": "mlflow_model_writer", - "namespace": MODULE_NAMESPACE, - "model_name_formatter": derive_args.model_name_formatter, - "experiment_name_formatter": derive_args.experiment_name_formatter, - "timestamp_column_name": config.ae.timestamp_column_name, - "conda_env": { - 'channels': ['defaults', 'conda-forge'], - 'dependencies': ['python={}'.format('3.8'), 'pip'], - 'pip': ['mlflow', 'dfencoder'], - 'name': 'mlflow-env' - }, - "databricks_permissions": None - } - } - # Here we add a wrapped module that implements the full DFPTraining pipeline. - pipeline.add_stage( - LinearModulesStage( - config, - training_module_config, - input_port_name="input", - output_port_name="output", - )) - - pipeline.add_stage(MonitorStage(config, description="Training Module rate", smoothing=0.001)) - - else: - # Perform inference on the preprocessed data - pipeline.add_stage(DFPInferenceStage(config, model_name_formatter=derive_args.model_name_formatter)) - - pipeline.add_stage(MonitorStage(config, description="Inference rate", smoothing=0.001)) - - # Filter for only the anomalous logs - pipeline.add_stage( - FilterDetectionsStage(config, threshold=2.0, filter_source=FilterSource.DATAFRAME, field_name='mean_abs_z')) - - # Filter for only the anomalous logs - pipeline.add_stage(DFPPostprocessingStage(config)) - - # Exclude the columns we don't want in our output - pipeline.add_stage(SerializeStage(config, exclude=['batch_count', 'origin_hash', '_row_hash', '_batch_id'])) - - # Write all anomalies to a CSV file - pipeline.add_stage(WriteToFileStage(config, filename="dfp_detections_azure.csv", overwrite=True)) - - # Run the pipeline - pipeline.run() - - -if __name__ == "__main__": - run_pipeline(obj={}, auto_envvar_prefix='DFP', show_default=True, prog_name="dfp") diff --git a/examples/digital_fingerprinting/production/morpheus/dfp_azure_modules_training.py b/examples/digital_fingerprinting/production/morpheus/dfp_azure_modules_training.py index 993ca3af95..c0ad41d2a6 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp_azure_modules_training.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp_azure_modules_training.py @@ -111,21 +111,21 @@ def run_pipeline(train_users, derive_args = DeriveArgs(skip_user, only_user, start_time, - duration, log_level, cache_dir, sample_rate_s, + duration, + log_type="azure", tracking_uri=kwargs["tracking_uri"], - source="azure", train_users=train_users) derive_args.init() - config: Config = generate_ae_config(log_type="azure", + config: Config = generate_ae_config(derive_args.log_type, userid_column_name="username", timestamp_column_name="timestamp") - schema_builder = SchemaBuilder(config) + schema_builder = SchemaBuilder(config, derive_args.log_type) schema: Schema = schema_builder.build_schema() config_generator = ConfigGenerator(config, derive_args, schema) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp_duo_modules_inference.py b/examples/digital_fingerprinting/production/morpheus/dfp_duo_modules_inference.py index df5f9730f7..c19099a9d1 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp_duo_modules_inference.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp_duo_modules_inference.py @@ -57,7 +57,7 @@ @click.option( "--duration", type=str, - default="1d", + default="60d", help="The duration to run starting from start_time", ) @click.option( @@ -103,20 +103,20 @@ def run_pipeline(skip_user: typing.Tuple[str], derive_args = DeriveArgs(skip_user, only_user, start_time, - duration, log_level, cache_dir, sample_rate_s, - tracking_uri=kwargs["tracking_uri"], - source="duo") + duration, + log_type="duo", + tracking_uri=kwargs["tracking_uri"]) derive_args.init() - config: Config = generate_ae_config(log_type="duo", + config: Config = generate_ae_config(derive_args.log_type, userid_column_name="username", timestamp_column_name="timestamp") - schema_builder = SchemaBuilder(config) + schema_builder = SchemaBuilder(config, derive_args.log_type) schema: Schema = schema_builder.build_schema() config_generator = ConfigGenerator(config, derive_args, schema) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp_duo_modules_pipeline.py b/examples/digital_fingerprinting/production/morpheus/dfp_duo_modules_pipeline.py deleted file mode 100644 index 197a440f71..0000000000 --- a/examples/digital_fingerprinting/production/morpheus/dfp_duo_modules_pipeline.py +++ /dev/null @@ -1,310 +0,0 @@ -# Copyright (c) 2022-2023, NVIDIA CORPORATION. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import logging -import typing -from datetime import datetime - -import click -import dfp.modules.dfp_model_train_deploy # noqa: F401 -import dfp.modules.dfp_preprocessing # noqa: F401 -from dfp.messages.multi_dfp_message import MultiDFPMessage -from dfp.stages.dfp_inference_stage import DFPInferenceStage -from dfp.stages.dfp_postprocessing_stage import DFPPostprocessingStage -from dfp.stages.multi_file_source import MultiFileSource -from dfp.utils.config_generator import generate_ae_config -from dfp.utils.derive_args import DeriveArgs -from dfp.utils.derive_args import pyobj2str -from dfp.utils.module_ids import DFP_DATA_PREP -from dfp.utils.module_ids import DFP_MODEL_TRAIN_DEPLOY -from dfp.utils.module_ids import DFP_PREPROCESSING -from dfp.utils.module_ids import DFP_ROLLING_WINDOW -from dfp.utils.module_ids import DFP_SPLIT_USERS -from dfp.utils.module_ids import DFP_TRAINING -from dfp.utils.regex_utils import iso_date_regex_pattern -from dfp.utils.schema_utils import Schema -from dfp.utils.schema_utils import SchemaBuilder - -from morpheus._lib.common import FilterSource -from morpheus.cli.utils import get_log_levels -from morpheus.cli.utils import parse_log_level -from morpheus.config import Config -from morpheus.pipeline import LinearPipeline -from morpheus.stages.general.linear_modules_stage import LinearModulesStage -from morpheus.stages.general.monitor_stage import MonitorStage -from morpheus.stages.output.write_to_file_stage import WriteToFileStage -from morpheus.stages.postprocess.filter_detections_stage import FilterDetectionsStage -from morpheus.stages.postprocess.serialize_stage import SerializeStage -from morpheus.utils.module_ids import FILE_BATCHER -from morpheus.utils.module_ids import FILE_TO_DF -from morpheus.utils.module_ids import MLFLOW_MODEL_WRITER -from morpheus.utils.module_ids import MODULE_NAMESPACE - - -@click.command() -@click.option( - "--train_users", - type=click.Choice(["all", "generic", "individual", "none"], case_sensitive=False), - help=("Indicates whether or not to train per user or a generic model for all users. " - "Selecting none runs the inference pipeline."), -) -@click.option( - "--skip_user", - multiple=True, - type=str, - help="User IDs to skip. Mutually exclusive with only_user", -) -@click.option( - "--only_user", - multiple=True, - type=str, - help="Only users specified by this option will be included. Mutually exclusive with skip_user", -) -@click.option( - "--start_time", - type=click.DateTime( - formats=['%Y-%m-%d', '%Y-%m-%dT%H:%M:%S', '%Y-%m-%d %H:%M:%S', '%Y-%m-%dT%H:%M:%S%z', '%Y-%m-%d %H:%M:%S%z']), - default=None, - help="The start of the time window, if undefined start_date will be `now()-duration`", -) -@click.option( - "--duration", - type=str, - default="60d", - help="The duration to run starting from start_time", -) -@click.option( - "--cache_dir", - type=str, - default="./.cache/dfp", - show_envvar=True, - help="The location to cache data such as S3 downloads and pre-processed data", -) -@click.option("--log_level", - default=logging.getLevelName(Config().log_level), - type=click.Choice(get_log_levels(), case_sensitive=False), - callback=parse_log_level, - help="Specify the logging level to use.") -@click.option("--sample_rate_s", - type=int, - default=0, - show_envvar=True, - help="Minimum time step, in milliseconds, between object logs.") -@click.option( - "--input_file", - "-f", - type=str, - multiple=True, - help=("List of files to process. Can specify multiple arguments for multiple files. " - "Also accepts glob (*) wildcards and schema prefixes such as `s3://`. " - "For example, to make a local cache of an s3 bucket, use `filecache::s3://mybucket/*`. " - "See fsspec documentation for list of possible options."), -) -@click.option('--tracking_uri', - type=str, - default="http://mlflow:5000", - help=("The MLflow tracking URI to connect to the tracking backend.")) -def run_pipeline(train_users, - skip_user: typing.Tuple[str], - only_user: typing.Tuple[str], - start_time: datetime, - duration, - cache_dir, - log_level, - sample_rate_s, - **kwargs): - - derive_args = DeriveArgs(skip_user, - only_user, - start_time, - duration, - log_level, - cache_dir, - sample_rate_s, - tracking_uri=kwargs["tracking_uri"], - source="duo", - train_users=train_users) - - derive_args.init() - - config: Config = generate_ae_config(log_type="duo", - userid_column_name="username", - timestamp_column_name="timestamp") - - schema_builder = SchemaBuilder(config) - schema: Schema = schema_builder.build_schema() - - encoding = "latin1" - - # Convert schema as a string - source_schema_str = pyobj2str(schema.source, encoding=encoding) - preprocess_schema_str = pyobj2str(schema.preprocess, encoding=encoding) - - preprocessing_module_config = { - "module_id": DFP_PREPROCESSING, - "module_name": "dfp_preprocessing", - "namespace": MODULE_NAMESPACE, - FILE_BATCHER: { - "module_id": FILE_BATCHER, - "module_name": "file_batcher", - "namespace": MODULE_NAMESPACE, - "period": "D", - "sampling_rate_s": sample_rate_s, - "start_time": derive_args.start_time, - "end_time": derive_args.end_time, - "iso_date_regex_pattern": iso_date_regex_pattern - }, - FILE_TO_DF: { - "module_id": FILE_TO_DF, - "module_name": "FILE_TO_DF", - "namespace": MODULE_NAMESPACE, - "timestamp_column_name": config.ae.timestamp_column_name, - "parser_kwargs": { - "lines": False, "orient": "records" - }, - "cache_dir": cache_dir, - "filter_null": True, - "file_type": "JSON", - "schema": { - "schema_str": source_schema_str, "encoding": encoding - } - }, - DFP_SPLIT_USERS: { - "module_id": DFP_SPLIT_USERS, - "module_name": "dfp_fsplit_users", - "namespace": MODULE_NAMESPACE, - "include_generic": derive_args.include_generic, - "include_individual": derive_args.include_individual, - "skip_users": derive_args.skip_users, - "only_users": derive_args.only_users, - "timestamp_column_name": config.ae.timestamp_column_name, - "userid_column_name": config.ae.userid_column_name, - "fallback_username": config.ae.fallback_username - }, - DFP_ROLLING_WINDOW: { - "module_id": DFP_ROLLING_WINDOW, - "module_name": "dfp_rolling_window", - "namespace": MODULE_NAMESPACE, - "min_history": 300 if derive_args.is_training else 1, - "min_increment": 300 if derive_args.is_training else 0, - "max_history": "60d" if derive_args.is_training else "1d", - "cache_dir": cache_dir, - "timestamp_column_name": config.ae.timestamp_column_name - }, - DFP_DATA_PREP: { - "module_id": DFP_DATA_PREP, - "module_name": "dfp_data_prep", - "namespace": MODULE_NAMESPACE, - "timestamp_column_name": config.ae.timestamp_column_name, - "schema": { - "schema_str": preprocess_schema_str, "encoding": encoding - } - } - } - - # Create a linear pipeline object - pipeline = LinearPipeline(config) - - pipeline.set_source(MultiFileSource(config, filenames=list(kwargs["input_file"]))) - - # Here we add a wrapped module that implements the full DFPPreprocessing pipeline. - pipeline.add_stage( - LinearModulesStage(config, - preprocessing_module_config, - input_port_name="input", - output_port_name="output", - output_type=MultiDFPMessage)) - - pipeline.add_stage(MonitorStage(config, description="Preprocessing Module rate", smoothing=0.001)) - - if (derive_args.is_training): - - # Module configuration - training_module_config = { - "module_id": DFP_MODEL_TRAIN_DEPLOY, - "module_name": "dfp_model_train_deploy", - "namespace": MODULE_NAMESPACE, - DFP_TRAINING: { - "module_id": DFP_TRAINING, - "module_name": "dfp_training", - "namespace": MODULE_NAMESPACE, - "model_kwargs": { - "encoder_layers": [512, 500], # layers of the encoding part - "decoder_layers": [512], # layers of the decoding part - "activation": 'relu', # activation function - "swap_p": 0.2, # noise parameter - "lr": 0.001, # learning rate - "lr_decay": 0.99, # learning decay - "batch_size": 512, - "verbose": False, - "optimizer": 'sgd', # SGD optimizer is selected(Stochastic gradient descent) - "scaler": 'standard', # feature scaling method - "min_cats": 1, # cut off for minority categories - "progress_bar": False, - "device": "cuda" - }, - "feature_columns": config.ae.feature_columns, - "epochs": 30, - "validation_size": 0.10 - }, - MLFLOW_MODEL_WRITER: { - "module_id": MLFLOW_MODEL_WRITER, - "module_name": "mlflow_model_writer", - "namespace": MODULE_NAMESPACE, - "model_name_formatter": derive_args.model_name_formatter, - "experiment_name_formatter": derive_args.experiment_name_formatter, - "timestamp_column_name": config.ae.timestamp_column_name, - "conda_env": { - 'channels': ['defaults', 'conda-forge'], - 'dependencies': ['python={}'.format('3.8'), 'pip'], - 'pip': ['mlflow', 'dfencoder'], - 'name': 'mlflow-env' - }, - "databricks_permissions": None - } - } - # Here we add a wrapped module implementing the DFPTraining pipeline - pipeline.add_stage( - LinearModulesStage( - config, - training_module_config, - input_port_name="input", - output_port_name="output", - )) - - pipeline.add_stage(MonitorStage(config, description="Training Module rate", smoothing=0.001)) - - else: - pipeline.add_stage(DFPInferenceStage(config, model_name_formatter=derive_args.model_name_formatter)) - - pipeline.add_stage(MonitorStage(config, description="Inference rate", smoothing=0.001)) - - # Filter for only the anomalous logs - pipeline.add_stage( - FilterDetectionsStage(config, threshold=2.0, filter_source=FilterSource.DATAFRAME, field_name='mean_abs_z')) - - # Filter for only the anomalous logs - pipeline.add_stage(DFPPostprocessingStage(config)) - - # Exclude the columns we don't want in our output - pipeline.add_stage(SerializeStage(config, exclude=['batch_count', 'origin_hash', '_row_hash', '_batch_id'])) - - pipeline.add_stage(WriteToFileStage(config, filename="dfp_detections_duo.csv", overwrite=True)) - - # Run the pipeline - pipeline.run() - - -if __name__ == "__main__": - run_pipeline(obj={}, auto_envvar_prefix='DFP', show_default=True, prog_name="dfp") diff --git a/examples/digital_fingerprinting/production/morpheus/dfp_duo_modules_training.py b/examples/digital_fingerprinting/production/morpheus/dfp_duo_modules_training.py index a219657104..fa61570261 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp_duo_modules_training.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp_duo_modules_training.py @@ -111,21 +111,21 @@ def run_pipeline(train_users, derive_args = DeriveArgs(skip_user, only_user, start_time, - duration, log_level, cache_dir, sample_rate_s, + duration, + log_type="duo", tracking_uri=kwargs["tracking_uri"], - source="duo", train_users=train_users) derive_args.init() - config: Config = generate_ae_config(log_type="duo", + config: Config = generate_ae_config(derive_args.log_type, userid_column_name="username", timestamp_column_name="timestamp") - schema_builder = SchemaBuilder(config) + schema_builder = SchemaBuilder(config, derive_args.log_type) schema: Schema = schema_builder.build_schema() config_generator = ConfigGenerator(config, derive_args, schema) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp_modules_pipeline.py b/examples/digital_fingerprinting/production/morpheus/dfp_modules_pipeline.py index b082a58d33..742fce5473 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp_modules_pipeline.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp_modules_pipeline.py @@ -40,16 +40,18 @@ @click.option( "--log_type", type=click.Choice(["duo", "azure"], case_sensitive=False), - help=(""), + required=True, + help=("Indicates what type of logs are going to be used in the workload."), ) @click.option( - "--pipeline_type", + "--workload_type", type=click.Choice(["infer", "train", "train_and_infer"], case_sensitive=False), - help=(""), + required=True, + help=("Workload type either inference or training or inference + training"), ) @click.option( "--train_users", - type=click.Choice(["all", "generic", "individual", "none"], case_sensitive=False), + type=click.Choice(["all", "generic", "individual"], case_sensitive=False), help=("Indicates whether or not to train per user or a generic model for all users. " "Selecting none runs the inference pipeline."), ) @@ -73,13 +75,7 @@ help="The start of the time window, if undefined start_date will be `now()-duration`", ) @click.option( - "--inference_duration", - type=str, - default="1d", - help="The inference duration to run starting from start_time", -) -@click.option( - "--training_duration", + "--duration", type=str, default="60d", help="The training duration to run starting from start_time", @@ -116,29 +112,27 @@ default="http://mlflow:5000", help=("The MLflow tracking URI to connect to the tracking backend.")) def run_pipeline(log_type: str, - pipeline_type, - train_users, + workload_type: str, + train_users: str, skip_user: typing.Tuple[str], only_user: typing.Tuple[str], start_time: datetime, - inference_duration: str, - training_duration: str, - cache_dir, - log_level, - sample_rate_s, + duration: str, + cache_dir: str, + log_level: int, + sample_rate_s: int, **kwargs): derive_args = DeriveArgs(skip_user, only_user, start_time, - inference_duration, - training_duration, log_level, cache_dir, sample_rate_s, + duration, log_type, tracking_uri=kwargs["tracking_uri"], - pipeline_type=pipeline_type, + workload_type=workload_type, train_users=train_users) derive_args.init() @@ -194,9 +188,10 @@ def run_pipeline(log_type: str, elif "inference" in conf: inf_stage = pipeline.add_stage( LinearModulesStage(config, conf.get("inference"), input_port_name="input", output_port_name="output")) - mntr_stage = pipeline.add_stage(MonitorStage(config, description="Inference Pipeline rate", smoothing=0.001)) + inf_mntr_stage = pipeline.add_stage(MonitorStage(config, description="Inference Pipeline rate", + smoothing=0.001)) pipeline.add_edge(preproc_stage, inf_stage) - pipeline.add_edge(inf_stage, mntr_stage) + pipeline.add_edge(inf_stage, inf_mntr_stage) else: raise Exception("Required keys not found in the configuration to trigger the pipeline") diff --git a/morpheus/stages/general/broadcast_stage.py b/morpheus/stages/general/broadcast_stage.py index 4b8fee0c53..de5c67af51 100644 --- a/morpheus/stages/general/broadcast_stage.py +++ b/morpheus/stages/general/broadcast_stage.py @@ -27,12 +27,21 @@ class BroadcastStage(Stage): """ + Depending on the number of output ports specified, this stage broadcast messages to one or more nodes. + + + Parameters + ---------- + output_port_count : int + Output port count to broad cast messages. """ def __init__(self, c: Config, output_port_count: int = 2): super().__init__(c) + assert output_port_count > 0 + self._create_ports(1, output_port_count) self._output_port_count = output_port_count @@ -62,6 +71,11 @@ def accepted_types(self) -> typing.Tuple: """ return (typing.Any, ) + def _get_broadcast_node(self, builder) -> Broadcast: + # Create a broadcast node + node = Broadcast(builder, "broadcast") + return node + def _build(self, builder: mrc.Builder, in_stream_pairs: typing.List[StreamPair]) -> typing.List[StreamPair]: assert len(in_stream_pairs) == 1, "Only 1 input supported" @@ -69,18 +83,15 @@ def _build(self, builder: mrc.Builder, in_stream_pairs: typing.List[StreamPair]) in_stream_node = in_stream_pairs[0][0] output_type = in_stream_pairs[0][1] - # Create a broadcast node - broadcast_node = Broadcast(builder, "broadcast") - builder.make_edge(in_stream_node, broadcast_node) + node = self._get_broadcast_node(builder) - if self._output_port_count <= 0: - raise ValueError("Output port count must be greater than 0") + builder.make_edge(in_stream_node, node) out_stream_pairs = [] count = 0 while (count < self._output_port_count): - out_stream_pairs.append((broadcast_node, output_type)) + out_stream_pairs.append((node, output_type)) count += 1 return out_stream_pairs diff --git a/tests/test_broadcast_stage.py b/tests/test_broadcast_stage.py new file mode 100755 index 0000000000..bbd16ec84b --- /dev/null +++ b/tests/test_broadcast_stage.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python +# SPDX-FileCopyrightText: Copyright (c) 2023, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from unittest import mock + +import pytest + +from morpheus.stages.general.broadcast_stage import BroadcastStage + + +def test_constructor(config): + + b = BroadcastStage(config, output_port_count=3) + assert b._output_port_count == 3 + + # Just ensure that we get a valid non-empty tuple + accepted_types = b.accepted_types() + assert isinstance(accepted_types, tuple) + assert len(accepted_types) > 0 + + b = BroadcastStage(config) + assert b._output_port_count == 2 + + pytest.raises(AssertionError, BroadcastStage, config, output_port_count=0) + + +@pytest.mark.use_python +def test_build(config): + mock_builder = mock.MagicMock() + in_mock_stream_pairs = [(mock.MagicMock(), mock.MagicMock())] + + b = BroadcastStage(config) + b._get_broadcast_node = mock.MagicMock() + + mock_out_stream_pairs = b._build(mock_builder, in_mock_stream_pairs) + + assert len(mock_out_stream_pairs) == 2 + + mock_builder.make_edge.assert_called_once() From 91191022c8ece25e3d144f968c787ec6bfeda8a1 Mon Sep 17 00:00:00 2001 From: Devin Robison Date: Wed, 15 Feb 2023 17:05:03 -0700 Subject: [PATCH 014/157] More unit tests, add loader registry, various other improvements --- morpheus/_lib/cmake/libraries/morpheus.cmake | 2 + .../_lib/include/morpheus/io/data_loader.hpp | 19 ++- .../_lib/include/morpheus/io/loaders/all.hpp | 1 + .../_lib/include/morpheus/io/loaders/file.hpp | 3 +- .../include/morpheus/io/loaders/lambda.hpp | 41 +++++ .../include/morpheus/messages/control.hpp | 28 ++-- .../morpheus/objects/factory_registry.hpp | 93 +++++++++++ morpheus/_lib/src/io/data_loader.cpp | 15 +- morpheus/_lib/src/io/data_loader_registry.cpp | 44 ++++++ morpheus/_lib/src/io/loaders/file.cpp | 5 +- morpheus/_lib/src/io/loaders/lambda.cpp | 34 ++++ morpheus/_lib/src/messages/control.cpp | 27 ++-- .../_lib/src/modules/data_loader_module.cpp | 19 ++- morpheus/_lib/src/python_modules/common.cpp | 1 + morpheus/_lib/src/python_modules/messages.cpp | 74 +++++---- morpheus/_lib/tests/io/test_data_loader.cpp | 36 ++++- .../tests/messages/test_control_message.cpp | 34 +++- .../_lib/tests/messages/test_messages.hpp | 9 +- morpheus/_lib/tests/test_morpheus.cpp | 6 +- morpheus/_lib/tests/test_morpheus.hpp | 2 +- tests/messages/test_control_message.py | 1 + tests/modules/test_morpheus_modules.py | 147 +++++++++++++++--- tests/objects/test_loader_registry.py | 41 +++++ 23 files changed, 557 insertions(+), 125 deletions(-) create mode 100644 morpheus/_lib/include/morpheus/io/loaders/lambda.hpp create mode 100644 morpheus/_lib/include/morpheus/objects/factory_registry.hpp create mode 100644 morpheus/_lib/src/io/data_loader_registry.cpp create mode 100644 morpheus/_lib/src/io/loaders/lambda.cpp create mode 100644 tests/objects/test_loader_registry.py diff --git a/morpheus/_lib/cmake/libraries/morpheus.cmake b/morpheus/_lib/cmake/libraries/morpheus.cmake index 0b361f95fd..0200f77b96 100644 --- a/morpheus/_lib/cmake/libraries/morpheus.cmake +++ b/morpheus/_lib/cmake/libraries/morpheus.cmake @@ -17,9 +17,11 @@ message(STATUS "Adding library: morpheus") add_library(morpheus # Keep these sorted! ${MORPHEUS_LIB_ROOT}/src/io/data_loader.cpp + ${MORPHEUS_LIB_ROOT}/src/io/data_loader_registry.cpp ${MORPHEUS_LIB_ROOT}/src/io/deserializers.cpp ${MORPHEUS_LIB_ROOT}/src/io/loaders/file.cpp ${MORPHEUS_LIB_ROOT}/src/io/loaders/grpc.cpp + ${MORPHEUS_LIB_ROOT}/src/io/loaders/lambda.cpp ${MORPHEUS_LIB_ROOT}/src/io/loaders/payload.cpp ${MORPHEUS_LIB_ROOT}/src/io/loaders/rest.cpp ${MORPHEUS_LIB_ROOT}/src/io/serializers.cpp diff --git a/morpheus/_lib/include/morpheus/io/data_loader.hpp b/morpheus/_lib/include/morpheus/io/data_loader.hpp index 64dc048540..e519c44341 100644 --- a/morpheus/_lib/include/morpheus/io/data_loader.hpp +++ b/morpheus/_lib/include/morpheus/io/data_loader.hpp @@ -40,11 +40,26 @@ class DataLoader DataLoader(); ~DataLoader() = default; - // Probably a MessageMeta? + /** + * @brief Load data described by a control message + * @param control_message + * @return + */ std::shared_ptr load(MessageControl& control_message); - void register_loader(const std::string& loader_id, std::shared_ptr loader, bool overwrite = true); + /** + * @brief Register a loader instance with the data loader + * @param loader_id + * @param loader + * @param overwrite + */ + void add_loader(const std::string& loader_id, std::shared_ptr loader, bool overwrite = true); + /** + * @brief Remove a loader instance from the data loader + * @param loader_id + * @param throw_if_not_found + */ void remove_loader(const std::string& loader_id, bool throw_if_not_found = true); private: diff --git a/morpheus/_lib/include/morpheus/io/loaders/all.hpp b/morpheus/_lib/include/morpheus/io/loaders/all.hpp index a5fc39bdee..dc5d822fa8 100644 --- a/morpheus/_lib/include/morpheus/io/loaders/all.hpp +++ b/morpheus/_lib/include/morpheus/io/loaders/all.hpp @@ -19,5 +19,6 @@ #include "file.hpp" #include "grpc.hpp" +#include "lambda.hpp" #include "payload.hpp" #include "rest.hpp" diff --git a/morpheus/_lib/include/morpheus/io/loaders/file.hpp b/morpheus/_lib/include/morpheus/io/loaders/file.hpp index 69813c50db..2fb680a11d 100644 --- a/morpheus/_lib/include/morpheus/io/loaders/file.hpp +++ b/morpheus/_lib/include/morpheus/io/loaders/file.hpp @@ -23,7 +23,8 @@ namespace morpheus { #pragma GCC visibility push(default) /** - * @brief Very simple raw data loader that takes payload data on the control message and returns it + * @brief Very simple raw data loader that takes a list of files containing data that can be converted into a cuDF + * DataFrame. Loads the files into a cuDF DataFrame and returns a MessageMeta containing the DataFrame. * */ class FileDataLoader : public Loader diff --git a/morpheus/_lib/include/morpheus/io/loaders/lambda.hpp b/morpheus/_lib/include/morpheus/io/loaders/lambda.hpp new file mode 100644 index 0000000000..ff61c2624d --- /dev/null +++ b/morpheus/_lib/include/morpheus/io/loaders/lambda.hpp @@ -0,0 +1,41 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2022-2023, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include "morpheus/io/data_loader.hpp" + +namespace morpheus { +#pragma GCC visibility push(default) +/** + * @brief Very simple raw data loader that takes payload data on the control message and returns it + * + */ +class LambdaLoader : public Loader +{ + public: + LambdaLoader() = delete; + LambdaLoader(std::function(MessageControl&)> lambda_load); + ~LambdaLoader() = default; + + std::shared_ptr load(MessageControl& message) override; + + private: + std::function(MessageControl&)> m_lambda_load; +}; +#pragma GCC visibility pop +} // namespace morpheus \ No newline at end of file diff --git a/morpheus/_lib/include/morpheus/messages/control.hpp b/morpheus/_lib/include/morpheus/messages/control.hpp index aeba53e6f2..286a6fcdb0 100644 --- a/morpheus/_lib/include/morpheus/messages/control.hpp +++ b/morpheus/_lib/include/morpheus/messages/control.hpp @@ -30,35 +30,43 @@ class MessageControl { public: MessageControl() = default; - MessageControl(const nlohmann::json& message); + MessageControl(const nlohmann::json& config); /** - * @brief Set the message object - * @param message + * @brief Set the config object + * @param config */ - void message(const nlohmann::json& message); + void config(const nlohmann::json& config); /** - * + * @brief Get the config object * @return */ - const nlohmann::json& message() const; + const nlohmann::json& config() const; + /** + * @brief Set the payload object + * @param payload + */ void payload(const std::shared_ptr& payload); + /** + * @brief Get the payload object + * @return Shared pointer to the message payload + */ std::shared_ptr payload(); private: std::shared_ptr m_payload{nullptr}; - nlohmann::json m_message{}; + nlohmann::json m_config{}; }; struct ControlMessageProxy { - static std::shared_ptr create(pybind11::dict& message); + static std::shared_ptr create(pybind11::dict& config); - static pybind11::dict message(MessageControl& self); - static void message(MessageControl& self, pybind11::dict& message); + static pybind11::dict config(MessageControl& self); + static void config(MessageControl& self, pybind11::dict& config); }; #pragma GCC visibility pop diff --git a/morpheus/_lib/include/morpheus/objects/factory_registry.hpp b/morpheus/_lib/include/morpheus/objects/factory_registry.hpp new file mode 100644 index 0000000000..7195a69a08 --- /dev/null +++ b/morpheus/_lib/include/morpheus/objects/factory_registry.hpp @@ -0,0 +1,93 @@ +/** + * SPDX-FileCopyrightText: Copyright (c) 2021-2023, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +#include "morpheus/io/data_loader.hpp" + +#include +#include + +#include +#include +#include +#include +#include + +namespace morpheus { +#pragma GCC visibility push(default) +template +class FactoryRegistry +{ + public: + static std::shared_ptr get_constructor(const std::string& name) + { + if (m_object_constructors.count(name) == 0) + { + throw std::runtime_error("Unknown data loader: " + name); + } + return m_object_constructors[name](); + } + + static void register_constructor(const std::string& name, + const std::function()>& loader_fn) + { + if (m_object_constructors.count(name) > 0) + { + throw std::runtime_error("Duplicate data loader registration: " + name); + } + m_object_constructors[name] = loader_fn; + } + + static void unregister_constructor(const std::string& name, bool optional = false) + { + if (m_object_constructors.count(name) == 0) + { + if (optional) + { + return; + } + throw std::runtime_error("Unknown data loader: " + name); + } + m_object_constructors.erase(name); + } + + private: + static std::map()>> m_object_constructors; +}; + +// TODO(Devin): this shouldn't be templated, and should be specific to Loader +template +struct FactoryRegistryProxy +{ + template + static void register_proxy_constructor(const std::string& name, + std::function proxy_constructor); + + static void register_factory_cleanup_fn(const std::string& name) + { + { + auto at_exit = pybind11::module_::import("atexit"); + at_exit.attr("register")(pybind11::cpp_function([name]() { + VLOG(2) << "(atexit) Unregistering loader: " << name; + + // Try unregister -- ignore if already unregistered + FactoryRegistry::unregister_constructor(name, true); + })); + } + } +}; +#pragma GCC visibility pop + +} // namespace morpheus \ No newline at end of file diff --git a/morpheus/_lib/src/io/data_loader.cpp b/morpheus/_lib/src/io/data_loader.cpp index fe4940adf9..e1b747fc76 100644 --- a/morpheus/_lib/src/io/data_loader.cpp +++ b/morpheus/_lib/src/io/data_loader.cpp @@ -28,24 +28,23 @@ DataLoader::DataLoader() : m_loaders{} {} std::shared_ptr DataLoader::load(MessageControl& control_message) { - auto payload = control_message.message(); + auto payload = control_message.config(); if (payload.contains("loader_id")) { - // TODO - std::string loader_id = payload["loader_id"]; - auto loader = m_loaders.find(loader_id); + auto loader_id = payload["loader_id"]; + auto loader = m_loaders.find(loader_id); if (loader != m_loaders.end()) { - VLOG(5) << "Loading data using loader: " << loader_id - << " for message: " << control_message.message().dump() << std::endl; + VLOG(5) << "Loading data using loader: " << loader_id << " for message: " << control_message.config().dump() + << std::endl; return std::move(loader->second->load(control_message)); } } - throw std::runtime_error("No loader registered for message: " + control_message.message().dump()); + throw std::runtime_error("No loader registered for message: " + control_message.config().dump()); } -void DataLoader::register_loader(const std::string& loader_id, std::shared_ptr loader, bool overwrite) +void DataLoader::add_loader(const std::string& loader_id, std::shared_ptr loader, bool overwrite) { if (!overwrite and m_loaders.find(loader_id) != m_loaders.end()) { diff --git a/morpheus/_lib/src/io/data_loader_registry.cpp b/morpheus/_lib/src/io/data_loader_registry.cpp new file mode 100644 index 0000000000..e019e5aa79 --- /dev/null +++ b/morpheus/_lib/src/io/data_loader_registry.cpp @@ -0,0 +1,44 @@ +/** + * SPDX-FileCopyrightText: Copyright (c) 2021-2023, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "morpheus/io/data_loader.hpp" +#include "morpheus/io/loaders/lambda.hpp" +#include "morpheus/messages/meta.hpp" +#include "morpheus/objects/factory_registry.hpp" + +namespace morpheus { +template <> +std::map()>> FactoryRegistry::m_object_constructors{}; + +template class FactoryRegistry; + +template <> +template <> +void FactoryRegistryProxy::register_proxy_constructor( + const std::string& name, + std::function(MessageControl& control_message)> proxy_constructor) +{ + FactoryRegistry::register_constructor(name, [proxy_constructor]() { + return std::make_shared([proxy_constructor](MessageControl& control_message) { + return std::move(proxy_constructor(control_message)); + }); + }); + + register_factory_cleanup_fn(name); +} + +} // namespace morpheus \ No newline at end of file diff --git a/morpheus/_lib/src/io/loaders/file.cpp b/morpheus/_lib/src/io/loaders/file.cpp index 63d9a1a756..3a512f85d9 100644 --- a/morpheus/_lib/src/io/loaders/file.cpp +++ b/morpheus/_lib/src/io/loaders/file.cpp @@ -43,7 +43,7 @@ std::shared_ptr FileDataLoader::load(MessageControl& message) mod_cudf = cache_handle.get_module("cudf"); // TODO(Devin) : error checking + improve robustness - auto config = message.message(); + auto config = message.config(); if (!config.contains("files")) { throw std::runtime_error("'File Loader' control message specified no files to load"); @@ -53,7 +53,7 @@ std::shared_ptr FileDataLoader::load(MessageControl& message) std::string strategy = config.value("strategy", "aggregate"); if (strategy != "aggregate") { - throw std::runtime_error("Only 'merge' strategy is currently supported"); + throw std::runtime_error("Only 'aggregate' strategy is currently supported"); } auto files = config["files"]; @@ -71,6 +71,7 @@ std::shared_ptr FileDataLoader::load(MessageControl& message) VLOG(5) << "Loading file: " << file.dump(2); + // TODO(Devin): Any extensions missing? auto current_df = mod_cudf.attr("DataFrame")(); if (extension == "csv") { diff --git a/morpheus/_lib/src/io/loaders/lambda.cpp b/morpheus/_lib/src/io/loaders/lambda.cpp new file mode 100644 index 0000000000..f8909144aa --- /dev/null +++ b/morpheus/_lib/src/io/loaders/lambda.cpp @@ -0,0 +1,34 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2022-2023, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "morpheus/io/loaders/lambda.hpp" + +#include + +namespace morpheus { +LambdaLoader::LambdaLoader(std::function(MessageControl&)> lambda_load) + : m_lambda_load(lambda_load) +{ +} + +std::shared_ptr LambdaLoader::load(MessageControl& message) +{ + VLOG(30) << "Called LambdaLoader::load()"; + + return std::move(m_lambda_load(message)); +} +} // namespace morpheus \ No newline at end of file diff --git a/morpheus/_lib/src/messages/control.cpp b/morpheus/_lib/src/messages/control.cpp index 7b98fba7e3..372cf26214 100644 --- a/morpheus/_lib/src/messages/control.cpp +++ b/morpheus/_lib/src/messages/control.cpp @@ -24,50 +24,49 @@ namespace py = pybind11; namespace morpheus { -MessageControl::MessageControl(const nlohmann::json& message) : m_message(message) {} +MessageControl::MessageControl(const nlohmann::json& config) : m_config(config) {} -const nlohmann::json& MessageControl::message() const +const nlohmann::json& MessageControl::config() const { - return m_message; + return m_config; } -void MessageControl::message(const nlohmann::json& message) +void MessageControl::config(const nlohmann::json& config) { - m_message = message; + m_config = config; } std::shared_ptr MessageControl::payload() { - // TODO(Devin): do something else auto temp = std::move(m_payload); - m_payload = nullptr; + // TODO(Devin): Decide if we copy or steal the payload + // m_payload = nullptr; return temp; } void MessageControl::payload(const std::shared_ptr& payload) { - // TODO(Devin): can we just overwrite? m_payload = payload; } /*** Proxy Implementations ***/ -std::shared_ptr ControlMessageProxy::create(py::dict& message) +std::shared_ptr ControlMessageProxy::create(py::dict& config) { - return std::make_shared(mrc::pymrc::cast_from_pyobject(message)); + return std::make_shared(mrc::pymrc::cast_from_pyobject(config)); } -py::dict ControlMessageProxy::message(MessageControl& self) +py::dict ControlMessageProxy::config(MessageControl& self) { - auto dict = mrc::pymrc::cast_from_json(self.message()); + auto dict = mrc::pymrc::cast_from_json(self.config()); return dict; } -void ControlMessageProxy::message(MessageControl& self, py::dict& message) +void ControlMessageProxy::config(MessageControl& self, py::dict& config) { - self.message(mrc::pymrc::cast_from_pyobject(message)); + self.config(mrc::pymrc::cast_from_pyobject(config)); } } // namespace morpheus diff --git a/morpheus/_lib/src/modules/data_loader_module.cpp b/morpheus/_lib/src/modules/data_loader_module.cpp index 66f798955b..530e7a1d6d 100644 --- a/morpheus/_lib/src/modules/data_loader_module.cpp +++ b/morpheus/_lib/src/modules/data_loader_module.cpp @@ -24,7 +24,6 @@ #include #include #include -#include #include @@ -42,29 +41,37 @@ DataLoaderModule::DataLoaderModule(std::string module_name, nlohmann::json _conf void DataLoaderModule::initialize(mrc::segment::Builder& builder) { // TODO(Devin): Modularize loader lookups, and standardize this a bit more - if (config().contains("loaders")) + if (config().contains("loaders") and config()["loaders"].size() > 0) { auto loader_list = config()["loaders"]; for (json::iterator it = loader_list.begin(); it != loader_list.end(); ++it) { if (*it == "file") { - m_data_loader.register_loader("file", std::make_unique()); + m_data_loader.add_loader("file", std::make_unique()); } else if (*it == "grpc") { - m_data_loader.register_loader("grpc", std::make_unique()); + m_data_loader.add_loader("grpc", std::make_unique()); } else if (*it == "payload") { - m_data_loader.register_loader("payload", std::make_unique()); + m_data_loader.add_loader("payload", std::make_unique()); } else if (*it == "rest") { - m_data_loader.register_loader("rest", std::make_unique()); + m_data_loader.add_loader("rest", std::make_unique()); + } + else + { + throw std::runtime_error("Unknown or unsupported loader type: " + (*it).dump()); } } } + else + { + LOG(WARNING) << "No loaders specified in config"; + } auto loader_node = builder.make_node, std::shared_ptr>( "input", rxcpp::operators::map([this](std::shared_ptr control_message) { diff --git a/morpheus/_lib/src/python_modules/common.cpp b/morpheus/_lib/src/python_modules/common.cpp index 44649fb499..6368ee5739 100644 --- a/morpheus/_lib/src/python_modules/common.cpp +++ b/morpheus/_lib/src/python_modules/common.cpp @@ -70,6 +70,7 @@ PYBIND11_MODULE(common, _module) _module.def("tyepid_to_numpy_str", [](TypeId tid) { return DType(tid).type_str(); }); + // TODO(Devin): Add support for other file types (e.g. parquet, etc.) py::enum_(_module, "FileTypes", "The type of files that the `FileSourceStage` can read and `WriteToFileStage` can write. Use " diff --git a/morpheus/_lib/src/python_modules/messages.cpp b/morpheus/_lib/src/python_modules/messages.cpp index ea276f6514..7ae66c0340 100644 --- a/morpheus/_lib/src/python_modules/messages.cpp +++ b/morpheus/_lib/src/python_modules/messages.cpp @@ -15,6 +15,7 @@ * limitations under the License. */ +#include "morpheus/io/loaders/lambda.hpp" #include "morpheus/messages/control.hpp" #include "morpheus/messages/memory/inference_memory.hpp" #include "morpheus/messages/memory/inference_memory_fil.hpp" @@ -30,6 +31,7 @@ #include "morpheus/messages/multi_response.hpp" #include "morpheus/messages/multi_response_probs.hpp" #include "morpheus/objects/data_table.hpp" +#include "morpheus/objects/factory_registry.hpp" #include "morpheus/objects/mutable_table_ctx_mgr.hpp" #include "morpheus/utilities/cudf_util.hpp" #include "morpheus/version.hpp" @@ -58,9 +60,9 @@ namespace morpheus { namespace fs = std::filesystem; namespace py = pybind11; -PYBIND11_MODULE(messages, py_mod) +PYBIND11_MODULE(messages, _module) { - py_mod.doc() = R"pbdoc( + _module.doc() = R"pbdoc( ----------------------- .. currentmodule:: morpheus.messages .. autosummary:: @@ -71,14 +73,14 @@ PYBIND11_MODULE(messages, py_mod) // Load the cudf helpers load_cudf_helpers(); - mrc::pymrc::import(py_mod, "cupy"); - mrc::pymrc::import(py_mod, "morpheus._lib.common"); + mrc::pymrc::import(_module, "cupy"); + mrc::pymrc::import(_module, "morpheus._lib.common"); // Required for SegmentObject - mrc::pymrc::import(py_mod, "mrc.core.node"); + mrc::pymrc::import(_module, "mrc.core.node"); // Allows python objects to keep DataTable objects alive - py::class_>(py_mod, "DataTable"); + py::class_>(_module, "DataTable"); mrc::pymrc::PortBuilderUtil::register_port_util>(); mrc::pymrc::PortBuilderUtil::register_port_util>(); @@ -123,24 +125,31 @@ PYBIND11_MODULE(messages, py_mod) mrc::edge::EdgeConnector, std::shared_ptr>::register_converter(); - py::class_>(py_mod, "MessageControl") + // TODO(Devin): Circle back on return value policy choices + py::class_>(_module, "MessageControl") .def(py::init<>()) .def(py::init(py::overload_cast(&ControlMessageProxy::create)), py::return_value_policy::move) - .def("message", - pybind11::overload_cast(&ControlMessageProxy::message), - py::return_value_policy::reference_internal) - .def("message", - pybind11::overload_cast(&ControlMessageProxy::message), - py::arg("message"), - py::return_value_policy::reference_internal) - .def( - "payload", pybind11::overload_cast<>(&MessageControl::payload), py::return_value_policy::reference_internal) + .def("config", + pybind11::overload_cast(&ControlMessageProxy::config), + py::return_value_policy::move) + .def("config", + pybind11::overload_cast(&ControlMessageProxy::config), + py::arg("config"), + py::return_value_policy::move) + .def("payload", pybind11::overload_cast<>(&MessageControl::payload), py::return_value_policy::move) .def("payload", pybind11::overload_cast&>(&MessageControl::payload), - py::return_value_policy::reference_internal); + py::return_value_policy::move); + + py::class_, std::shared_ptr>>(_module, "DataLoaderRegistry") + .def_static( + "register_loader", + &FactoryRegistryProxy::register_proxy_constructor, MessageControl&>, + py::arg("name"), + py::arg("loader")); // Context manager for Mutable Dataframes. Attempting to use it outside of a with block will raise an exception - py::class_>(py_mod, "MutableTableCtxMgr") + py::class_>(_module, "MutableTableCtxMgr") .def("__enter__", &MutableTableCtxMgr::enter, py::return_value_policy::reference) .def("__exit__", &MutableTableCtxMgr::exit) .def("__getattr__", &MutableTableCtxMgr::throw_usage_error) @@ -148,7 +157,7 @@ PYBIND11_MODULE(messages, py_mod) .def("__setattr__", &MutableTableCtxMgr::throw_usage_error) .def("__setitem__", &MutableTableCtxMgr::throw_usage_error); - py::class_>(py_mod, "MessageMeta") + py::class_>(_module, "MessageMeta") .def(py::init<>(&MessageMetaInterfaceProxy::init_python), py::arg("df")) .def_property_readonly("count", &MessageMetaInterfaceProxy::count) .def_property_readonly("df", &MessageMetaInterfaceProxy::df_property, py::return_value_policy::move) @@ -156,7 +165,7 @@ PYBIND11_MODULE(messages, py_mod) .def("mutable_dataframe", &MessageMetaInterfaceProxy::mutable_dataframe, py::return_value_policy::move) .def_static("make_from_file", &MessageMetaInterfaceProxy::init_cpp); - py::class_>(py_mod, "MultiMessage") + py::class_>(_module, "MultiMessage") .def(py::init<>(&MultiMessageInterfaceProxy::init), py::arg("meta"), py::arg("mess_offset"), @@ -183,10 +192,10 @@ PYBIND11_MODULE(messages, py_mod) py::return_value_policy::move) .def("get_meta_list", &MultiMessageInterfaceProxy::get_meta_list, py::return_value_policy::move); - py::class_>(py_mod, "InferenceMemory") + py::class_>(_module, "InferenceMemory") .def_property_readonly("count", &InferenceMemoryInterfaceProxy::get_count); - py::class_>(py_mod, "InferenceMemoryNLP") + py::class_>(_module, "InferenceMemoryNLP") .def(py::init<>(&InferenceMemoryNLPInterfaceProxy::init), py::arg("count"), py::arg("input_ids"), @@ -202,7 +211,7 @@ PYBIND11_MODULE(messages, py_mod) .def_property( "seq_ids", &InferenceMemoryNLPInterfaceProxy::get_seq_ids, &InferenceMemoryNLPInterfaceProxy::set_seq_ids); - py::class_>(py_mod, "InferenceMemoryFIL") + py::class_>(_module, "InferenceMemoryFIL") .def(py::init<>(&InferenceMemoryFILInterfaceProxy::init), py::arg("count"), py::arg("input__0"), @@ -215,7 +224,7 @@ PYBIND11_MODULE(messages, py_mod) .def_property( "seq_ids", &InferenceMemoryFILInterfaceProxy::get_seq_ids, &InferenceMemoryFILInterfaceProxy::set_seq_ids); - py::class_>(py_mod, + py::class_>(_module, "MultiInferenceMessage") .def(py::init<>(&MultiInferenceMessageInterfaceProxy::init), py::arg("meta"), @@ -231,7 +240,7 @@ PYBIND11_MODULE(messages, py_mod) .def("get_slice", &MultiInferenceMessageInterfaceProxy::get_slice, py::return_value_policy::reference_internal); py::class_>( - py_mod, "MultiInferenceNLPMessage") + _module, "MultiInferenceNLPMessage") .def(py::init<>(&MultiInferenceNLPMessageInterfaceProxy::init), py::arg("meta"), py::arg("mess_offset"), @@ -247,7 +256,7 @@ PYBIND11_MODULE(messages, py_mod) .def_property_readonly("seq_ids", &MultiInferenceNLPMessageInterfaceProxy::seq_ids); py::class_>( - py_mod, "MultiInferenceFILMessage") + _module, "MultiInferenceFILMessage") .def(py::init<>(&MultiInferenceFILMessageInterfaceProxy::init), py::arg("meta"), py::arg("mess_offset"), @@ -259,23 +268,24 @@ PYBIND11_MODULE(messages, py_mod) .def_property_readonly("offset", &MultiInferenceFILMessageInterfaceProxy::offset) .def_property_readonly("count", &MultiInferenceFILMessageInterfaceProxy::count); - py::class_>(py_mod, "TensorMemory") + py::class_>(_module, "TensorMemory") .def_readonly("count", &TensorMemory::count); - py::class_>(py_mod, "ResponseMemory") + py::class_>(_module, "ResponseMemory") .def_readonly("count", &ResponseMemory::count) .def("get_output", &ResponseMemoryInterfaceProxy::get_output, py::return_value_policy::reference_internal) .def("get_output_tensor", &ResponseMemoryInterfaceProxy::get_output_tensor, py::return_value_policy::reference_internal); - py::class_>(py_mod, "ResponseMemoryProbs") + py::class_>(_module, + "ResponseMemoryProbs") .def(py::init<>(&ResponseMemoryProbsInterfaceProxy::init), py::arg("count"), py::arg("probs")) .def_property_readonly("count", &ResponseMemoryProbsInterfaceProxy::count) .def_property( "probs", &ResponseMemoryProbsInterfaceProxy::get_probs, &ResponseMemoryProbsInterfaceProxy::set_probs); - py::class_>(py_mod, + py::class_>(_module, "MultiResponseMessage") .def(py::init<>(&MultiResponseMessageInterfaceProxy::init), py::arg("meta"), @@ -290,7 +300,7 @@ PYBIND11_MODULE(messages, py_mod) .def("get_output", &MultiResponseMessageInterfaceProxy::get_output); py::class_>( - py_mod, "MultiResponseProbsMessage") + _module, "MultiResponseProbsMessage") .def(py::init<>(&MultiResponseProbsMessageInterfaceProxy::init), py::arg("meta"), py::arg("mess_offset"), @@ -303,7 +313,7 @@ PYBIND11_MODULE(messages, py_mod) .def_property_readonly("count", &MultiResponseProbsMessageInterfaceProxy::count) .def_property_readonly("probs", &MultiResponseProbsMessageInterfaceProxy::probs); - py_mod.attr("__version__") = + _module.attr("__version__") = MRC_CONCAT_STR(morpheus_VERSION_MAJOR << "." << morpheus_VERSION_MINOR << "." << morpheus_VERSION_PATCH); } } // namespace morpheus diff --git a/morpheus/_lib/tests/io/test_data_loader.cpp b/morpheus/_lib/tests/io/test_data_loader.cpp index 29d515cda4..b2159c10ce 100644 --- a/morpheus/_lib/tests/io/test_data_loader.cpp +++ b/morpheus/_lib/tests/io/test_data_loader.cpp @@ -63,9 +63,9 @@ TEST_F(TestDataLoader, DataLoaderRegisterLoaderTest) } // data_loader.register_loader("file", std::make_unique()); - data_loader.register_loader("grpc", std::make_unique()); - data_loader.register_loader("payload", std::make_unique()); - data_loader.register_loader("rest", std::make_unique()); + data_loader.add_loader("grpc", std::make_unique()); + data_loader.add_loader("payload", std::make_unique()); + data_loader.add_loader("rest", std::make_unique()); for (auto& loader : loaders) { @@ -76,13 +76,31 @@ TEST_F(TestDataLoader, DataLoaderRegisterLoaderTest) } } +TEST_F(TestDataLoader, DataLoaderRemoveLoaderTest) +{ + auto data_loader = DataLoader(); + + nlohmann::json config; + config["loader_id"] = "grpc"; + + auto msg = MessageControl(config); + + EXPECT_THROW(data_loader.load(msg), std::runtime_error); + data_loader.add_loader("grpc", std::make_unique()); + + EXPECT_NO_THROW(data_loader.load(msg)); + + data_loader.remove_loader("grpc"); + EXPECT_THROW(data_loader.load(msg), std::runtime_error); +} + /** * @brief Check that we can send a control message, with a raw data payload and load it correctly. */ TEST_F(TestDataLoader, PayloadLoaderTest) { auto data_loader = DataLoader(); - data_loader.register_loader("payload", std::make_unique()); + data_loader.add_loader("payload", std::make_unique()); nlohmann::json config; config["loader_id"] = "payload"; @@ -102,9 +120,9 @@ TEST_F(TestDataLoader, PayloadLoaderTest) TEST_F(TestDataLoader, FileLoaderTest) { auto data_loader = DataLoader(); - data_loader.register_loader("file", std::make_unique()); + data_loader.add_loader("file", std::make_unique()); - auto string_df = create_mock_dataframe({"col1", "col2", "col3"}, {"int32", "float32", "string"}, 5); + auto string_df = create_mock_csv_file({"col1", "col2", "col3"}, {"int32", "float32", "string"}, 5); char temp_file[] = "/tmp/morpheus_test_XXXXXXXX"; int fd = mkstemp(temp_file); @@ -115,8 +133,10 @@ TEST_F(TestDataLoader, FileLoaderTest) nlohmann::json config; config["loader_id"] = "file"; - config["strategy"] = "merge"; - config["files"] = {std::string(temp_file)}; + config["strategy"] = "aggregate"; + config["files"] = nlohmann::json::array(); + + config["files"].push_back({{"path", std::string(temp_file)}, {"type", "csv"}}); auto msg = MessageControl(config); diff --git a/morpheus/_lib/tests/messages/test_control_message.cpp b/morpheus/_lib/tests/messages/test_control_message.cpp index 79a9e3a5f1..8f0cdd3c1c 100644 --- a/morpheus/_lib/tests/messages/test_control_message.cpp +++ b/morpheus/_lib/tests/messages/test_control_message.cpp @@ -19,6 +19,9 @@ #include "test_messages.hpp" #include "morpheus/messages/control.hpp" +#include "morpheus/messages/meta.hpp" + +#include using namespace morpheus; using namespace morpheus::test; @@ -32,21 +35,40 @@ TEST_F(TestControlMessage, InitializationTest) auto msg_two = MessageControl(config); - ASSERT_EQ(msg_two.message().contains("some_value"), true); - ASSERT_EQ(msg_two.message()["some_value"], "42"); + ASSERT_EQ(msg_two.config().contains("some_value"), true); + ASSERT_EQ(msg_two.config()["some_value"], "42"); } TEST_F(TestControlMessage, SetMessageTest) { auto msg = MessageControl(); - ASSERT_EQ(msg.message().contains("some_value"), false); + ASSERT_EQ(msg.config().contains("some_value"), false); auto config = nlohmann::json(); config["some_value"] = "42"; - msg.message(config); + msg.config(config); + + ASSERT_EQ(msg.config().contains("some_value"), true); + ASSERT_EQ(msg.config()["some_value"], "42"); +} + +TEST_F(TestControlMessage, PayloadTest) +{ + auto msg = MessageControl(); + + ASSERT_EQ(msg.payload(), nullptr); + + auto null_payload = std::shared_ptr(nullptr); + + msg.payload(null_payload); + + ASSERT_EQ(msg.payload(), null_payload); + + auto data_payload = create_mock_msg_meta({"col1", "col2", "col3"}, {"int32", "float32", "string"}, 5); + + msg.payload(data_payload); - ASSERT_EQ(msg.message().contains("some_value"), true); - ASSERT_EQ(msg.message()["some_value"], "42"); + ASSERT_EQ(msg.payload(), data_payload); } \ No newline at end of file diff --git a/morpheus/_lib/tests/messages/test_messages.hpp b/morpheus/_lib/tests/messages/test_messages.hpp index c10096d75d..8181b3ccaa 100644 --- a/morpheus/_lib/tests/messages/test_messages.hpp +++ b/morpheus/_lib/tests/messages/test_messages.hpp @@ -20,13 +20,6 @@ #include "../test_morpheus.hpp" // IWYU pragma: associated namespace morpheus::test { -class TestMessages : public ::testing::Test -{ - protected: - void SetUp() override {} - void TearDown() override {} -}; - -using TestControlMessage = TestMessages; +using TestControlMessage = TestWithPythonInterpreter; // NOLINT } // namespace morpheus::test \ No newline at end of file diff --git a/morpheus/_lib/tests/test_morpheus.cpp b/morpheus/_lib/tests/test_morpheus.cpp index 09723019b3..a608c18285 100644 --- a/morpheus/_lib/tests/test_morpheus.cpp +++ b/morpheus/_lib/tests/test_morpheus.cpp @@ -71,7 +71,7 @@ std::filesystem::path get_morpheus_root() return std::filesystem::path{root}; } -std::string create_mock_dataframe(std::vector cols, std::vector dtypes, std::size_t rows) +std::string create_mock_csv_file(std::vector cols, std::vector dtypes, std::size_t rows) { assert(cols.size() == dtypes.size()); static std::vector random_strings = {"field1", "test123", "abc", "xyz", "123", "foo", "bar", "baz"}; @@ -102,7 +102,7 @@ std::string create_mock_dataframe(std::vector cols, std::vector create_mock_msg_meta(std::vector cols, std::vector dtypes, std::size_t rows) { - auto string_df = create_mock_dataframe(cols, dtypes, rows); + auto string_df = create_mock_csv_file(cols, dtypes, rows); pybind11::gil_scoped_acquire gil; pybind11::module_ mod_cudf; diff --git a/morpheus/_lib/tests/test_morpheus.hpp b/morpheus/_lib/tests/test_morpheus.hpp index 6098f60840..4df63bb9c1 100644 --- a/morpheus/_lib/tests/test_morpheus.hpp +++ b/morpheus/_lib/tests/test_morpheus.hpp @@ -63,7 +63,7 @@ class TestWithPythonInterpreter : public ::testing::Test */ std::filesystem::path get_morpheus_root(); -std::string create_mock_dataframe(std::vector cols, std::vector dtypes, std::size_t rows); +std::string create_mock_csv_file(std::vector cols, std::vector dtypes, std::size_t rows); std::shared_ptr create_mock_msg_meta(std::vector cols, std::vector dtypes, diff --git a/tests/messages/test_control_message.py b/tests/messages/test_control_message.py index a1e1cb01c7..bf876de8d6 100644 --- a/tests/messages/test_control_message.py +++ b/tests/messages/test_control_message.py @@ -72,3 +72,4 @@ def test_control_message_set_and_get_payload(): test_control_message_init() test_control_message_get() test_control_message_set() + test_control_message_set_and_get_payload() diff --git a/tests/modules/test_morpheus_modules.py b/tests/modules/test_morpheus_modules.py index a21261547a..d24e23ca87 100644 --- a/tests/modules/test_morpheus_modules.py +++ b/tests/modules/test_morpheus_modules.py @@ -26,6 +26,18 @@ import morpheus.messages as messages +def on_next(data): + pass + + +def on_error(): + pass + + +def on_complete(): + pass + + def test_contains_namespace(): registry = mrc.ModuleRegistry @@ -56,11 +68,115 @@ def test_get_module(): module_instance = fn_constructor("ModuleDataLoaderTest", config) +def test_get_module_with_bad_config_no_loaders(): + def init_wrapper(builder: mrc.Builder): + def gen_data(): + for i in range(packet_count): + config = {"loader_id": "payload"} + msg = messages.MessageControl(config) + yield msg + + source = builder.make_source("source", gen_data) + + config = {"loaders": []} + # This will unpack the config and forward it's payload (MessageMeta) to the sink + data_loader = builder.load_module("DataLoader", "morpheus", "ModuleDataLoaderTest", config) + + sink = builder.make_sink("sink", on_next, on_error, on_complete) + + builder.make_edge(source, data_loader.input_port("input")) + builder.make_edge(data_loader.output_port("output"), sink) + + pipeline = mrc.Pipeline() + pipeline.make_segment("main", init_wrapper) + + options = mrc.Options() + options.topology.user_cpuset = "0-1" + + executor = mrc.Executor(options) + executor.register_pipeline(pipeline) + + try: + executor.start() + assert (False, "This should fail, because no loaders were specified in the config and none were added.") + executor.join() + except Exception: + pass + + +def test_get_module_with_bad_loader_type(): + def init_wrapper(builder: mrc.Builder): + def gen_data(): + for i in range(packet_count): + config = {"loader_id": "payload"} + msg = messages.MessageControl(config) + yield msg + + source = builder.make_source("source", gen_data) + + config = {"loaders": ["not_a_loader(tm)"]} + # This will unpack the config and forward it's payload (MessageMeta) to the sink + data_loader = builder.load_module("DataLoader", "morpheus", "ModuleDataLoaderTest", config) + + sink = builder.make_sink("sink", on_next, on_error, on_complete) + + builder.make_edge(source, data_loader.input_port("input")) + builder.make_edge(data_loader.output_port("output"), sink) + + pipeline = mrc.Pipeline() + try: + pipeline.make_segment("main", init_wrapper) + assert (False, "This should fail, because the loader type is not a valid loader") + except Exception: + pass + + +def test_get_module_with_bad_control_message(): + def init_wrapper(builder: mrc.Builder): + def gen_data(): + for i in range(packet_count): + config = {"loader_id": "not_a_loader(tm)"} + msg = messages.MessageControl(config) + yield msg + + source = builder.make_source("source", gen_data) + + config = {"loaders": ["payload"]} + # This will unpack the config and forward its payload (MessageMeta) to the sink + data_loader = builder.load_module("DataLoader", "morpheus", "ModuleDataLoaderTest", config) + + sink = builder.make_sink("sink", on_next, on_error, on_complete) + + builder.make_edge(source, data_loader.input_port("input")) + builder.make_edge(data_loader.output_port("output"), sink) + + pipeline = mrc.Pipeline() + pipeline.make_segment("main", init_wrapper) + + options = mrc.Options() + options.topology.user_cpuset = "0-1" + + executor = mrc.Executor(options) + executor.register_pipeline(pipeline) + + try: + executor.start() + assert (False, "We should never get here, because the control message specifies an invalid loader") + executor.join() + except Exception: + pass + + packet_count = 5 packets_received = 0 def test_payload_loader_module(): + registry = mrc.ModuleRegistry + + fn_constructor = registry.get_module_constructor("DataLoader", "morpheus") + assert fn_constructor is not None + def init_wrapper(builder: mrc.Builder): df = cudf.DataFrame({ 'col1': [1, 2, 3, 4, 5], @@ -80,29 +196,18 @@ def gen_data(): yield msg - def on_next(data): + def _on_next(data): global packets_received packets_received += 1 assert (data.df == df) - def on_error(): - pass - - def on_complete(): - pass - - registry = mrc.ModuleRegistry - - fn_constructor = registry.get_module_constructor("DataLoader", "morpheus") - assert fn_constructor is not None - source = builder.make_source("source", gen_data) - config = {"loaders": "payload"} - # This will unpack the config and forward it's payload (MessageMeta) to the sink + config = {"loaders": ["payload"]} + # This will unpack the config and forward its payload (MessageMeta) to the sink data_loader = builder.load_module("DataLoader", "morpheus", "ModuleDataLoaderTest", config) - sink = builder.make_sink("sink", on_next, on_error, on_complete) + sink = builder.make_sink("sink", _on_next, on_error, on_complete) builder.make_edge(source, data_loader.input_port("input")) builder.make_edge(data_loader.output_port("output"), sink) @@ -179,17 +284,11 @@ def gen_data(): msg = messages.MessageControl(config) yield msg - def on_next(data): + def _on_next(data): global packets_received packets_received += 1 assert (data.df == df) - def on_error(): - pass - - def on_complete(): - pass - registry = mrc.ModuleRegistry fn_constructor = registry.get_module_constructor("DataLoader", "morpheus") @@ -197,11 +296,11 @@ def on_complete(): source = builder.make_source("source", gen_data) - config = {"loaders": "file"} + config = {"loaders": ["file"]} # This will unpack the config and forward its payload (MessageMeta) to the sink data_loader = builder.load_module("DataLoader", "morpheus", "ModuleDataLoaderTest", config) - sink = builder.make_sink("sink", on_next, on_error, on_complete) + sink = builder.make_sink("sink", _on_next, on_error, on_complete) builder.make_edge(source, data_loader.input_port("input")) builder.make_edge(data_loader.output_port("output"), sink) diff --git a/tests/objects/test_loader_registry.py b/tests/objects/test_loader_registry.py new file mode 100644 index 0000000000..d421b83bf3 --- /dev/null +++ b/tests/objects/test_loader_registry.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python +# SPDX-FileCopyrightText: Copyright (c) 2022-2023, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import mrc +import cudf + +import morpheus.messages as messages +import morpheus._lib.messages as _messages + + +def test_loader_registry(): + def csv_test_loader(control_message: messages.MessageControl): + config = control_message.config() + if ('files' not in config): + raise ValueError("No files specified in config") + files = config['files'] + + df = None + for file in files: + filepath = file['path'] + if df is None: + df = cudf.read_csv(filepath) + else: + df = df.append(cudf.read_csv(filepath)) + + return messages.MessageMeta(df) + + _messages.DataLoaderRegistry.register_loader("csv", csv_test_loader) \ No newline at end of file From 17aa7b3e5563642a127004040bedc7ae3ac08e16 Mon Sep 17 00:00:00 2001 From: Devin Robison Date: Wed, 15 Feb 2023 17:08:16 -0700 Subject: [PATCH 015/157] Test fixes --- tests/messages/test_control_message.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/tests/messages/test_control_message.py b/tests/messages/test_control_message.py index bf876de8d6..590533d7b2 100644 --- a/tests/messages/test_control_message.py +++ b/tests/messages/test_control_message.py @@ -31,25 +31,25 @@ def test_control_message_get(): raw_control_message = _messages.MessageControl({"test": "test_rcm"}) control_message = messages.MessageControl({"test": "test_cm"}) - assert "test" in raw_control_message.message() - assert raw_control_message.message()["test"] == "test_rcm" + assert "test" in raw_control_message.config() + assert raw_control_message.config()["test"] == "test_rcm" - assert "test" in control_message.message() - assert control_message.message()["test"] == "test_cm" + assert "test" in control_message.config() + assert control_message.config()["test"] == "test_cm" def test_control_message_set(): raw_control_message = _messages.MessageControl() control_message = messages.MessageControl() - raw_control_message.message({"test": "test_rcm"}) - control_message.message({"test": "test_cm"}) + raw_control_message.config({"test": "test_rcm"}) + control_message.config({"test": "test_cm"}) - assert "test" in raw_control_message.message() - assert raw_control_message.message()["test"] == "test_rcm" + assert "test" in raw_control_message.config() + assert raw_control_message.config()["test"] == "test_rcm" - assert "test" in control_message.message() - assert control_message.message()["test"] == "test_cm" + assert "test" in control_message.config() + assert control_message.config()["test"] == "test_cm" def test_control_message_set_and_get_payload(): From c1f42862fd4d6cbdf4fad6915e5c8c94bd88f8d2 Mon Sep 17 00:00:00 2001 From: bsuryadevara Date: Wed, 15 Feb 2023 23:36:08 -0600 Subject: [PATCH 016/157] Added non linear modules stage for dfp pipeline --- .../morpheus/benchmarks/dfp_config.py | 2 +- .../morpheus/dfp/modules/dfp_deployment.py | 87 +++++++++++++ .../morpheus/dfp/modules/dfp_inf.py | 1 - .../morpheus/dfp/modules/dfp_preproc.py | 10 +- .../morpheus/dfp/utils/config_generator.py | 30 +++-- .../morpheus/dfp/utils/module_ids.py | 1 + .../morpheus/dfp_modules_pipeline.py | 71 ++++------- .../stages/general/nonlinear_modules_stage.py | 120 ++++++++++++++++++ 8 files changed, 258 insertions(+), 64 deletions(-) create mode 100644 examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_deployment.py create mode 100644 morpheus/stages/general/nonlinear_modules_stage.py diff --git a/examples/digital_fingerprinting/production/morpheus/benchmarks/dfp_config.py b/examples/digital_fingerprinting/production/morpheus/benchmarks/dfp_config.py index 4ed8fdc42b..bac08ba44c 100644 --- a/examples/digital_fingerprinting/production/morpheus/benchmarks/dfp_config.py +++ b/examples/digital_fingerprinting/production/morpheus/benchmarks/dfp_config.py @@ -271,7 +271,7 @@ def get_stages_conf(self) -> typing.Dict[str, any]: stages_conf["sampling_rate_s"] = 0 stages_conf["cache_dir"] = "./.cache/dfp" stages_conf["include_generic"] = True - stages_conf["include_individual"] = [] + stages_conf["include_individual"] = False stages_conf["skip_users"] = [] stages_conf["only_users"] = [] stages_conf["model_name_formatter"] = self._get_model_name_formatter() diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_deployment.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_deployment.py new file mode 100644 index 0000000000..3e3b19a193 --- /dev/null +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_deployment.py @@ -0,0 +1,87 @@ +# Copyright (c) 2023, NVIDIA CORPORATION. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import logging + +import dfp.modules.dfp_inf # noqa: F401 +import dfp.modules.dfp_preproc # noqa: F401 +import dfp.modules.dfp_tra # noqa: F401 +import mrc +from mrc.core.node import Broadcast + +from morpheus.utils.module_ids import MODULE_NAMESPACE +from morpheus.utils.module_utils import get_module_config +from morpheus.utils.module_utils import load_module +from morpheus.utils.module_utils import register_module + +from ..utils.module_ids import DFP_DEPLOYMENT +from ..utils.module_ids import DFP_INF +from ..utils.module_ids import DFP_PREPROC +from ..utils.module_ids import DFP_TRA + +logger = logging.getLogger(__name__) + + +@register_module(DFP_DEPLOYMENT, MODULE_NAMESPACE) +def dfp_inf(builder: mrc.Builder): + + module_config = get_module_config(DFP_DEPLOYMENT, builder) + + preproc_conf = module_config.get(DFP_PREPROC, None) + infer_conf = module_config.get(DFP_INF, None) + train_conf = module_config.get(DFP_TRA, None) + + if "output_port_count" not in module_config: + raise Exception("Missing required attribute 'output_port_count'") + + output_port_count = module_config.get("output_port_count") + + preproc_module = load_module(preproc_conf, builder=builder) + + out_streams = [] + + if (train_conf is not None and infer_conf is not None): + + # Load module from registry. + infer_module = load_module(infer_conf, builder=builder) + train_module = load_module(train_conf, builder=builder) + + # Create broadcast node to fork the pipeline. + boradcast_node = Broadcast(builder, "broadcast") + + builder.make_edge(preproc_module.output_port("output"), boradcast_node) + builder.make_edge(boradcast_node, infer_module.input_port("input")) + builder.make_edge(boradcast_node, train_module.input_port("input")) + + out_streams = [train_module.output_port("output"), infer_module.output_port("output")] + + elif infer_conf is not None: + infer_module = load_module(infer_conf, builder=builder) + builder.make_edge(preproc_module.output_port("output"), infer_module.input_port("input")) + out_streams = [infer_module.output_port("output")] + + elif train_conf is not None: + train_module = load_module(train_conf, builder=builder) + builder.make_edge(preproc_module.output_port("output"), train_module.input_port("input")) + out_streams = [train_module.output_port("output")] + + else: + raise Exception("Expected DFP deployment workload_types are not found.") + + # Register input and output port for a module. + builder.register_module_input("input", preproc_module.input_port("input")) + + for i in range(output_port_count): + # Output ports are registered in increment order. + builder.register_module_output(f"output-{i}", out_streams[i]) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inf.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inf.py index 00933f2506..07608d0eef 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inf.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inf.py @@ -18,7 +18,6 @@ import dfp.modules.dfp_inference # noqa: F401 import dfp.modules.dfp_postprocessing # noqa: F401 import dfp.modules.dfp_rolling_window # noqa: F401 -import dfp.modules.dfp_split_users # noqa: F401 import mrc import morpheus.modules.filter_detections # noqa: F401 diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_preproc.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_preproc.py index 03cd8ab91e..4183488cad 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_preproc.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_preproc.py @@ -1,4 +1,4 @@ -# Copyright (c) 2022-2023, NVIDIA CORPORATION. +# Copyright (c) 2023, NVIDIA CORPORATION. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -35,7 +35,7 @@ @register_module(DFP_PREPROC, MODULE_NAMESPACE) def dfp_preproc(builder: mrc.Builder): """ - This module function allows for the consolidation of multiple dfp pipeline modules relevent to inference + This module function allows for the consolidation of multiple dfp pipeline modules relevent to inference/training process into a single module. Parameters @@ -53,12 +53,12 @@ def dfp_preproc(builder: mrc.Builder): # Load modules file_batcher_module = load_module(file_batcher_conf, builder=builder) file_to_dataframe_module = load_module(file_to_df_conf, builder=builder) - dfp_split_users_modules = load_module(dfp_split_users_conf, builder=builder) + dfp_split_users_module = load_module(dfp_split_users_conf, builder=builder) # Make an edge between the modules. builder.make_edge(file_batcher_module.output_port("output"), file_to_dataframe_module.input_port("input")) - builder.make_edge(file_to_dataframe_module.output_port("output"), dfp_split_users_modules.input_port("input")) + builder.make_edge(file_to_dataframe_module.output_port("output"), dfp_split_users_module.input_port("input")) # Register input and output port for a module. builder.register_module_input("input", file_batcher_module.input_port("input")) - builder.register_module_output("output", dfp_split_users_modules.output_port("output")) + builder.register_module_output("output", dfp_split_users_module.output_port("output")) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py b/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py index 7a9bf5ad36..24de71df16 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py @@ -17,6 +17,7 @@ from dfp.utils.derive_args import DeriveArgs from dfp.utils.derive_args import pyobj2str from dfp.utils.module_ids import DFP_DATA_PREP +from dfp.utils.module_ids import DFP_DEPLOYMENT from dfp.utils.module_ids import DFP_INF from dfp.utils.module_ids import DFP_INFERENCE from dfp.utils.module_ids import DFP_INFERENCE_PIPELINE @@ -44,6 +45,8 @@ from morpheus.utils.module_ids import SERIALIZE from morpheus.utils.module_ids import WRITE_TO_FILE +TRAINING_AND_INFERENCE = "Training and Inference" + class ConfigGenerator: @@ -60,15 +63,24 @@ def get_conf(self): conf = {} - conf["preproc"] = self.preproc_conf() + conf["module_id"] = DFP_DEPLOYMENT + conf["module_name"] = "dfp_deployment" + conf["namespace"] = MODULE_NAMESPACE + conf["output_port_count"] = 1 + + conf[DFP_PREPROC] = self.preproc_conf() if self._derive_args.is_train_and_infer: - conf["training"] = self.train_conf() - conf["inference"] = self.infer_conf() + conf[DFP_TRA] = self.train_conf() + conf[DFP_INF] = self.infer_conf() + conf["output_port_count"] = 2 + conf["workload"] = TRAINING_AND_INFERENCE elif self._derive_args.is_training: - conf["training"] = self.train_conf() + conf[DFP_TRA] = self.train_conf() + conf["workload"] = DFP_TRAINING else: - conf["inference"] = self.infer_conf() + conf[DFP_INF] = self.infer_conf() + conf["workload"] = DFP_INFERENCE return conf @@ -126,7 +138,7 @@ def infer_conf(self): "namespace": MODULE_NAMESPACE, DFP_ROLLING_WINDOW: { "module_id": DFP_ROLLING_WINDOW, - "module_name": "dfp_rolling_window", + "module_name": "dfp_rolling_window_infer", "namespace": MODULE_NAMESPACE, "min_history": 1, "min_increment": 0, @@ -136,7 +148,7 @@ def infer_conf(self): }, DFP_DATA_PREP: { "module_id": DFP_DATA_PREP, - "module_name": "dfp_data_prep", + "module_name": "dfp_data_prep_infer", "namespace": MODULE_NAMESPACE, "timestamp_column_name": self._config.ae.timestamp_column_name, "schema": { @@ -193,7 +205,7 @@ def train_conf(self): "namespace": MODULE_NAMESPACE, DFP_ROLLING_WINDOW: { "module_id": DFP_ROLLING_WINDOW, - "module_name": "dfp_rolling_window", + "module_name": "dfp_rolling_window_tra", "namespace": MODULE_NAMESPACE, "min_history": 300, "min_increment": 300, @@ -203,7 +215,7 @@ def train_conf(self): }, DFP_DATA_PREP: { "module_id": DFP_DATA_PREP, - "module_name": "dfp_data_prep", + "module_name": "dfp_data_prep_tra", "namespace": MODULE_NAMESPACE, "timestamp_column_name": self._config.ae.timestamp_column_name, "schema": { diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/utils/module_ids.py b/examples/digital_fingerprinting/production/morpheus/dfp/utils/module_ids.py index 3a76a6e324..d0af919394 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/utils/module_ids.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/utils/module_ids.py @@ -25,3 +25,4 @@ DFP_PREPROC = "DFPPreproc" DFP_INF = "DFPInf" DFP_TRA = "DFPTra" +DFP_DEPLOYMENT = "DFPDeployment" diff --git a/examples/digital_fingerprinting/production/morpheus/dfp_modules_pipeline.py b/examples/digital_fingerprinting/production/morpheus/dfp_modules_pipeline.py index 742fce5473..ea2fe26d62 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp_modules_pipeline.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp_modules_pipeline.py @@ -17,10 +17,9 @@ from datetime import datetime import click -import dfp.modules.dfp_inf # noqa: F401 -import dfp.modules.dfp_preproc # noqa: F401 -import dfp.modules.dfp_tra # noqa: F401 +import dfp.modules.dfp_deployment # noqa: F401 from dfp.stages.multi_file_source import MultiFileSource +from dfp.utils.config_generator import TRAINING_AND_INFERENCE from dfp.utils.config_generator import ConfigGenerator from dfp.utils.config_generator import generate_ae_config from dfp.utils.derive_args import DeriveArgs @@ -31,9 +30,8 @@ from morpheus.cli.utils import parse_log_level from morpheus.config import Config from morpheus.pipeline.pipeline import Pipeline -from morpheus.stages.general.broadcast_stage import BroadcastStage -from morpheus.stages.general.linear_modules_stage import LinearModulesStage from morpheus.stages.general.monitor_stage import MonitorStage +from morpheus.stages.general.nonlinear_modules_stage import NonLinearModulesStage @click.command() @@ -145,56 +143,33 @@ def run_pipeline(log_type: str, config_generator = ConfigGenerator(config, derive_args, schema) conf = config_generator.get_conf() + workload = conf.get("workload") # Create a pipeline object pipeline = Pipeline(config) source_stage = pipeline.add_stage(MultiFileSource(config, filenames=list(kwargs["input_file"]))) - # Here we add a wrapped module that implements the DFP Inference pipeline - preproc_stage = pipeline.add_stage( - LinearModulesStage(config, conf.get("preproc"), input_port_name="input", output_port_name="output")) - - pipeline.add_edge(source_stage, preproc_stage) - - if "training" in conf and "inference" in conf: - broadcast_stage = pipeline.add_stage(BroadcastStage(config, output_port_count=2)) - - pipeline.add_edge(preproc_stage, broadcast_stage) - - inf_stage = pipeline.add_stage( - LinearModulesStage(config, conf.get("inference"), input_port_name="input", output_port_name="output")) - - tra_stage = pipeline.add_stage( - LinearModulesStage(config, conf.get("training"), input_port_name="input", output_port_name="output")) - - inf_mntr_stage = pipeline.add_stage(MonitorStage(config, description="Inference Pipeline rate", - smoothing=0.001)) - tra_mntr_stage = pipeline.add_stage(MonitorStage(config, description="Training Pipeline rate", smoothing=0.001)) - - pipeline.add_edge(broadcast_stage.output_ports[0], inf_stage) - pipeline.add_edge(broadcast_stage.output_ports[1], tra_stage) - pipeline.add_edge(inf_stage, inf_mntr_stage) - pipeline.add_edge(tra_stage, tra_mntr_stage) - - elif "training" in conf: - - tra_stage = pipeline.add_stage( - LinearModulesStage(config, conf.get("training"), input_port_name="input", output_port_name="output")) - mntr_stage = pipeline.add_stage(MonitorStage(config, description="Training Pipeline rate", smoothing=0.001)) - pipeline.add_edge(preproc_stage, tra_stage) - pipeline.add_edge(tra_stage, mntr_stage) - - elif "inference" in conf: - inf_stage = pipeline.add_stage( - LinearModulesStage(config, conf.get("inference"), input_port_name="input", output_port_name="output")) - inf_mntr_stage = pipeline.add_stage(MonitorStage(config, description="Inference Pipeline rate", - smoothing=0.001)) - pipeline.add_edge(preproc_stage, inf_stage) - pipeline.add_edge(inf_stage, inf_mntr_stage) - + # Here we add a wrapped module that implements the DFP Deployment + dfp_deployment_stage = pipeline.add_stage( + NonLinearModulesStage(config, + conf, + input_port_name="input", + output_port_name="output", + output_port_count=conf.get("output_port_count"))) + + pipeline.add_edge(source_stage, dfp_deployment_stage) + + if workload == TRAINING_AND_INFERENCE: + inf_mntr_stage = pipeline.add_stage( + MonitorStage(config, description="DFPInference Pipeline rate", smoothing=0.001)) + tra_mntr_stage = pipeline.add_stage( + MonitorStage(config, description="DFPTraining Pipeline rate", smoothing=0.001)) + pipeline.add_edge(dfp_deployment_stage.output_ports[0], tra_mntr_stage) + pipeline.add_edge(dfp_deployment_stage.output_ports[1], inf_mntr_stage) else: - raise Exception("Required keys not found in the configuration to trigger the pipeline") + mntr_stage = pipeline.add_stage(MonitorStage(config, description=f"{workload} Pipeline rate", smoothing=0.001)) + pipeline.add_edge(dfp_deployment_stage.output_ports[0], mntr_stage) # Run the pipeline pipeline.run() diff --git a/morpheus/stages/general/nonlinear_modules_stage.py b/morpheus/stages/general/nonlinear_modules_stage.py new file mode 100644 index 0000000000..b89c183180 --- /dev/null +++ b/morpheus/stages/general/nonlinear_modules_stage.py @@ -0,0 +1,120 @@ +# Copyright (c) 2023, NVIDIA CORPORATION. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import logging +import typing + +import mrc +from mrc.core.node import Broadcast + +from morpheus.config import Config +from morpheus.pipeline.stage import Stage +from morpheus.pipeline.stream_pair import StreamPair +from morpheus.utils.module_utils import load_module + +logger = logging.getLogger(__name__) + + +class NonLinearModulesStage(Stage): + """ + Loads an existing, registered, MRC SegmentModule and wraps it as a Morpheus Stage. + + Parameters + ---------- + c : `morpheus.config.Config` + Pipeline configuration instance. + module_config : typing.Dict + Module configuration. + input_port_name : str + Name of the input port for the registered module. + output_port_name : str + Name of the output port for the registered module. + output_port_count : str + Number of output ports for the registered module. + input_type : default `typing.Any` + The stage acceptable input type. + output_type : default `typing.Any` + The output type that the stage produces. + + """ + + def __init__(self, + c: Config, + module_config: typing.Dict, + input_port_name: str, + output_port_name: str, + output_port_count: int, + input_type=typing.Any, + output_type=typing.Any): + + super().__init__(c) + + self._input_type = input_type + self._ouput_type = output_type + self._module_config = module_config + self._input_port_name = input_port_name + self._output_port_name = output_port_name + + assert output_port_count > 0, "Output port count must be >= 1" + + self._create_ports(1, output_port_count) + self._output_port_count = output_port_count + + @property + def name(self) -> str: + return self._module_config.get("module_name", "non_linear_module") + + def supports_cpp_node(self): + return False + + def input_types(self) -> typing.Tuple: + """ + Returns input type for the current stage. + """ + + return (typing.Any, ) + + def accepted_types(self) -> typing.Tuple: + """ + Accepted input types for this stage are returned. + + Returns + ------- + typing.Tuple + Accepted input types. + + """ + return (typing.Any, ) + + def _build(self, builder: mrc.Builder, in_stream_pairs: typing.List[StreamPair]) -> typing.List[StreamPair]: + + assert len(in_stream_pairs) == 1, "Only 1 input supported" + + in_stream_node = in_stream_pairs[0][0] + + # Laod module from registry. + module = load_module(self._module_config, builder=builder) + mod_in_stream = module.input_port(self._input_port_name) + + builder.make_edge(in_stream_node, mod_in_stream) + + out_stream_pairs = [] + + count = 0 + while (count < self._output_port_count): + out_port = f"output-{count}" + out_stream_pairs.append((module.output_port(out_port), self._ouput_type)) + count += 1 + + return out_stream_pairs From 2003b7ee1eeb2c5314daddadc783c002095cbcf5 Mon Sep 17 00:00:00 2001 From: bsuryadevara Date: Thu, 16 Feb 2023 09:46:04 -0600 Subject: [PATCH 017/157] Added non linear modules stage for dfp pipeline --- .../morpheus/dfp/modules/dfp_deployment.py | 6 +- .../morpheus/dfp/utils/config_generator.py | 63 +++++++++---------- .../morpheus/dfp_azure_modules_inference.py | 4 +- .../morpheus/dfp_azure_modules_training.py | 4 +- .../morpheus/dfp_duo_modules_inference.py | 4 +- .../morpheus/dfp_duo_modules_training.py | 4 +- .../morpheus/dfp_modules_pipeline.py | 44 +++++++------ 7 files changed, 67 insertions(+), 62 deletions(-) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_deployment.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_deployment.py index 3e3b19a193..3a25bc5148 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_deployment.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_deployment.py @@ -49,8 +49,6 @@ def dfp_inf(builder: mrc.Builder): preproc_module = load_module(preproc_conf, builder=builder) - out_streams = [] - if (train_conf is not None and infer_conf is not None): # Load module from registry. @@ -60,6 +58,7 @@ def dfp_inf(builder: mrc.Builder): # Create broadcast node to fork the pipeline. boradcast_node = Broadcast(builder, "broadcast") + # Make an edge between modules builder.make_edge(preproc_module.output_port("output"), boradcast_node) builder.make_edge(boradcast_node, infer_module.input_port("input")) builder.make_edge(boradcast_node, train_module.input_port("input")) @@ -79,9 +78,10 @@ def dfp_inf(builder: mrc.Builder): else: raise Exception("Expected DFP deployment workload_types are not found.") - # Register input and output port for a module. + # Register input port for a module. builder.register_module_input("input", preproc_module.input_port("input")) + # Register output ports for a module. for i in range(output_port_count): # Output ports are registered in increment order. builder.register_module_output(f"output-{i}", out_streams[i]) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py b/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py index 24de71df16..78a45a12b7 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py @@ -45,8 +45,6 @@ from morpheus.utils.module_ids import SERIALIZE from morpheus.utils.module_ids import WRITE_TO_FILE -TRAINING_AND_INFERENCE = "Training and Inference" - class ConfigGenerator: @@ -59,34 +57,33 @@ def __init__(self, config: Config, derive_args: DeriveArgs, schema: Schema, enco self._preprocess_schema_str = pyobj2str(schema.preprocess, encoding=encoding) self._input_message_type = pyobj2str(MultiMessage, encoding) - def get_conf(self): + def get_module_config(self): - conf = {} + module_config = {} - conf["module_id"] = DFP_DEPLOYMENT - conf["module_name"] = "dfp_deployment" - conf["namespace"] = MODULE_NAMESPACE - conf["output_port_count"] = 1 + module_config["module_id"] = DFP_DEPLOYMENT + module_config["module_name"] = "dfp_deployment" + module_config["namespace"] = MODULE_NAMESPACE + module_config["output_port_count"] = 1 - conf[DFP_PREPROC] = self.preproc_conf() + module_config[DFP_PREPROC] = self.preproc_module_config() if self._derive_args.is_train_and_infer: - conf[DFP_TRA] = self.train_conf() - conf[DFP_INF] = self.infer_conf() - conf["output_port_count"] = 2 - conf["workload"] = TRAINING_AND_INFERENCE + module_config[DFP_TRA] = self.train_module_config() + module_config[DFP_INF] = self.infer_module_config() + module_config["output_port_count"] = 2 elif self._derive_args.is_training: - conf[DFP_TRA] = self.train_conf() - conf["workload"] = DFP_TRAINING + module_config[DFP_TRA] = self.train_module_config() + module_config["workload"] = DFP_TRAINING else: - conf[DFP_INF] = self.infer_conf() - conf["workload"] = DFP_INFERENCE + module_config[DFP_INF] = self.infer_module_config() + module_config["workload"] = DFP_INFERENCE - return conf + return module_config - def preproc_conf(self): + def preproc_module_config(self): - module_conf = { + module_config = { "module_id": DFP_PREPROC, "module_name": "dfp_preproc", "namespace": MODULE_NAMESPACE, @@ -129,10 +126,10 @@ def preproc_conf(self): } } - return module_conf + return module_config - def infer_conf(self): - module_conf = { + def infer_module_config(self): + module_config = { "module_id": DFP_INF, "module_name": "dfp_inf", "namespace": MODULE_NAMESPACE, @@ -195,11 +192,11 @@ def infer_conf(self): } } - return module_conf + return module_config - def train_conf(self): + def train_module_config(self): - module_conf = { + module_config = { "module_id": DFP_TRA, "module_name": "dfp_tra", "namespace": MODULE_NAMESPACE, @@ -262,11 +259,11 @@ def train_conf(self): } } - return module_conf + return module_config - def inf_pipe_module_conf(self): + def inf_pipe_module_config(self): - module_conf = { + module_config = { "module_id": DFP_INFERENCE_PIPELINE, "module_name": "dfp_inference_pipeline", "namespace": MODULE_NAMESPACE, @@ -366,10 +363,10 @@ def inf_pipe_module_conf(self): } } - return module_conf + return module_config - def tra_pipe_module_conf(self): - module_conf = { + def tra_pipe_module_config(self): + module_config = { "module_id": DFP_TRAINING_PIPELINE, "module_name": "dfp_training_pipeline", "namespace": MODULE_NAMESPACE, @@ -469,7 +466,7 @@ def tra_pipe_module_conf(self): } } - return module_conf + return module_config def generate_ae_config(log_type: str, diff --git a/examples/digital_fingerprinting/production/morpheus/dfp_azure_modules_inference.py b/examples/digital_fingerprinting/production/morpheus/dfp_azure_modules_inference.py index 8056e9bc3d..674c4ec132 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp_azure_modules_inference.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp_azure_modules_inference.py @@ -120,7 +120,7 @@ def run_pipeline(skip_user: typing.Tuple[str], config_generator = ConfigGenerator(config, derive_args, schema) - module_conf = config_generator.inf_pipe_module_conf() + module_config = config_generator.inf_pipe_module_config() # Create a linear pipeline object pipeline = LinearPipeline(config) @@ -128,7 +128,7 @@ def run_pipeline(skip_user: typing.Tuple[str], pipeline.set_source(MultiFileSource(config, filenames=list(kwargs["input_file"]))) # Here we add a wrapped module that implements the DFP Inference pipeline - pipeline.add_stage(LinearModulesStage(config, module_conf, input_port_name="input", output_port_name="output")) + pipeline.add_stage(LinearModulesStage(config, module_config, input_port_name="input", output_port_name="output")) pipeline.add_stage(MonitorStage(config, description="Inference Pipeline rate", smoothing=0.001)) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp_azure_modules_training.py b/examples/digital_fingerprinting/production/morpheus/dfp_azure_modules_training.py index c0ad41d2a6..349247bf3b 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp_azure_modules_training.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp_azure_modules_training.py @@ -130,7 +130,7 @@ def run_pipeline(train_users, config_generator = ConfigGenerator(config, derive_args, schema) - module_conf = config_generator.tra_pipe_module_conf() + module_config = config_generator.tra_pipe_module_config() # Create a linear pipeline object pipeline = LinearPipeline(config) @@ -138,7 +138,7 @@ def run_pipeline(train_users, pipeline.set_source(MultiFileSource(config, filenames=list(kwargs["input_file"]))) # Here we add a wrapped module that implements the full DFP Training pipeline - pipeline.add_stage(LinearModulesStage(config, module_conf, input_port_name="input", output_port_name="output")) + pipeline.add_stage(LinearModulesStage(config, module_config, input_port_name="input", output_port_name="output")) pipeline.add_stage(MonitorStage(config, description="Training Pipeline rate", smoothing=0.001)) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp_duo_modules_inference.py b/examples/digital_fingerprinting/production/morpheus/dfp_duo_modules_inference.py index c19099a9d1..d80e6e4da6 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp_duo_modules_inference.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp_duo_modules_inference.py @@ -121,7 +121,7 @@ def run_pipeline(skip_user: typing.Tuple[str], config_generator = ConfigGenerator(config, derive_args, schema) - module_conf = config_generator.inf_pipe_module_conf() + module_config = config_generator.inf_pipe_module_config() # Create a linear pipeline object pipeline = LinearPipeline(config) @@ -129,7 +129,7 @@ def run_pipeline(skip_user: typing.Tuple[str], pipeline.set_source(MultiFileSource(config, filenames=list(kwargs["input_file"]))) # Here we add a wrapped module that implements the DFP Inference pipeline - pipeline.add_stage(LinearModulesStage(config, module_conf, input_port_name="input", output_port_name="output")) + pipeline.add_stage(LinearModulesStage(config, module_config, input_port_name="input", output_port_name="output")) pipeline.add_stage(MonitorStage(config, description="Inference Pipeline rate", smoothing=0.001)) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp_duo_modules_training.py b/examples/digital_fingerprinting/production/morpheus/dfp_duo_modules_training.py index fa61570261..f2c1b83948 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp_duo_modules_training.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp_duo_modules_training.py @@ -130,7 +130,7 @@ def run_pipeline(train_users, config_generator = ConfigGenerator(config, derive_args, schema) - module_conf = config_generator.tra_pipe_module_conf() + module_config = config_generator.tra_pipe_module_config() # Create a linear pipeline object pipeline = LinearPipeline(config) @@ -138,7 +138,7 @@ def run_pipeline(train_users, pipeline.set_source(MultiFileSource(config, filenames=list(kwargs["input_file"]))) # Here we add a wrapped module that implements the full DFP Training pipeline - pipeline.add_stage(LinearModulesStage(config, module_conf, input_port_name="input", output_port_name="output")) + pipeline.add_stage(LinearModulesStage(config, module_config, input_port_name="input", output_port_name="output")) pipeline.add_stage(MonitorStage(config, description="Training Pipeline rate", smoothing=0.001)) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp_modules_pipeline.py b/examples/digital_fingerprinting/production/morpheus/dfp_modules_pipeline.py index ea2fe26d62..7cb91ac310 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp_modules_pipeline.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp_modules_pipeline.py @@ -19,7 +19,6 @@ import click import dfp.modules.dfp_deployment # noqa: F401 from dfp.stages.multi_file_source import MultiFileSource -from dfp.utils.config_generator import TRAINING_AND_INFERENCE from dfp.utils.config_generator import ConfigGenerator from dfp.utils.config_generator import generate_ae_config from dfp.utils.derive_args import DeriveArgs @@ -129,21 +128,26 @@ def run_pipeline(log_type: str, sample_rate_s, duration, log_type, - tracking_uri=kwargs["tracking_uri"], - workload_type=workload_type, - train_users=train_users) + kwargs["tracking_uri"], + workload_type, + train_users) derive_args.init() - config: Config = generate_ae_config(log_type, userid_column_name="username", timestamp_column_name="timestamp") + userid_column_name = "username" + timestamp_column_name = "timestamp" + + config: Config = generate_ae_config(log_type, userid_column_name, timestamp_column_name) schema_builder = SchemaBuilder(config, log_type) schema: Schema = schema_builder.build_schema() config_generator = ConfigGenerator(config, derive_args, schema) - conf = config_generator.get_conf() - workload = conf.get("workload") + module_config = config_generator.get_module_config() + + workload = module_config.get("workload") + output_port_count = module_config.get("output_port_count") # Create a pipeline object pipeline = Pipeline(config) @@ -153,23 +157,27 @@ def run_pipeline(log_type: str, # Here we add a wrapped module that implements the DFP Deployment dfp_deployment_stage = pipeline.add_stage( NonLinearModulesStage(config, - conf, + module_config, input_port_name="input", output_port_name="output", - output_port_count=conf.get("output_port_count"))) + output_port_count=output_port_count)) pipeline.add_edge(source_stage, dfp_deployment_stage) - if workload == TRAINING_AND_INFERENCE: - inf_mntr_stage = pipeline.add_stage( - MonitorStage(config, description="DFPInference Pipeline rate", smoothing=0.001)) - tra_mntr_stage = pipeline.add_stage( - MonitorStage(config, description="DFPTraining Pipeline rate", smoothing=0.001)) - pipeline.add_edge(dfp_deployment_stage.output_ports[0], tra_mntr_stage) - pipeline.add_edge(dfp_deployment_stage.output_ports[1], inf_mntr_stage) + dfp_output_ports = dfp_deployment_stage.output_ports + if len(dfp_output_ports) > 1: + tra_mntr_stage = MonitorStage(config, description="DFPTraining Pipeline rate", smoothing=0.001) + inf_mntr_stage = MonitorStage(config, description="DFPInference Pipeline rate", smoothing=0.001) + + tra_mntr_stage = pipeline.add_stage(tra_mntr_stage) + inf_mntr_stage = pipeline.add_stage(inf_mntr_stage) + + pipeline.add_edge(dfp_output_ports[0], tra_mntr_stage) + pipeline.add_edge(dfp_output_ports[1], inf_mntr_stage) else: - mntr_stage = pipeline.add_stage(MonitorStage(config, description=f"{workload} Pipeline rate", smoothing=0.001)) - pipeline.add_edge(dfp_deployment_stage.output_ports[0], mntr_stage) + monitor_stage = MonitorStage(config, description=f"{workload} Pipeline rate", smoothing=0.001) + monitor_stage = pipeline.add_stage(monitor_stage) + pipeline.add_edge(dfp_output_ports[0], monitor_stage) # Run the pipeline pipeline.run() From 769c85dfa07403838343f5220e64b78223494194 Mon Sep 17 00:00:00 2001 From: bsuryadevara Date: Thu, 16 Feb 2023 11:07:56 -0600 Subject: [PATCH 018/157] style check --- .../production/morpheus/dfp/modules/dfp_rolling_window.py | 2 -- morpheus/stages/general/nonlinear_modules_stage.py | 1 - 2 files changed, 3 deletions(-) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_rolling_window.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_rolling_window.py index 3cbb0e413a..64c70ae84e 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_rolling_window.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_rolling_window.py @@ -119,8 +119,6 @@ def build_window(message: DFPMessageMeta) -> MultiDFPMessage: raise RuntimeError(("Overlapping rolling history detected. " "Rolling history can only be used with non-overlapping batches")) - train_offset = train_df.index.get_loc(first_row_idx) - # Otherwise return a new message return MultiDFPMessage(meta=DFPMessageMeta(df=train_df, user_id=user_id), mess_offset=0, diff --git a/morpheus/stages/general/nonlinear_modules_stage.py b/morpheus/stages/general/nonlinear_modules_stage.py index b89c183180..b1acb693dd 100644 --- a/morpheus/stages/general/nonlinear_modules_stage.py +++ b/morpheus/stages/general/nonlinear_modules_stage.py @@ -16,7 +16,6 @@ import typing import mrc -from mrc.core.node import Broadcast from morpheus.config import Config from morpheus.pipeline.stage import Stage From b53b4df947d041ede53283a54cb2b9bf130844d8 Mon Sep 17 00:00:00 2001 From: bsuryadevara Date: Thu, 16 Feb 2023 11:52:56 -0600 Subject: [PATCH 019/157] modified variable naming conventions --- .../production/morpheus/dfp_modules_pipeline.py | 14 +++++++------- morpheus/stages/general/broadcast_stage.py | 4 ++-- morpheus/stages/general/nonlinear_modules_stage.py | 12 ++++++------ 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp_modules_pipeline.py b/examples/digital_fingerprinting/production/morpheus/dfp_modules_pipeline.py index 7cb91ac310..a1da07805d 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp_modules_pipeline.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp_modules_pipeline.py @@ -118,6 +118,7 @@ def run_pipeline(log_type: str, cache_dir: str, log_level: int, sample_rate_s: int, + tracking_uri, **kwargs): derive_args = DeriveArgs(skip_user, @@ -128,7 +129,7 @@ def run_pipeline(log_type: str, sample_rate_s, duration, log_type, - kwargs["tracking_uri"], + tracking_uri, workload_type, train_users) @@ -159,25 +160,24 @@ def run_pipeline(log_type: str, NonLinearModulesStage(config, module_config, input_port_name="input", - output_port_name="output", + output_port_name_prefix="output", output_port_count=output_port_count)) pipeline.add_edge(source_stage, dfp_deployment_stage) - dfp_output_ports = dfp_deployment_stage.output_ports - if len(dfp_output_ports) > 1: + if len(dfp_deployment_stage.output_ports) > 1: tra_mntr_stage = MonitorStage(config, description="DFPTraining Pipeline rate", smoothing=0.001) inf_mntr_stage = MonitorStage(config, description="DFPInference Pipeline rate", smoothing=0.001) tra_mntr_stage = pipeline.add_stage(tra_mntr_stage) inf_mntr_stage = pipeline.add_stage(inf_mntr_stage) - pipeline.add_edge(dfp_output_ports[0], tra_mntr_stage) - pipeline.add_edge(dfp_output_ports[1], inf_mntr_stage) + pipeline.add_edge(dfp_deployment_stage.output_ports[0], tra_mntr_stage) + pipeline.add_edge(dfp_deployment_stage.output_ports[1], inf_mntr_stage) else: monitor_stage = MonitorStage(config, description=f"{workload} Pipeline rate", smoothing=0.001) monitor_stage = pipeline.add_stage(monitor_stage) - pipeline.add_edge(dfp_output_ports[0], monitor_stage) + pipeline.add_edge(dfp_deployment_stage.output_ports[0], monitor_stage) # Run the pipeline pipeline.run() diff --git a/morpheus/stages/general/broadcast_stage.py b/morpheus/stages/general/broadcast_stage.py index de5c67af51..d1563935c2 100644 --- a/morpheus/stages/general/broadcast_stage.py +++ b/morpheus/stages/general/broadcast_stage.py @@ -40,7 +40,7 @@ def __init__(self, c: Config, output_port_count: int = 2): super().__init__(c) - assert output_port_count > 0 + assert output_port_count > 0, "Output port count must be >= 1" self._create_ports(1, output_port_count) self._output_port_count = output_port_count @@ -78,7 +78,7 @@ def _get_broadcast_node(self, builder) -> Broadcast: def _build(self, builder: mrc.Builder, in_stream_pairs: typing.List[StreamPair]) -> typing.List[StreamPair]: - assert len(in_stream_pairs) == 1, "Only 1 input supported" + assert len(in_stream_pairs) == 1, "Only 1 input is supported" in_stream_node = in_stream_pairs[0][0] output_type = in_stream_pairs[0][1] diff --git a/morpheus/stages/general/nonlinear_modules_stage.py b/morpheus/stages/general/nonlinear_modules_stage.py index b1acb693dd..4a690555bc 100644 --- a/morpheus/stages/general/nonlinear_modules_stage.py +++ b/morpheus/stages/general/nonlinear_modules_stage.py @@ -37,8 +37,8 @@ class NonLinearModulesStage(Stage): Module configuration. input_port_name : str Name of the input port for the registered module. - output_port_name : str - Name of the output port for the registered module. + output_port_name_prefix : str + Prefix name of the output ports for the registered module. output_port_count : str Number of output ports for the registered module. input_type : default `typing.Any` @@ -52,7 +52,7 @@ def __init__(self, c: Config, module_config: typing.Dict, input_port_name: str, - output_port_name: str, + output_port_name_prefix: str, output_port_count: int, input_type=typing.Any, output_type=typing.Any): @@ -63,7 +63,7 @@ def __init__(self, self._ouput_type = output_type self._module_config = module_config self._input_port_name = input_port_name - self._output_port_name = output_port_name + self._output_port_name_prefix = output_port_name_prefix assert output_port_count > 0, "Output port count must be >= 1" @@ -98,7 +98,7 @@ def accepted_types(self) -> typing.Tuple: def _build(self, builder: mrc.Builder, in_stream_pairs: typing.List[StreamPair]) -> typing.List[StreamPair]: - assert len(in_stream_pairs) == 1, "Only 1 input supported" + assert len(in_stream_pairs) == 1, "Only 1 input is supported" in_stream_node = in_stream_pairs[0][0] @@ -112,7 +112,7 @@ def _build(self, builder: mrc.Builder, in_stream_pairs: typing.List[StreamPair]) count = 0 while (count < self._output_port_count): - out_port = f"output-{count}" + out_port = f"{self._output_port_name_prefix}-{count}" out_stream_pairs.append((module.output_port(out_port), self._ouput_type)) count += 1 From b8026c0ff089d0df1ed08dae74c670863bd6d51b Mon Sep 17 00:00:00 2001 From: Devin Robison Date: Thu, 16 Feb 2023 12:43:17 -0700 Subject: [PATCH 020/157] Checkpoint loader registry is working, all pieces in place for initial testing with control messages and data loaders --- .../morpheus/io/data_loader_registry.hpp | 45 ++++++++ .../morpheus/objects/factory_registry.hpp | 61 ++++++----- morpheus/_lib/src/io/data_loader_registry.cpp | 38 +++++-- morpheus/_lib/src/python_modules/common.cpp | 24 ++++ morpheus/_lib/src/python_modules/messages.cpp | 11 +- morpheus/messages/message_control.py | 4 +- tests/io/test_loader_registry.py | 103 ++++++++++++++++++ tests/messages/test_control_message.py | 10 +- tests/modules/test_morpheus_modules.py | 1 - tests/objects/test_loader_registry.py | 41 ------- 10 files changed, 245 insertions(+), 93 deletions(-) create mode 100644 morpheus/_lib/include/morpheus/io/data_loader_registry.hpp create mode 100644 tests/io/test_loader_registry.py delete mode 100644 tests/objects/test_loader_registry.py diff --git a/morpheus/_lib/include/morpheus/io/data_loader_registry.hpp b/morpheus/_lib/include/morpheus/io/data_loader_registry.hpp new file mode 100644 index 0000000000..9be166db6d --- /dev/null +++ b/morpheus/_lib/include/morpheus/io/data_loader_registry.hpp @@ -0,0 +1,45 @@ +/** + * SPDX-FileCopyrightText: Copyright (c) 2021-2023, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include "morpheus/io/data_loader.hpp" +#include "morpheus/objects/factory_registry.hpp" + +#include +#include + +#include +#include +#include +#include +#include + +namespace morpheus { +#pragma GCC visibility push(default) + +using LoaderRegistry = FactoryRegistry; // NOLINT + +struct LoaderRegistryProxy +{ + static void register_proxy_constructor( + const std::string& name, std::function(MessageControl&)> proxy_constructor, + bool throw_if_exists = true); + + static void register_factory_cleanup_fn(const std::string& name); +}; +} // namespace morpheus diff --git a/morpheus/_lib/include/morpheus/objects/factory_registry.hpp b/morpheus/_lib/include/morpheus/objects/factory_registry.hpp index 7195a69a08..c3afd38ed6 100644 --- a/morpheus/_lib/include/morpheus/objects/factory_registry.hpp +++ b/morpheus/_lib/include/morpheus/objects/factory_registry.hpp @@ -14,6 +14,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + +#pragma once + #include "morpheus/io/data_loader.hpp" #include @@ -23,6 +26,7 @@ #include #include #include +#include #include namespace morpheus { @@ -31,8 +35,18 @@ template class FactoryRegistry { public: + static bool contains(const std::string& name) + { + std::lock_guard lock(m_mutex); + return m_object_constructors.count(name) > 0; + } + static std::shared_ptr get_constructor(const std::string& name) { + std::lock_guard lock(m_mutex); + VLOG(2) << "Retrieving factory constructor: " << name << "(" << mrc::boost_type_name() + << ")"; + if (m_object_constructors.count(name) == 0) { throw std::runtime_error("Unknown data loader: " + name); @@ -41,53 +55,44 @@ class FactoryRegistry } static void register_constructor(const std::string& name, - const std::function()>& loader_fn) + const std::function()>& loader_fn, + bool throw_if_exists = true) { + std::lock_guard lock(m_mutex); + VLOG(2) << "Registering factory constructor: " << name << "(" << mrc::boost_type_name() + << ")"; if (m_object_constructors.count(name) > 0) { - throw std::runtime_error("Duplicate data loader registration: " + name); + if (throw_if_exists) + { + throw std::runtime_error("Duplicate data loader registration: " + name); + } } m_object_constructors[name] = loader_fn; } - static void unregister_constructor(const std::string& name, bool optional = false) + static void unregister_constructor(const std::string& name, bool throw_if_missing = true) { + std::lock_guard lock(m_mutex); + VLOG(2) << "Un-registering factory constructor: " << name << "(" << mrc::boost_type_name() + << ")"; if (m_object_constructors.count(name) == 0) { - if (optional) + if (throw_if_missing) { - return; + throw std::runtime_error("Unknown data loader: " + name); } - throw std::runtime_error("Unknown data loader: " + name); + + return; } m_object_constructors.erase(name); } private: - static std::map()>> m_object_constructors; + static std::mutex m_mutex; + static std::map()>> m_object_constructors; }; -// TODO(Devin): this shouldn't be templated, and should be specific to Loader -template -struct FactoryRegistryProxy -{ - template - static void register_proxy_constructor(const std::string& name, - std::function proxy_constructor); - - static void register_factory_cleanup_fn(const std::string& name) - { - { - auto at_exit = pybind11::module_::import("atexit"); - at_exit.attr("register")(pybind11::cpp_function([name]() { - VLOG(2) << "(atexit) Unregistering loader: " << name; - - // Try unregister -- ignore if already unregistered - FactoryRegistry::unregister_constructor(name, true); - })); - } - } -}; #pragma GCC visibility pop } // namespace morpheus \ No newline at end of file diff --git a/morpheus/_lib/src/io/data_loader_registry.cpp b/morpheus/_lib/src/io/data_loader_registry.cpp index e019e5aa79..332d605467 100644 --- a/morpheus/_lib/src/io/data_loader_registry.cpp +++ b/morpheus/_lib/src/io/data_loader_registry.cpp @@ -15,6 +15,8 @@ * limitations under the License. */ +#include "morpheus/io/data_loader_registry.hpp" + #include "morpheus/io/data_loader.hpp" #include "morpheus/io/loaders/lambda.hpp" #include "morpheus/messages/meta.hpp" @@ -24,21 +26,39 @@ namespace morpheus { template <> std::map()>> FactoryRegistry::m_object_constructors{}; +template <> +std::mutex FactoryRegistry::m_mutex{}; + template class FactoryRegistry; -template <> -template <> -void FactoryRegistryProxy::register_proxy_constructor( +void LoaderRegistryProxy::register_proxy_constructor( const std::string& name, - std::function(MessageControl& control_message)> proxy_constructor) + std::function(MessageControl& control_message)> proxy_constructor, + bool throw_if_exists) { - FactoryRegistry::register_constructor(name, [proxy_constructor]() { - return std::make_shared([proxy_constructor](MessageControl& control_message) { - return std::move(proxy_constructor(control_message)); - }); - }); + FactoryRegistry::register_constructor( + name, + [proxy_constructor]() { + return std::make_shared([proxy_constructor](MessageControl& control_message) { + return std::move(proxy_constructor(control_message)); + }); + }, + throw_if_exists); register_factory_cleanup_fn(name); } +void LoaderRegistryProxy::register_factory_cleanup_fn(const std::string& name) +{ + { + auto at_exit = pybind11::module_::import("atexit"); + at_exit.attr("register")(pybind11::cpp_function([name]() { + VLOG(2) << "(atexit) Unregistering loader: " << name; + + // Try unregister -- ignore if already unregistered + FactoryRegistry::unregister_constructor(name, false); + })); + } +} + } // namespace morpheus \ No newline at end of file diff --git a/morpheus/_lib/src/python_modules/common.cpp b/morpheus/_lib/src/python_modules/common.cpp index 6368ee5739..47491e685b 100644 --- a/morpheus/_lib/src/python_modules/common.cpp +++ b/morpheus/_lib/src/python_modules/common.cpp @@ -15,6 +15,8 @@ * limitations under the License. */ +#include "morpheus/io/data_loader_registry.hpp" +#include "morpheus/io/loaders/all.hpp" #include "morpheus/objects/dtype.hpp" // for TypeId #include "morpheus/objects/fiber_queue.hpp" #include "morpheus/objects/file_types.hpp" @@ -25,6 +27,7 @@ #include "morpheus/version.hpp" #include +#include #include #include @@ -44,6 +47,27 @@ PYBIND11_MODULE(common, _module) // Load the cudf helpers load_cudf_helpers(); + LoaderRegistry::register_constructor( + "file", []() { return std::make_unique(); }, false); + LoaderRegistry::register_constructor( + "grpc", []() { return std::make_unique(); }, false); + LoaderRegistry::register_constructor( + "payload", []() { return std::make_unique(); }, false); + LoaderRegistry::register_constructor( + "rest", []() { return std::make_unique(); }, false); + + py::class_>(_module, "DataLoaderRegistry") + .def_static("contains", &LoaderRegistry::contains) + .def_static("register_loader", + &LoaderRegistryProxy::register_proxy_constructor, + py::arg("name"), + py::arg("loader"), + py::arg("throw_if_exists") = true) + .def_static("unregister_loader", + &LoaderRegistry::unregister_constructor, + py::arg("name"), + py::arg("throw_if_not_exists") = true); + py::class_(_module, "Tensor") .def_property_readonly("__cuda_array_interface__", &TensorObjectInterfaceProxy::cuda_array_interface); diff --git a/morpheus/_lib/src/python_modules/messages.cpp b/morpheus/_lib/src/python_modules/messages.cpp index 7ae66c0340..63925baac9 100644 --- a/morpheus/_lib/src/python_modules/messages.cpp +++ b/morpheus/_lib/src/python_modules/messages.cpp @@ -15,7 +15,6 @@ * limitations under the License. */ -#include "morpheus/io/loaders/lambda.hpp" #include "morpheus/messages/control.hpp" #include "morpheus/messages/memory/inference_memory.hpp" #include "morpheus/messages/memory/inference_memory_fil.hpp" @@ -31,7 +30,6 @@ #include "morpheus/messages/multi_response.hpp" #include "morpheus/messages/multi_response_probs.hpp" #include "morpheus/objects/data_table.hpp" -#include "morpheus/objects/factory_registry.hpp" #include "morpheus/objects/mutable_table_ctx_mgr.hpp" #include "morpheus/utilities/cudf_util.hpp" #include "morpheus/version.hpp" @@ -141,14 +139,7 @@ PYBIND11_MODULE(messages, _module) pybind11::overload_cast&>(&MessageControl::payload), py::return_value_policy::move); - py::class_, std::shared_ptr>>(_module, "DataLoaderRegistry") - .def_static( - "register_loader", - &FactoryRegistryProxy::register_proxy_constructor, MessageControl&>, - py::arg("name"), - py::arg("loader")); - - // Context manager for Mutable Dataframes. Attempting to use it outside of a with block will raise an exception + // Context manager for Mutable Dataframes. Attempting to use it outside a with block will raise an exception py::class_>(_module, "MutableTableCtxMgr") .def("__enter__", &MutableTableCtxMgr::enter, py::return_value_policy::reference) .def("__exit__", &MutableTableCtxMgr::exit) diff --git a/morpheus/messages/message_control.py b/morpheus/messages/message_control.py index 33b2e8c025..c11342c83c 100644 --- a/morpheus/messages/message_control.py +++ b/morpheus/messages/message_control.py @@ -17,5 +17,5 @@ class MessageControl(MessageBase, cpp_class=_messages.MessageControl): - def __init__(self): - super().__init__() + def __init__(self, *arg, **kwargs): + super().__init__(*arg, **kwargs) diff --git a/tests/io/test_loader_registry.py b/tests/io/test_loader_registry.py new file mode 100644 index 0000000000..ed943bb1c6 --- /dev/null +++ b/tests/io/test_loader_registry.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python +# SPDX-FileCopyrightText: Copyright (c) 2022-2023, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import cudf + +import morpheus.messages as messages +from morpheus._lib.common import DataLoaderRegistry + + +def test_loader_registry_contains(): + assert (not DataLoaderRegistry.contains("not_a_loader")) + + assert (DataLoaderRegistry.contains("file")) + assert (DataLoaderRegistry.contains("grpc")) + assert (DataLoaderRegistry.contains("payload")) + assert (DataLoaderRegistry.contains("rest")) + + +def test_loader_registry_register_loader(): + def test_loader(control_message: messages.MessageControl): + config = control_message.config() + if ('files' not in config): + raise ValueError("No files specified in config") + files = config['files'] + + df = None + for file in files: + filepath = file['path'] + if df is None: + df = cudf.read_csv(filepath) + else: + df = df.append(cudf.read_csv(filepath)) + + return messages.MessageMeta(df) + + # Should be able to register a new loader + DataLoaderRegistry.register_loader("test_loader_registry_register_loader", test_loader) + assert (DataLoaderRegistry.contains("test_loader_registry_register_loader")) + + # Should be able to overwrite an existing loader if we request it + DataLoaderRegistry.register_loader("test_loader_registry_register_loader", test_loader, False) + + try: + # Shouldn't allow us to overwrite an existing loader by default + DataLoaderRegistry.register_loader("test_loader_registry_register_loader", test_loader) + assert (False) + except RuntimeError: + assert (True) + + +def test_loader_registry_unregister_loader(): + def test_loader(control_message: messages.MessageControl): + config = control_message.config() + if ('files' not in config): + raise ValueError("No files specified in config") + files = config['files'] + + df = None + for file in files: + filepath = file['path'] + if df is None: + df = cudf.read_csv(filepath) + else: + df = df.append(cudf.read_csv(filepath)) + + return messages.MessageMeta(df) + + # Should be able to register a new loader + DataLoaderRegistry.register_loader("test_loader_registry_unregister_loader", test_loader) + assert (DataLoaderRegistry.contains("test_loader_registry_unregister_loader")) + + # Should be able to unregister a loader + DataLoaderRegistry.unregister_loader("test_loader_registry_unregister_loader") + assert (not DataLoaderRegistry.contains("test_loader_registry_unregister_loader")) + + # Shouldn't be able to unregister a loader that doesn't exist + try: + DataLoaderRegistry.unregister_loader("test_loader_registry_unregister_loader") + assert (False) + except RuntimeError: + assert (True) + + # Should be able to unregister a loader that doesn't exist if we request it + DataLoaderRegistry.unregister_loader("test_loader_registry_unregister_loader", False) + + +if __name__ == "__main__": + test_loader_registry_contains() + test_loader_registry_register_loader() + test_loader_registry_unregister_loader() diff --git a/tests/messages/test_control_message.py b/tests/messages/test_control_message.py index 590533d7b2..5b2fd24997 100644 --- a/tests/messages/test_control_message.py +++ b/tests/messages/test_control_message.py @@ -14,11 +14,13 @@ # See the License for the specific language governing permissions and # limitations under the License. +import pytest import cudf -import morpheus.messages as messages import morpheus._lib.messages as _messages +import morpheus.messages as messages +@pytest.mark.usefixtures("config_only_cpp") def test_control_message_init(): raw_control_message_one = _messages.MessageControl() raw_control_message_two = _messages.MessageControl({"test": "test"}) @@ -27,6 +29,7 @@ def test_control_message_init(): control_message_two = messages.MessageControl({"test": "test"}) +@pytest.mark.usefixtures("config_only_cpp") def test_control_message_get(): raw_control_message = _messages.MessageControl({"test": "test_rcm"}) control_message = messages.MessageControl({"test": "test_cm"}) @@ -38,6 +41,7 @@ def test_control_message_get(): assert control_message.config()["test"] == "test_cm" +@pytest.mark.usefixtures("config_only_cpp") def test_control_message_set(): raw_control_message = _messages.MessageControl() control_message = messages.MessageControl() @@ -52,6 +56,7 @@ def test_control_message_set(): assert control_message.config()["test"] == "test_cm" +@pytest.mark.usefixtures("config_only_cpp") def test_control_message_set_and_get_payload(): df = cudf.DataFrame({ 'col1': [1, 2, 3, 4, 5], @@ -59,7 +64,8 @@ def test_control_message_set_and_get_payload(): 'col3': ['a', 'b', 'c', 'd', 'e'], 'col4': [True, False, True, False, True] }) - msg = _messages.MessageControl() + + msg = messages.MessageControl() payload = messages.MessageMeta(df) msg.payload(payload) diff --git a/tests/modules/test_morpheus_modules.py b/tests/modules/test_morpheus_modules.py index d24e23ca87..f2b3ed3172 100644 --- a/tests/modules/test_morpheus_modules.py +++ b/tests/modules/test_morpheus_modules.py @@ -21,7 +21,6 @@ import tempfile import os -import morpheus._lib.messages as _messages import morpheus.modules # Used to load and register morpheus modules import morpheus.messages as messages diff --git a/tests/objects/test_loader_registry.py b/tests/objects/test_loader_registry.py deleted file mode 100644 index d421b83bf3..0000000000 --- a/tests/objects/test_loader_registry.py +++ /dev/null @@ -1,41 +0,0 @@ -#!/usr/bin/env python -# SPDX-FileCopyrightText: Copyright (c) 2022-2023, NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import mrc -import cudf - -import morpheus.messages as messages -import morpheus._lib.messages as _messages - - -def test_loader_registry(): - def csv_test_loader(control_message: messages.MessageControl): - config = control_message.config() - if ('files' not in config): - raise ValueError("No files specified in config") - files = config['files'] - - df = None - for file in files: - filepath = file['path'] - if df is None: - df = cudf.read_csv(filepath) - else: - df = df.append(cudf.read_csv(filepath)) - - return messages.MessageMeta(df) - - _messages.DataLoaderRegistry.register_loader("csv", csv_test_loader) \ No newline at end of file From c14406efab9c18e0367cd0dd02522c8ee6058327 Mon Sep 17 00:00:00 2001 From: Devin Robison Date: Thu, 16 Feb 2023 14:18:31 -0700 Subject: [PATCH 021/157] Add c++ side LoaderRegistry unittests --- .../morpheus/objects/factory_registry.hpp | 1 + morpheus/_lib/src/io/loaders/grpc.cpp | 2 + morpheus/_lib/src/io/loaders/rest.cpp | 2 + .../_lib/src/modules/data_loader_module.cpp | 20 +- morpheus/_lib/tests/CMakeLists.txt | 1 + morpheus/_lib/tests/io/test_data_loader.cpp | 21 +- .../tests/io/test_data_loader_registry.cpp | 72 ++++++ morpheus/_lib/tests/io/test_io.hpp | 2 + .../tests/modules/test_data_loader_module.cpp | 220 +++++++++--------- morpheus/_lib/tests/test_morpheus.cpp | 11 + 10 files changed, 210 insertions(+), 142 deletions(-) create mode 100644 morpheus/_lib/tests/io/test_data_loader_registry.cpp diff --git a/morpheus/_lib/include/morpheus/objects/factory_registry.hpp b/morpheus/_lib/include/morpheus/objects/factory_registry.hpp index c3afd38ed6..1d61068573 100644 --- a/morpheus/_lib/include/morpheus/objects/factory_registry.hpp +++ b/morpheus/_lib/include/morpheus/objects/factory_registry.hpp @@ -41,6 +41,7 @@ class FactoryRegistry return m_object_constructors.count(name) > 0; } + // TODO(Devin): Rename -- this isn't a constructor, its creating an instance static std::shared_ptr get_constructor(const std::string& name) { std::lock_guard lock(m_mutex); diff --git a/morpheus/_lib/src/io/loaders/grpc.cpp b/morpheus/_lib/src/io/loaders/grpc.cpp index 7d2bbb2595..2ac19c3811 100644 --- a/morpheus/_lib/src/io/loaders/grpc.cpp +++ b/morpheus/_lib/src/io/loaders/grpc.cpp @@ -25,6 +25,8 @@ std::shared_ptr GRPCDataLoader::load(MessageControl& message) VLOG(30) << "Called GRPCDataLoader::load()"; // TODO(Devin): Implement this + throw std::runtime_error("GRPCDataLoader::load() not implemented yet"); + return std::move(message.payload()); } } // namespace morpheus \ No newline at end of file diff --git a/morpheus/_lib/src/io/loaders/rest.cpp b/morpheus/_lib/src/io/loaders/rest.cpp index 58b19092e4..d68d739219 100644 --- a/morpheus/_lib/src/io/loaders/rest.cpp +++ b/morpheus/_lib/src/io/loaders/rest.cpp @@ -25,6 +25,8 @@ std::shared_ptr RESTDataLoader::load(MessageControl& message) VLOG(30) << "Called RESTDataLoader::load()"; // TODO(Devin): Implement this + throw std::runtime_error("RESTDataLoader::load() not implemented yet"); + return std::move(message.payload()); } } // namespace morpheus \ No newline at end of file diff --git a/morpheus/_lib/src/modules/data_loader_module.cpp b/morpheus/_lib/src/modules/data_loader_module.cpp index 530e7a1d6d..b163d96b77 100644 --- a/morpheus/_lib/src/modules/data_loader_module.cpp +++ b/morpheus/_lib/src/modules/data_loader_module.cpp @@ -17,6 +17,7 @@ #include "morpheus/modules/data_loader_module.hpp" +#include "morpheus/io/data_loader_registry.hpp" #include "morpheus/io/loaders/all.hpp" #include "morpheus/messages/meta.hpp" @@ -46,25 +47,14 @@ void DataLoaderModule::initialize(mrc::segment::Builder& builder) auto loader_list = config()["loaders"]; for (json::iterator it = loader_list.begin(); it != loader_list.end(); ++it) { - if (*it == "file") + auto loader_id = it->get(); + if (LoaderRegistry::contains(loader_id)) { - m_data_loader.add_loader("file", std::make_unique()); - } - else if (*it == "grpc") - { - m_data_loader.add_loader("grpc", std::make_unique()); - } - else if (*it == "payload") - { - m_data_loader.add_loader("payload", std::make_unique()); - } - else if (*it == "rest") - { - m_data_loader.add_loader("rest", std::make_unique()); + m_data_loader.add_loader(loader_id, LoaderRegistry::get_constructor(*it)); } else { - throw std::runtime_error("Unknown or unsupported loader type: " + (*it).dump()); + throw std::runtime_error("Unknown or unsupported loader type: " + loader_id); } } } diff --git a/morpheus/_lib/tests/CMakeLists.txt b/morpheus/_lib/tests/CMakeLists.txt index 9c357ce539..02086b75aa 100644 --- a/morpheus/_lib/tests/CMakeLists.txt +++ b/morpheus/_lib/tests/CMakeLists.txt @@ -19,6 +19,7 @@ list(APPEND CMAKE_MESSAGE_CONTEXT "tests") add_executable(test_libmorpheus # test_cuda.cu io/test_data_loader.cpp + io/test_data_loader_registry.cpp messages/test_control_message.cpp modules/test_data_loader_module.cpp test_main.cpp diff --git a/morpheus/_lib/tests/io/test_data_loader.cpp b/morpheus/_lib/tests/io/test_data_loader.cpp index b2159c10ce..e647bbd269 100644 --- a/morpheus/_lib/tests/io/test_data_loader.cpp +++ b/morpheus/_lib/tests/io/test_data_loader.cpp @@ -21,18 +21,8 @@ #include "morpheus/io/loaders/all.hpp" #include "morpheus/messages/control.hpp" -#include -#include -#include -#include -#include -#include -#include -#include -#include #include -#include #include #include #include @@ -53,7 +43,7 @@ TEST_F(TestDataLoader, DataLoaderRegisterLoaderTest) nlohmann::json config; config["loader_id"] = ""; - std::vector loaders = {"grpc", "payload", "rest"}; + std::vector loaders = {"payload"}; for (auto& loader : loaders) { config["loader_id"] = loader; @@ -62,10 +52,7 @@ TEST_F(TestDataLoader, DataLoaderRegisterLoaderTest) EXPECT_THROW(data_loader.load(msg), std::runtime_error); } - // data_loader.register_loader("file", std::make_unique()); - data_loader.add_loader("grpc", std::make_unique()); data_loader.add_loader("payload", std::make_unique()); - data_loader.add_loader("rest", std::make_unique()); for (auto& loader : loaders) { @@ -81,16 +68,16 @@ TEST_F(TestDataLoader, DataLoaderRemoveLoaderTest) auto data_loader = DataLoader(); nlohmann::json config; - config["loader_id"] = "grpc"; + config["loader_id"] = "payload"; auto msg = MessageControl(config); EXPECT_THROW(data_loader.load(msg), std::runtime_error); - data_loader.add_loader("grpc", std::make_unique()); + data_loader.add_loader("payload", std::make_unique()); EXPECT_NO_THROW(data_loader.load(msg)); - data_loader.remove_loader("grpc"); + data_loader.remove_loader("payload"); EXPECT_THROW(data_loader.load(msg), std::runtime_error); } diff --git a/morpheus/_lib/tests/io/test_data_loader_registry.cpp b/morpheus/_lib/tests/io/test_data_loader_registry.cpp new file mode 100644 index 0000000000..ea271abd24 --- /dev/null +++ b/morpheus/_lib/tests/io/test_data_loader_registry.cpp @@ -0,0 +1,72 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2022-2023, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "test_io.hpp" + +#include "morpheus/io/data_loader_registry.hpp" +#include "morpheus/io/loaders/all.hpp" + +#include + +namespace py = pybind11; +using namespace morpheus; +using namespace morpheus::test; + +TEST_F(TestDataLoaderRegistry, LoaderRegistryContainsTest) +{ + ASSERT_FALSE(LoaderRegistry::contains("no_a_loader")); + + ASSERT_TRUE(LoaderRegistry::contains("file")); + ASSERT_TRUE(LoaderRegistry::contains("grpc")); + ASSERT_TRUE(LoaderRegistry::contains("payload")); + ASSERT_TRUE(LoaderRegistry::contains("rest")); +} + +TEST_F(TestDataLoaderRegistry, LoaderRegistryRegisterLoaderTest) +{ + ASSERT_FALSE(LoaderRegistry::contains("LoaderRegistryRegisterLoaderTest")); + + // Should be able to register a loader + LoaderRegistry::register_constructor("LoaderRegistryRegisterLoaderTest", + []() { return std::make_unique(); }); + ASSERT_TRUE(LoaderRegistry::contains("LoaderRegistryRegisterLoaderTest")); + + // Should be able to overwrite an existing loader if we request it + EXPECT_NO_THROW(LoaderRegistry::register_constructor( + "LoaderRegistryRegisterLoaderTest", []() { return std::make_unique(); }, false)); + + EXPECT_THROW(LoaderRegistry::register_constructor("LoaderRegistryRegisterLoaderTest", + []() { return std::make_unique(); }), + std::runtime_error); +} + +TEST_F(TestDataLoaderRegistry, LoaderRegistryUnregisterLoaderTest) +{ + ASSERT_FALSE(LoaderRegistry::contains("LoaderRegistryUnregisterLoaderTest")); + + // Should be able to register a loader + LoaderRegistry::register_constructor("LoaderRegistryUnregisterLoaderTest", + []() { return std::make_unique(); }); + ASSERT_TRUE(LoaderRegistry::contains("LoaderRegistryUnregisterLoaderTest")); + + // Should be able to unregister a loader + LoaderRegistry::unregister_constructor("LoaderRegistryUnregisterLoaderTest"); + ASSERT_FALSE(LoaderRegistry::contains("LoaderRegistryUnregisterLoaderTest")); + + ASSERT_THROW(LoaderRegistry::unregister_constructor("LoaderRegistryUnregisterLoaderTest"), std::runtime_error); + ASSERT_NO_THROW(LoaderRegistry::unregister_constructor("LoaderRegistryUnregisterLoaderTest", false)); +} diff --git a/morpheus/_lib/tests/io/test_io.hpp b/morpheus/_lib/tests/io/test_io.hpp index b47b301456..a1c278aed3 100644 --- a/morpheus/_lib/tests/io/test_io.hpp +++ b/morpheus/_lib/tests/io/test_io.hpp @@ -21,5 +21,7 @@ namespace morpheus::test { +TEST_CLASS(DataLoaderRegistry); + using TestDataLoader = TestWithPythonInterpreter; // NOLINT } // namespace morpheus::test \ No newline at end of file diff --git a/morpheus/_lib/tests/modules/test_data_loader_module.cpp b/morpheus/_lib/tests/modules/test_data_loader_module.cpp index bd5e9e89f8..7c91ac70aa 100644 --- a/morpheus/_lib/tests/modules/test_data_loader_module.cpp +++ b/morpheus/_lib/tests/modules/test_data_loader_module.cpp @@ -115,61 +115,61 @@ using namespace morpheus::test; // EXPECT_EQ(packet_count, 10); //} -TEST_F(TestDataLoaderModule, EndToEndGRPCDataLoaderTest) -{ - using namespace mrc::modules; - using namespace mrc; - - using sp_msg_meta_t = std::shared_ptr; - using sp_msg_ctrl_t = std::shared_ptr; - - std::size_t packet_count{0}; - - auto init_wrapper = [&packet_count](segment::Builder& builder) { - nlohmann::json config; - config["loaders"] = {"grpc"}; - auto data_loader_module = builder.make_module("DataLoaderTest", config); - - auto source = builder.make_source("source", [](rxcpp::subscriber& sub) { - if (sub.is_subscribed()) - { - for (int i = 0; i < 10; i++) - { - nlohmann::json config; - config["loader_id"] = "grpc"; - sub.on_next(std::make_shared(config)); - } - } - - sub.on_completed(); - }); - - builder.make_edge(source, data_loader_module->input_port("input")); - auto sink = builder.make_sink("sink", [&packet_count](sp_msg_meta_t input) { - packet_count++; - VLOG(10) << "Received message"; - }); - - builder.make_edge(data_loader_module->output_port("output"), sink); - }; - - std::unique_ptr m_pipeline; - m_pipeline = pipeline::make_pipeline(); - - m_pipeline->make_segment("main", init_wrapper); - - auto options = std::make_shared(); - options->topology().user_cpuset("0-1"); - options->topology().restrict_gpus(true); - options->engine_factories().set_default_engine_type(runnable::EngineType::Thread); - - Executor executor(options); - executor.register_pipeline(std::move(m_pipeline)); - executor.start(); - executor.join(); - - EXPECT_EQ(packet_count, 10); -} +// TEST_F(TestDataLoaderModule, EndToEndGRPCDataLoaderTest) +//{ +// using namespace mrc::modules; +// using namespace mrc; +// +// using sp_msg_meta_t = std::shared_ptr; +// using sp_msg_ctrl_t = std::shared_ptr; +// +// std::size_t packet_count{0}; +// +// auto init_wrapper = [&packet_count](segment::Builder& builder) { +// nlohmann::json config; +// config["loaders"] = {"grpc"}; +// auto data_loader_module = builder.make_module("DataLoaderTest", config); +// +// auto source = builder.make_source("source", [](rxcpp::subscriber& sub) { +// if (sub.is_subscribed()) +// { +// for (int i = 0; i < 10; i++) +// { +// nlohmann::json config; +// config["loader_id"] = "grpc"; +// sub.on_next(std::make_shared(config)); +// } +// } +// +// sub.on_completed(); +// }); +// +// builder.make_edge(source, data_loader_module->input_port("input")); +// auto sink = builder.make_sink("sink", [&packet_count](sp_msg_meta_t input) { +// packet_count++; +// VLOG(10) << "Received message"; +// }); +// +// builder.make_edge(data_loader_module->output_port("output"), sink); +// }; +// +// std::unique_ptr m_pipeline; +// m_pipeline = pipeline::make_pipeline(); +// +// m_pipeline->make_segment("main", init_wrapper); +// +// auto options = std::make_shared(); +// options->topology().user_cpuset("0-1"); +// options->topology().restrict_gpus(true); +// options->engine_factories().set_default_engine_type(runnable::EngineType::Thread); +// +// Executor executor(options); +// executor.register_pipeline(std::move(m_pipeline)); +// +// // Shouldn't work until gRPC loader is implemented. +// executor.start(); +// executor.join(); +// } TEST_F(TestDataLoaderModule, EndToEndPayloadDataLoaderTest) { @@ -228,58 +228,58 @@ TEST_F(TestDataLoaderModule, EndToEndPayloadDataLoaderTest) EXPECT_EQ(packet_count, 10); } -TEST_F(TestDataLoaderModule, EndToEndRESTDataLoaderTest) -{ - using namespace mrc::modules; - using namespace mrc; - - using sp_msg_meta_t = std::shared_ptr; - using sp_msg_ctrl_t = std::shared_ptr; - - std::size_t packet_count{0}; - - auto init_wrapper = [&packet_count](segment::Builder& builder) { - nlohmann::json config; - config["loaders"] = {"rest"}; - auto data_loader_module = builder.make_module("DataLoaderTest", config); - - auto source = builder.make_source("source", [](rxcpp::subscriber& sub) { - if (sub.is_subscribed()) - { - for (int i = 0; i < 10; i++) - { - nlohmann::json config; - config["loader_id"] = "rest"; - sub.on_next(std::make_shared(config)); - } - } - - sub.on_completed(); - }); - - builder.make_edge(source, data_loader_module->input_port("input")); - auto sink = builder.make_sink("sink", [&packet_count](sp_msg_meta_t input) { - packet_count++; - VLOG(10) << "Received message"; - }); - - builder.make_edge(data_loader_module->output_port("output"), sink); - }; - - std::unique_ptr m_pipeline; - m_pipeline = pipeline::make_pipeline(); - - m_pipeline->make_segment("main", init_wrapper); - - auto options = std::make_shared(); - options->topology().user_cpuset("0"); - options->topology().restrict_gpus(true); - options->engine_factories().set_default_engine_type(runnable::EngineType::Thread); - - Executor executor(options); - executor.register_pipeline(std::move(m_pipeline)); - executor.start(); - executor.join(); - - EXPECT_EQ(packet_count, 10); -} \ No newline at end of file +// TEST_F(TestDataLoaderModule, EndToEndRESTDataLoaderTest) +//{ +// using namespace mrc::modules; +// using namespace mrc; +// +// using sp_msg_meta_t = std::shared_ptr; +// using sp_msg_ctrl_t = std::shared_ptr; +// +// std::size_t packet_count{0}; +// +// auto init_wrapper = [&packet_count](segment::Builder& builder) { +// nlohmann::json config; +// config["loaders"] = {"rest"}; +// auto data_loader_module = builder.make_module("DataLoaderTest", config); +// +// auto source = builder.make_source("source", [](rxcpp::subscriber& sub) { +// if (sub.is_subscribed()) +// { +// for (int i = 0; i < 10; i++) +// { +// nlohmann::json config; +// config["loader_id"] = "rest"; +// sub.on_next(std::make_shared(config)); +// } +// } +// +// sub.on_completed(); +// }); +// +// builder.make_edge(source, data_loader_module->input_port("input")); +// auto sink = builder.make_sink("sink", [&packet_count](sp_msg_meta_t input) { +// packet_count++; +// VLOG(10) << "Received message"; +// }); +// +// builder.make_edge(data_loader_module->output_port("output"), sink); +// }; +// +// std::unique_ptr m_pipeline; +// m_pipeline = pipeline::make_pipeline(); +// +// m_pipeline->make_segment("main", init_wrapper); +// +// auto options = std::make_shared(); +// options->topology().user_cpuset("0"); +// options->topology().restrict_gpus(true); +// options->engine_factories().set_default_engine_type(runnable::EngineType::Thread); +// +// Executor executor(options); +// executor.register_pipeline(std::move(m_pipeline)); +// executor.start(); +// executor.join(); +// +// EXPECT_EQ(packet_count, 10); +// } \ No newline at end of file diff --git a/morpheus/_lib/tests/test_morpheus.cpp b/morpheus/_lib/tests/test_morpheus.cpp index a608c18285..b0f556c518 100644 --- a/morpheus/_lib/tests/test_morpheus.cpp +++ b/morpheus/_lib/tests/test_morpheus.cpp @@ -17,6 +17,8 @@ #include "test_morpheus.hpp" +#include "morpheus/io/data_loader_registry.hpp" +#include "morpheus/io/loaders/all.hpp" #include "morpheus/messages/meta.hpp" #include @@ -46,6 +48,15 @@ bool TestWithPythonInterpreter::m_initialized = false; void TestWithPythonInterpreter::SetUp() { initialize_interpreter(); + + LoaderRegistry::register_constructor( + "file", []() { return std::make_unique(); }, false); + LoaderRegistry::register_constructor( + "grpc", []() { return std::make_unique(); }, false); + LoaderRegistry::register_constructor( + "payload", []() { return std::make_unique(); }, false); + LoaderRegistry::register_constructor( + "rest", []() { return std::make_unique(); }, false); } void TestWithPythonInterpreter::TearDown() {} From da1533147f66fbff6ca35701268d45748c0baf53 Mon Sep 17 00:00:00 2001 From: Devin Robison Date: Thu, 16 Feb 2023 14:38:08 -0700 Subject: [PATCH 022/157] Add more unittests --- morpheus/_lib/tests/CMakeLists.txt | 1 + morpheus/_lib/tests/io/test_data_loader.cpp | 1 + morpheus/_lib/tests/io/test_io.hpp | 1 + morpheus/_lib/tests/io/test_loaders.cpp | 90 +++++++++++++++++++++ 4 files changed, 93 insertions(+) create mode 100644 morpheus/_lib/tests/io/test_loaders.cpp diff --git a/morpheus/_lib/tests/CMakeLists.txt b/morpheus/_lib/tests/CMakeLists.txt index 02086b75aa..5b3a57b478 100644 --- a/morpheus/_lib/tests/CMakeLists.txt +++ b/morpheus/_lib/tests/CMakeLists.txt @@ -20,6 +20,7 @@ add_executable(test_libmorpheus # test_cuda.cu io/test_data_loader.cpp io/test_data_loader_registry.cpp + io/test_loaders.cpp messages/test_control_message.cpp modules/test_data_loader_module.cpp test_main.cpp diff --git a/morpheus/_lib/tests/io/test_data_loader.cpp b/morpheus/_lib/tests/io/test_data_loader.cpp index e647bbd269..da18dd4884 100644 --- a/morpheus/_lib/tests/io/test_data_loader.cpp +++ b/morpheus/_lib/tests/io/test_data_loader.cpp @@ -132,4 +132,5 @@ TEST_F(TestDataLoader, FileLoaderTest) data_file.close(); auto mm2 = data_loader.load(msg); + unlink(temp_file); } diff --git a/morpheus/_lib/tests/io/test_io.hpp b/morpheus/_lib/tests/io/test_io.hpp index a1c278aed3..9089a6244b 100644 --- a/morpheus/_lib/tests/io/test_io.hpp +++ b/morpheus/_lib/tests/io/test_io.hpp @@ -22,6 +22,7 @@ namespace morpheus::test { TEST_CLASS(DataLoaderRegistry); +TEST_CLASS(Loader); using TestDataLoader = TestWithPythonInterpreter; // NOLINT } // namespace morpheus::test \ No newline at end of file diff --git a/morpheus/_lib/tests/io/test_loaders.cpp b/morpheus/_lib/tests/io/test_loaders.cpp new file mode 100644 index 0000000000..19973ba874 --- /dev/null +++ b/morpheus/_lib/tests/io/test_loaders.cpp @@ -0,0 +1,90 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2022-2023, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "test_io.hpp" + +#include "morpheus/io/loaders/all.hpp" +#include "morpheus/messages/control.hpp" + +#include +#include + +namespace py = pybind11; +using namespace morpheus; +using namespace morpheus::test; + +TEST_F(TestLoader, LoaderInitializationTest) +{ + auto file = FileDataLoader(); + auto grpc = GRPCDataLoader(); + auto payload = PayloadDataLoader(); + auto rest = RESTDataLoader(); +} + +TEST_F(TestLoader, LoaderFileTest) +{ + auto string_df = create_mock_csv_file({"col1", "col2", "col3"}, {"int32", "float32", "string"}, 5); + + char temp_file[] = "/tmp/morpheus_test_XXXXXXXX"; + int fd = mkstemp(temp_file); + if (fd == -1) + { + GTEST_SKIP() << "Failed to create temporary file, skipping test"; + } + + nlohmann::json config; + config["loader_id"] = "file"; + config["strategy"] = "aggregate"; + config["files"] = nlohmann::json::array(); + + config["files"].push_back({{"path", std::string(temp_file)}, {"type", "csv"}}); + + std::fstream data_file(temp_file, std::ios::out | std::ios::binary | std::ios::trunc); + data_file << string_df; + data_file.close(); + + auto msg = MessageControl(config); + auto loader = FileDataLoader(); + + EXPECT_NO_THROW(loader.load(msg)); + + unlink(temp_file); +} + +TEST_F(TestLoader, LoaderGRPCTest) +{ + auto msg = MessageControl(); + auto loader = GRPCDataLoader(); + + EXPECT_THROW(loader.load(msg), std::runtime_error); +} + +TEST_F(TestLoader, LoaderPayloadTest) +{ + auto msg = MessageControl(); + auto loader = PayloadDataLoader(); + + EXPECT_NO_THROW(loader.load(msg)); +} + +TEST_F(TestLoader, LoaderRESTTest) +{ + auto msg = MessageControl(); + auto loader = RESTDataLoader(); + + EXPECT_THROW(loader.load(msg), std::runtime_error); +} From 78d67e848f2387db99a571db7cd2a45a4bf40eed Mon Sep 17 00:00:00 2001 From: Devin Robison Date: Fri, 17 Feb 2023 13:37:37 -0700 Subject: [PATCH 023/157] Switch data loaders to consume and produce control messages. Anything that needs the payload can extract it --- .../_lib/include/morpheus/io/data_loader.hpp | 7 ++++--- .../morpheus/io/data_loader_registry.hpp | 3 ++- .../_lib/include/morpheus/io/loaders/file.hpp | 4 ++-- .../_lib/include/morpheus/io/loaders/grpc.hpp | 2 +- .../include/morpheus/io/loaders/lambda.hpp | 6 +++--- .../include/morpheus/io/loaders/payload.hpp | 2 +- .../_lib/include/morpheus/io/loaders/rest.hpp | 2 +- morpheus/_lib/src/io/data_loader.cpp | 20 ++++++++++++++----- morpheus/_lib/src/io/data_loader_registry.cpp | 4 ++-- morpheus/_lib/src/io/loaders/file.cpp | 7 ++++--- morpheus/_lib/src/io/loaders/grpc.cpp | 4 +--- morpheus/_lib/src/io/loaders/lambda.cpp | 4 ++-- morpheus/_lib/src/io/loaders/payload.cpp | 4 ++-- morpheus/_lib/src/io/loaders/rest.cpp | 4 +--- .../_lib/src/modules/data_loader_module.cpp | 4 ++-- morpheus/_lib/tests/io/test_data_loader.cpp | 15 +++++++------- morpheus/_lib/tests/io/test_loaders.cpp | 8 ++++---- .../tests/modules/test_data_loader_module.cpp | 2 +- tests/modules/test_morpheus_modules.py | 10 +++++----- 19 files changed, 61 insertions(+), 51 deletions(-) diff --git a/morpheus/_lib/include/morpheus/io/data_loader.hpp b/morpheus/_lib/include/morpheus/io/data_loader.hpp index e519c44341..907e390137 100644 --- a/morpheus/_lib/include/morpheus/io/data_loader.hpp +++ b/morpheus/_lib/include/morpheus/io/data_loader.hpp @@ -29,9 +29,10 @@ namespace morpheus { class Loader { public: - virtual ~Loader() = default; + ~Loader() = default; - virtual std::shared_ptr load(MessageControl& message) = 0; + virtual std::shared_ptr payload(std::shared_ptr message); + virtual std::shared_ptr load(std::shared_ptr message); }; class DataLoader @@ -45,7 +46,7 @@ class DataLoader * @param control_message * @return */ - std::shared_ptr load(MessageControl& control_message); + std::shared_ptr load(std::shared_ptr control_message); /** * @brief Register a loader instance with the data loader diff --git a/morpheus/_lib/include/morpheus/io/data_loader_registry.hpp b/morpheus/_lib/include/morpheus/io/data_loader_registry.hpp index 9be166db6d..21b6dddc86 100644 --- a/morpheus/_lib/include/morpheus/io/data_loader_registry.hpp +++ b/morpheus/_lib/include/morpheus/io/data_loader_registry.hpp @@ -37,7 +37,8 @@ using LoaderRegistry = FactoryRegistry; // NOLINT struct LoaderRegistryProxy { static void register_proxy_constructor( - const std::string& name, std::function(MessageControl&)> proxy_constructor, + const std::string& name, + std::function(std::shared_ptr)> proxy_constructor, bool throw_if_exists = true); static void register_factory_cleanup_fn(const std::string& name); diff --git a/morpheus/_lib/include/morpheus/io/loaders/file.hpp b/morpheus/_lib/include/morpheus/io/loaders/file.hpp index 2fb680a11d..db0634b37d 100644 --- a/morpheus/_lib/include/morpheus/io/loaders/file.hpp +++ b/morpheus/_lib/include/morpheus/io/loaders/file.hpp @@ -24,7 +24,7 @@ namespace morpheus { #pragma GCC visibility push(default) /** * @brief Very simple raw data loader that takes a list of files containing data that can be converted into a cuDF - * DataFrame. Loads the files into a cuDF DataFrame and returns a MessageMeta containing the DataFrame. + * DataFrame. Loads the files into a cuDF DataFrame and returns a MessageControl containing the DataFrame. * */ class FileDataLoader : public Loader @@ -33,7 +33,7 @@ class FileDataLoader : public Loader FileDataLoader() = default; ~FileDataLoader() = default; - std::shared_ptr load(MessageControl& message) override; + std::shared_ptr load(std::shared_ptr message) final; }; #pragma GCC visibility pop } // namespace morpheus \ No newline at end of file diff --git a/morpheus/_lib/include/morpheus/io/loaders/grpc.hpp b/morpheus/_lib/include/morpheus/io/loaders/grpc.hpp index 8c5cd9c82a..6032d0fd6e 100644 --- a/morpheus/_lib/include/morpheus/io/loaders/grpc.hpp +++ b/morpheus/_lib/include/morpheus/io/loaders/grpc.hpp @@ -31,7 +31,7 @@ class GRPCDataLoader : public Loader GRPCDataLoader() = default; ~GRPCDataLoader() = default; - std::shared_ptr load(MessageControl& message) override; + std::shared_ptr load(std::shared_ptr message) final; }; #pragma GCC visibility pop } // namespace morpheus \ No newline at end of file diff --git a/morpheus/_lib/include/morpheus/io/loaders/lambda.hpp b/morpheus/_lib/include/morpheus/io/loaders/lambda.hpp index ff61c2624d..335f07340e 100644 --- a/morpheus/_lib/include/morpheus/io/loaders/lambda.hpp +++ b/morpheus/_lib/include/morpheus/io/loaders/lambda.hpp @@ -29,13 +29,13 @@ class LambdaLoader : public Loader { public: LambdaLoader() = delete; - LambdaLoader(std::function(MessageControl&)> lambda_load); + LambdaLoader(std::function(std::shared_ptr)> lambda_load); ~LambdaLoader() = default; - std::shared_ptr load(MessageControl& message) override; + std::shared_ptr load(std::shared_ptr message) final; private: - std::function(MessageControl&)> m_lambda_load; + std::function(std::shared_ptr)> m_lambda_load; }; #pragma GCC visibility pop } // namespace morpheus \ No newline at end of file diff --git a/morpheus/_lib/include/morpheus/io/loaders/payload.hpp b/morpheus/_lib/include/morpheus/io/loaders/payload.hpp index 693881cd1a..39e16f67d9 100644 --- a/morpheus/_lib/include/morpheus/io/loaders/payload.hpp +++ b/morpheus/_lib/include/morpheus/io/loaders/payload.hpp @@ -33,7 +33,7 @@ class PayloadDataLoader : public Loader PayloadDataLoader() = default; ~PayloadDataLoader() = default; - std::shared_ptr load(MessageControl& control_message) override; + std::shared_ptr load(std::shared_ptr control_message) final; }; #pragma GCC visibility pop } // namespace morpheus \ No newline at end of file diff --git a/morpheus/_lib/include/morpheus/io/loaders/rest.hpp b/morpheus/_lib/include/morpheus/io/loaders/rest.hpp index fc16cfdf71..a557953fc5 100644 --- a/morpheus/_lib/include/morpheus/io/loaders/rest.hpp +++ b/morpheus/_lib/include/morpheus/io/loaders/rest.hpp @@ -31,7 +31,7 @@ class RESTDataLoader : public Loader RESTDataLoader() = default; ~RESTDataLoader() = default; - std::shared_ptr load(MessageControl& message) override; + std::shared_ptr load(std::shared_ptr message) final; }; #pragma GCC visibility pop } // namespace morpheus \ No newline at end of file diff --git a/morpheus/_lib/src/io/data_loader.cpp b/morpheus/_lib/src/io/data_loader.cpp index e1b747fc76..db7ad1bcd9 100644 --- a/morpheus/_lib/src/io/data_loader.cpp +++ b/morpheus/_lib/src/io/data_loader.cpp @@ -26,22 +26,32 @@ namespace morpheus { DataLoader::DataLoader() : m_loaders{} {} -std::shared_ptr DataLoader::load(MessageControl& control_message) +std::shared_ptr Loader::payload(std::shared_ptr message) { - auto payload = control_message.config(); + return std::move(message->payload()); +} + +std::shared_ptr Loader::load(std::shared_ptr message) +{ + return std::move(message); +} + +std::shared_ptr DataLoader::load(std::shared_ptr control_message) +{ + auto payload = control_message->config(); if (payload.contains("loader_id")) { auto loader_id = payload["loader_id"]; auto loader = m_loaders.find(loader_id); if (loader != m_loaders.end()) { - VLOG(5) << "Loading data using loader: " << loader_id << " for message: " << control_message.config().dump() - << std::endl; + VLOG(5) << "Loading data using loader: " << loader_id + << " for message: " << control_message->config().dump() << std::endl; return std::move(loader->second->load(control_message)); } } - throw std::runtime_error("No loader registered for message: " + control_message.config().dump()); + throw std::runtime_error("No loader registered for message: " + control_message->config().dump()); } void DataLoader::add_loader(const std::string& loader_id, std::shared_ptr loader, bool overwrite) diff --git a/morpheus/_lib/src/io/data_loader_registry.cpp b/morpheus/_lib/src/io/data_loader_registry.cpp index 332d605467..92c6be2749 100644 --- a/morpheus/_lib/src/io/data_loader_registry.cpp +++ b/morpheus/_lib/src/io/data_loader_registry.cpp @@ -33,13 +33,13 @@ template class FactoryRegistry; void LoaderRegistryProxy::register_proxy_constructor( const std::string& name, - std::function(MessageControl& control_message)> proxy_constructor, + std::function(std::shared_ptr control_message)> proxy_constructor, bool throw_if_exists) { FactoryRegistry::register_constructor( name, [proxy_constructor]() { - return std::make_shared([proxy_constructor](MessageControl& control_message) { + return std::make_shared([proxy_constructor](std::shared_ptr control_message) { return std::move(proxy_constructor(control_message)); }); }, diff --git a/morpheus/_lib/src/io/loaders/file.cpp b/morpheus/_lib/src/io/loaders/file.cpp index 3a512f85d9..3ace5dcbb5 100644 --- a/morpheus/_lib/src/io/loaders/file.cpp +++ b/morpheus/_lib/src/io/loaders/file.cpp @@ -30,7 +30,7 @@ namespace {} namespace morpheus { -std::shared_ptr FileDataLoader::load(MessageControl& message) +std::shared_ptr FileDataLoader::load(std::shared_ptr message) { namespace py = pybind11; VLOG(30) << "Called FileDataLoader::load()"; @@ -43,7 +43,7 @@ std::shared_ptr FileDataLoader::load(MessageControl& message) mod_cudf = cache_handle.get_module("cudf"); // TODO(Devin) : error checking + improve robustness - auto config = message.config(); + auto config = message->config(); if (!config.contains("files")) { throw std::runtime_error("'File Loader' control message specified no files to load"); @@ -117,6 +117,7 @@ std::shared_ptr FileDataLoader::load(MessageControl& message) } } - return MessageMeta::create_from_python(std::move(dataframe)); + message->payload(MessageMeta::create_from_python(std::move(dataframe))); + return message; } } // namespace morpheus \ No newline at end of file diff --git a/morpheus/_lib/src/io/loaders/grpc.cpp b/morpheus/_lib/src/io/loaders/grpc.cpp index 2ac19c3811..d5c097f3e6 100644 --- a/morpheus/_lib/src/io/loaders/grpc.cpp +++ b/morpheus/_lib/src/io/loaders/grpc.cpp @@ -20,13 +20,11 @@ #include namespace morpheus { -std::shared_ptr GRPCDataLoader::load(MessageControl& message) +std::shared_ptr GRPCDataLoader::load(std::shared_ptr message) { VLOG(30) << "Called GRPCDataLoader::load()"; // TODO(Devin): Implement this throw std::runtime_error("GRPCDataLoader::load() not implemented yet"); - - return std::move(message.payload()); } } // namespace morpheus \ No newline at end of file diff --git a/morpheus/_lib/src/io/loaders/lambda.cpp b/morpheus/_lib/src/io/loaders/lambda.cpp index f8909144aa..91b3c5d3c2 100644 --- a/morpheus/_lib/src/io/loaders/lambda.cpp +++ b/morpheus/_lib/src/io/loaders/lambda.cpp @@ -20,12 +20,12 @@ #include namespace morpheus { -LambdaLoader::LambdaLoader(std::function(MessageControl&)> lambda_load) +LambdaLoader::LambdaLoader(std::function(std::shared_ptr)> lambda_load) : m_lambda_load(lambda_load) { } -std::shared_ptr LambdaLoader::load(MessageControl& message) +std::shared_ptr LambdaLoader::load(std::shared_ptr message) { VLOG(30) << "Called LambdaLoader::load()"; diff --git a/morpheus/_lib/src/io/loaders/payload.cpp b/morpheus/_lib/src/io/loaders/payload.cpp index 0425d16e8d..cb4dbb2180 100644 --- a/morpheus/_lib/src/io/loaders/payload.cpp +++ b/morpheus/_lib/src/io/loaders/payload.cpp @@ -20,9 +20,9 @@ #include namespace morpheus { -std::shared_ptr PayloadDataLoader::load(MessageControl& message) +std::shared_ptr PayloadDataLoader::load(std::shared_ptr message) { VLOG(30) << "Called PayloadDataLoader::load()"; - return std::move(message.payload()); + return std::move(message); } } // namespace morpheus \ No newline at end of file diff --git a/morpheus/_lib/src/io/loaders/rest.cpp b/morpheus/_lib/src/io/loaders/rest.cpp index d68d739219..aeda445523 100644 --- a/morpheus/_lib/src/io/loaders/rest.cpp +++ b/morpheus/_lib/src/io/loaders/rest.cpp @@ -20,13 +20,11 @@ #include namespace morpheus { -std::shared_ptr RESTDataLoader::load(MessageControl& message) +std::shared_ptr RESTDataLoader::load(std::shared_ptr message) { VLOG(30) << "Called RESTDataLoader::load()"; // TODO(Devin): Implement this throw std::runtime_error("RESTDataLoader::load() not implemented yet"); - - return std::move(message.payload()); } } // namespace morpheus \ No newline at end of file diff --git a/morpheus/_lib/src/modules/data_loader_module.cpp b/morpheus/_lib/src/modules/data_loader_module.cpp index b163d96b77..e1f1d659f4 100644 --- a/morpheus/_lib/src/modules/data_loader_module.cpp +++ b/morpheus/_lib/src/modules/data_loader_module.cpp @@ -63,9 +63,9 @@ void DataLoaderModule::initialize(mrc::segment::Builder& builder) LOG(WARNING) << "No loaders specified in config"; } - auto loader_node = builder.make_node, std::shared_ptr>( + auto loader_node = builder.make_node, std::shared_ptr>( "input", rxcpp::operators::map([this](std::shared_ptr control_message) { - return m_data_loader.load(*control_message); + return m_data_loader.load(control_message); })); register_input_port("input", loader_node); diff --git a/morpheus/_lib/tests/io/test_data_loader.cpp b/morpheus/_lib/tests/io/test_data_loader.cpp index da18dd4884..f8bc1ce220 100644 --- a/morpheus/_lib/tests/io/test_data_loader.cpp +++ b/morpheus/_lib/tests/io/test_data_loader.cpp @@ -47,7 +47,7 @@ TEST_F(TestDataLoader, DataLoaderRegisterLoaderTest) for (auto& loader : loaders) { config["loader_id"] = loader; - auto msg = MessageControl(config); + auto msg = std::make_shared(config); EXPECT_THROW(data_loader.load(msg), std::runtime_error); } @@ -57,7 +57,7 @@ TEST_F(TestDataLoader, DataLoaderRegisterLoaderTest) for (auto& loader : loaders) { config["loader_id"] = loader; - auto msg = MessageControl(config); + auto msg = std::make_shared(config); EXPECT_NO_THROW(data_loader.load(msg)); } @@ -70,7 +70,7 @@ TEST_F(TestDataLoader, DataLoaderRemoveLoaderTest) nlohmann::json config; config["loader_id"] = "payload"; - auto msg = MessageControl(config); + auto msg = std::make_shared(config); EXPECT_THROW(data_loader.load(msg), std::runtime_error); data_loader.add_loader("payload", std::make_unique()); @@ -92,12 +92,13 @@ TEST_F(TestDataLoader, PayloadLoaderTest) nlohmann::json config; config["loader_id"] = "payload"; - auto msg = MessageControl(config); + auto msg = std::make_shared(config); auto mm = create_mock_msg_meta({"col1", "col2", "col3"}, {"int32", "float32", "string"}, 5); - msg.payload(mm); + msg->payload(mm); - auto mm2 = data_loader.load(msg); + auto msg2 = data_loader.load(msg); + auto mm2 = msg2->payload(); EXPECT_EQ(mm, mm2); } @@ -125,7 +126,7 @@ TEST_F(TestDataLoader, FileLoaderTest) config["files"].push_back({{"path", std::string(temp_file)}, {"type", "csv"}}); - auto msg = MessageControl(config); + auto msg = std::make_shared(config); std::fstream data_file(temp_file, std::ios::out | std::ios::binary | std::ios::trunc); data_file << string_df; diff --git a/morpheus/_lib/tests/io/test_loaders.cpp b/morpheus/_lib/tests/io/test_loaders.cpp index 19973ba874..60d00ef024 100644 --- a/morpheus/_lib/tests/io/test_loaders.cpp +++ b/morpheus/_lib/tests/io/test_loaders.cpp @@ -57,7 +57,7 @@ TEST_F(TestLoader, LoaderFileTest) data_file << string_df; data_file.close(); - auto msg = MessageControl(config); + auto msg = std::make_shared(config); auto loader = FileDataLoader(); EXPECT_NO_THROW(loader.load(msg)); @@ -67,7 +67,7 @@ TEST_F(TestLoader, LoaderFileTest) TEST_F(TestLoader, LoaderGRPCTest) { - auto msg = MessageControl(); + auto msg = std::make_shared(); auto loader = GRPCDataLoader(); EXPECT_THROW(loader.load(msg), std::runtime_error); @@ -75,7 +75,7 @@ TEST_F(TestLoader, LoaderGRPCTest) TEST_F(TestLoader, LoaderPayloadTest) { - auto msg = MessageControl(); + auto msg = std::make_shared(); auto loader = PayloadDataLoader(); EXPECT_NO_THROW(loader.load(msg)); @@ -83,7 +83,7 @@ TEST_F(TestLoader, LoaderPayloadTest) TEST_F(TestLoader, LoaderRESTTest) { - auto msg = MessageControl(); + auto msg = std::make_shared(); auto loader = RESTDataLoader(); EXPECT_THROW(loader.load(msg), std::runtime_error); diff --git a/morpheus/_lib/tests/modules/test_data_loader_module.cpp b/morpheus/_lib/tests/modules/test_data_loader_module.cpp index 7c91ac70aa..5d24012d0b 100644 --- a/morpheus/_lib/tests/modules/test_data_loader_module.cpp +++ b/morpheus/_lib/tests/modules/test_data_loader_module.cpp @@ -176,7 +176,7 @@ TEST_F(TestDataLoaderModule, EndToEndPayloadDataLoaderTest) using namespace mrc::modules; using namespace mrc; - using sp_msg_meta_t = std::shared_ptr; + using sp_msg_meta_t = std::shared_ptr; using sp_msg_ctrl_t = std::shared_ptr; std::size_t packet_count{0}; diff --git a/tests/modules/test_morpheus_modules.py b/tests/modules/test_morpheus_modules.py index f2b3ed3172..6bab799da1 100644 --- a/tests/modules/test_morpheus_modules.py +++ b/tests/modules/test_morpheus_modules.py @@ -25,7 +25,7 @@ import morpheus.messages as messages -def on_next(data): +def on_next(control_msg): pass @@ -195,10 +195,10 @@ def gen_data(): yield msg - def _on_next(data): + def _on_next(control_msg): global packets_received packets_received += 1 - assert (data.df == df) + assert (control_msg.payload().df == df) source = builder.make_source("source", gen_data) @@ -283,10 +283,10 @@ def gen_data(): msg = messages.MessageControl(config) yield msg - def _on_next(data): + def _on_next(control_msg): global packets_received packets_received += 1 - assert (data.df == df) + assert (control_msg.payload().df == df) registry = mrc.ModuleRegistry From db2111c358758f3b64f1bc5cba1f31825fceb5f6 Mon Sep 17 00:00:00 2001 From: Devin Robison Date: Fri, 17 Feb 2023 13:58:57 -0700 Subject: [PATCH 024/157] Naming fixes, add the ability to list factory objects in the loader registry --- .../morpheus/io/data_loader_registry.hpp | 2 +- .../morpheus/objects/factory_registry.hpp | 21 ++++++++++++---- morpheus/_lib/src/io/data_loader_registry.cpp | 6 ++--- .../_lib/src/modules/data_loader_module.cpp | 2 +- morpheus/_lib/src/python_modules/common.cpp | 16 +++++++------ .../tests/io/test_data_loader_registry.cpp | 20 ++++++++-------- morpheus/_lib/tests/test_morpheus.cpp | 8 +++---- tests/io/test_loader_registry.py | 24 ++++++++++++++----- 8 files changed, 62 insertions(+), 37 deletions(-) diff --git a/morpheus/_lib/include/morpheus/io/data_loader_registry.hpp b/morpheus/_lib/include/morpheus/io/data_loader_registry.hpp index 21b6dddc86..13ca367ff8 100644 --- a/morpheus/_lib/include/morpheus/io/data_loader_registry.hpp +++ b/morpheus/_lib/include/morpheus/io/data_loader_registry.hpp @@ -36,7 +36,7 @@ using LoaderRegistry = FactoryRegistry; // NOLINT struct LoaderRegistryProxy { - static void register_proxy_constructor( + static void register_proxy_factory_fn( const std::string& name, std::function(std::shared_ptr)> proxy_constructor, bool throw_if_exists = true); diff --git a/morpheus/_lib/include/morpheus/objects/factory_registry.hpp b/morpheus/_lib/include/morpheus/objects/factory_registry.hpp index 1d61068573..f986488beb 100644 --- a/morpheus/_lib/include/morpheus/objects/factory_registry.hpp +++ b/morpheus/_lib/include/morpheus/objects/factory_registry.hpp @@ -41,8 +41,19 @@ class FactoryRegistry return m_object_constructors.count(name) > 0; } + static std::vector list() + { + std::lock_guard lock(m_mutex); + std::vector names; + for (const auto& [name, _] : m_object_constructors) + { + names.push_back(name); + } + return names; + } + // TODO(Devin): Rename -- this isn't a constructor, its creating an instance - static std::shared_ptr get_constructor(const std::string& name) + static std::shared_ptr create_object_from_factory(const std::string& name) { std::lock_guard lock(m_mutex); VLOG(2) << "Retrieving factory constructor: " << name << "(" << mrc::boost_type_name() @@ -55,9 +66,9 @@ class FactoryRegistry return m_object_constructors[name](); } - static void register_constructor(const std::string& name, - const std::function()>& loader_fn, - bool throw_if_exists = true) + static void register_factory_fn(const std::string& name, + const std::function()>& loader_fn, + bool throw_if_exists = true) { std::lock_guard lock(m_mutex); VLOG(2) << "Registering factory constructor: " << name << "(" << mrc::boost_type_name() @@ -72,7 +83,7 @@ class FactoryRegistry m_object_constructors[name] = loader_fn; } - static void unregister_constructor(const std::string& name, bool throw_if_missing = true) + static void unregister_factory_fn(const std::string& name, bool throw_if_missing = true) { std::lock_guard lock(m_mutex); VLOG(2) << "Un-registering factory constructor: " << name << "(" << mrc::boost_type_name() diff --git a/morpheus/_lib/src/io/data_loader_registry.cpp b/morpheus/_lib/src/io/data_loader_registry.cpp index 92c6be2749..7fdb82839a 100644 --- a/morpheus/_lib/src/io/data_loader_registry.cpp +++ b/morpheus/_lib/src/io/data_loader_registry.cpp @@ -31,12 +31,12 @@ std::mutex FactoryRegistry::m_mutex{}; template class FactoryRegistry; -void LoaderRegistryProxy::register_proxy_constructor( +void LoaderRegistryProxy::register_proxy_factory_fn( const std::string& name, std::function(std::shared_ptr control_message)> proxy_constructor, bool throw_if_exists) { - FactoryRegistry::register_constructor( + FactoryRegistry::register_factory_fn( name, [proxy_constructor]() { return std::make_shared([proxy_constructor](std::shared_ptr control_message) { @@ -56,7 +56,7 @@ void LoaderRegistryProxy::register_factory_cleanup_fn(const std::string& name) VLOG(2) << "(atexit) Unregistering loader: " << name; // Try unregister -- ignore if already unregistered - FactoryRegistry::unregister_constructor(name, false); + FactoryRegistry::unregister_factory_fn(name, false); })); } } diff --git a/morpheus/_lib/src/modules/data_loader_module.cpp b/morpheus/_lib/src/modules/data_loader_module.cpp index e1f1d659f4..9623e74134 100644 --- a/morpheus/_lib/src/modules/data_loader_module.cpp +++ b/morpheus/_lib/src/modules/data_loader_module.cpp @@ -50,7 +50,7 @@ void DataLoaderModule::initialize(mrc::segment::Builder& builder) auto loader_id = it->get(); if (LoaderRegistry::contains(loader_id)) { - m_data_loader.add_loader(loader_id, LoaderRegistry::get_constructor(*it)); + m_data_loader.add_loader(loader_id, LoaderRegistry::create_object_from_factory(*it)); } else { diff --git a/morpheus/_lib/src/python_modules/common.cpp b/morpheus/_lib/src/python_modules/common.cpp index 47491e685b..c6d11dee2d 100644 --- a/morpheus/_lib/src/python_modules/common.cpp +++ b/morpheus/_lib/src/python_modules/common.cpp @@ -29,6 +29,7 @@ #include #include #include +#include #include @@ -47,24 +48,25 @@ PYBIND11_MODULE(common, _module) // Load the cudf helpers load_cudf_helpers(); - LoaderRegistry::register_constructor( + LoaderRegistry::register_factory_fn( "file", []() { return std::make_unique(); }, false); - LoaderRegistry::register_constructor( + LoaderRegistry::register_factory_fn( "grpc", []() { return std::make_unique(); }, false); - LoaderRegistry::register_constructor( + LoaderRegistry::register_factory_fn( "payload", []() { return std::make_unique(); }, false); - LoaderRegistry::register_constructor( + LoaderRegistry::register_factory_fn( "rest", []() { return std::make_unique(); }, false); py::class_>(_module, "DataLoaderRegistry") - .def_static("contains", &LoaderRegistry::contains) + .def_static("contains", &LoaderRegistry::contains, py::arg("name")) + .def_static("list", &LoaderRegistry::list) .def_static("register_loader", - &LoaderRegistryProxy::register_proxy_constructor, + &LoaderRegistryProxy::register_proxy_factory_fn, py::arg("name"), py::arg("loader"), py::arg("throw_if_exists") = true) .def_static("unregister_loader", - &LoaderRegistry::unregister_constructor, + &LoaderRegistry::unregister_factory_fn, py::arg("name"), py::arg("throw_if_not_exists") = true); diff --git a/morpheus/_lib/tests/io/test_data_loader_registry.cpp b/morpheus/_lib/tests/io/test_data_loader_registry.cpp index ea271abd24..edddee0a08 100644 --- a/morpheus/_lib/tests/io/test_data_loader_registry.cpp +++ b/morpheus/_lib/tests/io/test_data_loader_registry.cpp @@ -41,16 +41,16 @@ TEST_F(TestDataLoaderRegistry, LoaderRegistryRegisterLoaderTest) ASSERT_FALSE(LoaderRegistry::contains("LoaderRegistryRegisterLoaderTest")); // Should be able to register a loader - LoaderRegistry::register_constructor("LoaderRegistryRegisterLoaderTest", - []() { return std::make_unique(); }); + LoaderRegistry::register_factory_fn("LoaderRegistryRegisterLoaderTest", + []() { return std::make_unique(); }); ASSERT_TRUE(LoaderRegistry::contains("LoaderRegistryRegisterLoaderTest")); // Should be able to overwrite an existing loader if we request it - EXPECT_NO_THROW(LoaderRegistry::register_constructor( + EXPECT_NO_THROW(LoaderRegistry::register_factory_fn( "LoaderRegistryRegisterLoaderTest", []() { return std::make_unique(); }, false)); - EXPECT_THROW(LoaderRegistry::register_constructor("LoaderRegistryRegisterLoaderTest", - []() { return std::make_unique(); }), + EXPECT_THROW(LoaderRegistry::register_factory_fn("LoaderRegistryRegisterLoaderTest", + []() { return std::make_unique(); }), std::runtime_error); } @@ -59,14 +59,14 @@ TEST_F(TestDataLoaderRegistry, LoaderRegistryUnregisterLoaderTest) ASSERT_FALSE(LoaderRegistry::contains("LoaderRegistryUnregisterLoaderTest")); // Should be able to register a loader - LoaderRegistry::register_constructor("LoaderRegistryUnregisterLoaderTest", - []() { return std::make_unique(); }); + LoaderRegistry::register_factory_fn("LoaderRegistryUnregisterLoaderTest", + []() { return std::make_unique(); }); ASSERT_TRUE(LoaderRegistry::contains("LoaderRegistryUnregisterLoaderTest")); // Should be able to unregister a loader - LoaderRegistry::unregister_constructor("LoaderRegistryUnregisterLoaderTest"); + LoaderRegistry::unregister_factory_fn("LoaderRegistryUnregisterLoaderTest"); ASSERT_FALSE(LoaderRegistry::contains("LoaderRegistryUnregisterLoaderTest")); - ASSERT_THROW(LoaderRegistry::unregister_constructor("LoaderRegistryUnregisterLoaderTest"), std::runtime_error); - ASSERT_NO_THROW(LoaderRegistry::unregister_constructor("LoaderRegistryUnregisterLoaderTest", false)); + ASSERT_THROW(LoaderRegistry::unregister_factory_fn("LoaderRegistryUnregisterLoaderTest"), std::runtime_error); + ASSERT_NO_THROW(LoaderRegistry::unregister_factory_fn("LoaderRegistryUnregisterLoaderTest", false)); } diff --git a/morpheus/_lib/tests/test_morpheus.cpp b/morpheus/_lib/tests/test_morpheus.cpp index b0f556c518..63a87ee93f 100644 --- a/morpheus/_lib/tests/test_morpheus.cpp +++ b/morpheus/_lib/tests/test_morpheus.cpp @@ -49,13 +49,13 @@ void TestWithPythonInterpreter::SetUp() { initialize_interpreter(); - LoaderRegistry::register_constructor( + LoaderRegistry::register_factory_fn( "file", []() { return std::make_unique(); }, false); - LoaderRegistry::register_constructor( + LoaderRegistry::register_factory_fn( "grpc", []() { return std::make_unique(); }, false); - LoaderRegistry::register_constructor( + LoaderRegistry::register_factory_fn( "payload", []() { return std::make_unique(); }, false); - LoaderRegistry::register_constructor( + LoaderRegistry::register_factory_fn( "rest", []() { return std::make_unique(); }, false); } diff --git a/tests/io/test_loader_registry.py b/tests/io/test_loader_registry.py index ed943bb1c6..36bd9def4d 100644 --- a/tests/io/test_loader_registry.py +++ b/tests/io/test_loader_registry.py @@ -23,10 +23,18 @@ def test_loader_registry_contains(): assert (not DataLoaderRegistry.contains("not_a_loader")) - assert (DataLoaderRegistry.contains("file")) - assert (DataLoaderRegistry.contains("grpc")) - assert (DataLoaderRegistry.contains("payload")) - assert (DataLoaderRegistry.contains("rest")) + should_have = ["file", "grpc", "payload", "rest"] + for loader in should_have: + # Make sure all the loaders we expect to be in the registry are + assert (DataLoaderRegistry.contains(loader)) + + loaders = DataLoaderRegistry.list() + for loader in should_have: + # Make sure all the loaders in the registry are in the list + assert (loader in loaders) + + # Make sure all the loaders in the list are contained in the registry + assert (DataLoaderRegistry.contains(loader)) def test_loader_registry_register_loader(): @@ -44,7 +52,9 @@ def test_loader(control_message: messages.MessageControl): else: df = df.append(cudf.read_csv(filepath)) - return messages.MessageMeta(df) + control_message.payload(messages.MessageMeta(df)) + + return control_message # Should be able to register a new loader DataLoaderRegistry.register_loader("test_loader_registry_register_loader", test_loader) @@ -76,7 +86,9 @@ def test_loader(control_message: messages.MessageControl): else: df = df.append(cudf.read_csv(filepath)) - return messages.MessageMeta(df) + control_message.payload(messages.MessageMeta(df)) + + return control_message # Should be able to register a new loader DataLoaderRegistry.register_loader("test_loader_registry_unregister_loader", test_loader) From fabcaa8291fafce2123de1263b1c60a5b2da8be0 Mon Sep 17 00:00:00 2001 From: Devin Robison Date: Fri, 17 Feb 2023 15:35:03 -0700 Subject: [PATCH 025/157] Fix template 'definition not available' warning. Add file list loader --- morpheus/_lib/cmake/libraries/morpheus.cmake | 1 + .../_lib/include/morpheus/objects/factory_registry.hpp | 7 +++++++ morpheus/_lib/src/io/data_loader.cpp | 6 +++--- morpheus/_lib/src/modules/data_loader_module.cpp | 1 - 4 files changed, 11 insertions(+), 4 deletions(-) diff --git a/morpheus/_lib/cmake/libraries/morpheus.cmake b/morpheus/_lib/cmake/libraries/morpheus.cmake index 0200f77b96..8727993a2a 100644 --- a/morpheus/_lib/cmake/libraries/morpheus.cmake +++ b/morpheus/_lib/cmake/libraries/morpheus.cmake @@ -20,6 +20,7 @@ add_library(morpheus ${MORPHEUS_LIB_ROOT}/src/io/data_loader_registry.cpp ${MORPHEUS_LIB_ROOT}/src/io/deserializers.cpp ${MORPHEUS_LIB_ROOT}/src/io/loaders/file.cpp + ${MORPHEUS_LIB_ROOT}/src/io/loaders/file_list.cpp ${MORPHEUS_LIB_ROOT}/src/io/loaders/grpc.cpp ${MORPHEUS_LIB_ROOT}/src/io/loaders/lambda.cpp ${MORPHEUS_LIB_ROOT}/src/io/loaders/payload.cpp diff --git a/morpheus/_lib/include/morpheus/objects/factory_registry.hpp b/morpheus/_lib/include/morpheus/objects/factory_registry.hpp index f986488beb..a769227db9 100644 --- a/morpheus/_lib/include/morpheus/objects/factory_registry.hpp +++ b/morpheus/_lib/include/morpheus/objects/factory_registry.hpp @@ -105,6 +105,13 @@ class FactoryRegistry static std::map()>> m_object_constructors; }; +template +std::mutex FactoryRegistry::m_mutex; + +template +std::map()>> + FactoryRegistry::m_object_constructors; + #pragma GCC visibility pop } // namespace morpheus \ No newline at end of file diff --git a/morpheus/_lib/src/io/data_loader.cpp b/morpheus/_lib/src/io/data_loader.cpp index db7ad1bcd9..c8e6dbf7f2 100644 --- a/morpheus/_lib/src/io/data_loader.cpp +++ b/morpheus/_lib/src/io/data_loader.cpp @@ -38,10 +38,10 @@ std::shared_ptr Loader::load(std::shared_ptr mes std::shared_ptr DataLoader::load(std::shared_ptr control_message) { - auto payload = control_message->config(); - if (payload.contains("loader_id")) + auto config = control_message->config(); + if (config.contains("loader_id")) { - auto loader_id = payload["loader_id"]; + auto loader_id = config["loader_id"]; auto loader = m_loaders.find(loader_id); if (loader != m_loaders.end()) { diff --git a/morpheus/_lib/src/modules/data_loader_module.cpp b/morpheus/_lib/src/modules/data_loader_module.cpp index 9623e74134..b7ae9a4b27 100644 --- a/morpheus/_lib/src/modules/data_loader_module.cpp +++ b/morpheus/_lib/src/modules/data_loader_module.cpp @@ -41,7 +41,6 @@ DataLoaderModule::DataLoaderModule(std::string module_name, nlohmann::json _conf void DataLoaderModule::initialize(mrc::segment::Builder& builder) { - // TODO(Devin): Modularize loader lookups, and standardize this a bit more if (config().contains("loaders") and config()["loaders"].size() > 0) { auto loader_list = config()["loaders"]; From a4e6be90a646a64abf7477c03ead2b2968524da1 Mon Sep 17 00:00:00 2001 From: Devin Robison Date: Fri, 17 Feb 2023 15:35:48 -0700 Subject: [PATCH 026/157] Add file list loader --- .../include/morpheus/io/loaders/file_list.hpp | 37 +++++++++++ morpheus/_lib/src/io/loaders/file_list.cpp | 64 +++++++++++++++++++ 2 files changed, 101 insertions(+) create mode 100644 morpheus/_lib/include/morpheus/io/loaders/file_list.hpp create mode 100644 morpheus/_lib/src/io/loaders/file_list.cpp diff --git a/morpheus/_lib/include/morpheus/io/loaders/file_list.hpp b/morpheus/_lib/include/morpheus/io/loaders/file_list.hpp new file mode 100644 index 0000000000..c3209cad6b --- /dev/null +++ b/morpheus/_lib/include/morpheus/io/loaders/file_list.hpp @@ -0,0 +1,37 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2022-2023, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include "morpheus/io/data_loader.hpp" + +namespace morpheus { +#pragma GCC visibility push(default) +/** + * @brief Very simple raw data loader that takes payload data on the control message and returns it + * + */ +class FileListLoader : public Loader +{ + public: + FileListLoader() = default; + ~FileListLoader() = default; + + std::shared_ptr load(std::shared_ptr control_message) final; +}; +#pragma GCC visibility pop +} // namespace morpheus \ No newline at end of file diff --git a/morpheus/_lib/src/io/loaders/file_list.cpp b/morpheus/_lib/src/io/loaders/file_list.cpp new file mode 100644 index 0000000000..80f1931e82 --- /dev/null +++ b/morpheus/_lib/src/io/loaders/file_list.cpp @@ -0,0 +1,64 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2022-2023, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "morpheus/io/loaders/file_list.hpp" + +#include + +#include + +namespace morpheus { +std::shared_ptr FileListLoader::load(std::shared_ptr control_message) +{ + VLOG(30) << "Called FileListLoader::load()"; + + auto config = control_message->config(); + if (!config.contains("directories")) + { + throw std::runtime_error("FileListLoader: No directories specified in config"); + } + + auto files = nlohmann::json::array(); + auto directories = config["directories"]; + for (auto& directory : directories) + { + auto dirpath = boost::filesystem::path(directory); + if (!boost::filesystem::is_directory(dirpath)) + { + throw std::runtime_error("FileListLoader: " + directory.get() + " is not a directory"); + } + + for (boost::filesystem::directory_iterator itr(dirpath); itr != boost::filesystem::directory_iterator(); ++itr) + { + if (boost::filesystem::is_regular_file(itr->path())) + { + auto filename = itr->path().filename().string(); + VLOG(30) << "FileListLoader: Found file: " << filename; + + files.push_back(filename); + } + } + } + + // TODO(Devin): Improve robustness + // For now, a directory listing will just create an updated control message with the new file list + // Should this be a data frame payload instead? + config["data"] = {{"loader_id", "file"}, {"properties", {"files", files}}}; + + return control_message; +} +} // namespace morpheus \ No newline at end of file From 856b8134e00ff92e5288b3a772d87be79870e8c3 Mon Sep 17 00:00:00 2001 From: Devin Robison Date: Tue, 21 Feb 2023 12:22:09 -0700 Subject: [PATCH 027/157] Factory updates --- morpheus/_lib/src/io/data_loader_registry.cpp | 6 ------ morpheus/_lib/src/python_modules/messages.cpp | 14 ++++++++------ 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/morpheus/_lib/src/io/data_loader_registry.cpp b/morpheus/_lib/src/io/data_loader_registry.cpp index 7fdb82839a..97e2652cf7 100644 --- a/morpheus/_lib/src/io/data_loader_registry.cpp +++ b/morpheus/_lib/src/io/data_loader_registry.cpp @@ -23,12 +23,6 @@ #include "morpheus/objects/factory_registry.hpp" namespace morpheus { -template <> -std::map()>> FactoryRegistry::m_object_constructors{}; - -template <> -std::mutex FactoryRegistry::m_mutex{}; - template class FactoryRegistry; void LoaderRegistryProxy::register_proxy_factory_fn( diff --git a/morpheus/_lib/src/python_modules/messages.cpp b/morpheus/_lib/src/python_modules/messages.cpp index 63925baac9..b7b0583f61 100644 --- a/morpheus/_lib/src/python_modules/messages.cpp +++ b/morpheus/_lib/src/python_modules/messages.cpp @@ -125,19 +125,21 @@ PYBIND11_MODULE(messages, _module) // TODO(Devin): Circle back on return value policy choices py::class_>(_module, "MessageControl") - .def(py::init<>()) - .def(py::init(py::overload_cast(&ControlMessageProxy::create)), py::return_value_policy::move) + .def(py::init<>(), py::return_value_policy::reference_internal) + .def(py::init(py::overload_cast(&ControlMessageProxy::create)), + py::return_value_policy::reference_internal) .def("config", pybind11::overload_cast(&ControlMessageProxy::config), - py::return_value_policy::move) + py::return_value_policy::reference_internal) .def("config", pybind11::overload_cast(&ControlMessageProxy::config), py::arg("config"), - py::return_value_policy::move) - .def("payload", pybind11::overload_cast<>(&MessageControl::payload), py::return_value_policy::move) + py::return_value_policy::reference_internal) + .def( + "payload", pybind11::overload_cast<>(&MessageControl::payload), py::return_value_policy::reference_internal) .def("payload", pybind11::overload_cast&>(&MessageControl::payload), - py::return_value_policy::move); + py::return_value_policy::reference_internal); // Context manager for Mutable Dataframes. Attempting to use it outside a with block will raise an exception py::class_>(_module, "MutableTableCtxMgr") From 5994b7e86dab3b8af887dccc4ebf9fa1358ee1e0 Mon Sep 17 00:00:00 2001 From: Devin Robison Date: Tue, 21 Feb 2023 12:24:30 -0700 Subject: [PATCH 028/157] Update to allow config passing to loaders --- .../_lib/include/morpheus/io/data_loader.hpp | 9 +++++++++ .../_lib/include/morpheus/io/loaders/file.hpp | 4 +++- .../include/morpheus/io/loaders/file_list.hpp | 4 +++- .../_lib/include/morpheus/io/loaders/grpc.hpp | 4 +++- .../include/morpheus/io/loaders/lambda.hpp | 6 ++++-- .../include/morpheus/io/loaders/payload.hpp | 4 +++- .../_lib/include/morpheus/io/loaders/rest.hpp | 4 +++- .../morpheus/objects/factory_registry.hpp | 13 ++++++++----- morpheus/_lib/src/io/data_loader.cpp | 10 ++++++++++ morpheus/_lib/src/io/data_loader_registry.cpp | 10 ++++++---- morpheus/_lib/src/io/loaders/file.cpp | 3 +++ morpheus/_lib/src/io/loaders/file_list.cpp | 3 +++ morpheus/_lib/src/io/loaders/grpc.cpp | 2 ++ morpheus/_lib/src/io/loaders/lambda.cpp | 9 +++++---- morpheus/_lib/src/io/loaders/payload.cpp | 2 ++ morpheus/_lib/src/io/loaders/rest.cpp | 2 ++ morpheus/_lib/src/python_modules/common.cpp | 8 ++++---- .../tests/io/test_data_loader_registry.cpp | 19 ++++++++++++------- .../tests/modules/test_data_loader_module.cpp | 12 ++++-------- morpheus/_lib/tests/test_morpheus.cpp | 8 ++++---- 20 files changed, 93 insertions(+), 43 deletions(-) diff --git a/morpheus/_lib/include/morpheus/io/data_loader.hpp b/morpheus/_lib/include/morpheus/io/data_loader.hpp index 907e390137..971d4a7cd1 100644 --- a/morpheus/_lib/include/morpheus/io/data_loader.hpp +++ b/morpheus/_lib/include/morpheus/io/data_loader.hpp @@ -31,8 +31,17 @@ class Loader public: ~Loader() = default; + Loader() = default; + Loader(nlohmann::json config); + virtual std::shared_ptr payload(std::shared_ptr message); virtual std::shared_ptr load(std::shared_ptr message); + + protected: + nlohmann::json config() const; + + private: + nlohmann::json m_config{}; }; class DataLoader diff --git a/morpheus/_lib/include/morpheus/io/loaders/file.hpp b/morpheus/_lib/include/morpheus/io/loaders/file.hpp index db0634b37d..665f640c77 100644 --- a/morpheus/_lib/include/morpheus/io/loaders/file.hpp +++ b/morpheus/_lib/include/morpheus/io/loaders/file.hpp @@ -30,9 +30,11 @@ namespace morpheus { class FileDataLoader : public Loader { public: - FileDataLoader() = default; ~FileDataLoader() = default; + FileDataLoader() = default; + FileDataLoader(nlohmann::json config); + std::shared_ptr load(std::shared_ptr message) final; }; #pragma GCC visibility pop diff --git a/morpheus/_lib/include/morpheus/io/loaders/file_list.hpp b/morpheus/_lib/include/morpheus/io/loaders/file_list.hpp index c3209cad6b..03c9bf9b79 100644 --- a/morpheus/_lib/include/morpheus/io/loaders/file_list.hpp +++ b/morpheus/_lib/include/morpheus/io/loaders/file_list.hpp @@ -28,9 +28,11 @@ namespace morpheus { class FileListLoader : public Loader { public: - FileListLoader() = default; ~FileListLoader() = default; + FileListLoader() = default; + FileListLoader(nlohmann::json config); + std::shared_ptr load(std::shared_ptr control_message) final; }; #pragma GCC visibility pop diff --git a/morpheus/_lib/include/morpheus/io/loaders/grpc.hpp b/morpheus/_lib/include/morpheus/io/loaders/grpc.hpp index 6032d0fd6e..4594454f14 100644 --- a/morpheus/_lib/include/morpheus/io/loaders/grpc.hpp +++ b/morpheus/_lib/include/morpheus/io/loaders/grpc.hpp @@ -28,9 +28,11 @@ namespace morpheus { class GRPCDataLoader : public Loader { public: - GRPCDataLoader() = default; ~GRPCDataLoader() = default; + GRPCDataLoader() = default; + GRPCDataLoader(nlohmann::json config); + std::shared_ptr load(std::shared_ptr message) final; }; #pragma GCC visibility pop diff --git a/morpheus/_lib/include/morpheus/io/loaders/lambda.hpp b/morpheus/_lib/include/morpheus/io/loaders/lambda.hpp index 335f07340e..35ff659ca4 100644 --- a/morpheus/_lib/include/morpheus/io/loaders/lambda.hpp +++ b/morpheus/_lib/include/morpheus/io/loaders/lambda.hpp @@ -28,10 +28,12 @@ namespace morpheus { class LambdaLoader : public Loader { public: - LambdaLoader() = delete; - LambdaLoader(std::function(std::shared_ptr)> lambda_load); ~LambdaLoader() = default; + LambdaLoader() = delete; + LambdaLoader(std::function(std::shared_ptr)> lambda_load, + nlohmann::json config = {}); + std::shared_ptr load(std::shared_ptr message) final; private: diff --git a/morpheus/_lib/include/morpheus/io/loaders/payload.hpp b/morpheus/_lib/include/morpheus/io/loaders/payload.hpp index 39e16f67d9..743101f55e 100644 --- a/morpheus/_lib/include/morpheus/io/loaders/payload.hpp +++ b/morpheus/_lib/include/morpheus/io/loaders/payload.hpp @@ -30,9 +30,11 @@ namespace morpheus { class PayloadDataLoader : public Loader { public: - PayloadDataLoader() = default; ~PayloadDataLoader() = default; + PayloadDataLoader() = default; + PayloadDataLoader(nlohmann::json config); + std::shared_ptr load(std::shared_ptr control_message) final; }; #pragma GCC visibility pop diff --git a/morpheus/_lib/include/morpheus/io/loaders/rest.hpp b/morpheus/_lib/include/morpheus/io/loaders/rest.hpp index a557953fc5..f4feba7840 100644 --- a/morpheus/_lib/include/morpheus/io/loaders/rest.hpp +++ b/morpheus/_lib/include/morpheus/io/loaders/rest.hpp @@ -28,9 +28,11 @@ namespace morpheus { class RESTDataLoader : public Loader { public: - RESTDataLoader() = default; ~RESTDataLoader() = default; + RESTDataLoader() = default; + RESTDataLoader(nlohmann::json config); + std::shared_ptr load(std::shared_ptr message) final; }; #pragma GCC visibility pop diff --git a/morpheus/_lib/include/morpheus/objects/factory_registry.hpp b/morpheus/_lib/include/morpheus/objects/factory_registry.hpp index a769227db9..fbfda0265e 100644 --- a/morpheus/_lib/include/morpheus/objects/factory_registry.hpp +++ b/morpheus/_lib/include/morpheus/objects/factory_registry.hpp @@ -53,7 +53,8 @@ class FactoryRegistry } // TODO(Devin): Rename -- this isn't a constructor, its creating an instance - static std::shared_ptr create_object_from_factory(const std::string& name) + static std::shared_ptr create_object_from_factory(const std::string& name, + nlohmann::json config = {}) { std::lock_guard lock(m_mutex); VLOG(2) << "Retrieving factory constructor: " << name << "(" << mrc::boost_type_name() @@ -63,11 +64,12 @@ class FactoryRegistry { throw std::runtime_error("Unknown data loader: " + name); } - return m_object_constructors[name](); + + return m_object_constructors[name](config); } static void register_factory_fn(const std::string& name, - const std::function()>& loader_fn, + const std::function(nlohmann::json)>& loader_fn, bool throw_if_exists = true) { std::lock_guard lock(m_mutex); @@ -102,14 +104,15 @@ class FactoryRegistry private: static std::mutex m_mutex; - static std::map()>> m_object_constructors; + static std::map(nlohmann::json)>> + m_object_constructors; }; template std::mutex FactoryRegistry::m_mutex; template -std::map()>> +std::map(nlohmann::json)>> FactoryRegistry::m_object_constructors; #pragma GCC visibility pop diff --git a/morpheus/_lib/src/io/data_loader.cpp b/morpheus/_lib/src/io/data_loader.cpp index c8e6dbf7f2..2a19e372f3 100644 --- a/morpheus/_lib/src/io/data_loader.cpp +++ b/morpheus/_lib/src/io/data_loader.cpp @@ -26,6 +26,16 @@ namespace morpheus { DataLoader::DataLoader() : m_loaders{} {} +Loader::Loader(nlohmann::json config) +{ + m_config = std::move(config); +} + +nlohmann::json Loader::config() const +{ + return m_config; +} + std::shared_ptr Loader::payload(std::shared_ptr message) { return std::move(message->payload()); diff --git a/morpheus/_lib/src/io/data_loader_registry.cpp b/morpheus/_lib/src/io/data_loader_registry.cpp index 97e2652cf7..15d4afc6ed 100644 --- a/morpheus/_lib/src/io/data_loader_registry.cpp +++ b/morpheus/_lib/src/io/data_loader_registry.cpp @@ -32,10 +32,12 @@ void LoaderRegistryProxy::register_proxy_factory_fn( { FactoryRegistry::register_factory_fn( name, - [proxy_constructor]() { - return std::make_shared([proxy_constructor](std::shared_ptr control_message) { - return std::move(proxy_constructor(control_message)); - }); + [proxy_constructor](nlohmann::json config) { + return std::make_shared( + [proxy_constructor](std::shared_ptr control_message) { + return std::move(proxy_constructor(control_message)); + }, + config); }, throw_if_exists); diff --git a/morpheus/_lib/src/io/loaders/file.cpp b/morpheus/_lib/src/io/loaders/file.cpp index 3ace5dcbb5..cf2cf6a6b3 100644 --- a/morpheus/_lib/src/io/loaders/file.cpp +++ b/morpheus/_lib/src/io/loaders/file.cpp @@ -30,6 +30,9 @@ namespace {} namespace morpheus { + +FileDataLoader::FileDataLoader(nlohmann::json config) : Loader(config) {} + std::shared_ptr FileDataLoader::load(std::shared_ptr message) { namespace py = pybind11; diff --git a/morpheus/_lib/src/io/loaders/file_list.cpp b/morpheus/_lib/src/io/loaders/file_list.cpp index 80f1931e82..4f1680c08c 100644 --- a/morpheus/_lib/src/io/loaders/file_list.cpp +++ b/morpheus/_lib/src/io/loaders/file_list.cpp @@ -22,6 +22,9 @@ #include namespace morpheus { + +FileListLoader::FileListLoader(nlohmann::json config) : Loader(config) {} + std::shared_ptr FileListLoader::load(std::shared_ptr control_message) { VLOG(30) << "Called FileListLoader::load()"; diff --git a/morpheus/_lib/src/io/loaders/grpc.cpp b/morpheus/_lib/src/io/loaders/grpc.cpp index d5c097f3e6..a254dcaffd 100644 --- a/morpheus/_lib/src/io/loaders/grpc.cpp +++ b/morpheus/_lib/src/io/loaders/grpc.cpp @@ -20,6 +20,8 @@ #include namespace morpheus { +GRPCDataLoader::GRPCDataLoader(nlohmann::json config) : Loader(config) {} + std::shared_ptr GRPCDataLoader::load(std::shared_ptr message) { VLOG(30) << "Called GRPCDataLoader::load()"; diff --git a/morpheus/_lib/src/io/loaders/lambda.cpp b/morpheus/_lib/src/io/loaders/lambda.cpp index 91b3c5d3c2..bae4c6accb 100644 --- a/morpheus/_lib/src/io/loaders/lambda.cpp +++ b/morpheus/_lib/src/io/loaders/lambda.cpp @@ -20,10 +20,11 @@ #include namespace morpheus { -LambdaLoader::LambdaLoader(std::function(std::shared_ptr)> lambda_load) - : m_lambda_load(lambda_load) -{ -} +LambdaLoader::LambdaLoader(std::function(std::shared_ptr)> lambda_load, + nlohmann::json config) : + Loader(config), + m_lambda_load(lambda_load) +{} std::shared_ptr LambdaLoader::load(std::shared_ptr message) { diff --git a/morpheus/_lib/src/io/loaders/payload.cpp b/morpheus/_lib/src/io/loaders/payload.cpp index cb4dbb2180..cb3a8a45e0 100644 --- a/morpheus/_lib/src/io/loaders/payload.cpp +++ b/morpheus/_lib/src/io/loaders/payload.cpp @@ -20,6 +20,8 @@ #include namespace morpheus { +PayloadDataLoader::PayloadDataLoader(nlohmann::json config) : Loader(config) {} + std::shared_ptr PayloadDataLoader::load(std::shared_ptr message) { VLOG(30) << "Called PayloadDataLoader::load()"; diff --git a/morpheus/_lib/src/io/loaders/rest.cpp b/morpheus/_lib/src/io/loaders/rest.cpp index aeda445523..f82bbaad1c 100644 --- a/morpheus/_lib/src/io/loaders/rest.cpp +++ b/morpheus/_lib/src/io/loaders/rest.cpp @@ -20,6 +20,8 @@ #include namespace morpheus { +RESTDataLoader::RESTDataLoader(nlohmann::json config) : Loader(config) {} + std::shared_ptr RESTDataLoader::load(std::shared_ptr message) { VLOG(30) << "Called RESTDataLoader::load()"; diff --git a/morpheus/_lib/src/python_modules/common.cpp b/morpheus/_lib/src/python_modules/common.cpp index c6d11dee2d..0ab02c8e1b 100644 --- a/morpheus/_lib/src/python_modules/common.cpp +++ b/morpheus/_lib/src/python_modules/common.cpp @@ -49,13 +49,13 @@ PYBIND11_MODULE(common, _module) load_cudf_helpers(); LoaderRegistry::register_factory_fn( - "file", []() { return std::make_unique(); }, false); + "file", [](nlohmann::json config) { return std::make_unique(config); }, false); LoaderRegistry::register_factory_fn( - "grpc", []() { return std::make_unique(); }, false); + "grpc", [](nlohmann::json config) { return std::make_unique(config); }, false); LoaderRegistry::register_factory_fn( - "payload", []() { return std::make_unique(); }, false); + "payload", [](nlohmann::json config) { return std::make_unique(config); }, false); LoaderRegistry::register_factory_fn( - "rest", []() { return std::make_unique(); }, false); + "rest", [](nlohmann::json config) { return std::make_unique(config); }, false); py::class_>(_module, "DataLoaderRegistry") .def_static("contains", &LoaderRegistry::contains, py::arg("name")) diff --git a/morpheus/_lib/tests/io/test_data_loader_registry.cpp b/morpheus/_lib/tests/io/test_data_loader_registry.cpp index edddee0a08..493513e70c 100644 --- a/morpheus/_lib/tests/io/test_data_loader_registry.cpp +++ b/morpheus/_lib/tests/io/test_data_loader_registry.cpp @@ -41,16 +41,20 @@ TEST_F(TestDataLoaderRegistry, LoaderRegistryRegisterLoaderTest) ASSERT_FALSE(LoaderRegistry::contains("LoaderRegistryRegisterLoaderTest")); // Should be able to register a loader - LoaderRegistry::register_factory_fn("LoaderRegistryRegisterLoaderTest", - []() { return std::make_unique(); }); + LoaderRegistry::register_factory_fn("LoaderRegistryRegisterLoaderTest", [](nlohmann::json config) { + return std::make_unique(config); + }); ASSERT_TRUE(LoaderRegistry::contains("LoaderRegistryRegisterLoaderTest")); // Should be able to overwrite an existing loader if we request it EXPECT_NO_THROW(LoaderRegistry::register_factory_fn( - "LoaderRegistryRegisterLoaderTest", []() { return std::make_unique(); }, false)); + "LoaderRegistryRegisterLoaderTest", + [](nlohmann::json config) { return std::make_unique(config); }, + false)); - EXPECT_THROW(LoaderRegistry::register_factory_fn("LoaderRegistryRegisterLoaderTest", - []() { return std::make_unique(); }), + EXPECT_THROW(LoaderRegistry::register_factory_fn( + "LoaderRegistryRegisterLoaderTest", + [](nlohmann::json config) { return std::make_unique(config); }), std::runtime_error); } @@ -59,8 +63,9 @@ TEST_F(TestDataLoaderRegistry, LoaderRegistryUnregisterLoaderTest) ASSERT_FALSE(LoaderRegistry::contains("LoaderRegistryUnregisterLoaderTest")); // Should be able to register a loader - LoaderRegistry::register_factory_fn("LoaderRegistryUnregisterLoaderTest", - []() { return std::make_unique(); }); + LoaderRegistry::register_factory_fn("LoaderRegistryUnregisterLoaderTest", [](nlohmann::json config) { + return std::make_unique(config); + }); ASSERT_TRUE(LoaderRegistry::contains("LoaderRegistryUnregisterLoaderTest")); // Should be able to unregister a loader diff --git a/morpheus/_lib/tests/modules/test_data_loader_module.cpp b/morpheus/_lib/tests/modules/test_data_loader_module.cpp index 5d24012d0b..3bda53060d 100644 --- a/morpheus/_lib/tests/modules/test_data_loader_module.cpp +++ b/morpheus/_lib/tests/modules/test_data_loader_module.cpp @@ -45,7 +45,6 @@ using namespace morpheus::test; // using namespace mrc::modules; // using namespace mrc; // -// using sp_msg_meta_t = std::shared_ptr; // using sp_msg_ctrl_t = std::shared_ptr; // // std::size_t packet_count{0}; @@ -87,7 +86,7 @@ using namespace morpheus::test; // }); // // builder.make_edge(source, data_loader_module->input_port("input")); -// auto sink = builder.make_sink("sink", [&packet_count](sp_msg_meta_t input) { +// auto sink = builder.make_sink("sink", [&packet_count](sp_msg_ctrl_t input) { // packet_count++; // VLOG(30) << "Received message"; // }); @@ -120,7 +119,6 @@ using namespace morpheus::test; // using namespace mrc::modules; // using namespace mrc; // -// using sp_msg_meta_t = std::shared_ptr; // using sp_msg_ctrl_t = std::shared_ptr; // // std::size_t packet_count{0}; @@ -145,7 +143,7 @@ using namespace morpheus::test; // }); // // builder.make_edge(source, data_loader_module->input_port("input")); -// auto sink = builder.make_sink("sink", [&packet_count](sp_msg_meta_t input) { +// auto sink = builder.make_sink("sink", [&packet_count](sp_msg_ctrl_t input) { // packet_count++; // VLOG(10) << "Received message"; // }); @@ -176,7 +174,6 @@ TEST_F(TestDataLoaderModule, EndToEndPayloadDataLoaderTest) using namespace mrc::modules; using namespace mrc; - using sp_msg_meta_t = std::shared_ptr; using sp_msg_ctrl_t = std::shared_ptr; std::size_t packet_count{0}; @@ -202,7 +199,7 @@ TEST_F(TestDataLoaderModule, EndToEndPayloadDataLoaderTest) std::size_t x; builder.make_edge(source, data_loader_module->input_port("input")); - auto sink = builder.make_sink("sink", [&packet_count](sp_msg_meta_t input) { + auto sink = builder.make_sink("sink", [&packet_count](sp_msg_ctrl_t input) { packet_count++; VLOG(10) << "Received message"; }); @@ -233,7 +230,6 @@ TEST_F(TestDataLoaderModule, EndToEndPayloadDataLoaderTest) // using namespace mrc::modules; // using namespace mrc; // -// using sp_msg_meta_t = std::shared_ptr; // using sp_msg_ctrl_t = std::shared_ptr; // // std::size_t packet_count{0}; @@ -258,7 +254,7 @@ TEST_F(TestDataLoaderModule, EndToEndPayloadDataLoaderTest) // }); // // builder.make_edge(source, data_loader_module->input_port("input")); -// auto sink = builder.make_sink("sink", [&packet_count](sp_msg_meta_t input) { +// auto sink = builder.make_sink("sink", [&packet_count](sp_msg_ctrl_t input) { // packet_count++; // VLOG(10) << "Received message"; // }); diff --git a/morpheus/_lib/tests/test_morpheus.cpp b/morpheus/_lib/tests/test_morpheus.cpp index 63a87ee93f..30d8349e88 100644 --- a/morpheus/_lib/tests/test_morpheus.cpp +++ b/morpheus/_lib/tests/test_morpheus.cpp @@ -50,13 +50,13 @@ void TestWithPythonInterpreter::SetUp() initialize_interpreter(); LoaderRegistry::register_factory_fn( - "file", []() { return std::make_unique(); }, false); + "file", [](nlohmann::json config) { return std::make_unique(config); }, false); LoaderRegistry::register_factory_fn( - "grpc", []() { return std::make_unique(); }, false); + "grpc", [](nlohmann::json config) { return std::make_unique(config); }, false); LoaderRegistry::register_factory_fn( - "payload", []() { return std::make_unique(); }, false); + "payload", [](nlohmann::json config) { return std::make_unique(config); }, false); LoaderRegistry::register_factory_fn( - "rest", []() { return std::make_unique(); }, false); + "rest", [](nlohmann::json config) { return std::make_unique(config); }, false); } void TestWithPythonInterpreter::TearDown() {} From b684568071ba072de1e80b3931ccfbf793fe1d92 Mon Sep 17 00:00:00 2001 From: bsuryadevara Date: Tue, 21 Feb 2023 13:50:29 -0600 Subject: [PATCH 029/157] data loader integration to dfp pipeline --- .../morpheus/dfp/modules/dfp_data_prep.py | 1 + .../morpheus/dfp/modules/dfp_deployment.py | 2 +- .../morpheus/dfp/modules/dfp_preproc.py | 12 +- .../morpheus/dfp/modules/dfp_split_users.py | 26 +- .../morpheus/dfp/utils/config_generator.py | 18 +- .../morpheus/dfp_modules_pipeline.py | 9 +- morpheus/loaders/__init__.py | 0 morpheus/loaders/file_to_df_loader.py | 259 ++++++++++++++++++ morpheus/modules/__init__.py | 2 +- morpheus/modules/file_batcher.py | 28 +- morpheus/modules/serialize.py | 7 + morpheus/utils/loader_ids.py | 15 + morpheus/utils/loader_utils.py | 47 ++++ morpheus/utils/module_ids.py | 3 +- 14 files changed, 392 insertions(+), 37 deletions(-) create mode 100644 morpheus/loaders/__init__.py create mode 100644 morpheus/loaders/file_to_df_loader.py create mode 100644 morpheus/utils/loader_ids.py create mode 100644 morpheus/utils/loader_utils.py diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_data_prep.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_data_prep.py index 28b26294cf..225b455e06 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_data_prep.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_data_prep.py @@ -51,6 +51,7 @@ def dfp_data_prep(builder: mrc.Builder): schema = pickle.loads(bytes(schema_str, encoding)) def process_features(message: MultiDFPMessage): + if (message is None): return None diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_deployment.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_deployment.py index 3a25bc5148..86d7d01f11 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_deployment.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_deployment.py @@ -34,7 +34,7 @@ @register_module(DFP_DEPLOYMENT, MODULE_NAMESPACE) -def dfp_inf(builder: mrc.Builder): +def dfp_deployment(builder: mrc.Builder): module_config = get_module_config(DFP_DEPLOYMENT, builder) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_preproc.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_preproc.py index 4183488cad..a46198fbe0 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_preproc.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_preproc.py @@ -17,10 +17,12 @@ import dfp.modules.dfp_split_users # noqa: F401 import mrc +import morpheus._lib.modules # noqa: F401 +import morpheus.loaders.file_to_df_loader # noqa: F401 import morpheus.modules.file_batcher # noqa: F401 import morpheus.modules.file_to_df # noqa: F401 +from morpheus.utils.module_ids import DATA_LOADER from morpheus.utils.module_ids import FILE_BATCHER -from morpheus.utils.module_ids import FILE_TO_DF from morpheus.utils.module_ids import MODULE_NAMESPACE from morpheus.utils.module_utils import get_module_config from morpheus.utils.module_utils import load_module @@ -47,17 +49,17 @@ def dfp_preproc(builder: mrc.Builder): config = get_module_config(DFP_PREPROC, builder) file_batcher_conf = config.get(FILE_BATCHER, None) - file_to_df_conf = config.get(FILE_TO_DF, None) + data_loader_conf = config.get(DATA_LOADER, None) dfp_split_users_conf = config.get(DFP_SPLIT_USERS, None) # Load modules file_batcher_module = load_module(file_batcher_conf, builder=builder) - file_to_dataframe_module = load_module(file_to_df_conf, builder=builder) + data_loader_module = load_module(data_loader_conf, builder=builder) dfp_split_users_module = load_module(dfp_split_users_conf, builder=builder) # Make an edge between the modules. - builder.make_edge(file_batcher_module.output_port("output"), file_to_dataframe_module.input_port("input")) - builder.make_edge(file_to_dataframe_module.output_port("output"), dfp_split_users_module.input_port("input")) + builder.make_edge(file_batcher_module.output_port("output"), data_loader_module.input_port("input")) + builder.make_edge(data_loader_module.output_port("output"), dfp_split_users_module.input_port("input")) # Register input and output port for a module. builder.register_module_input("input", file_batcher_module.input_port("input")) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_split_users.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_split_users.py index f075a4d66b..f493b155b5 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_split_users.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_split_users.py @@ -17,11 +17,13 @@ import mrc import numpy as np +import pandas as pd from dfp.utils.logging_timer import log_time from mrc.core import operators as ops import cudf +from morpheus.messages import MessageControl from morpheus.utils.module_ids import MODULE_NAMESPACE from morpheus.utils.module_utils import get_module_config from morpheus.utils.module_utils import register_module @@ -56,34 +58,34 @@ def dfp_split_users(builder: mrc.Builder): # Map of user ids to total number of messages. Keeps indexes monotonic and increasing per user user_index_map: typing.Dict[str, int] = {} - def extract_users(message: cudf.DataFrame): + def extract_users(message: MessageControl): if (message is None): return [] + df = message.payload().df with log_time(logger.debug) as log_info: - if (isinstance(message, cudf.DataFrame)): + if (isinstance(df, cudf.DataFrame)): # Convert to pandas because cudf is slow at this - message = message.to_pandas() + df = df.to_pandas() + df[timestamp_column_name] = pd.to_datetime(df[timestamp_column_name], utc=True) split_dataframes: typing.Dict[str, cudf.DataFrame] = {} # If we are skipping users, do that here if (len(skip_users) > 0): - message = message[~message[userid_column_name].isin(skip_users)] + df = df[~df[userid_column_name].isin(skip_users)] if (len(only_users) > 0): - message = message[message[userid_column_name].isin(only_users)] + df = df[df[userid_column_name].isin(only_users)] # Split up the dataframes if (include_generic): - split_dataframes[fallback_username] = message + split_dataframes[fallback_username] = df if (include_individual): - split_dataframes.update( - {username: user_df - for username, user_df in message.groupby("username", sort=False)}) + split_dataframes.update({username: user_df for username, user_df in df.groupby("username", sort=False)}) output_messages: typing.List[DFPMessageMeta] = [] @@ -108,9 +110,9 @@ def extract_users(message: cudf.DataFrame): log_info.set_log( ("Batch split users complete. Input: %s rows from %s to %s. " "Output: %s users, rows/user min: %s, max: %s, avg: %.2f. Duration: {duration:.2f} ms"), - len(message), - message[timestamp_column_name].min(), - message[timestamp_column_name].max(), + len(df), + df[timestamp_column_name].min(), + df[timestamp_column_name].max(), len(rows_per_user), np.min(rows_per_user), np.max(rows_per_user), diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py b/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py index 78a45a12b7..d544b995c8 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py @@ -37,6 +37,8 @@ from morpheus.config import ConfigAutoEncoder from morpheus.config import CppConfig from morpheus.messages.multi_message import MultiMessage +from morpheus.utils.loader_ids import FILE_TO_DF_LOADER +from morpheus.utils.module_ids import DATA_LOADER from morpheus.utils.module_ids import FILE_BATCHER from morpheus.utils.module_ids import FILE_TO_DF from morpheus.utils.module_ids import FILTER_DETECTIONS @@ -95,12 +97,7 @@ def preproc_module_config(self): "sampling_rate_s": self._derive_args.sample_rate_s, "start_time": self._derive_args.time_fields.start_time, "end_time": self._derive_args.time_fields.end_time, - "iso_date_regex_pattern": iso_date_regex_pattern - }, - FILE_TO_DF: { - "module_id": FILE_TO_DF, - "module_name": "FILE_TO_DF", - "namespace": MODULE_NAMESPACE, + "iso_date_regex_pattern": iso_date_regex_pattern, "timestamp_column_name": self._config.ae.timestamp_column_name, "parser_kwargs": { "lines": False, "orient": "records" @@ -112,6 +109,12 @@ def preproc_module_config(self): "schema_str": self._source_schema_str, "encoding": self._encoding } }, + DATA_LOADER: { + "module_id": DATA_LOADER, + "module_name": "FileToDFDataLoader", + "namespace": MODULE_NAMESPACE, + "loaders": [FILE_TO_DF_LOADER] + }, DFP_SPLIT_USERS: { "module_id": DFP_SPLIT_USERS, "module_name": "dfp_split_users", @@ -181,7 +184,8 @@ def infer_module_config(self): "module_id": SERIALIZE, "module_name": "serialize", "namespace": MODULE_NAMESPACE, - "exclude": ['batch_count', 'origin_hash', '_row_hash', '_batch_id'] + "exclude": ['batch_count', 'origin_hash', '_row_hash', '_batch_id'], + "use_cpp": CppConfig.get_should_use_cpp() }, WRITE_TO_FILE: { "module_id": WRITE_TO_FILE, diff --git a/examples/digital_fingerprinting/production/morpheus/dfp_modules_pipeline.py b/examples/digital_fingerprinting/production/morpheus/dfp_modules_pipeline.py index a1da07805d..0cb5f00dbf 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp_modules_pipeline.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp_modules_pipeline.py @@ -77,6 +77,12 @@ default="60d", help="The training duration to run starting from start_time", ) +@click.option( + "--use_cpp", + type=click.BOOL, + default=False, + help=("Indicates what type of logs are going to be used in the workload."), +) @click.option( "--cache_dir", type=str, @@ -119,6 +125,7 @@ def run_pipeline(log_type: str, log_level: int, sample_rate_s: int, tracking_uri, + use_cpp, **kwargs): derive_args = DeriveArgs(skip_user, @@ -138,7 +145,7 @@ def run_pipeline(log_type: str, userid_column_name = "username" timestamp_column_name = "timestamp" - config: Config = generate_ae_config(log_type, userid_column_name, timestamp_column_name) + config: Config = generate_ae_config(log_type, userid_column_name, timestamp_column_name, use_cpp=use_cpp) schema_builder = SchemaBuilder(config, log_type) schema: Schema = schema_builder.build_schema() diff --git a/morpheus/loaders/__init__.py b/morpheus/loaders/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/morpheus/loaders/file_to_df_loader.py b/morpheus/loaders/file_to_df_loader.py new file mode 100644 index 0000000000..9592ab8c75 --- /dev/null +++ b/morpheus/loaders/file_to_df_loader.py @@ -0,0 +1,259 @@ +# Copyright (c) 2023, NVIDIA CORPORATION. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import hashlib +import json +import logging +import multiprocessing as mp +import os +import pickle +import time +import typing +from functools import partial + +import fsspec +import fsspec.utils +from morpheus.messages.message_meta import MessageMeta + +import pandas as pd + +import cudf + +from morpheus.messages import MessageControl +from morpheus._lib.common import FileTypes +from morpheus.cli.utils import str_to_file_type +from morpheus.io.deserializers import read_file_to_df +from morpheus.utils.column_info import process_dataframe +from morpheus.utils.loader_ids import FILE_TO_DF_LOADER +from morpheus.utils.loader_utils import register_loader + +logger = logging.getLogger(__name__) + +dask_cluster = None + + +@register_loader(FILE_TO_DF_LOADER) +def file_to_df_loader(message: MessageControl): + + config = message.config() + + files = config.get("files", None) + timestamp_column_name = config.get("timestamp_column_name", None) + schema_config = config.get("schema", None) + schema_str = schema_config.get("schema_str", None) + encoding = schema_config.get("encoding", None) + file_type = config.get("file_type", None) + filter_null = config.get("filter_null", None) + parser_kwargs = config.get("parser_kwargs", None) + cache_dir = config.get("cache_dir", None) + + download_method: typing.Literal["single_thread", "multiprocess", "dask", + "dask_thread"] = os.environ.get("MORPHEUS_FILE_DOWNLOAD_TYPE", "multiprocess") + cache_dir = os.path.join(cache_dir, "file_cache") + + # Load input schema + schema = pickle.loads(bytes(schema_str, encoding)) + + try: + file_type = str_to_file_type(file_type.lower()) + except Exception: + raise ValueError("Invalid input file type '{}'. Available file types are: CSV, JSON".format(file_type)) + + def single_object_to_dataframe(file_object: fsspec.core.OpenFile, + file_type: FileTypes, + filter_null: bool, + parser_kwargs: dict): + + retries = 0 + s3_df = None + while (retries < 2): + try: + with file_object as f: + s3_df = read_file_to_df(f, + file_type, + filter_nulls=filter_null, + df_type="pandas", + parser_kwargs=parser_kwargs) + + break + except Exception as e: + if (retries < 2): + logger.warning("Refreshing S3 credentials") + retries += 1 + else: + raise e + + # Run the pre-processing before returning + if (s3_df is None): + return s3_df + + s3_df = process_dataframe(df_in=s3_df, input_schema=schema) + + return s3_df + + def get_or_create_dataframe_from_s3_batch( + file_name_batch: typing.Tuple[typing.List[str], int]) -> typing.Tuple[cudf.DataFrame, bool]: + + if (not file_name_batch): + return None, False + + file_list = fsspec.open_files(file_name_batch[0]) + batch_count = file_name_batch[1] + + fs: fsspec.AbstractFileSystem = file_list.fs + + # Create a list of dictionaries that only contains the information we are interested in hashing. `ukey` just + # hashes all of the output of `info()` which is perfect + hash_data = [{"ukey": fs.ukey(file_object.path)} for file_object in file_list] + + # Convert to base 64 encoding to remove - values + objects_hash_hex = hashlib.md5(json.dumps(hash_data, sort_keys=True).encode()).hexdigest() + + batch_cache_location = os.path.join(cache_dir, "batches", f"{objects_hash_hex}.pkl") + + # Return the cache if it exists + if (os.path.exists(batch_cache_location)): + output_df = pd.read_pickle(batch_cache_location) + output_df["origin_hash"] = objects_hash_hex + output_df["batch_count"] = batch_count + + return (output_df, True) + + # Cache miss + download_method_func = partial(single_object_to_dataframe, + file_type=file_type, + filter_null=filter_null, + parser_kwargs=parser_kwargs) + + download_buckets = file_list + + # Loop over dataframes and concat into one + try: + dfs = [] + if (download_method.startswith("dask")): + # Create the client each time to ensure all connections to the cluster are + # closed (they can time out) + dask_cluster = get_dask_cluster(download_method) + with get_dask_client(dask_cluster) as client: + dfs = client.map(download_method_func, download_buckets) + + dfs = client.gather(dfs) + + elif (download_method == "multiprocessing"): + # Use multiprocessing here since parallel downloads are a pain + with mp.get_context("spawn").Pool(mp.cpu_count()) as p: + dfs = p.map(download_method_func, download_buckets) + else: + # Simply loop + for s3_object in download_buckets: + dfs.append(download_method_func(s3_object)) + + except Exception: + logger.exception("Failed to download logs. Error: ", exc_info=True) + return None, False + + if (not dfs): + logger.error("No logs were downloaded") + return None, False + + output_df: pd.DataFrame = pd.concat(dfs) + + # Finally sort by timestamp and then reset the index + output_df.sort_values(by=[timestamp_column_name], inplace=True) + + output_df.reset_index(drop=True, inplace=True) + + # Save dataframe to cache future runs + os.makedirs(os.path.dirname(batch_cache_location), exist_ok=True) + + try: + output_df.to_pickle(batch_cache_location) + except Exception: + logger.warning("Failed to save batch cache. Skipping cache for this batch.", exc_info=True) + + output_df["batch_count"] = batch_count + output_df["origin_hash"] = objects_hash_hex + + return (output_df, False) + + def convert_to_dataframe(file_name_batch: typing.Tuple[typing.List[str], int]): + + if (not file_name_batch): + return None + + start_time = time.time() + + try: + output_df, cache_hit = get_or_create_dataframe_from_s3_batch(file_name_batch) + + duration = (time.time() - start_time) * 1000.0 + + logger.debug("S3 objects to DF complete. Rows: %s, Cache: %s, Duration: %s ms", + len(output_df), + "hit" if cache_hit else "miss", + duration) + return output_df + except Exception: + logger.exception("Error while converting S3 buckets to DF.") + raise + + pdf = convert_to_dataframe(files) + + df = cudf.from_pandas(pdf) + + payload = MessageMeta(df) + message.payload(payload) + + return message + + +def get_dask_cluster(download_method: str): + + global dask_cluster + + if dask_cluster is None: + try: + import dask + from dask.distributed import LocalCluster + except ModuleNotFoundError: + raise Exception("Install 'dask' and 'distributed' to allow file downloads using dask mode.") + + logger.debug("Dask cluster doesn't exist. Creating dask cluster...") + + # Up the heartbeat interval which can get violated with long download times + dask.config.set({"distributed.client.heartbeat": "30s"}) + + dask_cluster = LocalCluster(start=True, processes=not download_method == "dask_thread") + + logger.debug("Creating dask cluster... Done. Dashboard: %s", dask_cluster.dashboard_link) + + return dask_cluster + + +def get_dask_client(dask_cluster): + + from dask.distributed import Client + dask_client = Client(get_dask_cluster(dask_cluster)) + logger.debug("Creating dask client %s ... Done.", dask_client) + + return dask_client + + +def close_dask_cluster(): + + if (dask_cluster is not None): + logger.debug("Stopping dask cluster...") + dask_cluster.close() + + logger.debug("Stopping dask cluster... Done.") diff --git a/morpheus/modules/__init__.py b/morpheus/modules/__init__.py index 339e06d1dd..e740fb8bca 100644 --- a/morpheus/modules/__init__.py +++ b/morpheus/modules/__init__.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -import morpheus._lib.modules \ No newline at end of file +import morpheus._lib.modules diff --git a/morpheus/modules/file_batcher.py b/morpheus/modules/file_batcher.py index 5b555cd4ef..17f70dc994 100644 --- a/morpheus/modules/file_batcher.py +++ b/morpheus/modules/file_batcher.py @@ -22,7 +22,9 @@ import pandas as pd from mrc.core import operators as ops +from morpheus.messages import MessageControl from morpheus.utils.file_utils import date_extractor +from morpheus.utils.loader_ids import FILE_TO_DF_LOADER from morpheus.utils.module_ids import FILE_BATCHER from morpheus.utils.module_ids import MODULE_NAMESPACE from morpheus.utils.module_utils import get_module_config @@ -32,7 +34,7 @@ @register_module(FILE_BATCHER, MODULE_NAMESPACE) -def file_batcher(builder: mrc.Builder): +def file_batcher(builder: mrc.Builder) -> MessageControl: """ This module loads the input files, removes files that are older than the chosen window of time, and then groups the remaining files by period that fall inside the window. @@ -103,7 +105,17 @@ def on_data(file_objects: fsspec.core.OpenFiles): df["key"] = full_names df["objects"] = file_objs - output_batches = [] + message_config = { + "loader_id": FILE_TO_DF_LOADER, + "timestamp_column_name": config.get("timestamp_column_name"), + "schema": config.get("schema"), + "file_type": config.get("file_type"), + "filter_null": config.get("filter_null"), + "parser_kwargs": config.get("parser_kwargs"), + "cache_dir": config.get("cache_dir") + } + + out_messages = [] if len(df) > 0: # Now split by the batching settings @@ -114,14 +126,12 @@ def on_data(file_objects: fsspec.core.OpenFiles): n_groups = len(period_gb) for group in period_gb.groups: period_df = period_gb.get_group(group) + filenames = period_df["key"].to_list() + message_config["files"] = (filenames, n_groups) + message = MessageControl(message_config) + out_messages.append(message) - obj_list = fsspec.core.OpenFiles(period_df["objects"].to_list(), - mode=file_objects.mode, - fs=file_objects.fs) - - output_batches.append((obj_list, n_groups)) - - return output_batches + return out_messages def node_fn(obs: mrc.Observable, sub: mrc.Subscriber): obs.pipe(ops.map(on_data), ops.flatten()).subscribe(sub) diff --git a/morpheus/modules/serialize.py b/morpheus/modules/serialize.py index a212842954..e19dcea165 100644 --- a/morpheus/modules/serialize.py +++ b/morpheus/modules/serialize.py @@ -19,6 +19,9 @@ import mrc +import cudf +import pandas as pd + from morpheus.messages import MultiMessage from morpheus.messages.message_meta import MessageMeta from morpheus.utils.module_ids import MODULE_NAMESPACE @@ -47,6 +50,7 @@ def serialize(builder: mrc.Builder): exclude_columns = config.get("exclude", [r'^ID$', r'^_ts_']) fixed_columns = config.get("fixed_columns", True) columns = config.get("columns", None) + use_cpp = config.get("use_cpp", False) def convert_to_df(x: MultiMessage, include_columns: typing.Pattern, @@ -89,6 +93,9 @@ def convert_to_df(x: MultiMessage, # Get metadata from columns df = x.get_meta(columns) + if (isinstance(df, pd.DataFrame) and use_cpp): + df = cudf.from_pandas(df) + return MessageMeta(df=df) if (include_columns is not None and len(include_columns) > 0): diff --git a/morpheus/utils/loader_ids.py b/morpheus/utils/loader_ids.py new file mode 100644 index 0000000000..25eac7c523 --- /dev/null +++ b/morpheus/utils/loader_ids.py @@ -0,0 +1,15 @@ +# Copyright (c) 2023, NVIDIA CORPORATION. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +FILE_TO_DF_LOADER = "FileToDFLoader" diff --git a/morpheus/utils/loader_utils.py b/morpheus/utils/loader_utils.py new file mode 100644 index 0000000000..ba38a4feb4 --- /dev/null +++ b/morpheus/utils/loader_utils.py @@ -0,0 +1,47 @@ +# Copyright (c) 2023, NVIDIA CORPORATION. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import logging + +from morpheus._lib.common import DataLoaderRegistry as registry + +logger = logging.getLogger(__name__) + + +def register_loader(loder_id): + """ + Registers a loader if not exists in the dataloader registry. + + Parameters + ---------- + loder_id : str + Unique identifier for a loader in the dataloader registry. + + Returns + ------- + inner_func + Encapsulated function. + """ + + def inner_func(func): + # Register a loader if not exists in the registry. + if not registry.contains(loder_id): + registry.register_loader(loder_id, func) + logger.debug("Laoder '{}' was successfully registered.".format(loder_id)) + else: + logger.debug("Module: '{}' already exists.".format(loder_id)) + + return func + + return inner_func diff --git a/morpheus/utils/module_ids.py b/morpheus/utils/module_ids.py index 99bd17f89a..2b37cfe965 100644 --- a/morpheus/utils/module_ids.py +++ b/morpheus/utils/module_ids.py @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -MODULE_NAMESPACE = "morpheus_modules" +MODULE_NAMESPACE = "morpheus" FILE_BATCHER = "FileBatcher" FILE_TO_DF = "FileToDF" @@ -20,3 +20,4 @@ SERIALIZE = "Serialize" WRITE_TO_FILE = "WriteToFile" FILTER_DETECTIONS = "filter_detections" +DATA_LOADER = "DataLoader" From 41e17ee0870f294dd9ff9176566b1fa42f5d332a Mon Sep 17 00:00:00 2001 From: bsuryadevara Date: Tue, 21 Feb 2023 14:08:00 -0600 Subject: [PATCH 030/157] data loader integration to dfp pipeline --- morpheus/modules/file_batcher.py | 33 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/morpheus/modules/file_batcher.py b/morpheus/modules/file_batcher.py index 17f70dc994..2db3516caa 100644 --- a/morpheus/modules/file_batcher.py +++ b/morpheus/modules/file_batcher.py @@ -47,7 +47,7 @@ def file_batcher(builder: mrc.Builder) -> MessageControl: config = get_module_config(FILE_BATCHER, builder) - TimestampFileObj = namedtuple("TimestampFileObj", ["timestamp", "file_object"]) + TimestampFileObj = namedtuple("TimestampFileObj", ["timestamp", "file_name"]) iso_date_regex_pattern = config.get("iso_date_regex_pattern", None) start_time = config.get("start_time", None) @@ -57,6 +57,16 @@ def file_batcher(builder: mrc.Builder) -> MessageControl: iso_date_regex = re.compile(iso_date_regex_pattern) + message_config = { + "loader_id": FILE_TO_DF_LOADER, + "timestamp_column_name": config.get("timestamp_column_name"), + "schema": config.get("schema"), + "file_type": config.get("file_type"), + "filter_null": config.get("filter_null"), + "parser_kwargs": config.get("parser_kwargs"), + "cache_dir": config.get("cache_dir") + } + def on_data(file_objects: fsspec.core.OpenFiles): # Determine the date of the file, and apply the window filter if we have one @@ -68,7 +78,7 @@ def on_data(file_objects: fsspec.core.OpenFiles): if ((start_time is not None and ts < start_time) or (end_time is not None and ts > end_time)): continue - ts_and_files.append(TimestampFileObj(ts, file_object)) + ts_and_files.append(TimestampFileObj(ts, file_object.full_name)) # sort the incoming data by date ts_and_files.sort(key=lambda x: x.timestamp) @@ -95,25 +105,14 @@ def on_data(file_objects: fsspec.core.OpenFiles): timestamps = [] full_names = [] - file_objs = [] - for (ts, file_object) in ts_and_files: + for (ts, file_name) in ts_and_files: timestamps.append(ts) - full_names.append(file_object.full_name) - file_objs.append(file_object) + full_names.append(file_name) df["ts"] = timestamps df["key"] = full_names - df["objects"] = file_objs - - message_config = { - "loader_id": FILE_TO_DF_LOADER, - "timestamp_column_name": config.get("timestamp_column_name"), - "schema": config.get("schema"), - "file_type": config.get("file_type"), - "filter_null": config.get("filter_null"), - "parser_kwargs": config.get("parser_kwargs"), - "cache_dir": config.get("cache_dir") - } + + nonlocal message_config out_messages = [] From 6ce39818db0ec443e7b877bf8b52c4c1e570b9f3 Mon Sep 17 00:00:00 2001 From: Devin Robison Date: Tue, 21 Feb 2023 13:27:09 -0700 Subject: [PATCH 031/157] Update loaders to take configurations --- .../_lib/src/modules/data_loader_module.cpp | 23 +++++--- .../tests/modules/test_data_loader_module.cpp | 57 +++++++++++++++++-- tests/modules/test_morpheus_modules.py | 40 +++++++++++-- 3 files changed, 105 insertions(+), 15 deletions(-) diff --git a/morpheus/_lib/src/modules/data_loader_module.cpp b/morpheus/_lib/src/modules/data_loader_module.cpp index b7ae9a4b27..f19d587df8 100644 --- a/morpheus/_lib/src/modules/data_loader_module.cpp +++ b/morpheus/_lib/src/modules/data_loader_module.cpp @@ -37,19 +37,25 @@ DataLoaderModule::DataLoaderModule(std::string module_name) : SegmentModule(modu DataLoaderModule::DataLoaderModule(std::string module_name, nlohmann::json _config) : SegmentModule(std::move(module_name), std::move(_config)) -{} - -void DataLoaderModule::initialize(mrc::segment::Builder& builder) { - if (config().contains("loaders") and config()["loaders"].size() > 0) + if (config().contains("loaders") and config()["loaders"].is_array() and !config()["loaders"].empty()) { auto loader_list = config()["loaders"]; for (json::iterator it = loader_list.begin(); it != loader_list.end(); ++it) { - auto loader_id = it->get(); + auto loader_id_it = it.value().find("id"); + if (loader_id_it == it.value().end()) + { + throw std::runtime_error("Loader id not specified"); + } + + auto loader_id = loader_id_it.value().get(); + auto loader_properties = it->value("properties", json({})); if (LoaderRegistry::contains(loader_id)) { - m_data_loader.add_loader(loader_id, LoaderRegistry::create_object_from_factory(*it)); + VLOG(2) << "Adding loader: " << loader_id << " with properties: " << loader_properties.dump(2); + m_data_loader.add_loader(loader_id, + LoaderRegistry::create_object_from_factory(loader_id, loader_properties)); } else { @@ -59,9 +65,12 @@ void DataLoaderModule::initialize(mrc::segment::Builder& builder) } else { - LOG(WARNING) << "No loaders specified in config"; + LOG(WARNING) << "No loaders specified in config: " << config().dump(2); } +} +void DataLoaderModule::initialize(mrc::segment::Builder& builder) +{ auto loader_node = builder.make_node, std::shared_ptr>( "input", rxcpp::operators::map([this](std::shared_ptr control_message) { return m_data_loader.load(control_message); diff --git a/morpheus/_lib/tests/modules/test_data_loader_module.cpp b/morpheus/_lib/tests/modules/test_data_loader_module.cpp index 3bda53060d..e0d44abbca 100644 --- a/morpheus/_lib/tests/modules/test_data_loader_module.cpp +++ b/morpheus/_lib/tests/modules/test_data_loader_module.cpp @@ -169,18 +169,68 @@ using namespace morpheus::test; // executor.join(); // } +/** + * @brief Test that the module can be initialized with a configuration. + * @details Loader specification schema: + * { + * "loaders": [ + * { + * "id": "loader_id_0", + * "properties": { + * "prop1": "prop1_value", + * "prop2": "prop2_value", + * ... + * }, + * { + * "id": "loader_id_1", + * "properties": { + * "prop1": "prop1_value", + * "prop2": "prop2_value", + * ... + * } + * ] + */ +TEST_F(TestDataLoaderModule, DataLoaderModuleInitializationTest) +{ + using namespace mrc::modules; + using namespace mrc; + + using namespace nlohmann; + + json config; + config["loaders"] = {{{"id", "payload"}, {"properties", {{"prop1", "prop1_value"}}}}}; + + json config_no_props; + config_no_props["loaders"] = {{{"id", "payload"}}}; + + json config_multi_loaders; + config_multi_loaders["loaders"] = {{{"id", "payload"}, {"properties", {{"prop1", "prop1_value"}}}}, + {{"id", "rest"}, {"properties", {{"prop1", "prop1_value"}}}}, + {{"id", "grpc"}, {"properties", {{"prop1", "prop1_value"}}}}, + {{"id", "file"}, {"properties", {{"prop1", "prop1_value"}}}}}; + + auto module_no_config = std::make_shared("DataLoaderTest1"); + auto module_empty_config = std::make_shared("DataLoaderTest2", json{}); + auto module_with_config = std::make_shared("DataLoaderTest3", config); + auto module_with_multiple_loaders = std::make_shared("DataLoaderTest4", config_multi_loaders); + auto module_with_config_no_props = std::make_shared("DataLoaderTest5", config_no_props); +} + TEST_F(TestDataLoaderModule, EndToEndPayloadDataLoaderTest) { using namespace mrc::modules; using namespace mrc; + using namespace nlohmann; + using sp_msg_ctrl_t = std::shared_ptr; std::size_t packet_count{0}; auto init_wrapper = [&packet_count](segment::Builder& builder) { - nlohmann::json config; - config["loaders"] = {"payload"}; + json config; + config["loaders"] = {{{"id", "payload"}, {"properties", {{"prop1", "prop1_value"}}}}}; + auto data_loader_module = builder.make_module("DataLoaderTest", config); auto source = builder.make_source("source", [](rxcpp::subscriber& sub) { @@ -197,11 +247,10 @@ TEST_F(TestDataLoaderModule, EndToEndPayloadDataLoaderTest) sub.on_completed(); }); - std::size_t x; builder.make_edge(source, data_loader_module->input_port("input")); auto sink = builder.make_sink("sink", [&packet_count](sp_msg_ctrl_t input) { packet_count++; - VLOG(10) << "Received message"; + VLOG(20) << "Received message"; }); builder.make_edge(data_loader_module->output_port("output"), sink); diff --git a/tests/modules/test_morpheus_modules.py b/tests/modules/test_morpheus_modules.py index 6bab799da1..686a1d69d3 100644 --- a/tests/modules/test_morpheus_modules.py +++ b/tests/modules/test_morpheus_modules.py @@ -113,7 +113,15 @@ def gen_data(): source = builder.make_source("source", gen_data) - config = {"loaders": ["not_a_loader(tm)"]} + config = {"loaders": [ + { + "id": "not_a_loader(tm)", + "properties": { + "file_types": "something", + "prop2": "something else" + } + } + ]} # This will unpack the config and forward it's payload (MessageMeta) to the sink data_loader = builder.load_module("DataLoader", "morpheus", "ModuleDataLoaderTest", config) @@ -140,7 +148,15 @@ def gen_data(): source = builder.make_source("source", gen_data) - config = {"loaders": ["payload"]} + config = {"loaders": [ + { + "id": "payload", + "properties": { + "file_types": "something", + "prop2": "something else" + } + } + ]} # This will unpack the config and forward its payload (MessageMeta) to the sink data_loader = builder.load_module("DataLoader", "morpheus", "ModuleDataLoaderTest", config) @@ -202,7 +218,15 @@ def _on_next(control_msg): source = builder.make_source("source", gen_data) - config = {"loaders": ["payload"]} + config = {"loaders": [ + { + "id": "payload", + "properties": { + "file_types": "something", + "prop2": "something else" + } + } + ]} # This will unpack the config and forward its payload (MessageMeta) to the sink data_loader = builder.load_module("DataLoader", "morpheus", "ModuleDataLoaderTest", config) @@ -295,7 +319,15 @@ def _on_next(control_msg): source = builder.make_source("source", gen_data) - config = {"loaders": ["file"]} + config = {"loaders": [ + { + "id": "file", + "properties": { + "file_types": "something", + "prop2": "something else" + } + } + ]} # This will unpack the config and forward its payload (MessageMeta) to the sink data_loader = builder.load_module("DataLoader", "morpheus", "ModuleDataLoaderTest", config) From 8c6d5d4d1f7dd95a55956da2765953a984ffee8c Mon Sep 17 00:00:00 2001 From: Devin Robison Date: Tue, 21 Feb 2023 16:23:15 -0700 Subject: [PATCH 032/157] Shift to new implicit control message schema --- .../_lib/include/morpheus/io/data_loader.hpp | 2 +- .../morpheus/io/data_loader_registry.hpp | 3 +- .../_lib/include/morpheus/io/loaders/file.hpp | 4 +- .../include/morpheus/io/loaders/file_list.hpp | 4 +- .../_lib/include/morpheus/io/loaders/grpc.hpp | 4 +- .../include/morpheus/io/loaders/lambda.hpp | 11 ++- .../include/morpheus/io/loaders/payload.hpp | 2 +- .../_lib/include/morpheus/io/loaders/rest.hpp | 4 +- morpheus/_lib/src/io/data_loader.cpp | 52 +++++++++++-- morpheus/_lib/src/io/data_loader_registry.cpp | 11 ++- morpheus/_lib/src/io/loaders/file.cpp | 13 ++-- morpheus/_lib/src/io/loaders/file_list.cpp | 5 +- morpheus/_lib/src/io/loaders/grpc.cpp | 4 +- morpheus/_lib/src/io/loaders/lambda.cpp | 11 ++- morpheus/_lib/src/io/loaders/payload.cpp | 4 +- morpheus/_lib/src/io/loaders/rest.cpp | 4 +- morpheus/_lib/tests/io/test_data_loader.cpp | 59 +++++++++----- morpheus/_lib/tests/io/test_loaders.cpp | 32 +++++--- .../tests/modules/test_data_loader_module.cpp | 17 +++-- tests/io/test_loader_registry.py | 18 +++-- tests/modules/test_morpheus_modules.py | 76 ++++++++++++++----- 21 files changed, 242 insertions(+), 98 deletions(-) diff --git a/morpheus/_lib/include/morpheus/io/data_loader.hpp b/morpheus/_lib/include/morpheus/io/data_loader.hpp index 971d4a7cd1..d61bde3c0c 100644 --- a/morpheus/_lib/include/morpheus/io/data_loader.hpp +++ b/morpheus/_lib/include/morpheus/io/data_loader.hpp @@ -35,7 +35,7 @@ class Loader Loader(nlohmann::json config); virtual std::shared_ptr payload(std::shared_ptr message); - virtual std::shared_ptr load(std::shared_ptr message); + virtual std::shared_ptr load(std::shared_ptr message, nlohmann::json task); protected: nlohmann::json config() const; diff --git a/morpheus/_lib/include/morpheus/io/data_loader_registry.hpp b/morpheus/_lib/include/morpheus/io/data_loader_registry.hpp index 13ca367ff8..93a74494bc 100644 --- a/morpheus/_lib/include/morpheus/io/data_loader_registry.hpp +++ b/morpheus/_lib/include/morpheus/io/data_loader_registry.hpp @@ -38,7 +38,8 @@ struct LoaderRegistryProxy { static void register_proxy_factory_fn( const std::string& name, - std::function(std::shared_ptr)> proxy_constructor, + std::function(std::shared_ptr, pybind11::dict)> + proxy_constructor, bool throw_if_exists = true); static void register_factory_cleanup_fn(const std::string& name); diff --git a/morpheus/_lib/include/morpheus/io/loaders/file.hpp b/morpheus/_lib/include/morpheus/io/loaders/file.hpp index 665f640c77..ec6a6309d3 100644 --- a/morpheus/_lib/include/morpheus/io/loaders/file.hpp +++ b/morpheus/_lib/include/morpheus/io/loaders/file.hpp @@ -20,6 +20,8 @@ #include "morpheus/io/data_loader.hpp" #include "morpheus/messages/meta.hpp" +#include + namespace morpheus { #pragma GCC visibility push(default) /** @@ -35,7 +37,7 @@ class FileDataLoader : public Loader FileDataLoader() = default; FileDataLoader(nlohmann::json config); - std::shared_ptr load(std::shared_ptr message) final; + std::shared_ptr load(std::shared_ptr message, nlohmann::json task) final; }; #pragma GCC visibility pop } // namespace morpheus \ No newline at end of file diff --git a/morpheus/_lib/include/morpheus/io/loaders/file_list.hpp b/morpheus/_lib/include/morpheus/io/loaders/file_list.hpp index 03c9bf9b79..397435ea33 100644 --- a/morpheus/_lib/include/morpheus/io/loaders/file_list.hpp +++ b/morpheus/_lib/include/morpheus/io/loaders/file_list.hpp @@ -19,6 +19,8 @@ #include "morpheus/io/data_loader.hpp" +#include + namespace morpheus { #pragma GCC visibility push(default) /** @@ -33,7 +35,7 @@ class FileListLoader : public Loader FileListLoader() = default; FileListLoader(nlohmann::json config); - std::shared_ptr load(std::shared_ptr control_message) final; + std::shared_ptr load(std::shared_ptr control_message, nlohmann::json task) final; }; #pragma GCC visibility pop } // namespace morpheus \ No newline at end of file diff --git a/morpheus/_lib/include/morpheus/io/loaders/grpc.hpp b/morpheus/_lib/include/morpheus/io/loaders/grpc.hpp index 4594454f14..0efccd5545 100644 --- a/morpheus/_lib/include/morpheus/io/loaders/grpc.hpp +++ b/morpheus/_lib/include/morpheus/io/loaders/grpc.hpp @@ -19,6 +19,8 @@ #include "morpheus/io/data_loader.hpp" +#include + namespace morpheus { #pragma GCC visibility push(default) /** @@ -33,7 +35,7 @@ class GRPCDataLoader : public Loader GRPCDataLoader() = default; GRPCDataLoader(nlohmann::json config); - std::shared_ptr load(std::shared_ptr message) final; + std::shared_ptr load(std::shared_ptr message, nlohmann::json task) final; }; #pragma GCC visibility pop } // namespace morpheus \ No newline at end of file diff --git a/morpheus/_lib/include/morpheus/io/loaders/lambda.hpp b/morpheus/_lib/include/morpheus/io/loaders/lambda.hpp index 35ff659ca4..a43044c2e9 100644 --- a/morpheus/_lib/include/morpheus/io/loaders/lambda.hpp +++ b/morpheus/_lib/include/morpheus/io/loaders/lambda.hpp @@ -19,6 +19,8 @@ #include "morpheus/io/data_loader.hpp" +#include + namespace morpheus { #pragma GCC visibility push(default) /** @@ -31,13 +33,14 @@ class LambdaLoader : public Loader ~LambdaLoader() = default; LambdaLoader() = delete; - LambdaLoader(std::function(std::shared_ptr)> lambda_load, - nlohmann::json config = {}); + LambdaLoader( + std::function(std::shared_ptr, nlohmann::json)> lambda_load, + nlohmann::json config = {}); - std::shared_ptr load(std::shared_ptr message) final; + std::shared_ptr load(std::shared_ptr message, nlohmann::json task) final; private: - std::function(std::shared_ptr)> m_lambda_load; + std::function(std::shared_ptr, nlohmann::json)> m_lambda_load; }; #pragma GCC visibility pop } // namespace morpheus \ No newline at end of file diff --git a/morpheus/_lib/include/morpheus/io/loaders/payload.hpp b/morpheus/_lib/include/morpheus/io/loaders/payload.hpp index 743101f55e..3494556853 100644 --- a/morpheus/_lib/include/morpheus/io/loaders/payload.hpp +++ b/morpheus/_lib/include/morpheus/io/loaders/payload.hpp @@ -35,7 +35,7 @@ class PayloadDataLoader : public Loader PayloadDataLoader() = default; PayloadDataLoader(nlohmann::json config); - std::shared_ptr load(std::shared_ptr control_message) final; + std::shared_ptr load(std::shared_ptr control_message, nlohmann::json task) final; }; #pragma GCC visibility pop } // namespace morpheus \ No newline at end of file diff --git a/morpheus/_lib/include/morpheus/io/loaders/rest.hpp b/morpheus/_lib/include/morpheus/io/loaders/rest.hpp index f4feba7840..02ce3c7423 100644 --- a/morpheus/_lib/include/morpheus/io/loaders/rest.hpp +++ b/morpheus/_lib/include/morpheus/io/loaders/rest.hpp @@ -19,6 +19,8 @@ #include "morpheus/io/data_loader.hpp" +#include + namespace morpheus { #pragma GCC visibility push(default) /** @@ -33,7 +35,7 @@ class RESTDataLoader : public Loader RESTDataLoader() = default; RESTDataLoader(nlohmann::json config); - std::shared_ptr load(std::shared_ptr message) final; + std::shared_ptr load(std::shared_ptr message, nlohmann::json task) final; }; #pragma GCC visibility pop } // namespace morpheus \ No newline at end of file diff --git a/morpheus/_lib/src/io/data_loader.cpp b/morpheus/_lib/src/io/data_loader.cpp index 2a19e372f3..ecd2d1dc55 100644 --- a/morpheus/_lib/src/io/data_loader.cpp +++ b/morpheus/_lib/src/io/data_loader.cpp @@ -41,27 +41,63 @@ std::shared_ptr Loader::payload(std::shared_ptr mes return std::move(message->payload()); } -std::shared_ptr Loader::load(std::shared_ptr message) +std::shared_ptr Loader::load(std::shared_ptr message, nlohmann::json task) { return std::move(message); } std::shared_ptr DataLoader::load(std::shared_ptr control_message) { - auto config = control_message->config(); - if (config.contains("loader_id")) + auto config = control_message->config(); + auto tasks_it = config.find("tasks"); + // TODO(Devin): Do we want to contemplate multiple load tasks on a single message? + for (auto task_it = tasks_it->begin(); task_it != tasks_it->end(); ++task_it) { - auto loader_id = config["loader_id"]; - auto loader = m_loaders.find(loader_id); + auto task = task_it.value(); + auto task_type = task.find("type"); + if (task_type == task.end() or task_type.value() != "load") + { + continue; + } + + // TODO(Devin): Temporary check, should be impossible to create a ControlMessage with an invalid schema + // once schema checking is incorporated. + // "type": "load", + // "properties": { + // "loader_id": "fsspec", + // "strategy": "aggregate", + // "files": [ + // { + // "path": "file_path", + // "type": "csv" + // }, + // { + // "path": "file_path_2" + // } + // ] + // } + if (!task.contains("properties")) + { + throw std::runtime_error("Invalid task specification: missing properties."); + } + + auto loader_id = task["properties"]["loader_id"]; + + auto loader = m_loaders.find(loader_id); if (loader != m_loaders.end()) { VLOG(5) << "Loading data using loader: " << loader_id << " for message: " << control_message->config().dump() << std::endl; - return std::move(loader->second->load(control_message)); + + tasks_it->erase(task_it); + return std::move(loader->second->load(control_message, task)); + } + else + { + throw std::runtime_error("Attempt to load using an unknown or unregistered data loader: " + + loader_id.get()); } } - - throw std::runtime_error("No loader registered for message: " + control_message->config().dump()); } void DataLoader::add_loader(const std::string& loader_id, std::shared_ptr loader, bool overwrite) diff --git a/morpheus/_lib/src/io/data_loader_registry.cpp b/morpheus/_lib/src/io/data_loader_registry.cpp index 15d4afc6ed..aba4925ade 100644 --- a/morpheus/_lib/src/io/data_loader_registry.cpp +++ b/morpheus/_lib/src/io/data_loader_registry.cpp @@ -22,20 +22,25 @@ #include "morpheus/messages/meta.hpp" #include "morpheus/objects/factory_registry.hpp" +#include +#include + namespace morpheus { template class FactoryRegistry; void LoaderRegistryProxy::register_proxy_factory_fn( const std::string& name, - std::function(std::shared_ptr control_message)> proxy_constructor, + std::function(std::shared_ptr control_message, pybind11::dict task)> + proxy_constructor, bool throw_if_exists) { FactoryRegistry::register_factory_fn( name, [proxy_constructor](nlohmann::json config) { return std::make_shared( - [proxy_constructor](std::shared_ptr control_message) { - return std::move(proxy_constructor(control_message)); + [proxy_constructor](std::shared_ptr control_message, nlohmann::json task) { + auto py_task = mrc::pymrc::cast_from_json(task); + return std::move(proxy_constructor(control_message, py_task)); }, config); }, diff --git a/morpheus/_lib/src/io/loaders/file.cpp b/morpheus/_lib/src/io/loaders/file.cpp index cf2cf6a6b3..b255248d83 100644 --- a/morpheus/_lib/src/io/loaders/file.cpp +++ b/morpheus/_lib/src/io/loaders/file.cpp @@ -21,6 +21,7 @@ #include "morpheus/messages/meta.hpp" #include +#include #include #include @@ -33,7 +34,7 @@ namespace morpheus { FileDataLoader::FileDataLoader(nlohmann::json config) : Loader(config) {} -std::shared_ptr FileDataLoader::load(std::shared_ptr message) +std::shared_ptr FileDataLoader::load(std::shared_ptr message, nlohmann::json task) { namespace py = pybind11; VLOG(30) << "Called FileDataLoader::load()"; @@ -46,21 +47,21 @@ std::shared_ptr FileDataLoader::load(std::shared_ptrconfig(); - if (!config.contains("files")) + auto task_properties = task["properties"]; + if (!task_properties["files"].is_array() or task_properties.empty()) { throw std::runtime_error("'File Loader' control message specified no files to load"); } - // TODO(Devin) : Migrate this to use the cudf::io interface - std::string strategy = config.value("strategy", "aggregate"); + std::string strategy = task_properties.value("strategy", "aggregate"); if (strategy != "aggregate") { throw std::runtime_error("Only 'aggregate' strategy is currently supported"); } - auto files = config["files"]; + auto files = task_properties["files"]; py::object dataframe = py::none(); + // TODO(Devin) : Migrate this to use the cudf::io interface for (auto& file : files) { boost::filesystem::path path(file.value("path", "")); diff --git a/morpheus/_lib/src/io/loaders/file_list.cpp b/morpheus/_lib/src/io/loaders/file_list.cpp index 4f1680c08c..0f1a1aa59f 100644 --- a/morpheus/_lib/src/io/loaders/file_list.cpp +++ b/morpheus/_lib/src/io/loaders/file_list.cpp @@ -18,6 +18,7 @@ #include "morpheus/io/loaders/file_list.hpp" #include +#include #include @@ -25,7 +26,9 @@ namespace morpheus { FileListLoader::FileListLoader(nlohmann::json config) : Loader(config) {} -std::shared_ptr FileListLoader::load(std::shared_ptr control_message) +// TODO(Devin): This is a temporary implementation +std::shared_ptr FileListLoader::load(std::shared_ptr control_message, + nlohmann::json task) { VLOG(30) << "Called FileListLoader::load()"; diff --git a/morpheus/_lib/src/io/loaders/grpc.cpp b/morpheus/_lib/src/io/loaders/grpc.cpp index a254dcaffd..12f814bb36 100644 --- a/morpheus/_lib/src/io/loaders/grpc.cpp +++ b/morpheus/_lib/src/io/loaders/grpc.cpp @@ -17,12 +17,14 @@ #include "morpheus/io/loaders/grpc.hpp" +#include + #include namespace morpheus { GRPCDataLoader::GRPCDataLoader(nlohmann::json config) : Loader(config) {} -std::shared_ptr GRPCDataLoader::load(std::shared_ptr message) +std::shared_ptr GRPCDataLoader::load(std::shared_ptr message, nlohmann::json task) { VLOG(30) << "Called GRPCDataLoader::load()"; diff --git a/morpheus/_lib/src/io/loaders/lambda.cpp b/morpheus/_lib/src/io/loaders/lambda.cpp index bae4c6accb..5e0b7a63fa 100644 --- a/morpheus/_lib/src/io/loaders/lambda.cpp +++ b/morpheus/_lib/src/io/loaders/lambda.cpp @@ -17,19 +17,22 @@ #include "morpheus/io/loaders/lambda.hpp" +#include + #include namespace morpheus { -LambdaLoader::LambdaLoader(std::function(std::shared_ptr)> lambda_load, - nlohmann::json config) : +LambdaLoader::LambdaLoader( + std::function(std::shared_ptr, nlohmann::json)> lambda_load, + nlohmann::json config) : Loader(config), m_lambda_load(lambda_load) {} -std::shared_ptr LambdaLoader::load(std::shared_ptr message) +std::shared_ptr LambdaLoader::load(std::shared_ptr message, nlohmann::json task) { VLOG(30) << "Called LambdaLoader::load()"; - return std::move(m_lambda_load(message)); + return std::move(m_lambda_load(message, task)); } } // namespace morpheus \ No newline at end of file diff --git a/morpheus/_lib/src/io/loaders/payload.cpp b/morpheus/_lib/src/io/loaders/payload.cpp index cb3a8a45e0..c9e0abb388 100644 --- a/morpheus/_lib/src/io/loaders/payload.cpp +++ b/morpheus/_lib/src/io/loaders/payload.cpp @@ -17,12 +17,14 @@ #include "morpheus/io/loaders/payload.hpp" +#include + #include namespace morpheus { PayloadDataLoader::PayloadDataLoader(nlohmann::json config) : Loader(config) {} -std::shared_ptr PayloadDataLoader::load(std::shared_ptr message) +std::shared_ptr PayloadDataLoader::load(std::shared_ptr message, nlohmann::json task) { VLOG(30) << "Called PayloadDataLoader::load()"; return std::move(message); diff --git a/morpheus/_lib/src/io/loaders/rest.cpp b/morpheus/_lib/src/io/loaders/rest.cpp index f82bbaad1c..2a0d9fe740 100644 --- a/morpheus/_lib/src/io/loaders/rest.cpp +++ b/morpheus/_lib/src/io/loaders/rest.cpp @@ -17,12 +17,14 @@ #include "morpheus/io/loaders/rest.hpp" +#include + #include namespace morpheus { RESTDataLoader::RESTDataLoader(nlohmann::json config) : Loader(config) {} -std::shared_ptr RESTDataLoader::load(std::shared_ptr message) +std::shared_ptr RESTDataLoader::load(std::shared_ptr message, nlohmann::json task) { VLOG(30) << "Called RESTDataLoader::load()"; diff --git a/morpheus/_lib/tests/io/test_data_loader.cpp b/morpheus/_lib/tests/io/test_data_loader.cpp index f8bc1ce220..619ceac28c 100644 --- a/morpheus/_lib/tests/io/test_data_loader.cpp +++ b/morpheus/_lib/tests/io/test_data_loader.cpp @@ -40,14 +40,19 @@ TEST_F(TestDataLoader, DataLoaderRegisterLoaderTest) { auto data_loader = DataLoader(); - nlohmann::json config; - config["loader_id"] = ""; + nlohmann::json message_config; + message_config["tasks"] = {{{"type", "load"}, + {"properties", + { + {"loader_id", "payload"}, + }}}}; std::vector loaders = {"payload"}; for (auto& loader : loaders) { - config["loader_id"] = loader; - auto msg = std::make_shared(config); + message_config["tasks"][0]["properties"]["loader_id"] = loader; + + auto msg = std::make_shared(message_config); EXPECT_THROW(data_loader.load(msg), std::runtime_error); } @@ -56,8 +61,9 @@ TEST_F(TestDataLoader, DataLoaderRegisterLoaderTest) for (auto& loader : loaders) { - config["loader_id"] = loader; - auto msg = std::make_shared(config); + message_config["tasks"][0]["properties"]["loader_id"] = loader; + + auto msg = std::make_shared(message_config); EXPECT_NO_THROW(data_loader.load(msg)); } @@ -67,10 +73,14 @@ TEST_F(TestDataLoader, DataLoaderRemoveLoaderTest) { auto data_loader = DataLoader(); - nlohmann::json config; - config["loader_id"] = "payload"; + nlohmann::json message_config; + message_config["tasks"] = {{{"type", "load"}, + {"properties", + { + {"loader_id", "payload"}, + }}}}; - auto msg = std::make_shared(config); + auto msg = std::make_shared(message_config); EXPECT_THROW(data_loader.load(msg), std::runtime_error); data_loader.add_loader("payload", std::make_unique()); @@ -89,10 +99,14 @@ TEST_F(TestDataLoader, PayloadLoaderTest) auto data_loader = DataLoader(); data_loader.add_loader("payload", std::make_unique()); - nlohmann::json config; - config["loader_id"] = "payload"; + nlohmann::json message_config; + message_config["tasks"] = {{{"type", "load"}, + {"properties", + { + {"loader_id", "payload"}, + }}}}; - auto msg = std::make_shared(config); + auto msg = std::make_shared(message_config); auto mm = create_mock_msg_meta({"col1", "col2", "col3"}, {"int32", "float32", "string"}, 5); msg->payload(mm); @@ -119,14 +133,19 @@ TEST_F(TestDataLoader, FileLoaderTest) GTEST_SKIP() << "Failed to create temporary file, skipping test"; } - nlohmann::json config; - config["loader_id"] = "file"; - config["strategy"] = "aggregate"; - config["files"] = nlohmann::json::array(); - - config["files"].push_back({{"path", std::string(temp_file)}, {"type", "csv"}}); - - auto msg = std::make_shared(config); + nlohmann::json message_config; + message_config["tasks"] = {{{"type", "load"}, + {"properties", + { + {"loader_id", "file"}, + {"strategy", "aggregate"}, + {"files", + { + {{"path", std::string(temp_file)}, {"type", "csv"}}, + }}, + }}}}; + + auto msg = std::make_shared(message_config); std::fstream data_file(temp_file, std::ios::out | std::ios::binary | std::ios::trunc); data_file << string_df; diff --git a/morpheus/_lib/tests/io/test_loaders.cpp b/morpheus/_lib/tests/io/test_loaders.cpp index 60d00ef024..cdbcb5810e 100644 --- a/morpheus/_lib/tests/io/test_loaders.cpp +++ b/morpheus/_lib/tests/io/test_loaders.cpp @@ -46,21 +46,28 @@ TEST_F(TestLoader, LoaderFileTest) GTEST_SKIP() << "Failed to create temporary file, skipping test"; } - nlohmann::json config; - config["loader_id"] = "file"; - config["strategy"] = "aggregate"; - config["files"] = nlohmann::json::array(); - - config["files"].push_back({{"path", std::string(temp_file)}, {"type", "csv"}}); + nlohmann::json message_config; + message_config["tasks"] = {{{"type", "load"}, + {"properties", + { + {"loader_id", "file"}, + {"strategy", "aggregate"}, + {"files", + { + {{"path", std::string(temp_file)}, {"type", "csv"}}, + }}, + }}}}; + + auto task = message_config["tasks"][0]; std::fstream data_file(temp_file, std::ios::out | std::ios::binary | std::ios::trunc); data_file << string_df; data_file.close(); - auto msg = std::make_shared(config); + auto msg = std::make_shared(message_config); auto loader = FileDataLoader(); - EXPECT_NO_THROW(loader.load(msg)); + EXPECT_NO_THROW(loader.load(msg, task)); unlink(temp_file); } @@ -68,23 +75,26 @@ TEST_F(TestLoader, LoaderFileTest) TEST_F(TestLoader, LoaderGRPCTest) { auto msg = std::make_shared(); + auto task = nlohmann::json(); auto loader = GRPCDataLoader(); - EXPECT_THROW(loader.load(msg), std::runtime_error); + EXPECT_THROW(loader.load(msg, task), std::runtime_error); } TEST_F(TestLoader, LoaderPayloadTest) { auto msg = std::make_shared(); + auto task = nlohmann::json(); auto loader = PayloadDataLoader(); - EXPECT_NO_THROW(loader.load(msg)); + EXPECT_NO_THROW(loader.load(msg, task)); } TEST_F(TestLoader, LoaderRESTTest) { auto msg = std::make_shared(); + auto task = nlohmann::json(); auto loader = RESTDataLoader(); - EXPECT_THROW(loader.load(msg), std::runtime_error); + EXPECT_THROW(loader.load(msg, task), std::runtime_error); } diff --git a/morpheus/_lib/tests/modules/test_data_loader_module.cpp b/morpheus/_lib/tests/modules/test_data_loader_module.cpp index e0d44abbca..8ca378e893 100644 --- a/morpheus/_lib/tests/modules/test_data_loader_module.cpp +++ b/morpheus/_lib/tests/modules/test_data_loader_module.cpp @@ -228,19 +228,24 @@ TEST_F(TestDataLoaderModule, EndToEndPayloadDataLoaderTest) std::size_t packet_count{0}; auto init_wrapper = [&packet_count](segment::Builder& builder) { - json config; - config["loaders"] = {{{"id", "payload"}, {"properties", {{"prop1", "prop1_value"}}}}}; + json module_config; + module_config["loaders"] = {{{"id", "payload"}, {"properties", {{"prop1", "prop1_value"}}}}}; - auto data_loader_module = builder.make_module("DataLoaderTest", config); + auto data_loader_module = builder.make_module("DataLoaderTest", module_config); auto source = builder.make_source("source", [](rxcpp::subscriber& sub) { if (sub.is_subscribed()) { for (int i = 0; i < 10; i++) { - nlohmann::json config; - config["loader_id"] = "payload"; - sub.on_next(std::make_shared(config)); + nlohmann::json message_config; + message_config["tasks"] = {{{"type", "load"}, + {"properties", + { + {"loader_id", "payload"}, + }}}}; + + sub.on_next(std::make_shared(message_config)); } } diff --git a/tests/io/test_loader_registry.py b/tests/io/test_loader_registry.py index 36bd9def4d..1c8f934a85 100644 --- a/tests/io/test_loader_registry.py +++ b/tests/io/test_loader_registry.py @@ -38,11 +38,12 @@ def test_loader_registry_contains(): def test_loader_registry_register_loader(): - def test_loader(control_message: messages.MessageControl): - config = control_message.config() - if ('files' not in config): + def test_loader(control_message: messages.MessageControl, task: dict): + task_properties = task['properties'] + if ('files' not in task_properties): raise ValueError("No files specified in config") - files = config['files'] + + files = task_properties['files'] df = None for file in files: @@ -72,11 +73,12 @@ def test_loader(control_message: messages.MessageControl): def test_loader_registry_unregister_loader(): - def test_loader(control_message: messages.MessageControl): - config = control_message.config() - if ('files' not in config): + def test_loader(control_message: messages.MessageControl, task: dict): + task_properties = task['properties'] + if ('files' not in task_properties): raise ValueError("No files specified in config") - files = config['files'] + + files = task_properties['files'] df = None for file in files: diff --git a/tests/modules/test_morpheus_modules.py b/tests/modules/test_morpheus_modules.py index 686a1d69d3..01995563ae 100644 --- a/tests/modules/test_morpheus_modules.py +++ b/tests/modules/test_morpheus_modules.py @@ -71,7 +71,15 @@ def test_get_module_with_bad_config_no_loaders(): def init_wrapper(builder: mrc.Builder): def gen_data(): for i in range(packet_count): - config = {"loader_id": "payload"} + config = { + "tasks": [{ + "type": "load", + "properties": { + "loader_id": "payload", + "strategy": "aggregate" + } + }] + } msg = messages.MessageControl(config) yield msg @@ -107,7 +115,15 @@ def test_get_module_with_bad_loader_type(): def init_wrapper(builder: mrc.Builder): def gen_data(): for i in range(packet_count): - config = {"loader_id": "payload"} + config = { + "tasks": [{ + "type": "load", + "properties": { + "loader_id": "payload", + "strategy": "aggregate" + } + }] + } msg = messages.MessageControl(config) yield msg @@ -142,7 +158,15 @@ def test_get_module_with_bad_control_message(): def init_wrapper(builder: mrc.Builder): def gen_data(): for i in range(packet_count): - config = {"loader_id": "not_a_loader(tm)"} + config = { + "tasks": [{ + "type": "load", + "properties": { + "loader_id": "not_a_loader(tm)", + "strategy": "aggregate" + } + }] + } msg = messages.MessageControl(config) yield msg @@ -202,7 +226,15 @@ def init_wrapper(builder: mrc.Builder): def gen_data(): global packet_count - config = {"loader_id": "payload"} + config = { + "tasks": [{ + "type": "load", + "properties": { + "loader_id": "payload", + "strategy": "aggregate" + } + }] + } payload = messages.MessageMeta(df) for i in range(packet_count): @@ -282,27 +314,37 @@ def gen_data(): for f in files: # Check with the file type config = { - "loader_id": "file", - "strategy": "aggregate", - "files": [ - { - "path": f[0], - "type": f[1] + "tasks": [{ + "type": "load", + "properties": { + "loader_id": "file", + "strategy": "aggregate", + "files": [ + { + "path": f[0], + "type": f[1] + } + ] } - ] + }] } msg = messages.MessageControl(config) yield msg # Make sure we can auto-detect the file type config = { - "loader_id": "file", - "strategy": "aggregate", - "files": [ - { - "path": f[0], + "tasks": [{ + "type": "load", + "properties": { + "loader_id": "file", + "strategy": "aggregate", + "files": [ + { + "path": f[0], + } + ] } - ] + }] } msg = messages.MessageControl(config) yield msg From 5b577bafa6747daad326b810e297a585c1fe28b8 Mon Sep 17 00:00:00 2001 From: Devin Robison Date: Tue, 21 Feb 2023 17:08:24 -0700 Subject: [PATCH 033/157] Start adding pieces for schema validation --- .../include/morpheus/messages/control.hpp | 2 + .../morpheus/modules/data_loader_module.hpp | 2 + morpheus/_lib/src/io/data_loader.cpp | 10 ++-- morpheus/_lib/src/messages/control.cpp | 55 +++++++++++++++++++ .../_lib/src/modules/data_loader_module.cpp | 31 +++++++++++ 5 files changed, 95 insertions(+), 5 deletions(-) diff --git a/morpheus/_lib/include/morpheus/messages/control.hpp b/morpheus/_lib/include/morpheus/messages/control.hpp index 286a6fcdb0..c8606121cc 100644 --- a/morpheus/_lib/include/morpheus/messages/control.hpp +++ b/morpheus/_lib/include/morpheus/messages/control.hpp @@ -57,6 +57,8 @@ class MessageControl std::shared_ptr payload(); private: + static const std::string s_config_schema; // NOLINT + std::shared_ptr m_payload{nullptr}; nlohmann::json m_config{}; }; diff --git a/morpheus/_lib/include/morpheus/modules/data_loader_module.hpp b/morpheus/_lib/include/morpheus/modules/data_loader_module.hpp index af5a109aab..610b8b4bc8 100644 --- a/morpheus/_lib/include/morpheus/modules/data_loader_module.hpp +++ b/morpheus/_lib/include/morpheus/modules/data_loader_module.hpp @@ -38,6 +38,8 @@ class DataLoaderModule : public mrc::modules::SegmentModule, public mrc::modules std::string module_type_name() const override; private: + static const std::string s_config_schema; // NOLINT + DataLoader m_data_loader{}; }; #pragma GCC visibility pop diff --git a/morpheus/_lib/src/io/data_loader.cpp b/morpheus/_lib/src/io/data_loader.cpp index ecd2d1dc55..4955151d78 100644 --- a/morpheus/_lib/src/io/data_loader.cpp +++ b/morpheus/_lib/src/io/data_loader.cpp @@ -92,12 +92,12 @@ std::shared_ptr DataLoader::load(std::shared_ptr tasks_it->erase(task_it); return std::move(loader->second->load(control_message, task)); } - else - { - throw std::runtime_error("Attempt to load using an unknown or unregistered data loader: " + - loader_id.get()); - } + + throw std::runtime_error("Attempt to load using an unknown or unregistered data loader: " + + loader_id.get()); } + + return std::move(control_message); } void DataLoader::add_loader(const std::string& loader_id, std::shared_ptr loader, bool overwrite) diff --git a/morpheus/_lib/src/messages/control.cpp b/morpheus/_lib/src/messages/control.cpp index 372cf26214..d32fdc80fb 100644 --- a/morpheus/_lib/src/messages/control.cpp +++ b/morpheus/_lib/src/messages/control.cpp @@ -24,6 +24,61 @@ namespace py = pybind11; namespace morpheus { +const std::string MessageControl::s_config_schema = R"( +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ControlMessage", + "type": "object", + "required": ["tasks"], + "properties": { + "tasks": { + "type": "array", + "items": { + "type": "object", + "required": ["type", "properties"], + "properties": { + "type": { + "type": "string", + "enum": ["load", "inference", "training"] + }, + "properties": { + "type": "object", + "allOf": [ + { + "if": { + "properties": { + "type": { "const": "load" } + } + }, + "then": { + "required": ["loader_id", "strategy"], + "properties": { + "loader_id": { "type": "string" }, + "strategy": { "type": "string" } + } + } + }, + { + "if": { + "properties": { + "type": { "enum": ["inference", "training"] } + } + }, + "then": { + "properties": { + "params": { "type": "object" } + } + } + } + ] + } + } + } + } + } +} +)"; + MessageControl::MessageControl(const nlohmann::json& config) : m_config(config) {} const nlohmann::json& MessageControl::config() const diff --git a/morpheus/_lib/src/modules/data_loader_module.cpp b/morpheus/_lib/src/modules/data_loader_module.cpp index f19d587df8..bd587a3037 100644 --- a/morpheus/_lib/src/modules/data_loader_module.cpp +++ b/morpheus/_lib/src/modules/data_loader_module.cpp @@ -33,11 +33,42 @@ using nlohmann::json; namespace morpheus { +const std::string DataLoaderModule::s_config_schema = R"( +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "DataLoaderModule", + "type": "object", + "required": ["loaders"], + "properties": { + "loaders": { + "type": "array", + "items": { + "type": "object", + "required": ["id"], + "properties": { + "id": { + "type": "string" + }, + "properties": { + "type": "object" + } + } + } + } + } +} +)"; + DataLoaderModule::DataLoaderModule(std::string module_name) : SegmentModule(module_name) {} DataLoaderModule::DataLoaderModule(std::string module_name, nlohmann::json _config) : SegmentModule(std::move(module_name), std::move(_config)) { + if (config().contains("loaders")) + { + // TODO(Devin): Add schema validation + } + if (config().contains("loaders") and config()["loaders"].is_array() and !config()["loaders"].empty()) { auto loader_list = config()["loaders"]; From 01da8529042cad7ef3bb3bc4aa332867cc5df4ac Mon Sep 17 00:00:00 2001 From: Devin Robison Date: Wed, 22 Feb 2023 12:38:12 -0700 Subject: [PATCH 034/157] Checkpoint, maybe inference wasn't so bad! --- .../morpheus/dfp/modules/dfp_data_prep.py | 18 ++++++++- .../morpheus/dfp/modules/dfp_inference.py | 35 +++++++++++------ .../morpheus/dfp/utils/config_generator.py | 7 +++- morpheus/_lib/src/messages/control.cpp | 39 +++++++++++++++++-- morpheus/modules/file_batcher.py | 1 + 5 files changed, 82 insertions(+), 18 deletions(-) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_data_prep.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_data_prep.py index 225b455e06..9c48bd8340 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_data_prep.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_data_prep.py @@ -23,6 +23,7 @@ from morpheus.utils.module_ids import MODULE_NAMESPACE from morpheus.utils.module_utils import get_module_config from morpheus.utils.module_utils import register_module +from morpheus.messages import MessageControl from ..messages.multi_dfp_message import MultiDFPMessage from ..utils.module_ids import DFP_DATA_PREP @@ -72,7 +73,22 @@ def process_features(message: MultiDFPMessage): message.get_meta(timestamp_column_name).max(), duration) - return message + # TODO(Devin): Updated to use control message passing. + message_config = { + "tasks": [ + { + "type": "inference", + "params": { + "user_id": message.get_meta("user_id") + "data": "payload" + } + } + ] + } + control_message = MessageControl(message_config) + control_message.payload(message) + + return control_message def node_fn(obs: mrc.Observable, sub: mrc.Subscriber): obs.pipe(ops.map(process_features)).subscribe(sub) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference.py index 7a341bc852..67290e83c2 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference.py @@ -22,6 +22,7 @@ from mrc.core import operators as ops from morpheus.messages.multi_ae_message import MultiAEMessage +from morpheus.messages import MessageControl from morpheus.utils.module_ids import MODULE_NAMESPACE from morpheus.utils.module_utils import get_module_config from morpheus.utils.module_utils import register_module @@ -56,14 +57,18 @@ def get_model(user: str) -> ModelCache: return model_manager.load_user_model(client, user_id=user, fallback_user_ids=[fallback_user]) - def on_data(message: MultiDFPMessage): - if (not message or message.mess_count == 0): - return None - + def process_task(control_message: MessageControl, task: dict): start_time = time.time() + task_params = task['params'] + data_source = task_params['data'] + if (data_source != "payload"): + raise ValueError("Unsupported data source: {}".format(data_source)) + + user_id = task_params['user_id'] + + message = control_message.payload() df_user = message.get_meta() - user_id = message.user_id try: model_cache: ModelCache = get_model(user_id) @@ -95,15 +100,23 @@ def on_data(message: MultiDFPMessage): load_model_duration = (post_model_time - start_time) * 1000.0 get_anomaly_duration = (time.time() - post_model_time) * 1000.0 - logger.debug("Completed inference for user %s. Model load: %s ms, Model infer: %s ms. Start: %s, End: %s", - user_id, - load_model_duration, - get_anomaly_duration, - df_user[timestamp_column_name].min(), - df_user[timestamp_column_name].max()) + logger.debug( + "Completed inference for user %s. Model load: %s ms, Model infer: %s ms. Start: %s, End: %s", + user_id, + load_model_duration, + get_anomaly_duration, + df_user[timestamp_column_name].min(), + df_user[timestamp_column_name].max()) return output_message + def on_data(control_message: MessageControl): + config = control_message.config() + for task in config['tasks']: + if task['type'] == 'inference': + # TODO(Devin): Decide on what to do if we have multiple inference tasks + return process_task(control_message, task) + def node_fn(obs: mrc.Observable, sub: mrc.Subscriber): obs.pipe(ops.map(on_data)).subscribe(sub) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py b/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py index d544b995c8..396475e659 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py @@ -113,7 +113,11 @@ def preproc_module_config(self): "module_id": DATA_LOADER, "module_name": "FileToDFDataLoader", "namespace": MODULE_NAMESPACE, - "loaders": [FILE_TO_DF_LOADER] + "loaders": [ + { + "id": FILE_TO_DF_LOADER + } + ] }, DFP_SPLIT_USERS: { "module_id": DFP_SPLIT_USERS, @@ -478,7 +482,6 @@ def generate_ae_config(log_type: str, timestamp_column_name: str, use_cpp: bool = False, num_threads: int = os.cpu_count()): - config = Config() CppConfig.set_should_use_cpp(use_cpp) diff --git a/morpheus/_lib/src/messages/control.cpp b/morpheus/_lib/src/messages/control.cpp index d32fdc80fb..47f03394f9 100644 --- a/morpheus/_lib/src/messages/control.cpp +++ b/morpheus/_lib/src/messages/control.cpp @@ -47,14 +47,45 @@ const std::string MessageControl::s_config_schema = R"( { "if": { "properties": { - "type": { "const": "load" } + "type": { "const": "load" }, + "loader_id": { "const": "file" } } }, "then": { - "required": ["loader_id", "strategy"], + "required": ["loader_id", "strategy", "files"], "properties": { - "loader_id": { "type": "string" }, - "strategy": { "type": "string" } + "loader_id": { "type": "string", "enum": ["file"] }, + "strategy": { "type": "string" }, + "files": { + "type": "array", + "items": { + "type": "object", + "required": ["path", "type"], + "properties": { + "path": { "type": "string" }, + "type": { "type": "string" } + } + } + } + } + } + }, + { + "if": { + "properties": { + "type": { "const": "load" }, + "loader_id": { "const": "file_list" } + } + }, + "then": { + "required": ["loader_id", "strategy", "directories"], + "properties": { + "loader_id": { "type": "string", "enum": ["file_list"] }, + "strategy": { "type": "string" }, + "directories": { + "type": "array", + "items": { "type": "string" } + } } } }, diff --git a/morpheus/modules/file_batcher.py b/morpheus/modules/file_batcher.py index 2db3516caa..182de95eba 100644 --- a/morpheus/modules/file_batcher.py +++ b/morpheus/modules/file_batcher.py @@ -57,6 +57,7 @@ def file_batcher(builder: mrc.Builder) -> MessageControl: iso_date_regex = re.compile(iso_date_regex_pattern) + #TODO (Devin): add support for accessing the config within the Loader's execution context message_config = { "loader_id": FILE_TO_DF_LOADER, "timestamp_column_name": config.get("timestamp_column_name"), From 8cf682343b79c8e0fb8731e6273b516da5b6c49f Mon Sep 17 00:00:00 2001 From: bsuryadevara Date: Wed, 22 Feb 2023 18:00:09 -0600 Subject: [PATCH 035/157] data loader integration to dfp pipeline --- .../morpheus/dfp/modules/dfp_data_prep.py | 14 +++--- .../morpheus/dfp/modules/dfp_training.py | 43 ++++++++++++------- .../morpheus/dfp/utils/config_generator.py | 4 +- morpheus/modules/file_batcher.py | 28 +++++++----- 4 files changed, 56 insertions(+), 33 deletions(-) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_data_prep.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_data_prep.py index 225b455e06..548ed2161d 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_data_prep.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_data_prep.py @@ -15,6 +15,7 @@ import logging import pickle import time +from morpheus.messages.multi_message import MultiMessage import mrc from mrc.core import operators as ops @@ -23,6 +24,7 @@ from morpheus.utils.module_ids import MODULE_NAMESPACE from morpheus.utils.module_utils import get_module_config from morpheus.utils.module_utils import register_module +from morpheus.messages.message_control import MessageControl from ..messages.multi_dfp_message import MultiDFPMessage from ..utils.module_ids import DFP_DATA_PREP @@ -31,7 +33,7 @@ @register_module(DFP_DATA_PREP, MODULE_NAMESPACE) -def dfp_data_prep(builder: mrc.Builder): +def dfp_data_prep(builder: mrc.Builder) -> MessageControl: """ This module function prepares data for either inference or model training. @@ -60,9 +62,6 @@ def process_features(message: MultiDFPMessage): # Process the columns df_processed = process_dataframe(message.get_meta_dataframe(), schema) - # Apply the new dataframe, only the rows in the offset - message.set_meta_dataframe(list(df_processed.columns), df_processed) - if logger.isEnabledFor(logging.DEBUG): duration = (time.time() - start_time) * 1000.0 @@ -71,8 +70,13 @@ def process_features(message: MultiDFPMessage): message.get_meta(timestamp_column_name).min(), message.get_meta(timestamp_column_name).max(), duration) + message_config = {"tasks": [{"type": "inference", "params": {"user_id": message.user_id, "data": "payload"}}]} + + control_message = MessageControl(message_config) + multi_message = MultiMessage(meta=df_processed, mess_offset=message.mess_offset, mess_count=message.mess_count) + control_message.payload(multi_message) - return message + return control_message def node_fn(obs: mrc.Observable, sub: mrc.Subscriber): obs.pipe(ops.map(process_features)).subscribe(sub) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training.py index 421511a20f..ded40a5836 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training.py @@ -22,8 +22,7 @@ from morpheus.utils.module_ids import MODULE_NAMESPACE from morpheus.utils.module_utils import get_module_config from morpheus.utils.module_utils import register_module - -from ..messages.multi_dfp_message import MultiDFPMessage +from morpheus.messages.message_control import MessageControl from ..utils.module_ids import DFP_TRAINING logger = logging.getLogger(__name__) @@ -53,27 +52,39 @@ def dfp_training(builder: mrc.Builder): raise ValueError("validation_size={0} should be a positive float in the " "(0, 1) range".format(validation_size)) - def on_data(message: MultiDFPMessage): - if (message is None or message.mess_count == 0): + def on_data(message: MessageControl): + + if (message is None): return None - user_id = message.user_id + tasks = message.config()["tasks"] + + if len(tasks) == 0: + return None + + output_message = None + + for task in tasks: + if "inference" in task["type"] and "payload" in task["data"]: + multi_message = message.payload() + + final_df = multi_message.get_meta() - model = AutoEncoder(**model_kwargs) + user_id = task["params"]["user_id"] - final_df = message.get_meta_dataframe() + model = AutoEncoder(**model_kwargs) - # Only train on the feature columns - final_df = final_df[final_df.columns.intersection(feature_columns)] + # Only train on the feature columns + final_df = final_df[final_df.columns.intersection(feature_columns)] - logger.debug("Training AE model for user: '%s'...", user_id) - model.fit(final_df, epochs=epochs) - logger.debug("Training AE model for user: '%s'... Complete.", user_id) + logger.debug("Training AE model for user: '%s'...", user_id) + model.fit(final_df, epochs=epochs) + logger.debug("Training AE model for user: '%s'... Complete.", user_id) - output_message = MultiAEMessage(message.meta, - mess_offset=message.mess_offset, - mess_count=message.mess_count, - model=model) + output_message = MultiAEMessage(multi_message.meta, + mess_offset=multi_message.mess_offset, + mess_count=multi_message.mess_count, + model=model) return output_message diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py b/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py index d544b995c8..e76b0e984b 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py @@ -113,7 +113,9 @@ def preproc_module_config(self): "module_id": DATA_LOADER, "module_name": "FileToDFDataLoader", "namespace": MODULE_NAMESPACE, - "loaders": [FILE_TO_DF_LOADER] + "loaders": [{ + "id": FILE_TO_DF_LOADER + }] }, DFP_SPLIT_USERS: { "module_id": DFP_SPLIT_USERS, diff --git a/morpheus/modules/file_batcher.py b/morpheus/modules/file_batcher.py index 2db3516caa..e937de81d9 100644 --- a/morpheus/modules/file_batcher.py +++ b/morpheus/modules/file_batcher.py @@ -57,14 +57,20 @@ def file_batcher(builder: mrc.Builder) -> MessageControl: iso_date_regex = re.compile(iso_date_regex_pattern) - message_config = { - "loader_id": FILE_TO_DF_LOADER, - "timestamp_column_name": config.get("timestamp_column_name"), - "schema": config.get("schema"), - "file_type": config.get("file_type"), - "filter_null": config.get("filter_null"), - "parser_kwargs": config.get("parser_kwargs"), - "cache_dir": config.get("cache_dir") + message_control_conf = { + "tasks": [{ + "type": "load", + "properties": { + "loader_id": FILE_TO_DF_LOADER, + "strategy": "aggregate", + "timestamp_column_name": config.get("timestamp_column_name"), + "schema": config.get("schema"), + "file_type": config.get("file_type"), + "filter_null": config.get("filter_null"), + "parser_kwargs": config.get("parser_kwargs"), + "cache_dir": config.get("cache_dir") + } + }] } def on_data(file_objects: fsspec.core.OpenFiles): @@ -112,7 +118,7 @@ def on_data(file_objects: fsspec.core.OpenFiles): df["ts"] = timestamps df["key"] = full_names - nonlocal message_config + nonlocal message_control_conf out_messages = [] @@ -126,8 +132,8 @@ def on_data(file_objects: fsspec.core.OpenFiles): for group in period_gb.groups: period_df = period_gb.get_group(group) filenames = period_df["key"].to_list() - message_config["files"] = (filenames, n_groups) - message = MessageControl(message_config) + message_control_conf["tasks"][0]["properties"]["files"] = (filenames, n_groups) + message = MessageControl(message_control_conf) out_messages.append(message) return out_messages From a8401fff83b543a4853694711f7f8eb28f72d21c Mon Sep 17 00:00:00 2001 From: bsuryadevara Date: Wed, 22 Feb 2023 18:09:43 -0600 Subject: [PATCH 036/157] data loader integration to dfp pipeline --- .../production/morpheus/dfp/modules/dfp_data_prep.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_data_prep.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_data_prep.py index 548ed2161d..578c1f5e9c 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_data_prep.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_data_prep.py @@ -61,6 +61,8 @@ def process_features(message: MultiDFPMessage): # Process the columns df_processed = process_dataframe(message.get_meta_dataframe(), schema) + + message.set_meta_dataframe(list(df_processed.columns), df_processed) if logger.isEnabledFor(logging.DEBUG): duration = (time.time() - start_time) * 1000.0 @@ -73,7 +75,7 @@ def process_features(message: MultiDFPMessage): message_config = {"tasks": [{"type": "inference", "params": {"user_id": message.user_id, "data": "payload"}}]} control_message = MessageControl(message_config) - multi_message = MultiMessage(meta=df_processed, mess_offset=message.mess_offset, mess_count=message.mess_count) + multi_message = MultiMessage(meta=message.meta, mess_offset=message.mess_offset, mess_count=message.mess_count) control_message.payload(multi_message) return control_message From f574e071417148da3f7876f6c8bddf438ca9b8d1 Mon Sep 17 00:00:00 2001 From: bsuryadevara Date: Thu, 23 Feb 2023 12:28:32 -0600 Subject: [PATCH 037/157] updated message meta --- .../morpheus/dfp/modules/dfp_data_prep.py | 18 ++++++--- .../morpheus/dfp/modules/dfp_training.py | 17 +++++---- morpheus/messages/message_meta.py | 37 +++++++++++++++++++ morpheus/messages/multi_message.py | 11 +----- 4 files changed, 61 insertions(+), 22 deletions(-) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_data_prep.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_data_prep.py index 578c1f5e9c..72e234ce34 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_data_prep.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_data_prep.py @@ -15,7 +15,6 @@ import logging import pickle import time -from morpheus.messages.multi_message import MultiMessage import mrc from mrc.core import operators as ops @@ -61,7 +60,7 @@ def process_features(message: MultiDFPMessage): # Process the columns df_processed = process_dataframe(message.get_meta_dataframe(), schema) - + message.set_meta_dataframe(list(df_processed.columns), df_processed) if logger.isEnabledFor(logging.DEBUG): @@ -72,11 +71,20 @@ def process_features(message: MultiDFPMessage): message.get_meta(timestamp_column_name).min(), message.get_meta(timestamp_column_name).max(), duration) - message_config = {"tasks": [{"type": "inference", "params": {"user_id": message.user_id, "data": "payload"}}]} + message_config = { + "tasks": [{ + "type": "inference", + "params": { + "user_id": message.user_id, + "data": "payload", + "mess_offset": message.mess_offset, + "mess_count": message.mess_count + } + }] + } control_message = MessageControl(message_config) - multi_message = MultiMessage(meta=message.meta, mess_offset=message.mess_offset, mess_count=message.mess_count) - control_message.payload(multi_message) + control_message.payload(message.meta) return control_message diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training.py index ded40a5836..31df200eb2 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training.py @@ -13,6 +13,7 @@ # limitations under the License. import logging +from morpheus.messages.message_meta import MessageMeta import mrc from dfencoder import AutoEncoder @@ -66,11 +67,16 @@ def on_data(message: MessageControl): for task in tasks: if "inference" in task["type"] and "payload" in task["data"]: - multi_message = message.payload() - final_df = multi_message.get_meta() + task_params = task["params"] + mess_offset = task_params["mess_offset"] + mess_count = task_params["mess_count"] - user_id = task["params"]["user_id"] + meta: MessageMeta = message.payload() + + final_df = meta.get_meta_range(mess_offset, mess_count) + + user_id = task_params["user_id"] model = AutoEncoder(**model_kwargs) @@ -81,10 +87,7 @@ def on_data(message: MessageControl): model.fit(final_df, epochs=epochs) logger.debug("Training AE model for user: '%s'... Complete.", user_id) - output_message = MultiAEMessage(multi_message.meta, - mess_offset=multi_message.mess_offset, - mess_count=multi_message.mess_count, - model=model) + output_message = MultiAEMessage(meta, mess_offset=mess_offset, mess_count=mess_count, model=model) return output_message diff --git a/morpheus/messages/message_meta.py b/morpheus/messages/message_meta.py index d615012f43..8557c63269 100644 --- a/morpheus/messages/message_meta.py +++ b/morpheus/messages/message_meta.py @@ -14,9 +14,11 @@ import dataclasses import threading +import typing import warnings import pandas as pd +import cudf import morpheus._lib.messages as _messages from morpheus.messages.message_base import MessageBase @@ -108,6 +110,41 @@ def count(self) -> int: return len(self._df) + def get_meta_range(self, + mess_offset: int, + message_count: int, + columns: typing.Union[None, str, typing.List[str]] = None): + """ + Return column values from `morpheus.pipeline.messages.MessageMeta.df` from the specified start offset until the message count. + + Parameters + ---------- + mess_offset : int + Offset into the metadata batch. + mess_count : int + Messages count. + columns : typing.Union[None, str, typing.List[str]] + Input column names. Returns all columns if `None` is specified. When a string is passed, a `Series` is + returned. Otherwise a `Dataframe` is returned. + + Returns + ------- + Series or Dataframe + Column values from the dataframe. + + """ + + idx = self._df.index[mess_offset:mess_offset + message_count] + + if (isinstance(idx, cudf.RangeIndex)): + idx = slice(idx.start, idx.stop - 1, idx.step) + + if (columns is None): + return self._df.loc[idx, :] + else: + # If its a str or list, this is the same + return self._df.loc[idx, columns] + @dataclasses.dataclass(init=False) class UserMessageMeta(MessageMeta, cpp_class=None): diff --git a/morpheus/messages/multi_message.py b/morpheus/messages/multi_message.py index d9e944e309..40ff1fd709 100644 --- a/morpheus/messages/multi_message.py +++ b/morpheus/messages/multi_message.py @@ -104,16 +104,7 @@ def get_meta(self, columns: typing.Union[None, str, typing.List[str]] = None): """ - idx = self.meta._df.index[self.mess_offset:self.mess_offset + self.mess_count] - - if (isinstance(idx, cudf.RangeIndex)): - idx = slice(idx.start, idx.stop - 1, idx.step) - - if (columns is None): - return self.meta._df.loc[idx, :] - else: - # If its a str or list, this is the same - return self.meta._df.loc[idx, columns] + return self.meta.get_meta_range(self.mess_offset, self.mess_count, columns) def get_meta_list(self, col_name: str = None): """ From d722e66aa41ea28ad42da13d6b024862a7046e59 Mon Sep 17 00:00:00 2001 From: Devin Robison Date: Thu, 23 Feb 2023 17:05:03 -0700 Subject: [PATCH 038/157] Updates to fix missing caches --- .../production/morpheus/dfp/modules/dfp_data_prep.py | 11 +++++------ .../production/morpheus/dfp/modules/dfp_inference.py | 8 +++----- .../morpheus/dfp/modules/dfp_rolling_window.py | 11 ++++++++--- .../production/morpheus/dfp/utils/config_generator.py | 8 ++++---- morpheus/loaders/file_to_df_loader.py | 2 +- morpheus/modules/file_batcher.py | 1 - 6 files changed, 21 insertions(+), 20 deletions(-) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_data_prep.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_data_prep.py index 8cc5e13428..0094c34c51 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_data_prep.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_data_prep.py @@ -74,12 +74,11 @@ def process_features(message: MessageControl): if logger.isEnabledFor(logging.DEBUG): duration = (time.time() - start_time) * 1000.0 - # TODO(Devin): Fix this - # logger.debug("Preprocessed %s data for logs in %s to %s in %s ms", - # message.mess_count, - # message.get_meta(timestamp_column_name).min(), - # message.get_meta(timestamp_column_name).max(), - # duration) + logger.debug("Preprocessed %s data for logs in %s to %s in %s ms", + message.count, + df_processed[timestamp_column_name].min(), + df_processed[timestamp_column_name].max(), + duration) return message diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference.py index f1d176eb9f..caf5f0d201 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference.py @@ -86,15 +86,13 @@ def process_task(control_message: MessageControl, task: dict): post_model_time = time.time() + print("*** RUNNING DFP Inference ***") results_df = loaded_model.get_results(df_user, return_abs=True) + print(results_df) # Create an output message to allow setting meta dfp_mm = DFPMessageMeta(results_df, user_id=user_id) - multi_message = MultiDFPMessage(dfp_mm, mess_offset=0, mess_count=len(results_df)) - output_message = MultiAEMessage(multi_message.meta, - mess_offset=multi_message.mess_offset, - mess_count=multi_message.mess_count, - model=loaded_model) + output_message = MultiDFPMessage(dfp_mm, mess_offset=0, mess_count=len(results_df)) output_message.set_meta(list(results_df.columns), results_df) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_rolling_window.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_rolling_window.py index 3dc02c4588..35880f798f 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_rolling_window.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_rolling_window.py @@ -14,6 +14,7 @@ import logging import os +import json import typing from contextlib import contextmanager @@ -94,12 +95,16 @@ def build_window(message: MessageMeta, params: dict) -> MessageMeta: "Consider deleting the rolling window cache and restarting.")) return None + user_cache.save() + # Exit early if we dont have enough data if (user_cache.count < min_history): + logger.debug("Not enough data to train") return None # We have enough data, but has enough time since the last training taken place? if (user_cache.total_count - user_cache.last_train_count < min_increment): + logger.debug("Elapsed time since last train is too short") return None # Save the last train statistics @@ -132,7 +137,6 @@ def build_window(message: MessageMeta, params: dict) -> MessageMeta: # mess_count=len(train_df)) def on_data(message: MessageControl): - config = message.config() payload = message.payload() @@ -162,9 +166,10 @@ def on_data(message: MessageControl): log_info.disable() return None - message.payload(result) + message.payload(result) - return message + # print(json.dumps(message.config(), indent=4), flush=True) + return message def node_fn(obs: mrc.Observable, sub: mrc.Subscriber): obs.pipe(ops.map(on_data), ops.filter(lambda x: x is not None)).subscribe(sub) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py b/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py index d3e64949af..adf18d3ea1 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py @@ -170,7 +170,7 @@ def infer_module_config(self): "module_name": "filter_detections", "namespace": MODULE_NAMESPACE, "field_name": "mean_abs_z", - "threshold": 2.0, + "threshold": 1.0, "filter_source": "DATAFRAME", "schema": { "input_message_type": self._input_message_type, "encoding": self._encoding @@ -210,8 +210,8 @@ def train_module_config(self): "module_id": DFP_ROLLING_WINDOW, "module_name": "dfp_rolling_window_tra", "namespace": MODULE_NAMESPACE, - "min_history": 300, - "min_increment": 300, + "min_history": 30, + "min_increment": 0, "max_history": self._derive_args.duration, "cache_dir": self._derive_args.cache_dir, "timestamp_column_name": self._config.ae.timestamp_column_name @@ -342,7 +342,7 @@ def inf_pipe_module_config(self): "module_name": "filter_detections", "namespace": MODULE_NAMESPACE, "field_name": "mean_abs_z", - "threshold": 2.0, + "threshold": 1.0, "filter_source": "DATAFRAME", "schema": { "input_message_type": self._input_message_type, "encoding": self._encoding diff --git a/morpheus/loaders/file_to_df_loader.py b/morpheus/loaders/file_to_df_loader.py index fb1611fb3b..847a8c5e1a 100644 --- a/morpheus/loaders/file_to_df_loader.py +++ b/morpheus/loaders/file_to_df_loader.py @@ -59,7 +59,7 @@ def file_to_df_loader(message: MessageControl, task: dict): cache_dir = batcher_config.get("cache_dir", None) download_method: typing.Literal["single_thread", "multiprocess", "dask", - "dask_thread"] = os.environ.get("MORPHEUS_FILE_DOWNLOAD_TYPE", "multiprocess") + "dask_thread"] = os.environ.get("MORPHEUS_FILE_DOWNLOAD_TYPE", "multiprocess") cache_dir = os.path.join(cache_dir, "file_cache") diff --git a/morpheus/modules/file_batcher.py b/morpheus/modules/file_batcher.py index 28d7df2cd1..d99024bc96 100644 --- a/morpheus/modules/file_batcher.py +++ b/morpheus/modules/file_batcher.py @@ -58,7 +58,6 @@ def file_batcher(builder: mrc.Builder) -> MessageControl: iso_date_regex = re.compile(iso_date_regex_pattern) def on_data(file_objects: fsspec.core.OpenFiles): - # Determine the date of the file, and apply the window filter if we have one ts_and_files = [] for file_object in file_objects: From d5f34512961429f180e29b1347939c7810b725f7 Mon Sep 17 00:00:00 2001 From: bsuryadevara Date: Fri, 24 Feb 2023 00:56:04 -0600 Subject: [PATCH 039/157] dfp inference pipeline with control messages --- .../morpheus/dfp/modules/dfp_data_prep.py | 7 +--- .../morpheus/dfp/modules/dfp_inference.py | 38 ++++++++++++------- .../dfp/modules/dfp_postprocessing.py | 10 +++-- .../morpheus/dfp/modules/dfp_training.py | 3 +- .../morpheus/dfp/utils/config_generator.py | 6 +-- morpheus/loaders/file_to_df_loader.py | 19 +++++----- morpheus/messages/message_control.py | 1 + morpheus/modules/file_batcher.py | 12 +----- 8 files changed, 49 insertions(+), 47 deletions(-) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_data_prep.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_data_prep.py index 0094c34c51..74f00fcd3f 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_data_prep.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_data_prep.py @@ -20,14 +20,11 @@ import mrc from mrc.core import operators as ops -from ..messages.multi_dfp_message import DFPMessageMeta from morpheus.utils.column_info import process_dataframe from morpheus.utils.module_ids import MODULE_NAMESPACE from morpheus.utils.module_utils import get_module_config from morpheus.utils.module_utils import register_module -from morpheus.messages import MessageControl, MessageMeta, MultiMessage - -from ..messages.multi_dfp_message import MultiDFPMessage +from morpheus.messages import MessageControl, MessageMeta from ..utils.module_ids import DFP_DATA_PREP logger = logging.getLogger(__name__) @@ -88,4 +85,4 @@ def node_fn(obs: mrc.Observable, sub: mrc.Subscriber): node = builder.make_node_full(DFP_DATA_PREP, node_fn) builder.register_module_input("input", node) - builder.register_module_output("output", node) + builder.register_module_output("output", node) \ No newline at end of file diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference.py index caf5f0d201..b030f5dd4d 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference.py @@ -14,6 +14,7 @@ import logging import time +from morpheus.cli.utils import get_log_levels import mrc import cudf @@ -28,7 +29,7 @@ from morpheus.utils.module_ids import MODULE_NAMESPACE from morpheus.utils.module_utils import get_module_config from morpheus.utils.module_utils import register_module - +import pandas as pd from ..messages.multi_dfp_message import MultiDFPMessage from ..utils.module_ids import DFP_INFERENCE @@ -71,6 +72,7 @@ def process_task(control_message: MessageControl, task: dict): payload = control_message.payload() df_user = payload.df.to_pandas() + df_user[timestamp_column_name] = pd.to_datetime(df_user[timestamp_column_name], utc=True) try: model_cache: ModelCache = get_model(user_id) @@ -86,15 +88,25 @@ def process_task(control_message: MessageControl, task: dict): post_model_time = time.time() - print("*** RUNNING DFP Inference ***") results_df = loaded_model.get_results(df_user, return_abs=True) - print(results_df) + + include_cols = set(df_user.columns) - set(results_df.columns) + + for col in include_cols: + results_df[col] = df_user[col].copy(True) + + results_df = cudf.from_pandas(results_df) # Create an output message to allow setting meta dfp_mm = DFPMessageMeta(results_df, user_id=user_id) - output_message = MultiDFPMessage(dfp_mm, mess_offset=0, mess_count=len(results_df)) - output_message.set_meta(list(results_df.columns), results_df) + # TODO using MultiAEMessage instead MultiDFPMessage for user_id to be avaiable in following stages. + # output_message = MultiDFPMessage(dfp_mm, mess_offset=0, mess_count=len(results_df)) + + output_message = MultiAEMessage(dfp_mm, mess_offset=0, mess_count=len(results_df), model=loaded_model) + + # TODO this is not working. For work around look line 93-96 + # output_message.set_meta(list(results_df.columns), results_df) output_message.set_meta('model_version', f"{model_cache.reg_model_name}:{model_cache.reg_model_version}") @@ -102,13 +114,12 @@ def process_task(control_message: MessageControl, task: dict): load_model_duration = (post_model_time - start_time) * 1000.0 get_anomaly_duration = (time.time() - post_model_time) * 1000.0 - logger.debug( - "Completed inference for user %s. Model load: %s ms, Model infer: %s ms. Start: %s, End: %s", - user_id, - load_model_duration, - get_anomaly_duration, - df_user[timestamp_column_name].min(), - df_user[timestamp_column_name].max()) + logger.debug("Completed inference for user %s. Model load: %s ms, Model infer: %s ms. Start: %s, End: %s", + user_id, + load_model_duration, + get_anomaly_duration, + df_user[timestamp_column_name].min(), + df_user[timestamp_column_name].max()) return output_message @@ -117,7 +128,8 @@ def on_data(control_message: MessageControl): for task in config['tasks']: if task['type'] == 'inference': # TODO(Devin): Decide on what to do if we have multiple inference tasks - process_task(control_message, task) + out_message = process_task(control_message, task) + return out_message def node_fn(obs: mrc.Observable, sub: mrc.Subscriber): obs.pipe(ops.map(on_data)).subscribe(sub) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_postprocessing.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_postprocessing.py index 0793d984ad..a6a1fcc1ab 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_postprocessing.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_postprocessing.py @@ -47,10 +47,12 @@ def dfp_postprocessing(builder: mrc.Builder): def process_events(message: MultiAEMessage): # Assume that a filter stage preceedes this stage - df = message.get_meta() - df['event_time'] = datetime.now().strftime('%Y-%m-%dT%H:%M:%SZ') - df.replace(np.nan, 'NaN', regex=True, inplace=True) - message.set_meta(None, df) + # df = message.get_meta() + # df['event_time'] = datetime.now().strftime('%Y-%m-%dT%H:%M:%SZ') + # df.replace(np.nan, 'NaN', regex=True, inplace=True) + # TODO figure out why we are not able to set meta for a whole dataframe, but works for single column. + # message.set_meta(None, df) + message.set_meta("event_time", datetime.now().strftime('%Y-%m-%dT%H:%M:%SZ')) def on_data(message: MultiAEMessage): if (not message or message.mess_count == 0): diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training.py index c2079c4512..69c6790892 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training.py @@ -13,7 +13,6 @@ # limitations under the License. import logging -from morpheus.messages.message_meta import MessageMeta import cudf import mrc @@ -107,4 +106,4 @@ def node_fn(obs: mrc.Observable, sub: mrc.Subscriber): node = builder.make_node_full(DFP_TRAINING, node_fn) builder.register_module_input("input", node) - builder.register_module_output("output", node) + builder.register_module_output("output", node) \ No newline at end of file diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py b/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py index adf18d3ea1..58a43cbe3b 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py @@ -170,7 +170,7 @@ def infer_module_config(self): "module_name": "filter_detections", "namespace": MODULE_NAMESPACE, "field_name": "mean_abs_z", - "threshold": 1.0, + "threshold": 2.0, "filter_source": "DATAFRAME", "schema": { "input_message_type": self._input_message_type, "encoding": self._encoding @@ -210,8 +210,8 @@ def train_module_config(self): "module_id": DFP_ROLLING_WINDOW, "module_name": "dfp_rolling_window_tra", "namespace": MODULE_NAMESPACE, - "min_history": 30, - "min_increment": 0, + "min_history": 300, + "min_increment": 300, "max_history": self._derive_args.duration, "cache_dir": self._derive_args.cache_dir, "timestamp_column_name": self._config.ae.timestamp_column_name diff --git a/morpheus/loaders/file_to_df_loader.py b/morpheus/loaders/file_to_df_loader.py index 847a8c5e1a..a7c6b00fc6 100644 --- a/morpheus/loaders/file_to_df_loader.py +++ b/morpheus/loaders/file_to_df_loader.py @@ -59,7 +59,7 @@ def file_to_df_loader(message: MessageControl, task: dict): cache_dir = batcher_config.get("cache_dir", None) download_method: typing.Literal["single_thread", "multiprocess", "dask", - "dask_thread"] = os.environ.get("MORPHEUS_FILE_DOWNLOAD_TYPE", "multiprocess") + "dask_thread"] = os.environ.get("MORPHEUS_FILE_DOWNLOAD_TYPE", "multiprocess") cache_dir = os.path.join(cache_dir, "file_cache") @@ -103,14 +103,13 @@ def single_object_to_dataframe(file_object: fsspec.core.OpenFile, return s3_df - def get_or_create_dataframe_from_s3_batch( - file_name_batch: typing.Tuple[typing.List[str], int]) -> typing.Tuple[cudf.DataFrame, bool]: + def get_or_create_dataframe_from_s3_batch(file_name_batch: typing.List[str]) -> typing.Tuple[cudf.DataFrame, bool]: if (not file_name_batch): return None, False - file_list = fsspec.open_files(file_name_batch[0]) - batch_count = file_name_batch[1] + file_list = fsspec.open_files(file_name_batch) + # batch_count = file_name_batch[1] fs: fsspec.AbstractFileSystem = file_list.fs @@ -127,7 +126,7 @@ def get_or_create_dataframe_from_s3_batch( if (os.path.exists(batch_cache_location)): output_df = pd.read_pickle(batch_cache_location) output_df["origin_hash"] = objects_hash_hex - output_df["batch_count"] = batch_count + # output_df["batch_count"] = batch_count return (output_df, True) @@ -183,20 +182,20 @@ def get_or_create_dataframe_from_s3_batch( except Exception: logger.warning("Failed to save batch cache. Skipping cache for this batch.", exc_info=True) - output_df["batch_count"] = batch_count + # output_df["batch_count"] = batch_count output_df["origin_hash"] = objects_hash_hex return (output_df, False) - def convert_to_dataframe(file_name_batch: typing.Tuple[typing.List[str], int]): + def convert_to_dataframe(filenames: typing.List[str]): - if (not file_name_batch): + if (not filenames): return None start_time = time.time() try: - output_df, cache_hit = get_or_create_dataframe_from_s3_batch(file_name_batch) + output_df, cache_hit = get_or_create_dataframe_from_s3_batch(filenames) duration = (time.time() - start_time) * 1000.0 diff --git a/morpheus/messages/message_control.py b/morpheus/messages/message_control.py index c11342c83c..3b7ce22100 100644 --- a/morpheus/messages/message_control.py +++ b/morpheus/messages/message_control.py @@ -17,5 +17,6 @@ class MessageControl(MessageBase, cpp_class=_messages.MessageControl): + def __init__(self, *arg, **kwargs): super().__init__(*arg, **kwargs) diff --git a/morpheus/modules/file_batcher.py b/morpheus/modules/file_batcher.py index d99024bc96..51397a557e 100644 --- a/morpheus/modules/file_batcher.py +++ b/morpheus/modules/file_batcher.py @@ -131,17 +131,9 @@ def on_data(file_objects: fsspec.core.OpenFiles): } } - task_infer = { - "type": "inference", - "properties": { - } - } + task_infer = {"type": "inference", "properties": {}} - task_train = { - "type": "training", - "properties": { - } - } + task_train = {"type": "training", "properties": {}} message_config["tasks"] = [task_infer, task_train, load_task] message = MessageControl(message_config) From e3917de0a9062226910f1a069ccb78e8e3682235 Mon Sep 17 00:00:00 2001 From: bsuryadevara Date: Fri, 24 Feb 2023 11:05:02 -0600 Subject: [PATCH 040/157] added logging support for the modules --- .../production/morpheus/dfp/modules/dfp_data_prep.py | 8 ++++---- .../production/morpheus/dfp/modules/dfp_deployment.py | 2 +- .../production/morpheus/dfp/modules/dfp_inf.py | 2 +- .../production/morpheus/dfp/modules/dfp_inference.py | 6 ++---- .../morpheus/dfp/modules/dfp_inference_pipeline.py | 2 +- .../morpheus/dfp/modules/dfp_model_train_deploy.py | 2 +- .../production/morpheus/dfp/modules/dfp_postprocessing.py | 3 +-- .../production/morpheus/dfp/modules/dfp_preproc.py | 2 +- .../production/morpheus/dfp/modules/dfp_preprocessing.py | 2 +- .../production/morpheus/dfp/modules/dfp_rolling_window.py | 8 +++----- .../production/morpheus/dfp/modules/dfp_split_users.py | 3 +-- .../production/morpheus/dfp/modules/dfp_tra.py | 2 +- .../production/morpheus/dfp/modules/dfp_training.py | 6 ++---- .../morpheus/dfp/modules/dfp_training_pipeline.py | 2 +- morpheus/messages/message_meta.py | 3 ++- morpheus/modules/mlflow_model_writer.py | 3 --- 16 files changed, 23 insertions(+), 33 deletions(-) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_data_prep.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_data_prep.py index 74f00fcd3f..af4d4a1a54 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_data_prep.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_data_prep.py @@ -27,7 +27,7 @@ from morpheus.messages import MessageControl, MessageMeta from ..utils.module_ids import DFP_DATA_PREP -logger = logging.getLogger(__name__) +logger = logging.getLogger("morpheus.{}".format(__name__)) @register_module(DFP_DATA_PREP, MODULE_NAMESPACE) @@ -71,8 +71,8 @@ def process_features(message: MessageControl): if logger.isEnabledFor(logging.DEBUG): duration = (time.time() - start_time) * 1000.0 - logger.debug("Preprocessed %s data for logs in %s to %s in %s ms", - message.count, + logger.debug("Preprocessed %s data logs in %s to %s in %s ms", + len(df_processed), df_processed[timestamp_column_name].min(), df_processed[timestamp_column_name].max(), duration) @@ -85,4 +85,4 @@ def node_fn(obs: mrc.Observable, sub: mrc.Subscriber): node = builder.make_node_full(DFP_DATA_PREP, node_fn) builder.register_module_input("input", node) - builder.register_module_output("output", node) \ No newline at end of file + builder.register_module_output("output", node) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_deployment.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_deployment.py index 86d7d01f11..3620257438 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_deployment.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_deployment.py @@ -30,7 +30,7 @@ from ..utils.module_ids import DFP_PREPROC from ..utils.module_ids import DFP_TRA -logger = logging.getLogger(__name__) +logger = logging.getLogger("morpheus.{}".format(__name__)) @register_module(DFP_DEPLOYMENT, MODULE_NAMESPACE) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inf.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inf.py index 07608d0eef..b912c8cf2a 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inf.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inf.py @@ -37,7 +37,7 @@ from ..utils.module_ids import DFP_POST_PROCESSING from ..utils.module_ids import DFP_ROLLING_WINDOW -logger = logging.getLogger(__name__) +logger = logging.getLogger("morpheus.{}".format(__name__)) @register_module(DFP_INF, MODULE_NAMESPACE) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference.py index b030f5dd4d..3e59ac8667 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference.py @@ -14,7 +14,6 @@ import logging import time -from morpheus.cli.utils import get_log_levels import mrc import cudf @@ -23,17 +22,16 @@ from mlflow.tracking.client import MlflowClient from mrc.core import operators as ops -from ..messages.multi_dfp_message import MultiDFPMessage, DFPMessageMeta +from ..messages.multi_dfp_message import DFPMessageMeta from morpheus.messages.multi_ae_message import MultiAEMessage from morpheus.messages import MessageControl from morpheus.utils.module_ids import MODULE_NAMESPACE from morpheus.utils.module_utils import get_module_config from morpheus.utils.module_utils import register_module import pandas as pd -from ..messages.multi_dfp_message import MultiDFPMessage from ..utils.module_ids import DFP_INFERENCE -logger = logging.getLogger(__name__) +logger = logging.getLogger("morpheus.{}".format(__name__)) @register_module(DFP_INFERENCE, MODULE_NAMESPACE) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference_pipeline.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference_pipeline.py index 2a8eaa62d7..a10008aaca 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference_pipeline.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference_pipeline.py @@ -43,7 +43,7 @@ from ..utils.module_ids import DFP_ROLLING_WINDOW from ..utils.module_ids import DFP_SPLIT_USERS -logger = logging.getLogger(__name__) +logger = logging.getLogger("morpheus.{}".format(__name__)) @register_module(DFP_INFERENCE_PIPELINE, MODULE_NAMESPACE) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_model_train_deploy.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_model_train_deploy.py index 6a34c9c056..6e7d3006fb 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_model_train_deploy.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_model_train_deploy.py @@ -27,7 +27,7 @@ from ..utils.module_ids import DFP_MODEL_TRAIN_DEPLOY from ..utils.module_ids import DFP_TRAINING -logger = logging.getLogger(__name__) +logger = logging.getLogger("morpheus.{}".format(__name__)) @register_module(DFP_MODEL_TRAIN_DEPLOY, MODULE_NAMESPACE) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_postprocessing.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_postprocessing.py index a6a1fcc1ab..7a4be7c193 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_postprocessing.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_postprocessing.py @@ -17,7 +17,6 @@ from datetime import datetime import mrc -import numpy as np from mrc.core import operators as ops from morpheus.messages.multi_ae_message import MultiAEMessage @@ -27,7 +26,7 @@ from ..utils.module_ids import DFP_POST_PROCESSING -logger = logging.getLogger(__name__) +logger = logging.getLogger("morpheus.{}".format(__name__)) @register_module(DFP_POST_PROCESSING, MODULE_NAMESPACE) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_preproc.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_preproc.py index a46198fbe0..041d405b7c 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_preproc.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_preproc.py @@ -31,7 +31,7 @@ from ..utils.module_ids import DFP_PREPROC from ..utils.module_ids import DFP_SPLIT_USERS -logger = logging.getLogger(__name__) +logger = logging.getLogger("morpheus.{}".format(__name__)) @register_module(DFP_PREPROC, MODULE_NAMESPACE) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_preprocessing.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_preprocessing.py index 0b9be051f7..59de20cb1e 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_preprocessing.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_preprocessing.py @@ -33,7 +33,7 @@ from ..utils.module_ids import DFP_ROLLING_WINDOW from ..utils.module_ids import DFP_SPLIT_USERS -logger = logging.getLogger(__name__) +logger = logging.getLogger("morpheus.{}".format(__name__)) @register_module(DFP_PREPROCESSING, MODULE_NAMESPACE) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_rolling_window.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_rolling_window.py index 35880f798f..53f94bd0ae 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_rolling_window.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_rolling_window.py @@ -14,7 +14,6 @@ import logging import os -import json import typing from contextlib import contextmanager @@ -28,13 +27,12 @@ from morpheus.utils.module_ids import MODULE_NAMESPACE from morpheus.utils.module_utils import get_module_config from morpheus.utils.module_utils import register_module -from morpheus.messages import MessageControl, MessageMeta +from morpheus.messages import MessageControl +from morpheus.messages import MessageMeta -from ..messages.multi_dfp_message import DFPMessageMeta -from ..messages.multi_dfp_message import MultiDFPMessage from ..utils.module_ids import DFP_ROLLING_WINDOW -logger = logging.getLogger(__name__) +logger = logging.getLogger("morpheus.{}".format(__name__)) @register_module(DFP_ROLLING_WINDOW, MODULE_NAMESPACE) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_split_users.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_split_users.py index e4e25ee5e6..be3b81bb93 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_split_users.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_split_users.py @@ -28,10 +28,9 @@ from morpheus.utils.module_utils import get_module_config from morpheus.utils.module_utils import register_module -from ..messages.multi_dfp_message import DFPMessageMeta from ..utils.module_ids import DFP_SPLIT_USERS -logger = logging.getLogger(__name__) +logger = logging.getLogger("morpheus.{}".format(__name__)) @register_module(DFP_SPLIT_USERS, MODULE_NAMESPACE) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_tra.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_tra.py index fd3bff30d5..1648a00f8c 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_tra.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_tra.py @@ -31,7 +31,7 @@ from ..utils.module_ids import DFP_TRA from ..utils.module_ids import DFP_TRAINING -logger = logging.getLogger(__name__) +logger = logging.getLogger("morpheus.{}".format(__name__)) @register_module(DFP_TRA, MODULE_NAMESPACE) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training.py index 69c6790892..591d253496 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training.py @@ -27,7 +27,7 @@ from morpheus.messages.message_control import MessageControl from ..utils.module_ids import DFP_TRAINING -logger = logging.getLogger(__name__) +logger = logging.getLogger("morpheus.{}".format(__name__)) @register_module(DFP_TRAINING, MODULE_NAMESPACE) @@ -55,7 +55,6 @@ def dfp_training(builder: mrc.Builder): "(0, 1) range".format(validation_size)) def on_data(message: MessageControl): - print("*****TRAINING ON_DATA*****", flush=True) if (message is None): return None @@ -68,7 +67,6 @@ def on_data(message: MessageControl): # TODO (Devin): this is one reason why we can't have data_prep decide on control message type, because its # not tied to the downstream train/infer task - print("*****PROCESSING TASKS*****", flush=True) for task in tasks: if "training" in task["type"]: params = task["properties"] @@ -106,4 +104,4 @@ def node_fn(obs: mrc.Observable, sub: mrc.Subscriber): node = builder.make_node_full(DFP_TRAINING, node_fn) builder.register_module_input("input", node) - builder.register_module_output("output", node) \ No newline at end of file + builder.register_module_output("output", node) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training_pipeline.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training_pipeline.py index 949860738a..9c2ed06c76 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training_pipeline.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training_pipeline.py @@ -37,7 +37,7 @@ from ..utils.module_ids import DFP_TRAINING from ..utils.module_ids import DFP_TRAINING_PIPELINE -logger = logging.getLogger(__name__) +logger = logging.getLogger("morpheus.{}".format(__name__)) @register_module(DFP_TRAINING_PIPELINE, MODULE_NAMESPACE) diff --git a/morpheus/messages/message_meta.py b/morpheus/messages/message_meta.py index 8557c63269..0fedd14f1f 100644 --- a/morpheus/messages/message_meta.py +++ b/morpheus/messages/message_meta.py @@ -115,7 +115,8 @@ def get_meta_range(self, message_count: int, columns: typing.Union[None, str, typing.List[str]] = None): """ - Return column values from `morpheus.pipeline.messages.MessageMeta.df` from the specified start offset until the message count. + Return column values from `morpheus.pipeline.messages.MessageMeta.df` from the specified start offset + until the message count. Parameters ---------- diff --git a/morpheus/modules/mlflow_model_writer.py b/morpheus/modules/mlflow_model_writer.py index 870d5e0291..7bcb009bed 100644 --- a/morpheus/modules/mlflow_model_writer.py +++ b/morpheus/modules/mlflow_model_writer.py @@ -131,9 +131,6 @@ def apply_model_permissions(reg_model_name: str): def on_data(message: MultiAEMessage): user = message.meta.user_id - df = message.meta.df - - print(df.columns, flush=True) model: AutoEncoder = message.model From 537b69ee1265910807b5841a7d2d7a76ab1e1211 Mon Sep 17 00:00:00 2001 From: bsuryadevara Date: Fri, 24 Feb 2023 18:17:58 -0600 Subject: [PATCH 041/157] control message source implementation --- .../morpheus/dfp_modules_pipeline.py | 5 +- morpheus/loaders/file_list_loader.py | 64 ++++++++++++++++ morpheus/modules/file_batcher.py | 7 +- .../input/control_message_source_stage.py | 73 +++++++++++++++++++ morpheus/utils/loader_ids.py | 1 + .../control_messages/control_message.json | 3 + 6 files changed, 151 insertions(+), 2 deletions(-) create mode 100644 morpheus/loaders/file_list_loader.py create mode 100644 morpheus/stages/input/control_message_source_stage.py create mode 100644 tests/tests_data/control_messages/control_message.json diff --git a/examples/digital_fingerprinting/production/morpheus/dfp_modules_pipeline.py b/examples/digital_fingerprinting/production/morpheus/dfp_modules_pipeline.py index 0cb5f00dbf..8cccac6dd5 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp_modules_pipeline.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp_modules_pipeline.py @@ -31,6 +31,7 @@ from morpheus.pipeline.pipeline import Pipeline from morpheus.stages.general.monitor_stage import MonitorStage from morpheus.stages.general.nonlinear_modules_stage import NonLinearModulesStage +from morpheus.stages.input.control_message_source_stage import ControlMessageSourceStage @click.command() @@ -160,7 +161,9 @@ def run_pipeline(log_type: str, # Create a pipeline object pipeline = Pipeline(config) - source_stage = pipeline.add_stage(MultiFileSource(config, filenames=list(kwargs["input_file"]))) + source_stage = pipeline.add_stage(ControlMessageSourceStage(config, filenames=list(kwargs["input_file"]))) + + #source_stage = pipeline.add_stage(MultiFileSource(config, filenames=list(kwargs["input_file"]))) # Here we add a wrapped module that implements the DFP Deployment dfp_deployment_stage = pipeline.add_stage( diff --git a/morpheus/loaders/file_list_loader.py b/morpheus/loaders/file_list_loader.py new file mode 100644 index 0000000000..e8d1caff07 --- /dev/null +++ b/morpheus/loaders/file_list_loader.py @@ -0,0 +1,64 @@ +# Copyright (c) 2023, NVIDIA CORPORATION. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import hashlib +import json +import logging +import multiprocessing as mp +import os +import pickle +import time +import typing +from functools import partial + +import fsspec +import fsspec.utils +from morpheus.messages.message_meta import MessageMeta + +import pandas as pd + +import cudf + +from morpheus.messages import MessageControl +from morpheus._lib.common import FileTypes +from morpheus.cli.utils import str_to_file_type +from morpheus.io.deserializers import read_file_to_df +from morpheus.utils.column_info import process_dataframe +from morpheus.utils.loader_ids import FILE_LIST_LOADER +from morpheus.utils.loader_utils import register_loader + +logger = logging.getLogger(__name__) + +dask_cluster = None + + +@register_loader(FILE_LIST_LOADER) +def file_to_df_loader(message: MessageControl, task: dict): + task_properties = task["properties"] + files = task_properties["files"] + + file_objects: fsspec.core.OpenFiles = fsspec.open_files(files) + + if (len(file_objects) == 0): + raise RuntimeError(f"No files matched input strings: '{files}'. " + "Check your input pattern and ensure any credentials are correct") + + files = None + for file_object in file_objects: + files.append(file_object.full_name) + + message_config = message.config() + message_config["tasks"][0]["properties"]["files"] = files + message_control = MessageControl(message_config) + return message_control diff --git a/morpheus/modules/file_batcher.py b/morpheus/modules/file_batcher.py index 51397a557e..7cafd60f9a 100644 --- a/morpheus/modules/file_batcher.py +++ b/morpheus/modules/file_batcher.py @@ -57,8 +57,13 @@ def file_batcher(builder: mrc.Builder) -> MessageControl: iso_date_regex = re.compile(iso_date_regex_pattern) - def on_data(file_objects: fsspec.core.OpenFiles): + def on_data(message_control: MessageControl): # Determine the date of the file, and apply the window filter if we have one + message_config = message_control.config() + files = message_config["tasks"][0]["properties"]["files"] + + file_objects: fsspec.core.OpenFiles = fsspec.open_files(files) + ts_and_files = [] for file_object in file_objects: ts = date_extractor(file_object, iso_date_regex) diff --git a/morpheus/stages/input/control_message_source_stage.py b/morpheus/stages/input/control_message_source_stage.py new file mode 100644 index 0000000000..7393623017 --- /dev/null +++ b/morpheus/stages/input/control_message_source_stage.py @@ -0,0 +1,73 @@ +# Copyright (c) 2022-2023, NVIDIA CORPORATION. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import logging +import typing + +import fsspec +import fsspec.utils +import mrc +import json +from morpheus.config import Config +from morpheus.messages.message_control import MessageControl +from morpheus.pipeline.single_output_source import SingleOutputSource +from morpheus.pipeline.stream_pair import StreamPair + +logger = logging.getLogger("morpheus.{}".format(__name__)) + + +class ControlMessageSourceStage(SingleOutputSource): + """ + Source stage is used to recieve control messages from different sources. + + Parameters + ---------- + c : `morpheus.config.Config` + Pipeline configuration instance. + filenames : List[str] + List of paths to be read from, can be a list of S3 urls (`s3://path`) amd can include wildcard characters `*` + as defined by `fsspec`: + https://filesystem-spec.readthedocs.io/en/latest/api.html?highlight=open_files#fsspec.open_files + """ + + def __init__(self, c: Config, filenames: typing.List[str]): + super().__init__(c) + self._filenames = filenames + + @property + def name(self) -> str: + return "from-message-control" + + def supports_cpp_node(self): + return True + + def _create_control_message(self) -> MessageControl: + + openfiles: fsspec.core.OpenFiles = fsspec.open_files(self._filenames) + + if (len(openfiles) == 0): + raise RuntimeError(f"No files matched input strings: '{self._filenames}'. " + "Check your input pattern and ensure any credentials are correct") + + for openfile in openfiles: + with openfile as f: + message_config = json.load(f) + message_control = MessageControl(message_config) + yield message_control + + def _build_source(self, builder: mrc.Builder) -> StreamPair: + + out_stream = builder.make_source(self.unique_name, self._create_control_message()) + + return out_stream, fsspec.core.OpenFiles diff --git a/morpheus/utils/loader_ids.py b/morpheus/utils/loader_ids.py index 25eac7c523..6e57630711 100644 --- a/morpheus/utils/loader_ids.py +++ b/morpheus/utils/loader_ids.py @@ -13,3 +13,4 @@ # limitations under the License. FILE_TO_DF_LOADER = "FileToDFLoader" +FILE_LIST_LOADER = "FileListLoader" \ No newline at end of file diff --git a/tests/tests_data/control_messages/control_message.json b/tests/tests_data/control_messages/control_message.json new file mode 100644 index 0000000000..ac168d4ae1 --- /dev/null +++ b/tests/tests_data/control_messages/control_message.json @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2728f86b1e9dd1c757520fcdf4c5a5e4952c596d797bee66e7fbe25df61e3463 +size 192 From 0bc3e388be4ef89063c8f9ea3737ead276637714 Mon Sep 17 00:00:00 2001 From: Devin Robison Date: Fri, 24 Feb 2023 17:40:43 -0700 Subject: [PATCH 042/157] checkpoint --- .../morpheus/dfp/modules/dfp_inference.py | 21 ++-- .../dfp/modules/dfp_rolling_window.py | 26 +---- .../morpheus/dfp/modules/dfp_split_users.py | 17 ++- .../morpheus/dfp/modules/dfp_training.py | 61 ++++------ .../morpheus/dfp/utils/config_generator.py | 2 +- .../include/morpheus/messages/control.hpp | 66 ++++++++++- morpheus/_lib/src/io/data_loader.cpp | 40 +------ morpheus/_lib/src/io/loaders/file.cpp | 7 +- morpheus/_lib/src/io/loaders/file_list.cpp | 13 +-- morpheus/_lib/src/messages/control.cpp | 110 +++++++++++++++++- morpheus/_lib/src/python_modules/messages.cpp | 9 ++ morpheus/_lib/tests/io/test_data_loader.cpp | 30 +++-- morpheus/_lib/tests/io/test_loaders.cpp | 26 ++--- .../tests/messages/test_control_message.cpp | 64 ++++++++-- morpheus/loaders/file_to_df_loader.py | 17 ++- morpheus/modules/file_batcher.py | 55 ++++----- 16 files changed, 349 insertions(+), 215 deletions(-) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference.py index caf5f0d201..153b343f8e 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference.py @@ -62,13 +62,7 @@ def get_model(user: str) -> ModelCache: def process_task(control_message: MessageControl, task: dict): start_time = time.time() - task_params = task['properties'] - data_source = task_params['data'] - if (data_source != "payload"): - raise ValueError("Unsupported data source: {}".format(data_source)) - - user_id = task_params['user_id'] - + user_id = control_message.get_metadata("user_id") payload = control_message.payload() df_user = payload.df.to_pandas() @@ -86,9 +80,7 @@ def process_task(control_message: MessageControl, task: dict): post_model_time = time.time() - print("*** RUNNING DFP Inference ***") results_df = loaded_model.get_results(df_user, return_abs=True) - print(results_df) # Create an output message to allow setting meta dfp_mm = DFPMessageMeta(results_df, user_id=user_id) @@ -113,11 +105,12 @@ def process_task(control_message: MessageControl, task: dict): return output_message def on_data(control_message: MessageControl): - config = control_message.config() - for task in config['tasks']: - if task['type'] == 'inference': - # TODO(Devin): Decide on what to do if we have multiple inference tasks - process_task(control_message, task) + if (control_message is None): + return None + + while (control_message.has_task("inference")): + task = control_message.pop_task("inference") + process_task(control_message, task) def node_fn(obs: mrc.Observable, sub: mrc.Subscriber): obs.pipe(ops.map(on_data)).subscribe(sub) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_rolling_window.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_rolling_window.py index 35880f798f..8a3c18d3c3 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_rolling_window.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_rolling_window.py @@ -30,8 +30,6 @@ from morpheus.utils.module_utils import register_module from morpheus.messages import MessageControl, MessageMeta -from ..messages.multi_dfp_message import DFPMessageMeta -from ..messages.multi_dfp_message import MultiDFPMessage from ..utils.module_ids import DFP_ROLLING_WINDOW logger = logging.getLogger(__name__) @@ -79,10 +77,7 @@ def get_user_cache(user_id: str): yield user_cache - def build_window(message: MessageMeta, params: dict) -> MessageMeta: - - user_id = params["user_id"] - + def build_window(message: MessageMeta, user_id: str) -> MessageMeta: with get_user_cache(user_id) as user_cache: # incoming_df = message.get_df() @@ -96,6 +91,7 @@ def build_window(message: MessageMeta, params: dict) -> MessageMeta: return None user_cache.save() + logger.debug("Saved rolling window cache for %s == %d items", user_id, user_cache.total_count) # Exit early if we dont have enough data if (user_cache.count < min_history): @@ -131,29 +127,18 @@ def build_window(message: MessageMeta, params: dict) -> MessageMeta: # TODO(Devin): Optimize return MessageMeta(cudf.from_pandas(train_df)) - # Otherwise return a new message - # return MultiDFPMessage(meta=DFPMessageMeta(df=train_df, user_id=user_id), - # mess_offset=0, - # mess_count=len(train_df)) - def on_data(message: MessageControl): - config = message.config() payload = message.payload() - - for task in config["tasks"]: - if task["type"] == "load": - params = task["properties"] + user_id = message.get_metadata("user_id") with log_time(logger.debug) as log_info: - - result = build_window(payload, params) # Return a MessageMeta + result = build_window(payload, user_id) # Return a MessageMeta if (result is not None): - log_info.set_log( ("Rolling window complete for %s in {duration:0.2f} ms. " "Input: %s rows from %s to %s. Output: %s rows from %s to %s"), - params["user_id"], + user_id, len(payload.df), payload.df[timestamp_column_name].min(), payload.df[timestamp_column_name].max(), @@ -168,7 +153,6 @@ def on_data(message: MessageControl): message.payload(result) - # print(json.dumps(message.config(), indent=4), flush=True) return message def node_fn(obs: mrc.Observable, sub: mrc.Subscriber): diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_split_users.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_split_users.py index e4e25ee5e6..8984a0aee1 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_split_users.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_split_users.py @@ -55,11 +55,13 @@ def dfp_split_users(builder: mrc.Builder): include_generic = config.get("include_generic", False) include_individual = config.get("include_individual", False) - # Map of user ids to total number of messages. Keeps indexes monotonic and increasing per user + # Map of user ids to total number of messages. Keep indexes monotonic and increasing per user user_index_map: typing.Dict[str, int] = {} def extract_users(message: MessageControl): + logger.debug("Extracting users from message") if (message is None): + logger.debug("No message to extract users from") return [] df = message.payload().df @@ -96,23 +98,20 @@ def extract_users(message: MessageControl): user_df = split_dataframes[user_id] current_user_count = user_index_map.get(user_id, 0) + logger.debug("Current user count: %s", current_user_count) # Reset the index so that users see monotonically increasing indexes user_df.index = range(current_user_count, current_user_count + len(user_df)) user_index_map[user_id] = current_user_count + len(user_df) - control_config = message.config() - for task in control_config["tasks"]: - # TODO: This is a hack - task["properties"]["user_id"] = user_id - task["properties"]["data"] = "payload" + user_control_message = message.copy() + user_control_message.set_metadata("user_id", user_id) - control_message = MessageControl(control_config) user_cudf = cudf.from_pandas(user_df) - control_message.payload(MessageMeta(df=user_cudf)) + user_control_message.payload(MessageMeta(df=user_cudf)) # output_messages.append(DFPMessageMeta(df=user_df, user_id=user_id)) - output_messages.append(control_message) + output_messages.append(user_control_message) rows_per_user = [len(msg.payload().df.to_pandas()) for msg in output_messages] diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training.py index 2ae820c863..9eced67e70 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training.py @@ -54,49 +54,34 @@ def dfp_training(builder: mrc.Builder): raise ValueError("validation_size={0} should be a positive float in the " "(0, 1) range".format(validation_size)) - def on_data(message: MessageControl): - print("*****TRAINING ON_DATA*****", flush=True) - if (message is None): + def on_data(control_message: MessageControl): + if (control_message is None): return None - tasks = message.config()["tasks"] + output_message = None + while (control_message.has_task("training")): + task = control_message.pop_task("training") - if len(tasks) == 0: - return None + user_id = control_message.get_metadata("user_id") + message_meta = control_message.payload() - output_message = None + final_df = message_meta.df.to_pandas() + + model = AutoEncoder(**model_kwargs) + + # Only train on the feature columns + train_df = final_df[final_df.columns.intersection(feature_columns)] + + logger.debug("Training AE model for user: '%s'...", user_id) + model.fit(train_df, epochs=epochs) + logger.debug("Training AE model for user: '%s'... Complete.", user_id) - # TODO (Devin): this is one reason why we can't have data_prep decide on control message type, because its - # not tied to the downstream train/infer task - print("*****PROCESSING TASKS*****", flush=True) - for task in tasks: - if "training" in task["type"]: - params = task["properties"] - if (params["data"] != "payload"): - raise RuntimeError("Training module only supports payload data at the moment") - # multi_message = message.payload() - - # final_df = multi_message.get_meta() - message_meta = message.payload() - - user_id = params["user_id"] - final_df = message_meta.df.to_pandas() - - model = AutoEncoder(**model_kwargs) - - # Only train on the feature columns - train_df = final_df[final_df.columns.intersection(feature_columns)] - - logger.debug("Training AE model for user: '%s'...", user_id) - model.fit(train_df, epochs=epochs) - logger.debug("Training AE model for user: '%s'... Complete.", user_id) - - dfp_mm = DFPMessageMeta(cudf.DataFrame(final_df), user_id=user_id) - multi_message = MultiDFPMessage(dfp_mm, mess_offset=0, mess_count=len(final_df)) - output_message = MultiAEMessage(multi_message.meta, - mess_offset=multi_message.mess_offset, - mess_count=multi_message.mess_count, - model=model) + dfp_mm = DFPMessageMeta(cudf.DataFrame(final_df), user_id=user_id) + multi_message = MultiDFPMessage(dfp_mm, mess_offset=0, mess_count=len(final_df)) + output_message = MultiAEMessage(multi_message.meta, + mess_offset=multi_message.mess_offset, + mess_count=multi_message.mess_count, + model=model) return output_message diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py b/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py index adf18d3ea1..5ab4afd77a 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py @@ -210,7 +210,7 @@ def train_module_config(self): "module_id": DFP_ROLLING_WINDOW, "module_name": "dfp_rolling_window_tra", "namespace": MODULE_NAMESPACE, - "min_history": 30, + "min_history": 1200, "min_increment": 0, "max_history": self._derive_args.duration, "cache_dir": self._derive_args.cache_dir, diff --git a/morpheus/_lib/include/morpheus/messages/control.hpp b/morpheus/_lib/include/morpheus/messages/control.hpp index c8606121cc..c6129a48bb 100644 --- a/morpheus/_lib/include/morpheus/messages/control.hpp +++ b/morpheus/_lib/include/morpheus/messages/control.hpp @@ -29,8 +29,9 @@ class MessageMeta; class MessageControl { public: - MessageControl() = default; + MessageControl(); MessageControl(const nlohmann::json& config); + MessageControl(const MessageControl& other); // NO payload copy /** * @brief Set the config object @@ -44,6 +45,48 @@ class MessageControl */ const nlohmann::json& config() const; + /** + * @brief Add a task to the control message + * @param task + * @param type + */ + void add_task(const std::string& task_type, const nlohmann::json& task); + + /** + * @brief Check if a task of a given type exists + * @param type + * @return + */ + bool has_task(const std::string& task_type) const; + + /** + * @brief Get a task of the given type + * @param type + * @return + */ + const nlohmann::json pop_task(const std::string& task_type); + + /** + * @brief Add a metadata key-value pair to the control message + * @param key + * @param value + */ + void set_metadata(const std::string& key, const nlohmann::json& value); + + /** + * @brief Check if a metadata key exists + * @param key + * @return + */ + bool has_metadata(const std::string& key) const; + + /** + * @brief Get the metadata value for a given key + * @param key + * @return + */ + const nlohmann::json get_metadata(const std::string& key) const; + /** * @brief Set the payload object * @param payload @@ -60,15 +103,34 @@ class MessageControl static const std::string s_config_schema; // NOLINT std::shared_ptr m_payload{nullptr}; - nlohmann::json m_config{}; + + std::map m_task_count{}; + nlohmann::json m_task_config{}; }; struct ControlMessageProxy { static std::shared_ptr create(pybind11::dict& config); + static std::shared_ptr create(std::shared_ptr other); + + static std::shared_ptr copy(MessageControl& self); static pybind11::dict config(MessageControl& self); + + // Required for proxy conversion of json -> dict in python static void config(MessageControl& self, pybind11::dict& config); + + static void add_task(MessageControl& self, const std::string& type, pybind11::dict& task); + static pybind11::dict pop_task(MessageControl& self, const std::string& type); + + /** + * @brief Set a metadata key-value pair -- value must be json serializable + * @param self + * @param key + * @param value + */ + static void set_metadata(MessageControl& self, const std::string& key, pybind11::object& value); + static pybind11::object get_metadata(MessageControl& self, const std::string& key); }; #pragma GCC visibility pop diff --git a/morpheus/_lib/src/io/data_loader.cpp b/morpheus/_lib/src/io/data_loader.cpp index 4955151d78..9e5e1ff35a 100644 --- a/morpheus/_lib/src/io/data_loader.cpp +++ b/morpheus/_lib/src/io/data_loader.cpp @@ -48,40 +48,11 @@ std::shared_ptr Loader::load(std::shared_ptr mes std::shared_ptr DataLoader::load(std::shared_ptr control_message) { - auto config = control_message->config(); - auto tasks_it = config.find("tasks"); - // TODO(Devin): Do we want to contemplate multiple load tasks on a single message? - for (auto task_it = tasks_it->begin(); task_it != tasks_it->end(); ++task_it) + // TODO(Devin): Need to revisit to ensure we're handling multiple 'load' messages correctly + while (control_message->has_task("load")) { - auto task = task_it.value(); - auto task_type = task.find("type"); - if (task_type == task.end() or task_type.value() != "load") - { - continue; - } - - // TODO(Devin): Temporary check, should be impossible to create a ControlMessage with an invalid schema - // once schema checking is incorporated. - // "type": "load", - // "properties": { - // "loader_id": "fsspec", - // "strategy": "aggregate", - // "files": [ - // { - // "path": "file_path", - // "type": "csv" - // }, - // { - // "path": "file_path_2" - // } - // ] - // } - if (!task.contains("properties")) - { - throw std::runtime_error("Invalid task specification: missing properties."); - } - - auto loader_id = task["properties"]["loader_id"]; + auto task = control_message->pop_task("load"); + auto loader_id = task["loader_id"]; auto loader = m_loaders.find(loader_id); if (loader != m_loaders.end()) @@ -89,8 +60,7 @@ std::shared_ptr DataLoader::load(std::shared_ptr VLOG(5) << "Loading data using loader: " << loader_id << " for message: " << control_message->config().dump() << std::endl; - tasks_it->erase(task_it); - return std::move(loader->second->load(control_message, task)); + loader->second->load(control_message, task); } throw std::runtime_error("Attempt to load using an unknown or unregistered data loader: " + diff --git a/morpheus/_lib/src/io/loaders/file.cpp b/morpheus/_lib/src/io/loaders/file.cpp index b255248d83..17e6933d67 100644 --- a/morpheus/_lib/src/io/loaders/file.cpp +++ b/morpheus/_lib/src/io/loaders/file.cpp @@ -47,19 +47,18 @@ std::shared_ptr FileDataLoader::load(std::shared_ptr FileListLoader::load(std::shared_ptrconfig(); - if (!config.contains("directories")) - { - throw std::runtime_error("FileListLoader: No directories specified in config"); - } - auto files = nlohmann::json::array(); - auto directories = config["directories"]; + auto directories = task["directories"]; for (auto& directory : directories) { auto dirpath = boost::filesystem::path(directory); @@ -62,8 +56,9 @@ std::shared_ptr FileListLoader::load(std::shared_ptr #include #include @@ -110,16 +111,85 @@ const std::string MessageControl::s_config_schema = R"( } )"; -MessageControl::MessageControl(const nlohmann::json& config) : m_config(config) {} +MessageControl::MessageControl() : + m_task_config({{"tasks", nlohmann::json::array()}, {"metadata", nlohmann::json::object()}}) +{} + +MessageControl::MessageControl(const nlohmann::json& _config) : + m_task_config({{"tasks", nlohmann::json::array()}, {"metadata", nlohmann::json::object()}}) +{ + config(_config); +} + +MessageControl::MessageControl(const MessageControl& other) +{ + m_task_config = other.m_task_config; + m_task_count = other.m_task_count; +} const nlohmann::json& MessageControl::config() const { - return m_config; + return m_task_config; +} + +void MessageControl::add_task(const std::string& task_type, const nlohmann::json& task) +{ + // TODO(Devin) Schema check + VLOG(20) << "Adding task of type " << task_type << " to control message" << task.dump(4); + m_task_count[task_type] += 1; + m_task_config["tasks"].push_back({{"type", task_type}, {"properties", task}}); +} + +bool MessageControl::has_task(const std::string& task_type) const +{ + return m_task_count.contains(task_type) and m_task_count.at(task_type) > 0; +} + +void MessageControl::set_metadata(const std::string& key, const nlohmann::json& value) +{ + if (m_task_config["metadata"].contains(key)) + { + LOG(WARNING) << "Overwriting metadata key " << key << " with value " << value; + } + + m_task_config["metadata"][key] = value; +} + +bool MessageControl::has_metadata(const std::string& key) const +{ + return m_task_config["metadata"].contains(key); +} + +const nlohmann::json MessageControl::get_metadata(const std::string& key) const +{ + return m_task_config["metadata"].at(key); +} + +const nlohmann::json MessageControl::pop_task(const std::string& task_type) +{ + auto& tasks = m_task_config["tasks"]; + for (auto it = tasks.begin(); it != tasks.end(); ++it) + { + if (it->at("type") == task_type) + { + auto task = *it; + tasks.erase(it); + m_task_count[task_type] -= 1; + + return task["properties"]; + } + } + + throw std::runtime_error("No tasks of type " + task_type + " found"); } void MessageControl::config(const nlohmann::json& config) { - m_config = config; + auto& tasks = config["tasks"]; + for (const auto& task : tasks) + { + add_task(task.at("type"), task.at("properties")); + } } std::shared_ptr MessageControl::payload() @@ -143,6 +213,28 @@ std::shared_ptr ControlMessageProxy::create(py::dict& config) return std::make_shared(mrc::pymrc::cast_from_pyobject(config)); } +std::shared_ptr ControlMessageProxy::create(std::shared_ptr other) +{ + return std::make_shared(*other); +} + +std::shared_ptr ControlMessageProxy::copy(MessageControl& self) +{ + return std::make_shared(self); +} + +void ControlMessageProxy::add_task(MessageControl& self, const std::string& task_type, py::dict& task) +{ + self.add_task(task_type, mrc::pymrc::cast_from_pyobject(task)); +} + +py::dict ControlMessageProxy::pop_task(MessageControl& self, const std::string& task_type) +{ + auto task = self.pop_task(task_type); + + return mrc::pymrc::cast_from_json(task); +} + py::dict ControlMessageProxy::config(MessageControl& self) { auto dict = mrc::pymrc::cast_from_json(self.config()); @@ -150,6 +242,18 @@ py::dict ControlMessageProxy::config(MessageControl& self) return dict; } +py::object ControlMessageProxy::get_metadata(MessageControl& self, const std::string& key) +{ + auto dict = mrc::pymrc::cast_from_json(self.get_metadata(key)); + + return dict; +} + +void ControlMessageProxy::set_metadata(MessageControl& self, const std::string& key, pybind11::object& value) +{ + self.set_metadata(key, mrc::pymrc::cast_from_pyobject(value)); +} + void ControlMessageProxy::config(MessageControl& self, py::dict& config) { self.config(mrc::pymrc::cast_from_pyobject(config)); diff --git a/morpheus/_lib/src/python_modules/messages.cpp b/morpheus/_lib/src/python_modules/messages.cpp index 2776a032dc..731883e037 100644 --- a/morpheus/_lib/src/python_modules/messages.cpp +++ b/morpheus/_lib/src/python_modules/messages.cpp @@ -128,12 +128,21 @@ PYBIND11_MODULE(messages, _module) .def(py::init<>(), py::return_value_policy::reference_internal) .def(py::init(py::overload_cast(&ControlMessageProxy::create)), py::return_value_policy::reference_internal) + .def(py::init(py::overload_cast>(&ControlMessageProxy::create)), + py::return_value_policy::reference_internal) .def("config", pybind11::overload_cast(&ControlMessageProxy::config), py::return_value_policy::reference_internal) .def("config", pybind11::overload_cast(&ControlMessageProxy::config), py::arg("config")) + .def("copy", &ControlMessageProxy::copy, py::return_value_policy::reference_internal) + .def("add_task", &ControlMessageProxy::add_task, py::arg("task_type"), py::arg("task")) + .def("has_task", &MessageControl::has_task, py::arg("task_type")) + .def("pop_task", &ControlMessageProxy::pop_task, py::arg("task_type")) + .def("set_metadata", &ControlMessageProxy::set_metadata, py::arg("key"), py::arg("value")) + .def("has_metadata", &MessageControl::has_metadata, py::arg("key")) + .def("get_metadata", &ControlMessageProxy::get_metadata, py::arg("key")) .def("payload", pybind11::overload_cast<>(&MessageControl::payload), py::return_value_policy::move) .def("payload", pybind11::overload_cast&>(&MessageControl::payload)); diff --git a/morpheus/_lib/tests/io/test_data_loader.cpp b/morpheus/_lib/tests/io/test_data_loader.cpp index 619ceac28c..1a2f83ac8a 100644 --- a/morpheus/_lib/tests/io/test_data_loader.cpp +++ b/morpheus/_lib/tests/io/test_data_loader.cpp @@ -41,17 +41,11 @@ TEST_F(TestDataLoader, DataLoaderRegisterLoaderTest) auto data_loader = DataLoader(); nlohmann::json message_config; - message_config["tasks"] = {{{"type", "load"}, - {"properties", - { - {"loader_id", "payload"}, - }}}}; + message_config["tasks"] = {{{"type", "load"}, {"properties", {{"loader_id", "payload"}}}}}; std::vector loaders = {"payload"}; for (auto& loader : loaders) { - message_config["tasks"][0]["properties"]["loader_id"] = loader; - auto msg = std::make_shared(message_config); EXPECT_THROW(data_loader.load(msg), std::runtime_error); @@ -61,8 +55,6 @@ TEST_F(TestDataLoader, DataLoaderRegisterLoaderTest) for (auto& loader : loaders) { - message_config["tasks"][0]["properties"]["loader_id"] = loader; - auto msg = std::make_shared(message_config); EXPECT_NO_THROW(data_loader.load(msg)); @@ -73,22 +65,28 @@ TEST_F(TestDataLoader, DataLoaderRemoveLoaderTest) { auto data_loader = DataLoader(); - nlohmann::json message_config; - message_config["tasks"] = {{{"type", "load"}, - {"properties", - { - {"loader_id", "payload"}, - }}}}; + nlohmann::json task_properties; + task_properties = {{"loader_id", "payload"}}; - auto msg = std::make_shared(message_config); + auto msg = std::make_shared(); + // Load should fail if there are no loaders registered + msg->add_task("load", task_properties); EXPECT_THROW(data_loader.load(msg), std::runtime_error); + data_loader.add_loader("payload", std::make_unique()); + // Load should succeed if there is a loader registered + msg->add_task("load", task_properties); EXPECT_NO_THROW(data_loader.load(msg)); + // Load should fail if the loader is removed + msg->add_task("load", task_properties); data_loader.remove_loader("payload"); EXPECT_THROW(data_loader.load(msg), std::runtime_error); + + // Shouldn't fail, because there shouldn't be any load tasks on the control message + EXPECT_NO_THROW(data_loader.load(msg)); } /** diff --git a/morpheus/_lib/tests/io/test_loaders.cpp b/morpheus/_lib/tests/io/test_loaders.cpp index cdbcb5810e..02cc3c5d88 100644 --- a/morpheus/_lib/tests/io/test_loaders.cpp +++ b/morpheus/_lib/tests/io/test_loaders.cpp @@ -46,28 +46,24 @@ TEST_F(TestLoader, LoaderFileTest) GTEST_SKIP() << "Failed to create temporary file, skipping test"; } - nlohmann::json message_config; - message_config["tasks"] = {{{"type", "load"}, - {"properties", - { - {"loader_id", "file"}, - {"strategy", "aggregate"}, - {"files", - { - {{"path", std::string(temp_file)}, {"type", "csv"}}, - }}, - }}}}; - - auto task = message_config["tasks"][0]; + nlohmann::json task_properties; + task_properties = { + {"loader_id", "file"}, + {"strategy", "aggregate"}, + {"files", + { + {{"path", std::string(temp_file)}, {"type", "csv"}}, + }}, + }; std::fstream data_file(temp_file, std::ios::out | std::ios::binary | std::ios::trunc); data_file << string_df; data_file.close(); - auto msg = std::make_shared(message_config); + auto msg = std::make_shared(); auto loader = FileDataLoader(); - EXPECT_NO_THROW(loader.load(msg, task)); + EXPECT_NO_THROW(loader.load(msg, task_properties)); unlink(temp_file); } diff --git a/morpheus/_lib/tests/messages/test_control_message.cpp b/morpheus/_lib/tests/messages/test_control_message.cpp index 8f0cdd3c1c..778db10d0b 100644 --- a/morpheus/_lib/tests/messages/test_control_message.cpp +++ b/morpheus/_lib/tests/messages/test_control_message.cpp @@ -30,28 +30,76 @@ TEST_F(TestControlMessage, InitializationTest) { auto msg_one = MessageControl(); - auto config = nlohmann::json(); - config["some_value"] = "42"; + auto config = nlohmann::json(); + nlohmann::json task_properties; + task_properties = { + {"loader_id", "payload"}, + {"strategy", "aggregate"}, + }; + config["tasks"] = {{{"type", "load"}, {"properties", task_properties}}}; auto msg_two = MessageControl(config); - ASSERT_EQ(msg_two.config().contains("some_value"), true); - ASSERT_EQ(msg_two.config()["some_value"], "42"); + ASSERT_EQ(msg_two.config().contains("tasks"), true); } TEST_F(TestControlMessage, SetMessageTest) { auto msg = MessageControl(); + ASSERT_EQ(msg.config().contains("tasks"), true); + ASSERT_EQ(msg.config().contains("nope"), false); + + auto config = nlohmann::json(); + nlohmann::json task_properties; + task_properties = { + {"loader_id", "payload"}, + {"strategy", "aggregate"}, + }; + config["tasks"] = {{{"type", "load"}, {"properties", task_properties}}}; + + msg.config(config); + + ASSERT_EQ(msg.config().contains("tasks"), true); +} + +TEST_F(TestControlMessage, TaskTest) +{ + auto msg = MessageControl(); + ASSERT_EQ(msg.config().contains("some_value"), false); - auto config = nlohmann::json(); - config["some_value"] = "42"; + auto config = nlohmann::json(); + nlohmann::json task_properties; + task_properties = { + {"loader_id", "payload"}, + {"strategy", "aggregate"}, + }; + config["tasks"] = {{{"type", "load"}, {"properties", task_properties}}}; msg.config(config); - ASSERT_EQ(msg.config().contains("some_value"), true); - ASSERT_EQ(msg.config()["some_value"], "42"); + ASSERT_EQ(msg.config().contains("tasks"), true); + ASSERT_EQ(msg.has_task("load"), true); + ASSERT_EQ(msg.has_task("inference"), false); + ASSERT_EQ(msg.has_task("training"), false); + ASSERT_EQ(msg.has_task("custom"), false); + + msg.add_task("inference", {}); + ASSERT_EQ(msg.has_task("inference"), true); + + msg.pop_task("inference"); + ASSERT_EQ(msg.has_task("inference"), false); + + msg.add_task("training", {}); + ASSERT_EQ(msg.has_task("training"), true); + msg.pop_task("training"); + ASSERT_EQ(msg.has_task("training"), false); + + msg.add_task("custom", {}); + ASSERT_EQ(msg.has_task("custom"), true); + msg.pop_task("custom"); + ASSERT_EQ(msg.has_task("custom"), false); } TEST_F(TestControlMessage, PayloadTest) diff --git a/morpheus/loaders/file_to_df_loader.py b/morpheus/loaders/file_to_df_loader.py index 847a8c5e1a..85b506f408 100644 --- a/morpheus/loaders/file_to_df_loader.py +++ b/morpheus/loaders/file_to_df_loader.py @@ -45,9 +45,8 @@ @register_loader(FILE_TO_DF_LOADER) def file_to_df_loader(message: MessageControl, task: dict): - task_properties = task["properties"] - files = task_properties.get("files", None) - batcher_config = task_properties["batcher_config"] + files = task.get("files", None) + batcher_config = task["batcher_config"] timestamp_column_name = batcher_config.get("timestamp_column_name", None) schema_batcher_config = batcher_config.get("schema", None) @@ -211,10 +210,16 @@ def convert_to_dataframe(file_name_batch: typing.Tuple[typing.List[str], int]): pdf = convert_to_dataframe(files) - df = cudf.from_pandas(pdf) + if task.get("strategy", "aggregate") != "aggregate": + raise RuntimeError("Only 'aggregate' strategy is supported for file_to_df loader.") - payload = MessageMeta(df) - message.payload(payload) + df = cudf.from_pandas(pdf) + payload = message.payload() + if (payload is None): + message.payload(MessageMeta(df)) + else: + with message.payload().mutable_dataframe() as dfm: + dfm.concat(df) return message diff --git a/morpheus/modules/file_batcher.py b/morpheus/modules/file_batcher.py index d99024bc96..eab36aa801 100644 --- a/morpheus/modules/file_batcher.py +++ b/morpheus/modules/file_batcher.py @@ -34,7 +34,7 @@ @register_module(FILE_BATCHER, MODULE_NAMESPACE) -def file_batcher(builder: mrc.Builder) -> MessageControl: +def file_batcher(builder: mrc.Builder): """ This module loads the input files, removes files that are older than the chosen window of time, and then groups the remaining files by period that fall inside the window. @@ -100,8 +100,10 @@ def on_data(file_objects: fsspec.core.OpenFiles): df["ts"] = timestamps df["key"] = full_names - out_messages = [] - + control_message = MessageControl() + # TODO(Devin): remove this + control_message.add_task("inference", {}) + control_message.add_task("training", {}) if len(df) > 0: # Now split by the batching settings df_period = df["ts"].dt.to_period(period) @@ -109,48 +111,33 @@ def on_data(file_objects: fsspec.core.OpenFiles): period_gb = df.groupby(df_period) n_groups = len(period_gb) + logger.debug("Batching %d files => %d groups", len(df), n_groups) for group in period_gb.groups: period_df = period_gb.get_group(group) filenames = period_df["key"].to_list() - message_config = {} load_task = { - "type": "load", - "properties": { - "loader_id": FILE_TO_DF_LOADER, - "files": filenames, - "n_groups": n_groups, - "batcher_config": { # TODO(Devin): remove this - "timestamp_column_name": config.get("timestamp_column_name"), - "schema": config.get("schema"), - "file_type": config.get("file_type"), - "filter_null": config.get("filter_null"), - "parser_kwargs": config.get("parser_kwargs"), - "cache_dir": config.get("cache_dir") - } - } - } - - task_infer = { - "type": "inference", - "properties": { - } - } - - task_train = { - "type": "training", - "properties": { + "loader_id": FILE_TO_DF_LOADER, + "strategy": "aggregate", + "files": filenames, + "n_groups": n_groups, + "batcher_config": { # TODO(Devin): remove this + "timestamp_column_name": config.get("timestamp_column_name"), + "schema": config.get("schema"), + "file_type": config.get("file_type"), + "filter_null": config.get("filter_null"), + "parser_kwargs": config.get("parser_kwargs"), + "cache_dir": config.get("cache_dir") } } - message_config["tasks"] = [task_infer, task_train, load_task] - message = MessageControl(message_config) - out_messages.append(message) + # Temporary hack to support inference and training tasks + control_message.add_task("load", load_task) - return out_messages + return control_message def node_fn(obs: mrc.Observable, sub: mrc.Subscriber): - obs.pipe(ops.map(on_data), ops.flatten()).subscribe(sub) + obs.pipe(ops.map(on_data)).subscribe(sub) node = builder.make_node_full(FILE_BATCHER, node_fn) From 9889e919a1db6436852defc70a554c3d1e49ed6a Mon Sep 17 00:00:00 2001 From: Devin Robison Date: Sat, 25 Feb 2023 01:38:03 -0700 Subject: [PATCH 043/157] Checkpoint -- fixed multi-load per CM --- morpheus/_lib/src/io/data_loader.cpp | 11 +++++++---- morpheus/loaders/file_to_df_loader.py | 13 ++++++++++--- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/morpheus/_lib/src/io/data_loader.cpp b/morpheus/_lib/src/io/data_loader.cpp index 9e5e1ff35a..bc688f7a06 100644 --- a/morpheus/_lib/src/io/data_loader.cpp +++ b/morpheus/_lib/src/io/data_loader.cpp @@ -49,9 +49,10 @@ std::shared_ptr Loader::load(std::shared_ptr mes std::shared_ptr DataLoader::load(std::shared_ptr control_message) { // TODO(Devin): Need to revisit to ensure we're handling multiple 'load' messages correctly + std::cerr << control_message->config().dump(2) << std::endl; while (control_message->has_task("load")) { - auto task = control_message->pop_task("load"); + auto task = control_message->pop_task("load"); auto loader_id = task["loader_id"]; auto loader = m_loaders.find(loader_id); @@ -62,9 +63,11 @@ std::shared_ptr DataLoader::load(std::shared_ptr loader->second->load(control_message, task); } - - throw std::runtime_error("Attempt to load using an unknown or unregistered data loader: " + - loader_id.get()); + else + { + throw std::runtime_error("Attempt to load using an unknown or unregistered data loader: " + + loader_id.get()); + } } return std::move(control_message); diff --git a/morpheus/loaders/file_to_df_loader.py b/morpheus/loaders/file_to_df_loader.py index 85b506f408..67eedec76f 100644 --- a/morpheus/loaders/file_to_df_loader.py +++ b/morpheus/loaders/file_to_df_loader.py @@ -216,11 +216,18 @@ def convert_to_dataframe(file_name_batch: typing.Tuple[typing.List[str], int]): df = cudf.from_pandas(pdf) payload = message.payload() if (payload is None): + logger.debug("Creating new message with %s rows", len(df)) message.payload(MessageMeta(df)) + logger.debug("-- %d", len(message.payload().df)) else: - with message.payload().mutable_dataframe() as dfm: - dfm.concat(df) - + logger.debug("Appending %s rows to existing message", len(df)) + logger.debug("-- %d", len(message.payload().df)) + with payload.mutable_dataframe() as dfm: + dfm = cudf.concat([dfm, df], ignore_index=True) + message.payload(MessageMeta(dfm)) + logger.debug("-- %d", len(message.payload().df)) + + logger.debug("Returning message with %s rows", len(message.payload().df)) return message From fc3aa3749ea718bb4768c4672825db047da47a75 Mon Sep 17 00:00:00 2001 From: Devin Robison Date: Sat, 25 Feb 2023 18:02:26 -0700 Subject: [PATCH 044/157] Pipeline updates to handle different training categories -- often need to bypass rolling window --- .../morpheus/dfp/modules/dfp_inference.py | 1 - .../morpheus/dfp/modules/dfp_rolling_window.py | 16 ++++++++++++++-- .../morpheus/dfp/modules/dfp_split_users.py | 1 - morpheus/_lib/src/io/data_loader.cpp | 2 -- morpheus/loaders/file_to_df_loader.py | 12 ++++-------- morpheus/modules/file_batcher.py | 2 ++ morpheus/modules/mlflow_model_writer.py | 6 +----- 7 files changed, 21 insertions(+), 19 deletions(-) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference.py index 153b343f8e..e81827697a 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference.py @@ -23,7 +23,6 @@ from mrc.core import operators as ops from ..messages.multi_dfp_message import MultiDFPMessage, DFPMessageMeta -from morpheus.messages.multi_ae_message import MultiAEMessage from morpheus.messages import MessageControl from morpheus.utils.module_ids import MODULE_NAMESPACE from morpheus.utils.module_utils import get_module_config diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_rolling_window.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_rolling_window.py index 8a3c18d3c3..882e420d42 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_rolling_window.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_rolling_window.py @@ -78,6 +78,7 @@ def get_user_cache(user_id: str): yield user_cache def build_window(message: MessageMeta, user_id: str) -> MessageMeta: + print("Building rolling window for:", user_id, flush=True) with get_user_cache(user_id) as user_cache: # incoming_df = message.get_df() @@ -86,8 +87,8 @@ def build_window(message: MessageMeta, user_id: str) -> MessageMeta: if (not user_cache.append_dataframe(incoming_df=incoming_df)): # Then our incoming dataframe wasnt even covered by the window. Generate warning - logger.warn(("Incoming data preceeded existing history. " - "Consider deleting the rolling window cache and restarting.")) + logger.warning(("Incoming data preceeded existing history. " + "Consider deleting the rolling window cache and restarting.")) return None user_cache.save() @@ -95,11 +96,13 @@ def build_window(message: MessageMeta, user_id: str) -> MessageMeta: # Exit early if we dont have enough data if (user_cache.count < min_history): + print("Not enough data to train:", user_id, flush=True) logger.debug("Not enough data to train") return None # We have enough data, but has enough time since the last training taken place? if (user_cache.total_count - user_cache.last_train_count < min_increment): + print("Elapsed time since last train is too short:", user_id, flush=True) logger.debug("Elapsed time since last train is too short") return None @@ -125,12 +128,21 @@ def build_window(message: MessageMeta, user_id: str) -> MessageMeta: "Rolling history can only be used with non-overlapping batches")) # TODO(Devin): Optimize + print("Training finished, returning:", user_id, flush=True) return MessageMeta(cudf.from_pandas(train_df)) def on_data(message: MessageControl): payload = message.payload() user_id = message.get_metadata("user_id") + task_priority = None + if (message.has_metadata("task_priority")): + task_priority = message.get_metadata("task_priority") + + # If we're an explicit training or inference task, then we dont need to do any rolling window logic + if (task_priority is not None and task_priority == "immediate"): + return message + with log_time(logger.debug) as log_info: result = build_window(payload, user_id) # Return a MessageMeta diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_split_users.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_split_users.py index 8984a0aee1..7b3e48eb1a 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_split_users.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_split_users.py @@ -91,7 +91,6 @@ def extract_users(message: MessageControl): output_messages: typing.List[MessageControl] = [] for user_id in sorted(split_dataframes.keys()): - if (user_id in skip_users): continue diff --git a/morpheus/_lib/src/io/data_loader.cpp b/morpheus/_lib/src/io/data_loader.cpp index bc688f7a06..7ebc63eb93 100644 --- a/morpheus/_lib/src/io/data_loader.cpp +++ b/morpheus/_lib/src/io/data_loader.cpp @@ -48,8 +48,6 @@ std::shared_ptr Loader::load(std::shared_ptr mes std::shared_ptr DataLoader::load(std::shared_ptr control_message) { - // TODO(Devin): Need to revisit to ensure we're handling multiple 'load' messages correctly - std::cerr << control_message->config().dump(2) << std::endl; while (control_message->has_task("load")) { auto task = control_message->pop_task("load"); diff --git a/morpheus/loaders/file_to_df_loader.py b/morpheus/loaders/file_to_df_loader.py index 67eedec76f..d8c8232e83 100644 --- a/morpheus/loaders/file_to_df_loader.py +++ b/morpheus/loaders/file_to_df_loader.py @@ -45,6 +45,9 @@ @register_loader(FILE_TO_DF_LOADER) def file_to_df_loader(message: MessageControl, task: dict): + if task.get("strategy", "aggregate") != "aggregate": + raise RuntimeError("Only 'aggregate' strategy is supported for file_to_df loader.") + files = task.get("files", None) batcher_config = task["batcher_config"] @@ -210,24 +213,17 @@ def convert_to_dataframe(file_name_batch: typing.Tuple[typing.List[str], int]): pdf = convert_to_dataframe(files) - if task.get("strategy", "aggregate") != "aggregate": - raise RuntimeError("Only 'aggregate' strategy is supported for file_to_df loader.") - df = cudf.from_pandas(pdf) payload = message.payload() if (payload is None): - logger.debug("Creating new message with %s rows", len(df)) message.payload(MessageMeta(df)) logger.debug("-- %d", len(message.payload().df)) else: - logger.debug("Appending %s rows to existing message", len(df)) - logger.debug("-- %d", len(message.payload().df)) with payload.mutable_dataframe() as dfm: dfm = cudf.concat([dfm, df], ignore_index=True) message.payload(MessageMeta(dfm)) - logger.debug("-- %d", len(message.payload().df)) - logger.debug("Returning message with %s rows", len(message.payload().df)) + # logger.debug("Returning message with %s rows", len(message.payload().df)) return message diff --git a/morpheus/modules/file_batcher.py b/morpheus/modules/file_batcher.py index eab36aa801..29a728c555 100644 --- a/morpheus/modules/file_batcher.py +++ b/morpheus/modules/file_batcher.py @@ -134,6 +134,8 @@ def on_data(file_objects: fsspec.core.OpenFiles): # Temporary hack to support inference and training tasks control_message.add_task("load", load_task) + control_message.set_metadata("task_priority", "immediate") + return control_message def node_fn(obs: mrc.Observable, sub: mrc.Subscriber): diff --git a/morpheus/modules/mlflow_model_writer.py b/morpheus/modules/mlflow_model_writer.py index 870d5e0291..573b7c6333 100644 --- a/morpheus/modules/mlflow_model_writer.py +++ b/morpheus/modules/mlflow_model_writer.py @@ -116,7 +116,7 @@ def apply_model_permissions(reg_model_name: str): "access_control_list": [{ "group_name": group, "permission_level": permission } for group, - permission in databricks_permissions.items()] + permission in databricks_permissions.items()] } requests.patch(url=patch_registered_model_permissions_url, @@ -131,10 +131,6 @@ def apply_model_permissions(reg_model_name: str): def on_data(message: MultiAEMessage): user = message.meta.user_id - df = message.meta.df - - print(df.columns, flush=True) - model: AutoEncoder = message.model model_path = "dfencoder" From bba2fb5b5f288f9cd276ba0f01110fd0dcd152db Mon Sep 17 00:00:00 2001 From: Devin Robison Date: Sun, 26 Feb 2023 13:21:00 -0700 Subject: [PATCH 045/157] Fix inference messages --- .../morpheus/dfp/modules/dfp_inference.py | 15 +++++++++------ .../morpheus/dfp/modules/dfp_rolling_window.py | 3 --- .../production/morpheus/test_input.json | 17 ++++++++++------- morpheus/utils/loader_utils.py | 2 ++ 4 files changed, 21 insertions(+), 16 deletions(-) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference.py index 8809a778fc..870fed211c 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference.py @@ -82,19 +82,22 @@ def process_task(control_message: MessageControl, task: dict): results_df = loaded_model.get_results(df_user, return_abs=True) - #include_cols = set(df_user.columns) - set(results_df.columns) + include_cols = set(df_user.columns) - set(results_df.columns) - #for col in include_cols: - # results_df[col] = df_user[col].copy(True) + for col in include_cols: + results_df[col] = df_user[col].copy(True) - #results_df = cudf.from_pandas(results_df) + results_df = cudf.from_pandas(results_df) # Create an output message to allow setting meta dfp_mm = DFPMessageMeta(results_df, user_id=user_id) - output_message = MultiDFPMessage(dfp_mm, mess_offset=0, mess_count=len(results_df)) + multi_message = MultiDFPMessage(dfp_mm, mess_offset=0, mess_count=len(results_df)) + output_message = MultiAEMessage(multi_message.meta, + multi_message.mess_offset, + multi_message.mess_count, + loaded_model) output_message.set_meta(list(results_df.columns), results_df) - output_message.set_meta('model_version', f"{model_cache.reg_model_name}:{model_cache.reg_model_version}") if logger.isEnabledFor(logging.DEBUG): diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_rolling_window.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_rolling_window.py index 34a33b279f..1d4bf19400 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_rolling_window.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_rolling_window.py @@ -78,7 +78,6 @@ def get_user_cache(user_id: str): yield user_cache def build_window(message: MessageMeta, user_id: str) -> MessageMeta: - print("Building rolling window for:", user_id, flush=True) with get_user_cache(user_id) as user_cache: # incoming_df = message.get_df() @@ -96,13 +95,11 @@ def build_window(message: MessageMeta, user_id: str) -> MessageMeta: # Exit early if we dont have enough data if (user_cache.count < min_history): - print("Not enough data to train:", user_id, flush=True) logger.debug("Not enough data to train") return None # We have enough data, but has enough time since the last training taken place? if (user_cache.total_count - user_cache.last_train_count < min_increment): - print("Elapsed time since last train is too short:", user_id, flush=True) logger.debug("Elapsed time since last train is too short") return None diff --git a/examples/digital_fingerprinting/production/morpheus/test_input.json b/examples/digital_fingerprinting/production/morpheus/test_input.json index e1e72d68ed..82203e874d 100644 --- a/examples/digital_fingerprinting/production/morpheus/test_input.json +++ b/examples/digital_fingerprinting/production/morpheus/test_input.json @@ -5,7 +5,7 @@ { "type": "load", "properties": { - "loader_id": "fsspec", + "loader_id": "file_batcher_debugging", "files": [ "../../../../examples/data/dfp/duo-training-data/*.json" ] @@ -15,6 +15,10 @@ "type": "training", "properties": { } + }, + { + "type": "inference", + "properties": {} } ], "metadata": { @@ -26,20 +30,19 @@ { "type": "load", "properties": { - "loader_id": "fsspec", + "loader_id": "file_batcher_debugging", "files": [ - "../../../../examples/data/dfp/duo-training-data/*.json" + "../../../../examples/data/dfp/duo-inference-data/*.json" ] } }, { - "type": "training", - "properties": { - } + "type": "inference", + "properties": {} } ], "metadata": { - "data_type": "payload" + "data_type": "streaming" } } ] diff --git a/morpheus/utils/loader_utils.py b/morpheus/utils/loader_utils.py index ba38a4feb4..b3e04486bf 100644 --- a/morpheus/utils/loader_utils.py +++ b/morpheus/utils/loader_utils.py @@ -38,9 +38,11 @@ def inner_func(func): # Register a loader if not exists in the registry. if not registry.contains(loder_id): registry.register_loader(loder_id, func) + print("Laoder '{}' was successfully registered.".format(loder_id), flush=True) logger.debug("Laoder '{}' was successfully registered.".format(loder_id)) else: logger.debug("Module: '{}' already exists.".format(loder_id)) + print("Module: '{}' already exists.".format(loder_id), flush=True) return func From b89334c6640fcb37fd3c8167b1f98bb3ce419274 Mon Sep 17 00:00:00 2001 From: bsuryadevara Date: Mon, 27 Feb 2023 12:35:41 -0600 Subject: [PATCH 046/157] added fsspec dataloader --- .../morpheus/dfp/modules/dfp_preproc.py | 16 ++- .../morpheus/dfp/utils/config_generator.py | 17 ++- morpheus/loaders/file_list_loader.py | 64 ---------- morpheus/loaders/fsspec_loader.py | 115 ++++++++++++++++++ morpheus/modules/file_batcher.py | 61 +--------- morpheus/utils/loader_ids.py | 4 +- morpheus/utils/loader_utils.py | 14 +-- .../control_messages/control_message.json | 4 +- 8 files changed, 150 insertions(+), 145 deletions(-) delete mode 100644 morpheus/loaders/file_list_loader.py create mode 100644 morpheus/loaders/fsspec_loader.py diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_preproc.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_preproc.py index 041d405b7c..bf391dacb1 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_preproc.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_preproc.py @@ -19,8 +19,11 @@ import morpheus._lib.modules # noqa: F401 import morpheus.loaders.file_to_df_loader # noqa: F401 +import morpheus.loaders.fsspec_loader # noqa: F401 import morpheus.modules.file_batcher # noqa: F401 import morpheus.modules.file_to_df # noqa: F401 +from morpheus.utils.loader_ids import FILE_TO_DF_LOADER +from morpheus.utils.loader_ids import FSSPEC_LOADER from morpheus.utils.module_ids import DATA_LOADER from morpheus.utils.module_ids import FILE_BATCHER from morpheus.utils.module_ids import MODULE_NAMESPACE @@ -48,19 +51,22 @@ def dfp_preproc(builder: mrc.Builder): config = get_module_config(DFP_PREPROC, builder) + fsspec_data_loader_conf = config.get(FSSPEC_LOADER, None) file_batcher_conf = config.get(FILE_BATCHER, None) - data_loader_conf = config.get(DATA_LOADER, None) + file_to_df_data_loader_conf = config.get(FILE_TO_DF_LOADER, None) dfp_split_users_conf = config.get(DFP_SPLIT_USERS, None) # Load modules + fsspec_data_loader_module = load_module(fsspec_data_loader_conf, builder=builder) file_batcher_module = load_module(file_batcher_conf, builder=builder) - data_loader_module = load_module(data_loader_conf, builder=builder) + file_to_df_data_loader_module = load_module(file_to_df_data_loader_conf, builder=builder) dfp_split_users_module = load_module(dfp_split_users_conf, builder=builder) # Make an edge between the modules. - builder.make_edge(file_batcher_module.output_port("output"), data_loader_module.input_port("input")) - builder.make_edge(data_loader_module.output_port("output"), dfp_split_users_module.input_port("input")) + builder.make_edge(fsspec_data_loader_module.output_port("output"), file_batcher_module.input_port("input")) + builder.make_edge(file_batcher_module.output_port("output"), file_to_df_data_loader_module.input_port("input")) + builder.make_edge(file_to_df_data_loader_module.output_port("output"), dfp_split_users_module.input_port("input")) # Register input and output port for a module. - builder.register_module_input("input", file_batcher_module.input_port("input")) + builder.register_module_input("input", fsspec_data_loader_module.input_port("input")) builder.register_module_output("output", dfp_split_users_module.output_port("output")) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py b/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py index 58a43cbe3b..707a1ab852 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py @@ -38,6 +38,7 @@ from morpheus.config import CppConfig from morpheus.messages.multi_message import MultiMessage from morpheus.utils.loader_ids import FILE_TO_DF_LOADER +from morpheus.utils.loader_ids import FSSPEC_LOADER from morpheus.utils.module_ids import DATA_LOADER from morpheus.utils.module_ids import FILE_BATCHER from morpheus.utils.module_ids import FILE_TO_DF @@ -89,15 +90,19 @@ def preproc_module_config(self): "module_id": DFP_PREPROC, "module_name": "dfp_preproc", "namespace": MODULE_NAMESPACE, + FSSPEC_LOADER: { + "module_id": DATA_LOADER, + "module_name": "fsspec_dataloader", + "namespace": MODULE_NAMESPACE, + "loaders": [{ + "id": FSSPEC_LOADER + }] + }, FILE_BATCHER: { "module_id": FILE_BATCHER, "module_name": "file_batcher", "namespace": MODULE_NAMESPACE, "period": "D", - "sampling_rate_s": self._derive_args.sample_rate_s, - "start_time": self._derive_args.time_fields.start_time, - "end_time": self._derive_args.time_fields.end_time, - "iso_date_regex_pattern": iso_date_regex_pattern, "timestamp_column_name": self._config.ae.timestamp_column_name, "parser_kwargs": { "lines": False, "orient": "records" @@ -109,9 +114,9 @@ def preproc_module_config(self): "schema_str": self._source_schema_str, "encoding": self._encoding } }, - DATA_LOADER: { + FILE_TO_DF_LOADER: { "module_id": DATA_LOADER, - "module_name": "FileToDFDataLoader", + "module_name": "file_to_df_dataloader", "namespace": MODULE_NAMESPACE, "loaders": [{ "id": FILE_TO_DF_LOADER diff --git a/morpheus/loaders/file_list_loader.py b/morpheus/loaders/file_list_loader.py deleted file mode 100644 index e8d1caff07..0000000000 --- a/morpheus/loaders/file_list_loader.py +++ /dev/null @@ -1,64 +0,0 @@ -# Copyright (c) 2023, NVIDIA CORPORATION. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import hashlib -import json -import logging -import multiprocessing as mp -import os -import pickle -import time -import typing -from functools import partial - -import fsspec -import fsspec.utils -from morpheus.messages.message_meta import MessageMeta - -import pandas as pd - -import cudf - -from morpheus.messages import MessageControl -from morpheus._lib.common import FileTypes -from morpheus.cli.utils import str_to_file_type -from morpheus.io.deserializers import read_file_to_df -from morpheus.utils.column_info import process_dataframe -from morpheus.utils.loader_ids import FILE_LIST_LOADER -from morpheus.utils.loader_utils import register_loader - -logger = logging.getLogger(__name__) - -dask_cluster = None - - -@register_loader(FILE_LIST_LOADER) -def file_to_df_loader(message: MessageControl, task: dict): - task_properties = task["properties"] - files = task_properties["files"] - - file_objects: fsspec.core.OpenFiles = fsspec.open_files(files) - - if (len(file_objects) == 0): - raise RuntimeError(f"No files matched input strings: '{files}'. " - "Check your input pattern and ensure any credentials are correct") - - files = None - for file_object in file_objects: - files.append(file_object.full_name) - - message_config = message.config() - message_config["tasks"][0]["properties"]["files"] = files - message_control = MessageControl(message_config) - return message_control diff --git a/morpheus/loaders/fsspec_loader.py b/morpheus/loaders/fsspec_loader.py new file mode 100644 index 0000000000..bd000f07f8 --- /dev/null +++ b/morpheus/loaders/fsspec_loader.py @@ -0,0 +1,115 @@ +# Copyright (c) 2023, NVIDIA CORPORATION. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import logging +import re +from collections import namedtuple +from datetime import datetime +from datetime import timedelta +from datetime import timezone + +import dateutil.parser +import fsspec +import fsspec.utils +import pandas as pd + +import cudf + +from morpheus.messages import MessageControl +from morpheus.messages.message_meta import MessageMeta +from morpheus.utils.file_utils import date_extractor +from morpheus.utils.loader_ids import FSSPEC_LOADER +from morpheus.utils.loader_utils import register_loader + +logger = logging.getLogger(__name__) + +dask_cluster = None + + +@register_loader(FSSPEC_LOADER) +def fsspec_loader(message: MessageControl, task: dict) -> MessageControl: + + files = task.get("files", []) + start_time = task.get("start_time", None) + duration = task.get("duration", None) + sampling_rate_s = task.get("sampling_rate_s", 0) + iso_date_regex_pattern = task.get("iso_date_regex_pattern", None) + + file_objects: fsspec.core.OpenFiles = fsspec.open_files(files) + + if (len(file_objects) == 0): + raise RuntimeError(f"No files matched input strings: '{files}'. " + "Check your input pattern and ensure any credentials are correct") + + duration = timedelta(seconds=pd.Timedelta(duration).total_seconds()) + + if start_time is None: + end_time = datetime.now(tz=timezone.utc) + start_time = end_time - duration + else: + start_time = dateutil.parser.parse(start_time) + if start_time.tzinfo is None: + start_time = start_time.replace(tzinfo=timezone.utc) + + end_time = start_time + duration + + TimestampFileObj = namedtuple("TimestampFileObj", ["timestamp", "file_name"]) + + iso_date_regex = re.compile(iso_date_regex_pattern) + + ts_and_files = [] + + for file_object in file_objects: + ts = date_extractor(file_object, iso_date_regex) + + # Exclude any files outside the time window + if ((start_time is not None and ts < start_time) or (end_time is not None and ts > end_time)): + continue + + ts_and_files.append(TimestampFileObj(ts, file_object.full_name)) + + # sort the incoming data by date + ts_and_files.sort(key=lambda x: x.timestamp) + + # Create a dataframe with the incoming metadata + if ((len(ts_and_files) > 1) and (sampling_rate_s > 0)): + file_sampled_list = [] + + ts_last = ts_and_files[0].timestamp + + file_sampled_list.append(ts_and_files[0]) + + for idx in range(1, len(ts_and_files)): + ts = ts_and_files[idx].timestamp + + if ((ts - ts_last).seconds >= sampling_rate_s): + ts_and_files.append(ts_and_files[idx]) + ts_last = ts + else: + ts_and_files = file_sampled_list + + df = cudf.DataFrame() + + timestamps = [] + full_names = [] + for (ts, file_name) in ts_and_files: + timestamps.append(ts) + full_names.append(file_name) + + df["ts"] = timestamps + df["key"] = full_names + + message.payload(MessageMeta(df=df)) + + return message diff --git a/morpheus/modules/file_batcher.py b/morpheus/modules/file_batcher.py index 5e93fb7126..aa47e4ba88 100644 --- a/morpheus/modules/file_batcher.py +++ b/morpheus/modules/file_batcher.py @@ -13,17 +13,11 @@ # limitations under the License. import logging -import re -from collections import namedtuple -import fsspec -import fsspec.utils import mrc -import pandas as pd from mrc.core import operators as ops from morpheus.messages import MessageControl -from morpheus.utils.file_utils import date_extractor from morpheus.utils.loader_ids import FILE_TO_DF_LOADER from morpheus.utils.module_ids import FILE_BATCHER from morpheus.utils.module_ids import MODULE_NAMESPACE @@ -47,70 +41,21 @@ def file_batcher(builder: mrc.Builder): config = get_module_config(FILE_BATCHER, builder) - TimestampFileObj = namedtuple("TimestampFileObj", ["timestamp", "file_name"]) - - iso_date_regex_pattern = config.get("iso_date_regex_pattern", None) - start_time = config.get("start_time", None) - end_time = config.get("end_time", None) - sampling_rate_s = config.get("sampling_rate_s", None) period = config.get("period", None) - iso_date_regex = re.compile(iso_date_regex_pattern) - def on_data(control_message: MessageControl): # Determine the date of the file, and apply the window filter if we have one # This needs to be in the payload, not a task, because batcher isn't a data loader # TODO(Devin) - task = control_message.pop_task("load") - files = task["files"] + #control_message.pop_task("load") # TODO(Devin) data_type = "streaming" if (control_message.has_metadata("data_type")): data_type = control_message.get_metadata("data_type") - file_objects: fsspec.core.OpenFiles = fsspec.open_files(files) - - ts_and_files = [] - for file_object in file_objects: - ts = date_extractor(file_object, iso_date_regex) - - # Exclude any files outside the time window - if ((start_time is not None and ts < start_time) or (end_time is not None and ts > end_time)): - continue - - ts_and_files.append(TimestampFileObj(ts, file_object.full_name)) - - # sort the incoming data by date - ts_and_files.sort(key=lambda x: x.timestamp) - - # Create a dataframe with the incoming metadata - if ((len(ts_and_files) > 1) and (sampling_rate_s > 0)): - file_sampled_list = [] - - ts_last = ts_and_files[0].timestamp - - file_sampled_list.append(ts_and_files[0]) - - for idx in range(1, len(ts_and_files)): - ts = ts_and_files[idx].timestamp - - if ((ts - ts_last).seconds >= sampling_rate_s): - ts_and_files.append(ts_and_files[idx]) - ts_last = ts - else: - ts_and_files = file_sampled_list - - df = pd.DataFrame() - - timestamps = [] - full_names = [] - for (ts, file_name) in ts_and_files: - timestamps.append(ts) - full_names.append(file_name) - - df["ts"] = timestamps - df["key"] = full_names + payload = control_message.payload() + df = payload.df.to_pandas() # TODO(Devin): Clean this up control_messages = [] diff --git a/morpheus/utils/loader_ids.py b/morpheus/utils/loader_ids.py index 6e57630711..fc66bd98f5 100644 --- a/morpheus/utils/loader_ids.py +++ b/morpheus/utils/loader_ids.py @@ -12,5 +12,5 @@ # See the License for the specific language governing permissions and # limitations under the License. -FILE_TO_DF_LOADER = "FileToDFLoader" -FILE_LIST_LOADER = "FileListLoader" \ No newline at end of file +FILE_TO_DF_LOADER = "file_to_df" +FSSPEC_LOADER = "fsspec" diff --git a/morpheus/utils/loader_utils.py b/morpheus/utils/loader_utils.py index b3e04486bf..e4a1b3bfe3 100644 --- a/morpheus/utils/loader_utils.py +++ b/morpheus/utils/loader_utils.py @@ -19,13 +19,13 @@ logger = logging.getLogger(__name__) -def register_loader(loder_id): +def register_loader(loader_id): """ Registers a loader if not exists in the dataloader registry. Parameters ---------- - loder_id : str + loader_id : str Unique identifier for a loader in the dataloader registry. Returns @@ -36,13 +36,11 @@ def register_loader(loder_id): def inner_func(func): # Register a loader if not exists in the registry. - if not registry.contains(loder_id): - registry.register_loader(loder_id, func) - print("Laoder '{}' was successfully registered.".format(loder_id), flush=True) - logger.debug("Laoder '{}' was successfully registered.".format(loder_id)) + if not registry.contains(loader_id): + registry.register_loader(loader_id, func) + logger.debug("Loader '{}' was successfully registered.".format(loader_id)) else: - logger.debug("Module: '{}' already exists.".format(loder_id)) - print("Module: '{}' already exists.".format(loder_id), flush=True) + logger.debug("Loader: '{}' already exists.".format(loader_id)) return func diff --git a/tests/tests_data/control_messages/control_message.json b/tests/tests_data/control_messages/control_message.json index ac168d4ae1..e6641d6c01 100644 --- a/tests/tests_data/control_messages/control_message.json +++ b/tests/tests_data/control_messages/control_message.json @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2728f86b1e9dd1c757520fcdf4c5a5e4952c596d797bee66e7fbe25df61e3463 -size 192 +oid sha256:325f94f2df7bc994d3422c31134a10801691a2dbc0f7e164cf95f6e7f569003f +size 588 From 1b43dfc5ca552abbdcaee03c9008815014d2df9e Mon Sep 17 00:00:00 2001 From: bsuryadevara Date: Mon, 27 Feb 2023 17:14:05 -0600 Subject: [PATCH 047/157] forked pipeline to run training and inference in parallel --- .../morpheus/dfp/modules/dfp_data_prep.py | 11 +-- .../morpheus/dfp/modules/dfp_deployment.py | 35 +++------- .../morpheus/dfp/modules/dfp_inference.py | 11 +-- .../dfp/modules/dfp_rolling_window.py | 15 ++-- .../morpheus/dfp/utils/config_generator.py | 31 ++++----- .../morpheus/dfp_modules_pipeline.py | 38 ++++------ morpheus/loaders/fsspec_loader.py | 69 +------------------ morpheus/modules/file_batcher.py | 66 ++++++++++++++++-- ...es_stage.py => multi_port_module_stage.py} | 2 +- .../control_message_azure.json | 3 + .../control_messages/control_message_duo.json | 3 + 11 files changed, 135 insertions(+), 149 deletions(-) rename morpheus/stages/general/{nonlinear_modules_stage.py => multi_port_module_stage.py} (99%) create mode 100644 tests/tests_data/control_messages/control_message_azure.json create mode 100644 tests/tests_data/control_messages/control_message_duo.json diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_data_prep.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_data_prep.py index af4d4a1a54..3125183204 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_data_prep.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_data_prep.py @@ -16,15 +16,18 @@ import pickle import time -import cudf import mrc from mrc.core import operators as ops +import cudf + +from morpheus.messages import MessageControl +from morpheus.messages import MessageMeta from morpheus.utils.column_info import process_dataframe from morpheus.utils.module_ids import MODULE_NAMESPACE from morpheus.utils.module_utils import get_module_config from morpheus.utils.module_utils import register_module -from morpheus.messages import MessageControl, MessageMeta + from ..utils.module_ids import DFP_DATA_PREP logger = logging.getLogger("morpheus.{}".format(__name__)) @@ -42,7 +45,7 @@ def dfp_data_prep(builder: mrc.Builder): """ config = get_module_config(DFP_DATA_PREP, builder) - + task_type = config.get("task_type", None) schema_config = config.get("schema", None) schema_str = schema_config.get("schema_str", None) encoding = schema_config.get("encoding", None) @@ -53,7 +56,7 @@ def dfp_data_prep(builder: mrc.Builder): # def process_features(message: MultiDFPMessage): def process_features(message: MessageControl): - if (message is None): + if (message is None or not message.has_task(task_type)): return None start_time = time.time() diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_deployment.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_deployment.py index 3620257438..a035a76b1a 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_deployment.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_deployment.py @@ -49,34 +49,19 @@ def dfp_deployment(builder: mrc.Builder): preproc_module = load_module(preproc_conf, builder=builder) - if (train_conf is not None and infer_conf is not None): + # Load module from registry. + infer_module = load_module(infer_conf, builder=builder) + train_module = load_module(train_conf, builder=builder) - # Load module from registry. - infer_module = load_module(infer_conf, builder=builder) - train_module = load_module(train_conf, builder=builder) + # Create broadcast node to fork the pipeline. + boradcast_node = Broadcast(builder, "broadcast") - # Create broadcast node to fork the pipeline. - boradcast_node = Broadcast(builder, "broadcast") + # Make an edge between modules + builder.make_edge(preproc_module.output_port("output"), boradcast_node) + builder.make_edge(boradcast_node, infer_module.input_port("input")) + builder.make_edge(boradcast_node, train_module.input_port("input")) - # Make an edge between modules - builder.make_edge(preproc_module.output_port("output"), boradcast_node) - builder.make_edge(boradcast_node, infer_module.input_port("input")) - builder.make_edge(boradcast_node, train_module.input_port("input")) - - out_streams = [train_module.output_port("output"), infer_module.output_port("output")] - - elif infer_conf is not None: - infer_module = load_module(infer_conf, builder=builder) - builder.make_edge(preproc_module.output_port("output"), infer_module.input_port("input")) - out_streams = [infer_module.output_port("output")] - - elif train_conf is not None: - train_module = load_module(train_conf, builder=builder) - builder.make_edge(preproc_module.output_port("output"), train_module.input_port("input")) - out_streams = [train_module.output_port("output")] - - else: - raise Exception("Expected DFP deployment workload_types are not found.") + out_streams = [train_module.output_port("output"), infer_module.output_port("output")] # Register input port for a module. builder.register_module_input("input", preproc_module.input_port("input")) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference.py index 870fed211c..eddcb8ce3b 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference.py @@ -16,19 +16,22 @@ import time import mrc -import cudf import pandas as pd from dfp.utils.model_cache import ModelCache from dfp.utils.model_cache import ModelManager from mlflow.tracking.client import MlflowClient from mrc.core import operators as ops -from morpheus.messages.multi_ae_message import MultiAEMessage -from ..messages.multi_dfp_message import MultiDFPMessage, DFPMessageMeta +import cudf + from morpheus.messages import MessageControl +from morpheus.messages.multi_ae_message import MultiAEMessage from morpheus.utils.module_ids import MODULE_NAMESPACE from morpheus.utils.module_utils import get_module_config from morpheus.utils.module_utils import register_module + +from ..messages.multi_dfp_message import DFPMessageMeta +from ..messages.multi_dfp_message import MultiDFPMessage from ..utils.module_ids import DFP_INFERENCE logger = logging.getLogger("morpheus.{}".format(__name__)) @@ -97,7 +100,7 @@ def process_task(control_message: MessageControl, task: dict): multi_message.mess_offset, multi_message.mess_count, loaded_model) - output_message.set_meta(list(results_df.columns), results_df) + # output_message.set_meta(list(results_df.columns), results_df) output_message.set_meta('model_version', f"{model_cache.reg_model_name}:{model_cache.reg_model_version}") if logger.isEnabledFor(logging.DEBUG): diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_rolling_window.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_rolling_window.py index 1d4bf19400..f69045aabe 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_rolling_window.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_rolling_window.py @@ -17,18 +17,19 @@ import typing from contextlib import contextmanager -import cudf import mrc import pandas as pd from dfp.utils.cached_user_window import CachedUserWindow from dfp.utils.logging_timer import log_time from mrc.core import operators as ops +import cudf + +from morpheus.messages import MessageControl +from morpheus.messages import MessageMeta from morpheus.utils.module_ids import MODULE_NAMESPACE from morpheus.utils.module_utils import get_module_config from morpheus.utils.module_utils import register_module -from morpheus.messages import MessageControl -from morpheus.messages import MessageMeta from ..utils.module_ids import DFP_ROLLING_WINDOW @@ -47,7 +48,7 @@ def dfp_rolling_window(builder: mrc.Builder): """ config = get_module_config(DFP_ROLLING_WINDOW, builder) - + task_type = config.get("task_type", None) timestamp_column_name = config.get("timestamp_column_name", None) min_history = config.get("min_history", None) max_history = config.get("max_history", None) @@ -129,6 +130,10 @@ def build_window(message: MessageMeta, user_id: str) -> MessageMeta: return MessageMeta(cudf.from_pandas(train_df)) def on_data(control_message: MessageControl): + + if not control_message.has_task(task_type): + return None + payload = control_message.payload() user_id = control_message.get_metadata("user_id") @@ -164,7 +169,7 @@ def on_data(control_message: MessageControl): rw_control_message.payload(result) # TODO(Devin): Configure based on module config # TODO(Devin): Stop using dfp rolling window for inference, it makes zero sense - rw_control_message.add_task("training", {}) + rw_control_message.add_task(task_type, {}) rw_control_message.set_metadata("user_id", user_id) rw_control_message.set_metadata("data_type", "payload") diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py b/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py index 707a1ab852..2d08c21924 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py @@ -67,20 +67,11 @@ def get_module_config(self): module_config["module_id"] = DFP_DEPLOYMENT module_config["module_name"] = "dfp_deployment" module_config["namespace"] = MODULE_NAMESPACE - module_config["output_port_count"] = 1 module_config[DFP_PREPROC] = self.preproc_module_config() - - if self._derive_args.is_train_and_infer: - module_config[DFP_TRA] = self.train_module_config() - module_config[DFP_INF] = self.infer_module_config() - module_config["output_port_count"] = 2 - elif self._derive_args.is_training: - module_config[DFP_TRA] = self.train_module_config() - module_config["workload"] = DFP_TRAINING - else: - module_config[DFP_INF] = self.infer_module_config() - module_config["workload"] = DFP_INFERENCE + module_config[DFP_TRA] = self.train_module_config() + module_config[DFP_INF] = self.infer_module_config() + module_config["output_port_count"] = 2 return module_config @@ -103,6 +94,10 @@ def preproc_module_config(self): "module_name": "file_batcher", "namespace": MODULE_NAMESPACE, "period": "D", + "sampling_rate_s": self._derive_args.sample_rate_s, + "start_time": self._derive_args.time_fields.start_time, + "end_time": self._derive_args.time_fields.end_time, + "iso_date_regex_pattern": iso_date_regex_pattern, "timestamp_column_name": self._config.ae.timestamp_column_name, "parser_kwargs": { "lines": False, "orient": "records" @@ -151,7 +146,8 @@ def infer_module_config(self): "min_increment": 0, "max_history": "1d", "cache_dir": self._derive_args.cache_dir, - "timestamp_column_name": self._config.ae.timestamp_column_name + "timestamp_column_name": self._config.ae.timestamp_column_name, + "task_type": "inference" }, DFP_DATA_PREP: { "module_id": DFP_DATA_PREP, @@ -160,7 +156,8 @@ def infer_module_config(self): "timestamp_column_name": self._config.ae.timestamp_column_name, "schema": { "schema_str": self._preprocess_schema_str, "encoding": self._encoding - } + }, + "task_type": "inference" }, DFP_INFERENCE: { "module_id": DFP_INFERENCE, @@ -219,7 +216,8 @@ def train_module_config(self): "min_increment": 300, "max_history": self._derive_args.duration, "cache_dir": self._derive_args.cache_dir, - "timestamp_column_name": self._config.ae.timestamp_column_name + "timestamp_column_name": self._config.ae.timestamp_column_name, + "task_type": "training" }, DFP_DATA_PREP: { "module_id": DFP_DATA_PREP, @@ -228,7 +226,8 @@ def train_module_config(self): "timestamp_column_name": self._config.ae.timestamp_column_name, "schema": { "schema_str": self._preprocess_schema_str, "encoding": self._encoding - } + }, + "task_type": "training" }, DFP_TRAINING: { "module_id": DFP_TRAINING, diff --git a/examples/digital_fingerprinting/production/morpheus/dfp_modules_pipeline.py b/examples/digital_fingerprinting/production/morpheus/dfp_modules_pipeline.py index 8cccac6dd5..2acf833cf1 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp_modules_pipeline.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp_modules_pipeline.py @@ -18,7 +18,6 @@ import click import dfp.modules.dfp_deployment # noqa: F401 -from dfp.stages.multi_file_source import MultiFileSource from dfp.utils.config_generator import ConfigGenerator from dfp.utils.config_generator import generate_ae_config from dfp.utils.derive_args import DeriveArgs @@ -30,7 +29,7 @@ from morpheus.config import Config from morpheus.pipeline.pipeline import Pipeline from morpheus.stages.general.monitor_stage import MonitorStage -from morpheus.stages.general.nonlinear_modules_stage import NonLinearModulesStage +from morpheus.stages.general.multi_port_module_stage import MultiPortModuleStage from morpheus.stages.input.control_message_source_stage import ControlMessageSourceStage @@ -155,7 +154,6 @@ def run_pipeline(log_type: str, module_config = config_generator.get_module_config() - workload = module_config.get("workload") output_port_count = module_config.get("output_port_count") # Create a pipeline object @@ -163,31 +161,23 @@ def run_pipeline(log_type: str, source_stage = pipeline.add_stage(ControlMessageSourceStage(config, filenames=list(kwargs["input_file"]))) - #source_stage = pipeline.add_stage(MultiFileSource(config, filenames=list(kwargs["input_file"]))) - # Here we add a wrapped module that implements the DFP Deployment dfp_deployment_stage = pipeline.add_stage( - NonLinearModulesStage(config, - module_config, - input_port_name="input", - output_port_name_prefix="output", - output_port_count=output_port_count)) + MultiPortModuleStage(config, + module_config, + input_port_name="input", + output_port_name_prefix="output", + output_port_count=output_port_count)) + + train_moniter_stage = pipeline.add_stage( + MonitorStage(config, description="DFP Training Pipeline rate", smoothing=0.001)) + + infer_moniter_stage = pipeline.add_stage( + MonitorStage(config, description="DFP Inference Pipeline rate", smoothing=0.001)) pipeline.add_edge(source_stage, dfp_deployment_stage) - - if len(dfp_deployment_stage.output_ports) > 1: - tra_mntr_stage = MonitorStage(config, description="DFPTraining Pipeline rate", smoothing=0.001) - inf_mntr_stage = MonitorStage(config, description="DFPInference Pipeline rate", smoothing=0.001) - - tra_mntr_stage = pipeline.add_stage(tra_mntr_stage) - inf_mntr_stage = pipeline.add_stage(inf_mntr_stage) - - pipeline.add_edge(dfp_deployment_stage.output_ports[0], tra_mntr_stage) - pipeline.add_edge(dfp_deployment_stage.output_ports[1], inf_mntr_stage) - else: - monitor_stage = MonitorStage(config, description=f"{workload} Pipeline rate", smoothing=0.001) - monitor_stage = pipeline.add_stage(monitor_stage) - pipeline.add_edge(dfp_deployment_stage.output_ports[0], monitor_stage) + pipeline.add_edge(dfp_deployment_stage.output_ports[0], train_moniter_stage) + pipeline.add_edge(dfp_deployment_stage.output_ports[1], infer_moniter_stage) # Run the pipeline pipeline.run() diff --git a/morpheus/loaders/fsspec_loader.py b/morpheus/loaders/fsspec_loader.py index bd000f07f8..84d9f86e95 100644 --- a/morpheus/loaders/fsspec_loader.py +++ b/morpheus/loaders/fsspec_loader.py @@ -13,22 +13,14 @@ # limitations under the License. import logging -import re -from collections import namedtuple -from datetime import datetime -from datetime import timedelta -from datetime import timezone -import dateutil.parser import fsspec import fsspec.utils -import pandas as pd import cudf from morpheus.messages import MessageControl from morpheus.messages.message_meta import MessageMeta -from morpheus.utils.file_utils import date_extractor from morpheus.utils.loader_ids import FSSPEC_LOADER from morpheus.utils.loader_utils import register_loader @@ -41,10 +33,6 @@ def fsspec_loader(message: MessageControl, task: dict) -> MessageControl: files = task.get("files", []) - start_time = task.get("start_time", None) - duration = task.get("duration", None) - sampling_rate_s = task.get("sampling_rate_s", 0) - iso_date_regex_pattern = task.get("iso_date_regex_pattern", None) file_objects: fsspec.core.OpenFiles = fsspec.open_files(files) @@ -52,63 +40,12 @@ def fsspec_loader(message: MessageControl, task: dict) -> MessageControl: raise RuntimeError(f"No files matched input strings: '{files}'. " "Check your input pattern and ensure any credentials are correct") - duration = timedelta(seconds=pd.Timedelta(duration).total_seconds()) - - if start_time is None: - end_time = datetime.now(tz=timezone.utc) - start_time = end_time - duration - else: - start_time = dateutil.parser.parse(start_time) - if start_time.tzinfo is None: - start_time = start_time.replace(tzinfo=timezone.utc) - - end_time = start_time + duration - - TimestampFileObj = namedtuple("TimestampFileObj", ["timestamp", "file_name"]) - - iso_date_regex = re.compile(iso_date_regex_pattern) - - ts_and_files = [] + full_filenames = [] for file_object in file_objects: - ts = date_extractor(file_object, iso_date_regex) - - # Exclude any files outside the time window - if ((start_time is not None and ts < start_time) or (end_time is not None and ts > end_time)): - continue - - ts_and_files.append(TimestampFileObj(ts, file_object.full_name)) - - # sort the incoming data by date - ts_and_files.sort(key=lambda x: x.timestamp) - - # Create a dataframe with the incoming metadata - if ((len(ts_and_files) > 1) and (sampling_rate_s > 0)): - file_sampled_list = [] - - ts_last = ts_and_files[0].timestamp - - file_sampled_list.append(ts_and_files[0]) - - for idx in range(1, len(ts_and_files)): - ts = ts_and_files[idx].timestamp - - if ((ts - ts_last).seconds >= sampling_rate_s): - ts_and_files.append(ts_and_files[idx]) - ts_last = ts - else: - ts_and_files = file_sampled_list - - df = cudf.DataFrame() - - timestamps = [] - full_names = [] - for (ts, file_name) in ts_and_files: - timestamps.append(ts) - full_names.append(file_name) + full_filenames.append(file_object.full_name) - df["ts"] = timestamps - df["key"] = full_names + df = cudf.DataFrame(full_filenames, columns=['files']) message.payload(MessageMeta(df=df)) diff --git a/morpheus/modules/file_batcher.py b/morpheus/modules/file_batcher.py index aa47e4ba88..f8ad595ff6 100644 --- a/morpheus/modules/file_batcher.py +++ b/morpheus/modules/file_batcher.py @@ -13,11 +13,17 @@ # limitations under the License. import logging +import re +from collections import namedtuple +import fsspec +import fsspec.utils import mrc +import pandas as pd from mrc.core import operators as ops from morpheus.messages import MessageControl +from morpheus.utils.file_utils import date_extractor from morpheus.utils.loader_ids import FILE_TO_DF_LOADER from morpheus.utils.module_ids import FILE_BATCHER from morpheus.utils.module_ids import MODULE_NAMESPACE @@ -41,21 +47,73 @@ def file_batcher(builder: mrc.Builder): config = get_module_config(FILE_BATCHER, builder) + TimestampFileObj = namedtuple("TimestampFileObj", ["timestamp", "file_name"]) + + iso_date_regex_pattern = config.get("iso_date_regex_pattern", None) + start_time = config.get("start_time", None) + end_time = config.get("end_time", None) + sampling_rate_s = config.get("sampling_rate_s", None) period = config.get("period", None) + iso_date_regex = re.compile(iso_date_regex_pattern) + def on_data(control_message: MessageControl): # Determine the date of the file, and apply the window filter if we have one # This needs to be in the payload, not a task, because batcher isn't a data loader # TODO(Devin) - #control_message.pop_task("load") + # task = control_message.pop_task("load") + # files = task["files"] # TODO(Devin) data_type = "streaming" if (control_message.has_metadata("data_type")): data_type = control_message.get_metadata("data_type") - payload = control_message.payload() - df = payload.df.to_pandas() + df = control_message.payload().df + files = df.files.to_arrow().to_pylist() + + file_objects: fsspec.core.OpenFiles = fsspec.open_files(files) + + ts_and_files = [] + for file_object in file_objects: + ts = date_extractor(file_object, iso_date_regex) + + # Exclude any files outside the time window + if ((start_time is not None and ts < start_time) or (end_time is not None and ts > end_time)): + continue + + ts_and_files.append(TimestampFileObj(ts, file_object.full_name)) + + # sort the incoming data by date + ts_and_files.sort(key=lambda x: x.timestamp) + + # Create a dataframe with the incoming metadata + if ((len(ts_and_files) > 1) and (sampling_rate_s > 0)): + file_sampled_list = [] + + ts_last = ts_and_files[0].timestamp + + file_sampled_list.append(ts_and_files[0]) + + for idx in range(1, len(ts_and_files)): + ts = ts_and_files[idx].timestamp + + if ((ts - ts_last).seconds >= sampling_rate_s): + ts_and_files.append(ts_and_files[idx]) + ts_last = ts + else: + ts_and_files = file_sampled_list + + df = pd.DataFrame() + + timestamps = [] + full_names = [] + for (ts, file_name) in ts_and_files: + timestamps.append(ts) + full_names.append(file_name) + + df["ts"] = timestamps + df["key"] = full_names # TODO(Devin): Clean this up control_messages = [] @@ -88,7 +146,7 @@ def on_data(control_message: MessageControl): if (data_type == "payload"): control_message.add_task("load", load_task) elif (data_type == "streaming"): - batch_control_message = control_messages.copy() + batch_control_message = control_message.copy() batch_control_message.add_task("load", load_task) control_messages.append(batch_control_message) else: diff --git a/morpheus/stages/general/nonlinear_modules_stage.py b/morpheus/stages/general/multi_port_module_stage.py similarity index 99% rename from morpheus/stages/general/nonlinear_modules_stage.py rename to morpheus/stages/general/multi_port_module_stage.py index 4a690555bc..b03221ea52 100644 --- a/morpheus/stages/general/nonlinear_modules_stage.py +++ b/morpheus/stages/general/multi_port_module_stage.py @@ -25,7 +25,7 @@ logger = logging.getLogger(__name__) -class NonLinearModulesStage(Stage): +class MultiPortModuleStage(Stage): """ Loads an existing, registered, MRC SegmentModule and wraps it as a Morpheus Stage. diff --git a/tests/tests_data/control_messages/control_message_azure.json b/tests/tests_data/control_messages/control_message_azure.json new file mode 100644 index 0000000000..3e3c7848f5 --- /dev/null +++ b/tests/tests_data/control_messages/control_message_azure.json @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:08051b89309ed5b75def555de2c7325423a358d55c517d0945336622d8b01960 +size 637 diff --git a/tests/tests_data/control_messages/control_message_duo.json b/tests/tests_data/control_messages/control_message_duo.json new file mode 100644 index 0000000000..828561ef26 --- /dev/null +++ b/tests/tests_data/control_messages/control_message_duo.json @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1fdff4456c9ed8e85ed7bd823b4901f6028f7f396ede3085acc0d2581515d95d +size 629 From 53c80d8e97af89015c190e2b3febf183844dcf8b Mon Sep 17 00:00:00 2001 From: bsuryadevara Date: Mon, 27 Feb 2023 21:44:48 -0600 Subject: [PATCH 048/157] removed print statement --- .../production/morpheus/dfp/modules/dfp_rolling_window.py | 1 - 1 file changed, 1 deletion(-) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_rolling_window.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_rolling_window.py index f69045aabe..f5977fbfd0 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_rolling_window.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_rolling_window.py @@ -126,7 +126,6 @@ def build_window(message: MessageMeta, user_id: str) -> MessageMeta: "Rolling history can only be used with non-overlapping batches")) # TODO(Devin): Optimize - print("Training finished, returning:", user_id, flush=True) return MessageMeta(cudf.from_pandas(train_df)) def on_data(control_message: MessageControl): From 304b772f982b6fcab7440b085d1a48d3d3f7e68e Mon Sep 17 00:00:00 2001 From: Devin Robison Date: Mon, 27 Feb 2023 20:51:54 -0700 Subject: [PATCH 049/157] Remove spurrious print, tweak some things for testing --- .../dfp/modules/dfp_rolling_window.py | 3 - .../morpheus/dfp/utils/derive_args.py | 12 +- .../morpheus/dfp_modules_pipeline.py | 18 +- .../production/morpheus/test_input.json | 4 +- .../production/morpheus/test_input_infer.json | 324 ++++++++++++++++++ 5 files changed, 342 insertions(+), 19 deletions(-) create mode 100644 examples/digital_fingerprinting/production/morpheus/test_input_infer.json diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_rolling_window.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_rolling_window.py index f69045aabe..748156a9cc 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_rolling_window.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_rolling_window.py @@ -65,8 +65,6 @@ def get_user_cache(user_id: str): # Determine cache location cache_location = os.path.join(cache_dir, f"{user_id}.pkl") - user_cache = None - user_cache = user_cache_map.get(user_id, None) if (user_cache is None): @@ -126,7 +124,6 @@ def build_window(message: MessageMeta, user_id: str) -> MessageMeta: "Rolling history can only be used with non-overlapping batches")) # TODO(Devin): Optimize - print("Training finished, returning:", user_id, flush=True) return MessageMeta(cudf.from_pandas(train_df)) def on_data(control_message: MessageControl): diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/utils/derive_args.py b/examples/digital_fingerprinting/production/morpheus/dfp/utils/derive_args.py index 62bbec3695..a932e460ad 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/utils/derive_args.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/utils/derive_args.py @@ -45,7 +45,7 @@ def __init__(self, duration: str, log_type: str, tracking_uri: str, - workload_type: str = None, + # workload_type: str = None, train_users: str = None): self._skip_users = list(skip_user) @@ -59,7 +59,7 @@ def __init__(self, self._tracking_uri = tracking_uri self._sample_rate_s = sample_rate_s self._log_type = log_type - self._workload_type = workload_type + # self._workload_type = workload_type self._include_generic = None self._include_individual = None @@ -70,10 +70,10 @@ def __init__(self, train_flag = (train_users is not None and train_users) - self._is_training = (train_flag and workload_type != "infer") - self._is_train_and_infer = (train_flag and workload_type == "train_and_infer") - self._is_inference = not (self._is_training or self._is_train_and_infer or workload_type == "train" - or workload_type == "train_and_infer") + self._is_training = True # (train_flag and workload_type != "infer") + self._is_train_and_infer = True # (train_flag and workload_type == "train_and_infer") + self._is_inference = True # not (self._is_training or self._is_train_and_infer or workload_type == "train" + # or workload_type == "train_and_infer") def verify_init(func): diff --git a/examples/digital_fingerprinting/production/morpheus/dfp_modules_pipeline.py b/examples/digital_fingerprinting/production/morpheus/dfp_modules_pipeline.py index 2acf833cf1..dca9a67759 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp_modules_pipeline.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp_modules_pipeline.py @@ -40,12 +40,14 @@ required=True, help=("Indicates what type of logs are going to be used in the workload."), ) -@click.option( - "--workload_type", - type=click.Choice(["infer", "train", "train_and_infer"], case_sensitive=False), - required=True, - help=("Workload type either inference or training or inference + training"), -) + +# TODO(Devin): Shouldn't be needed anymore +#@click.option( +# "--workload_type", +# type=click.Choice(["infer", "train", "train_and_infer"], case_sensitive=False), +# required=True, +# help=("Workload type either inference or training or inference + training"), +#) @click.option( "--train_users", type=click.Choice(["all", "generic", "individual"], case_sensitive=False), @@ -115,7 +117,7 @@ default="http://mlflow:5000", help=("The MLflow tracking URI to connect to the tracking backend.")) def run_pipeline(log_type: str, - workload_type: str, + #workload_type: str, train_users: str, skip_user: typing.Tuple[str], only_user: typing.Tuple[str], @@ -137,7 +139,7 @@ def run_pipeline(log_type: str, duration, log_type, tracking_uri, - workload_type, + #workload_type, train_users) derive_args.init() diff --git a/examples/digital_fingerprinting/production/morpheus/test_input.json b/examples/digital_fingerprinting/production/morpheus/test_input.json index 82203e874d..f1872e5638 100644 --- a/examples/digital_fingerprinting/production/morpheus/test_input.json +++ b/examples/digital_fingerprinting/production/morpheus/test_input.json @@ -5,7 +5,7 @@ { "type": "load", "properties": { - "loader_id": "file_batcher_debugging", + "loader_id": "fsspec", "files": [ "../../../../examples/data/dfp/duo-training-data/*.json" ] @@ -30,7 +30,7 @@ { "type": "load", "properties": { - "loader_id": "file_batcher_debugging", + "loader_id": "fsspec", "files": [ "../../../../examples/data/dfp/duo-inference-data/*.json" ] diff --git a/examples/digital_fingerprinting/production/morpheus/test_input_infer.json b/examples/digital_fingerprinting/production/morpheus/test_input_infer.json new file mode 100644 index 0000000000..bdad3cc235 --- /dev/null +++ b/examples/digital_fingerprinting/production/morpheus/test_input_infer.json @@ -0,0 +1,324 @@ +{ + "inputs": [ + { + "tasks": [ + { + "type": "load", + "properties": { + "loader_id": "fsspec", + "files": [ + "../../../../examples/data/dfp/duo-inference-data/*.json" + ] + } + }, + { + "type": "inference", + "properties": {} + } + ], + "metadata": { + "data_type": "streaming" + } + }, + { + "tasks": [ + { + "type": "load", + "properties": { + "loader_id": "fsspec", + "files": [ + "../../../../examples/data/dfp/duo-inference-data/*.json" + ] + } + }, + { + "type": "inference", + "properties": {} + } + ], + "metadata": { + "data_type": "streaming" + } + }, + { + "tasks": [ + { + "type": "load", + "properties": { + "loader_id": "fsspec", + "files": [ + "../../../../examples/data/dfp/duo-inference-data/*.json" + ] + } + }, + { + "type": "inference", + "properties": {} + } + ], + "metadata": { + "data_type": "streaming" + } + }, + { + "tasks": [ + { + "type": "load", + "properties": { + "loader_id": "fsspec", + "files": [ + "../../../../examples/data/dfp/duo-inference-data/*.json" + ] + } + }, + { + "type": "inference", + "properties": {} + } + ], + "metadata": { + "data_type": "streaming" + } + }, + { + "tasks": [ + { + "type": "load", + "properties": { + "loader_id": "fsspec", + "files": [ + "../../../../examples/data/dfp/duo-inference-data/*.json" + ] + } + }, + { + "type": "inference", + "properties": {} + } + ], + "metadata": { + "data_type": "streaming" + } + }, + { + "tasks": [ + { + "type": "load", + "properties": { + "loader_id": "fsspec", + "files": [ + "../../../../examples/data/dfp/duo-inference-data/*.json" + ] + } + }, + { + "type": "inference", + "properties": {} + } + ], + "metadata": { + "data_type": "streaming" + } + }, + { + "tasks": [ + { + "type": "load", + "properties": { + "loader_id": "fsspec", + "files": [ + "../../../../examples/data/dfp/duo-inference-data/*.json" + ] + } + }, + { + "type": "inference", + "properties": {} + } + ], + "metadata": { + "data_type": "streaming" + } + }, + { + "tasks": [ + { + "type": "load", + "properties": { + "loader_id": "fsspec", + "files": [ + "../../../../examples/data/dfp/duo-inference-data/*.json" + ] + } + }, + { + "type": "inference", + "properties": {} + } + ], + "metadata": { + "data_type": "streaming" + } + }, + { + "tasks": [ + { + "type": "load", + "properties": { + "loader_id": "fsspec", + "files": [ + "../../../../examples/data/dfp/duo-inference-data/*.json" + ] + } + }, + { + "type": "inference", + "properties": {} + } + ], + "metadata": { + "data_type": "streaming" + } + }, + { + "tasks": [ + { + "type": "load", + "properties": { + "loader_id": "fsspec", + "files": [ + "../../../../examples/data/dfp/duo-inference-data/*.json" + ] + } + }, + { + "type": "inference", + "properties": {} + } + ], + "metadata": { + "data_type": "streaming" + } + }, + { + "tasks": [ + { + "type": "load", + "properties": { + "loader_id": "fsspec", + "files": [ + "../../../../examples/data/dfp/duo-inference-data/*.json" + ] + } + }, + { + "type": "inference", + "properties": {} + } + ], + "metadata": { + "data_type": "streaming" + } + }, + { + "tasks": [ + { + "type": "load", + "properties": { + "loader_id": "fsspec", + "files": [ + "../../../../examples/data/dfp/duo-inference-data/*.json" + ] + } + }, + { + "type": "inference", + "properties": {} + } + ], + "metadata": { + "data_type": "streaming" + } + }, + { + "tasks": [ + { + "type": "load", + "properties": { + "loader_id": "fsspec", + "files": [ + "../../../../examples/data/dfp/duo-inference-data/*.json" + ] + } + }, + { + "type": "inference", + "properties": {} + } + ], + "metadata": { + "data_type": "streaming" + } + }, + { + "tasks": [ + { + "type": "load", + "properties": { + "loader_id": "fsspec", + "files": [ + "../../../../examples/data/dfp/duo-inference-data/*.json" + ] + } + }, + { + "type": "inference", + "properties": {} + } + ], + "metadata": { + "data_type": "streaming" + } + }, + { + "tasks": [ + { + "type": "load", + "properties": { + "loader_id": "fsspec", + "files": [ + "../../../../examples/data/dfp/duo-inference-data/*.json" + ] + } + }, + { + "type": "inference", + "properties": {} + } + ], + "metadata": { + "data_type": "streaming" + } + }, + { + "tasks": [ + { + "type": "load", + "properties": { + "loader_id": "fsspec", + "files": [ + "../../../../examples/data/dfp/duo-inference-data/*.json" + ] + } + }, + { + "type": "inference", + "properties": {} + } + ], + "metadata": { + "data_type": "streaming" + } + } + ] +} \ No newline at end of file From 9db7d7ed8d3c2bf448b46568c0ba13c1840271e3 Mon Sep 17 00:00:00 2001 From: Devin Robison Date: Tue, 28 Feb 2023 15:31:08 -0700 Subject: [PATCH 050/157] Update config_generator workflow to reduce redundant parameters --- .../morpheus/dfp/modules/dfp_deployment.py | 8 +- .../morpheus/dfp/modules/dfp_inf.py | 18 ++-- .../dfp/modules/dfp_inference_pipeline.py | 24 +++-- .../morpheus/dfp/modules/dfp_preproc.py | 16 ++- .../morpheus/dfp/modules/dfp_preprocessing.py | 14 ++- .../morpheus/dfp/modules/dfp_tra.py | 12 ++- .../dfp/modules/dfp_training_pipeline.py | 18 ++-- .../morpheus/dfp/utils/config_generator.py | 98 ------------------- .../morpheus/dfp/utils/derive_args.py | 17 +--- .../morpheus/dfp_modules_pipeline.py | 25 +++-- morpheus/utils/module_utils.py | 10 ++ 11 files changed, 94 insertions(+), 166 deletions(-) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_deployment.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_deployment.py index a035a76b1a..3f1431132d 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_deployment.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_deployment.py @@ -24,6 +24,7 @@ from morpheus.utils.module_utils import get_module_config from morpheus.utils.module_utils import load_module from morpheus.utils.module_utils import register_module +from morpheus.utils.module_utils import get_config_with_overrides from ..utils.module_ids import DFP_DEPLOYMENT from ..utils.module_ids import DFP_INF @@ -35,12 +36,11 @@ @register_module(DFP_DEPLOYMENT, MODULE_NAMESPACE) def dfp_deployment(builder: mrc.Builder): - module_config = get_module_config(DFP_DEPLOYMENT, builder) - preproc_conf = module_config.get(DFP_PREPROC, None) - infer_conf = module_config.get(DFP_INF, None) - train_conf = module_config.get(DFP_TRA, None) + preproc_conf = get_config_with_overrides(module_config, DFP_PREPROC, "dfp_preproc") + infer_conf = get_config_with_overrides(module_config, DFP_INF, "dfp_inference") + train_conf = get_config_with_overrides(module_config, DFP_TRA, "dfp_training") if "output_port_count" not in module_config: raise Exception("Missing required attribute 'output_port_count'") diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inf.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inf.py index b912c8cf2a..aa028bf1d1 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inf.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inf.py @@ -28,6 +28,7 @@ from morpheus.utils.module_ids import SERIALIZE from morpheus.utils.module_ids import WRITE_TO_FILE from morpheus.utils.module_utils import get_module_config +from morpheus.utils.module_utils import get_config_with_overrides from morpheus.utils.module_utils import load_module from morpheus.utils.module_utils import register_module @@ -53,14 +54,17 @@ def dfp_inf(builder: mrc.Builder): """ config = get_module_config(DFP_INF, builder) + config["module_id"] = DFP_INF + config["namespace"] = MODULE_NAMESPACE + config["module_name"] = "dfp_inf" - dfp_rolling_window_conf = config.get(DFP_ROLLING_WINDOW, None) - dfp_data_prep_conf = config.get(DFP_DATA_PREP, None) - dfp_inference_conf = config.get(DFP_INFERENCE, None) - filter_detections_conf = config.get(FILTER_DETECTIONS, None) - dfp_post_proc_conf = config.get(DFP_POST_PROCESSING, None) - serialize_conf = config.get(SERIALIZE, None) - write_to_file_conf = config.get(WRITE_TO_FILE, None) + dfp_rolling_window_conf = get_config_with_overrides(config, DFP_ROLLING_WINDOW, "dfp_rolling_window") + dfp_data_prep_conf = get_config_with_overrides(config, DFP_DATA_PREP, "dfp_data_prep") + dfp_inference_conf = get_config_with_overrides(config, DFP_INFERENCE, "dfp_inference") + filter_detections_conf = get_config_with_overrides(config, FILTER_DETECTIONS, "filter_detections") + dfp_post_proc_conf = get_config_with_overrides(config, DFP_POST_PROCESSING, "dfp_postprocessing") + serialize_conf = get_config_with_overrides(config, SERIALIZE, "serialize") + write_to_file_conf = get_config_with_overrides(config, WRITE_TO_FILE, "write_to_file") # Load modules dfp_rolling_window_module = load_module(dfp_rolling_window_conf, builder=builder) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference_pipeline.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference_pipeline.py index a10008aaca..ec63c60c04 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference_pipeline.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference_pipeline.py @@ -32,6 +32,7 @@ from morpheus.utils.module_ids import MODULE_NAMESPACE from morpheus.utils.module_ids import SERIALIZE from morpheus.utils.module_ids import WRITE_TO_FILE +from morpheus.utils.module_utils import get_config_with_overrides from morpheus.utils.module_utils import get_module_config from morpheus.utils.module_utils import load_module from morpheus.utils.module_utils import register_module @@ -59,17 +60,20 @@ def dfp_inference_pipeline(builder: mrc.Builder): """ config = get_module_config(DFP_INFERENCE_PIPELINE, builder) + config["module_id"] = DFP_INFERENCE_PIPELINE + config["namespace"] = MODULE_NAMESPACE + config["module_name"] = "dfp_inference_pipeline" - file_batcher_conf = config.get(FILE_BATCHER, None) - file_to_df_conf = config.get(FILE_TO_DF, None) - dfp_split_users_conf = config.get(DFP_SPLIT_USERS, None) - dfp_rolling_window_conf = config.get(DFP_ROLLING_WINDOW, None) - dfp_data_prep_conf = config.get(DFP_DATA_PREP, None) - dfp_inference_conf = config.get(DFP_INFERENCE, None) - filter_detections_conf = config.get(FILTER_DETECTIONS, None) - dfp_post_proc_conf = config.get(DFP_POST_PROCESSING, None) - serialize_conf = config.get(SERIALIZE, None) - write_to_file_conf = config.get(WRITE_TO_FILE, None) + file_batcher_conf = get_config_with_overrides(config, FILE_BATCHER, "file_batcher") + file_to_df_conf = get_config_with_overrides(config, FILE_TO_DF, "file_to_df_dataloader") + dfp_split_users_conf = get_config_with_overrides(config, DFP_SPLIT_USERS, "dfp_split_users") + dfp_rolling_window_conf = get_config_with_overrides(config, DFP_ROLLING_WINDOW, "dfp_rolling_window_infer") + dfp_data_prep_conf = get_config_with_overrides(config, DFP_DATA_PREP, "dfp_data_prep") + dfp_inference_conf = get_config_with_overrides(config, DFP_INFERENCE, "dfp_inference") + filter_detections_conf = get_config_with_overrides(config, FILTER_DETECTIONS, "filter_detections") + dfp_post_proc_conf = get_config_with_overrides(config, DFP_POST_PROCESSING, "dfp_post_processing") + serialize_conf = get_config_with_overrides(config, SERIALIZE, "serialize") + write_to_file_conf = get_config_with_overrides(config, WRITE_TO_FILE, "write_to_file") # Load modules file_batcher_module = load_module(file_batcher_conf, builder=builder) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_preproc.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_preproc.py index bf391dacb1..2b7b5f1cd0 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_preproc.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_preproc.py @@ -27,6 +27,7 @@ from morpheus.utils.module_ids import DATA_LOADER from morpheus.utils.module_ids import FILE_BATCHER from morpheus.utils.module_ids import MODULE_NAMESPACE +from morpheus.utils.module_utils import get_config_with_overrides from morpheus.utils.module_utils import get_module_config from morpheus.utils.module_utils import load_module from morpheus.utils.module_utils import register_module @@ -50,13 +51,20 @@ def dfp_preproc(builder: mrc.Builder): """ config = get_module_config(DFP_PREPROC, builder) + config["module_id"] = DFP_PREPROC + config["module_name"] = "dfp_preproc" + config["namespace"] = MODULE_NAMESPACE - fsspec_data_loader_conf = config.get(FSSPEC_LOADER, None) - file_batcher_conf = config.get(FILE_BATCHER, None) - file_to_df_data_loader_conf = config.get(FILE_TO_DF_LOADER, None) - dfp_split_users_conf = config.get(DFP_SPLIT_USERS, None) + fsspec_data_loader_conf = get_config_with_overrides(config, FSSPEC_LOADER, "fsspec_dataloader") + fsspec_data_loader_conf["module_id"] = DATA_LOADER # Work around some naming issues. + file_batcher_conf = get_config_with_overrides(config, FILE_BATCHER, "file_batcher") + file_to_df_data_loader_conf = get_config_with_overrides(config, FILE_TO_DF_LOADER, "file_to_df_dataloader") + file_to_df_data_loader_conf["module_id"] = DATA_LOADER # Work around some naming issues. + dfp_split_users_conf = get_config_with_overrides(config, DFP_SPLIT_USERS, "dfp_split_users") # Load modules + import json + print(json.dumps(fsspec_data_loader_conf, indent=4, default=str)) fsspec_data_loader_module = load_module(fsspec_data_loader_conf, builder=builder) file_batcher_module = load_module(file_batcher_conf, builder=builder) file_to_df_data_loader_module = load_module(file_to_df_data_loader_conf, builder=builder) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_preprocessing.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_preprocessing.py index 59de20cb1e..9e6e940dc5 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_preprocessing.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_preprocessing.py @@ -25,6 +25,7 @@ from morpheus.utils.module_ids import FILE_TO_DF from morpheus.utils.module_ids import MODULE_NAMESPACE from morpheus.utils.module_utils import get_module_config +from morpheus.utils.module_utils import get_config_with_overrides from morpheus.utils.module_utils import load_module from morpheus.utils.module_utils import register_module @@ -49,12 +50,15 @@ def dfp_preprocessing(builder: mrc.Builder): """ config = get_module_config(DFP_PREPROCESSING, builder) + config["module_id"] = DFP_PREPROCESSING + config["namespace"] = MODULE_NAMESPACE + config["module_name"] = "dfp_preprocessing" - file_batcher_conf = config.get(FILE_BATCHER, None) - file_to_df_conf = config.get(FILE_TO_DF, None) - dfp_split_users_conf = config.get(DFP_SPLIT_USERS, None) - dfp_rolling_window_conf = config.get(DFP_ROLLING_WINDOW, None) - dfp_data_prep_conf = config.get(DFP_DATA_PREP, None) + file_batcher_conf = get_config_with_overrides(config, FILE_BATCHER, "file_batcher") + file_to_df_conf = get_config_with_overrides(config, FILE_TO_DF, "file_to_df") + dfp_split_users_conf = get_config_with_overrides(config, DFP_SPLIT_USERS, "dfp_split_users") + dfp_rolling_window_conf = get_config_with_overrides(config, DFP_ROLLING_WINDOW, "dfp_rolling_window") + dfp_data_prep_conf = get_config_with_overrides(config, DFP_DATA_PREP, "dfp_data_prep") # Load modules file_batcher_module = load_module(file_batcher_conf, builder=builder) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_tra.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_tra.py index 1648a00f8c..8cbae39d29 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_tra.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_tra.py @@ -25,6 +25,7 @@ from morpheus.utils.module_utils import get_module_config from morpheus.utils.module_utils import load_module from morpheus.utils.module_utils import register_module +from morpheus.utils.module_utils import get_config_with_overrides from ..utils.module_ids import DFP_DATA_PREP from ..utils.module_ids import DFP_ROLLING_WINDOW @@ -47,11 +48,14 @@ def dfp_tra(builder: mrc.Builder): """ config = get_module_config(DFP_TRA, builder) + config["module_id"] = DFP_TRA + config["namespace"] = MODULE_NAMESPACE + config["module_name"] = "dfp_tra" - dfp_rolling_window_conf = config.get(DFP_ROLLING_WINDOW, None) - dfp_data_prep_conf = config.get(DFP_DATA_PREP, None) - dfp_training_conf = config.get(DFP_TRAINING, None) - mlflow_model_writer_conf = config.get(MLFLOW_MODEL_WRITER, None) + dfp_rolling_window_conf = get_config_with_overrides(config, DFP_ROLLING_WINDOW, "dfp_rolling_window") + dfp_data_prep_conf = get_config_with_overrides(config, DFP_DATA_PREP, "dfp_data_prep") + dfp_training_conf = get_config_with_overrides(config, DFP_TRAINING, "dfp_training") + mlflow_model_writer_conf = get_config_with_overrides(config, MLFLOW_MODEL_WRITER, "mlflow_model_writer") # Load modules dfp_rolling_window_module = load_module(dfp_rolling_window_conf, builder=builder) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training_pipeline.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training_pipeline.py index 9c2ed06c76..f29b217ea7 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training_pipeline.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training_pipeline.py @@ -27,6 +27,7 @@ from morpheus.utils.module_ids import FILE_TO_DF from morpheus.utils.module_ids import MLFLOW_MODEL_WRITER from morpheus.utils.module_ids import MODULE_NAMESPACE +from morpheus.utils.module_utils import get_config_with_overrides from morpheus.utils.module_utils import get_module_config from morpheus.utils.module_utils import load_module from morpheus.utils.module_utils import register_module @@ -53,14 +54,17 @@ def dfp_training_pipeline(builder: mrc.Builder): """ config = get_module_config(DFP_TRAINING_PIPELINE, builder) + config["module_id"] = DFP_TRAINING_PIPELINE + config["namespace"] = MODULE_NAMESPACE + config["module_name"] = "dfp_training_pipeline" - file_batcher_conf = config.get(FILE_BATCHER, None) - file_to_df_conf = config.get(FILE_TO_DF, None) - dfp_split_users_conf = config.get(DFP_SPLIT_USERS, None) - dfp_rolling_window_conf = config.get(DFP_ROLLING_WINDOW, None) - dfp_data_prep_conf = config.get(DFP_DATA_PREP, None) - dfp_training_conf = config.get(DFP_TRAINING, None) - mlflow_model_writer_conf = config.get(MLFLOW_MODEL_WRITER, None) + file_batcher_conf = get_config_with_overrides(config, FILE_BATCHER, "file_batcher") + file_to_df_conf = get_config_with_overrides(config, FILE_TO_DF, "file_to_df") + dfp_split_users_conf = get_config_with_overrides(config, DFP_SPLIT_USERS, "dfp_split_users") + dfp_rolling_window_conf = get_config_with_overrides(config, DFP_ROLLING_WINDOW, "dfp_rolling_window") + dfp_data_prep_conf = get_config_with_overrides(config, DFP_DATA_PREP, "dfp_data_prep") + dfp_training_conf = get_config_with_overrides(config, DFP_TRAINING, "dfp_training") + mlflow_model_writer_conf = get_config_with_overrides(config, MLFLOW_MODEL_WRITER, "mlflow_model_writer") # Load modules file_batcher_module = load_module(file_batcher_conf, builder=builder) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py b/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py index 2d08c21924..a7b37ca2bf 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py @@ -52,7 +52,6 @@ class ConfigGenerator: def __init__(self, config: Config, derive_args: DeriveArgs, schema: Schema, encoding: str = "latin1"): - self._config = config self._derive_args = derive_args self._encoding = encoding @@ -61,7 +60,6 @@ def __init__(self, config: Config, derive_args: DeriveArgs, schema: Schema, enco self._input_message_type = pyobj2str(MultiMessage, encoding) def get_module_config(self): - module_config = {} module_config["module_id"] = DFP_DEPLOYMENT @@ -76,23 +74,13 @@ def get_module_config(self): return module_config def preproc_module_config(self): - module_config = { - "module_id": DFP_PREPROC, - "module_name": "dfp_preproc", - "namespace": MODULE_NAMESPACE, FSSPEC_LOADER: { - "module_id": DATA_LOADER, - "module_name": "fsspec_dataloader", - "namespace": MODULE_NAMESPACE, "loaders": [{ "id": FSSPEC_LOADER }] }, FILE_BATCHER: { - "module_id": FILE_BATCHER, - "module_name": "file_batcher", - "namespace": MODULE_NAMESPACE, "period": "D", "sampling_rate_s": self._derive_args.sample_rate_s, "start_time": self._derive_args.time_fields.start_time, @@ -110,17 +98,11 @@ def preproc_module_config(self): } }, FILE_TO_DF_LOADER: { - "module_id": DATA_LOADER, - "module_name": "file_to_df_dataloader", - "namespace": MODULE_NAMESPACE, "loaders": [{ "id": FILE_TO_DF_LOADER }] }, DFP_SPLIT_USERS: { - "module_id": DFP_SPLIT_USERS, - "module_name": "dfp_split_users", - "namespace": MODULE_NAMESPACE, "include_generic": self._derive_args.include_generic, "include_individual": self._derive_args.include_individual, "skip_users": self._derive_args.skip_users, @@ -139,9 +121,6 @@ def infer_module_config(self): "module_name": "dfp_inf", "namespace": MODULE_NAMESPACE, DFP_ROLLING_WINDOW: { - "module_id": DFP_ROLLING_WINDOW, - "module_name": "dfp_rolling_window_infer", - "namespace": MODULE_NAMESPACE, "min_history": 1, "min_increment": 0, "max_history": "1d", @@ -150,9 +129,6 @@ def infer_module_config(self): "task_type": "inference" }, DFP_DATA_PREP: { - "module_id": DFP_DATA_PREP, - "module_name": "dfp_data_prep_infer", - "namespace": MODULE_NAMESPACE, "timestamp_column_name": self._config.ae.timestamp_column_name, "schema": { "schema_str": self._preprocess_schema_str, "encoding": self._encoding @@ -160,17 +136,11 @@ def infer_module_config(self): "task_type": "inference" }, DFP_INFERENCE: { - "module_id": DFP_INFERENCE, - "module_name": "dfp_inference", - "namespace": MODULE_NAMESPACE, "model_name_formatter": self._derive_args.model_name_formatter, "fallback_username": self._config.ae.fallback_username, "timestamp_column_name": self._config.ae.timestamp_column_name }, FILTER_DETECTIONS: { - "module_id": FILTER_DETECTIONS, - "module_name": "filter_detections", - "namespace": MODULE_NAMESPACE, "field_name": "mean_abs_z", "threshold": 2.0, "filter_source": "DATAFRAME", @@ -179,22 +149,13 @@ def infer_module_config(self): } }, DFP_POST_PROCESSING: { - "module_id": DFP_POST_PROCESSING, - "module_name": "dfp_post_processing", - "namespace": MODULE_NAMESPACE, "timestamp_column_name": self._config.ae.timestamp_column_name }, SERIALIZE: { - "module_id": SERIALIZE, - "module_name": "serialize", - "namespace": MODULE_NAMESPACE, "exclude": ['batch_count', 'origin_hash', '_row_hash', '_batch_id'], "use_cpp": CppConfig.get_should_use_cpp() }, WRITE_TO_FILE: { - "module_id": WRITE_TO_FILE, - "module_name": "write_to_file", - "namespace": MODULE_NAMESPACE, "filename": "dfp_detections_{}.csv".format(self._derive_args.log_type), "overwrite": True } @@ -203,7 +164,6 @@ def infer_module_config(self): return module_config def train_module_config(self): - module_config = { "module_id": DFP_TRA, "module_name": "dfp_tra", @@ -272,15 +232,8 @@ def train_module_config(self): return module_config def inf_pipe_module_config(self): - module_config = { - "module_id": DFP_INFERENCE_PIPELINE, - "module_name": "dfp_inference_pipeline", - "namespace": MODULE_NAMESPACE, FILE_BATCHER: { - "module_id": FILE_BATCHER, - "module_name": "file_batcher", - "namespace": MODULE_NAMESPACE, "period": "D", "sampling_rate_s": self._derive_args.sample_rate_s, "start_time": self._derive_args.time_fields.start_time, @@ -288,9 +241,6 @@ def inf_pipe_module_config(self): "iso_date_regex_pattern": iso_date_regex_pattern }, FILE_TO_DF: { - "module_id": FILE_TO_DF, - "module_name": "FILE_TO_DF", - "namespace": MODULE_NAMESPACE, "timestamp_column_name": self._config.ae.timestamp_column_name, "parser_kwargs": { "lines": False, "orient": "records" @@ -303,9 +253,6 @@ def inf_pipe_module_config(self): } }, DFP_SPLIT_USERS: { - "module_id": DFP_SPLIT_USERS, - "module_name": "dfp_split_users", - "namespace": MODULE_NAMESPACE, "include_generic": self._derive_args.include_generic, "include_individual": self._derive_args.include_individual, "skip_users": self._derive_args.skip_users, @@ -315,9 +262,6 @@ def inf_pipe_module_config(self): "fallback_username": self._config.ae.fallback_username }, DFP_ROLLING_WINDOW: { - "module_id": DFP_ROLLING_WINDOW, - "module_name": "dfp_rolling_window", - "namespace": MODULE_NAMESPACE, "min_history": 1, "min_increment": 0, "max_history": "1d", @@ -325,26 +269,17 @@ def inf_pipe_module_config(self): "timestamp_column_name": self._config.ae.timestamp_column_name }, DFP_DATA_PREP: { - "module_id": DFP_DATA_PREP, - "module_name": "dfp_data_prep", - "namespace": MODULE_NAMESPACE, "timestamp_column_name": self._config.ae.timestamp_column_name, "schema": { "schema_str": self._preprocess_schema_str, "encoding": self._encoding } }, DFP_INFERENCE: { - "module_id": DFP_INFERENCE, - "module_name": "dfp_inference", - "namespace": MODULE_NAMESPACE, "model_name_formatter": self._derive_args.model_name_formatter, "fallback_username": self._config.ae.fallback_username, "timestamp_column_name": self._config.ae.timestamp_column_name }, FILTER_DETECTIONS: { - "module_id": FILTER_DETECTIONS, - "module_name": "filter_detections", - "namespace": MODULE_NAMESPACE, "field_name": "mean_abs_z", "threshold": 1.0, "filter_source": "DATAFRAME", @@ -353,21 +288,12 @@ def inf_pipe_module_config(self): } }, DFP_POST_PROCESSING: { - "module_id": DFP_POST_PROCESSING, - "module_name": "dfp_post_processing", - "namespace": MODULE_NAMESPACE, "timestamp_column_name": self._config.ae.timestamp_column_name }, SERIALIZE: { - "module_id": SERIALIZE, - "module_name": "serialize", - "namespace": MODULE_NAMESPACE, "exclude": ['batch_count', 'origin_hash', '_row_hash', '_batch_id'] }, WRITE_TO_FILE: { - "module_id": WRITE_TO_FILE, - "module_name": "write_to_file", - "namespace": MODULE_NAMESPACE, "filename": "dfp_detections_{}.csv".format(self._derive_args.log_type), "overwrite": True } @@ -377,13 +303,7 @@ def inf_pipe_module_config(self): def tra_pipe_module_config(self): module_config = { - "module_id": DFP_TRAINING_PIPELINE, - "module_name": "dfp_training_pipeline", - "namespace": MODULE_NAMESPACE, FILE_BATCHER: { - "module_id": FILE_BATCHER, - "module_name": "file_batcher", - "namespace": MODULE_NAMESPACE, "period": "D", "sampling_rate_s": self._derive_args.sample_rate_s, "start_time": self._derive_args.time_fields.start_time, @@ -391,9 +311,6 @@ def tra_pipe_module_config(self): "iso_date_regex_pattern": iso_date_regex_pattern }, FILE_TO_DF: { - "module_id": FILE_TO_DF, - "module_name": "FILE_TO_DF", - "namespace": MODULE_NAMESPACE, "timestamp_column_name": self._config.ae.timestamp_column_name, "parser_kwargs": { "lines": False, "orient": "records" @@ -406,9 +323,6 @@ def tra_pipe_module_config(self): } }, DFP_SPLIT_USERS: { - "module_id": DFP_SPLIT_USERS, - "module_name": "dfp_split_users", - "namespace": MODULE_NAMESPACE, "include_generic": self._derive_args.include_generic, "include_individual": self._derive_args.include_individual, "skip_users": self._derive_args.skip_users, @@ -418,9 +332,6 @@ def tra_pipe_module_config(self): "fallback_username": self._config.ae.fallback_username }, DFP_ROLLING_WINDOW: { - "module_id": DFP_ROLLING_WINDOW, - "module_name": "dfp_rolling_window", - "namespace": MODULE_NAMESPACE, "min_history": 300, "min_increment": 300, "max_history": self._derive_args.duration, @@ -428,18 +339,12 @@ def tra_pipe_module_config(self): "timestamp_column_name": self._config.ae.timestamp_column_name }, DFP_DATA_PREP: { - "module_id": DFP_DATA_PREP, - "module_name": "dfp_data_prep", - "namespace": MODULE_NAMESPACE, "timestamp_column_name": self._config.ae.timestamp_column_name, "schema": { "schema_str": self._preprocess_schema_str, "encoding": self._encoding } }, DFP_TRAINING: { - "module_id": DFP_TRAINING, - "module_name": "dfp_training", - "namespace": MODULE_NAMESPACE, "model_kwargs": { "encoder_layers": [512, 500], # layers of the encoding part "decoder_layers": [512], # layers of the decoding part @@ -460,9 +365,6 @@ def tra_pipe_module_config(self): "validation_size": 0.10 }, MLFLOW_MODEL_WRITER: { - "module_id": MLFLOW_MODEL_WRITER, - "module_name": "mlflow_model_writer", - "namespace": MODULE_NAMESPACE, "model_name_formatter": self._derive_args.model_name_formatter, "experiment_name_formatter": self._derive_args.experiment_name_formatter, "timestamp_column_name": self._config.ae.timestamp_column_name, diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/utils/derive_args.py b/examples/digital_fingerprinting/production/morpheus/dfp/utils/derive_args.py index a932e460ad..6093fb6a04 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/utils/derive_args.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/utils/derive_args.py @@ -45,7 +45,6 @@ def __init__(self, duration: str, log_type: str, tracking_uri: str, - # workload_type: str = None, train_users: str = None): self._skip_users = list(skip_user) @@ -59,7 +58,6 @@ def __init__(self, self._tracking_uri = tracking_uri self._sample_rate_s = sample_rate_s self._log_type = log_type - # self._workload_type = workload_type self._include_generic = None self._include_individual = None @@ -68,12 +66,9 @@ def __init__(self, self._model_name_formatter = "DFP-%s-{user_id}" % (log_type) self._experiment_name_formatter = "dfp/%s/training/{reg_model_name}" % (log_type) - train_flag = (train_users is not None and train_users) - - self._is_training = True # (train_flag and workload_type != "infer") - self._is_train_and_infer = True # (train_flag and workload_type == "train_and_infer") - self._is_inference = True # not (self._is_training or self._is_train_and_infer or workload_type == "train" - # or workload_type == "train_and_infer") + self._is_training = True + self._is_train_and_infer = True + self._is_inference = True def verify_init(func): @@ -171,7 +166,7 @@ def _create_time_fields(self, duration) -> TimeFields: def _set_mlflow_tracking_uri(self): if self._tracking_uri is None: - raise ValueError("tracking uri should not be None type.") + raise ValueError("tracking uri cannot be None.") # Initialize ML Flow mlflow.set_tracking_uri(self._tracking_uri) logger.info("Tracking URI: %s", mlflow.get_tracking_uri()) @@ -191,10 +186,6 @@ def init(self): self._set_mlflow_tracking_uri() self._initialized = True - if (len(self._only_users) > 0 and len(self._only_users) > 0): - logging.error("Option --skip_user and --only_user are mutually exclusive. Exiting") - - logger.info("Running training pipeline with the following options: ") logger.info("Train generic_user: %s", self._include_generic) logger.info("Skipping users: %s", self._skip_users) logger.info("Start Time: %s", self._start_time) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp_modules_pipeline.py b/examples/digital_fingerprinting/production/morpheus/dfp_modules_pipeline.py index dca9a67759..d2eb7efbf0 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp_modules_pipeline.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp_modules_pipeline.py @@ -40,14 +40,6 @@ required=True, help=("Indicates what type of logs are going to be used in the workload."), ) - -# TODO(Devin): Shouldn't be needed anymore -#@click.option( -# "--workload_type", -# type=click.Choice(["infer", "train", "train_and_infer"], case_sensitive=False), -# required=True, -# help=("Workload type either inference or training or inference + training"), -#) @click.option( "--train_users", type=click.Choice(["all", "generic", "individual"], case_sensitive=False), @@ -117,7 +109,6 @@ default="http://mlflow:5000", help=("The MLflow tracking URI to connect to the tracking backend.")) def run_pipeline(log_type: str, - #workload_type: str, train_users: str, skip_user: typing.Tuple[str], only_user: typing.Tuple[str], @@ -129,8 +120,10 @@ def run_pipeline(log_type: str, tracking_uri, use_cpp, **kwargs): + if (skip_user and only_user): + logging.error("Option --skip_user and --only_user are mutually exclusive. Exiting") - derive_args = DeriveArgs(skip_user, + derived_args = DeriveArgs(skip_user, only_user, start_time, log_level, @@ -139,20 +132,24 @@ def run_pipeline(log_type: str, duration, log_type, tracking_uri, - #workload_type, train_users) - derive_args.init() + derived_args.init() + # Default user_id column -- override with ControlMessage userid_column_name = "username" + # Default timestamp column -- override with ControlMessage timestamp_column_name = "timestamp" config: Config = generate_ae_config(log_type, userid_column_name, timestamp_column_name, use_cpp=use_cpp) + # Construct the data frame Schema used to normalize incoming data schema_builder = SchemaBuilder(config, log_type) schema: Schema = schema_builder.build_schema() - config_generator = ConfigGenerator(config, derive_args, schema) + # Create config helper used to generate config parameters for the DFP module + # This will populate to the minimum configuration parameters with intelligent default values + config_generator = ConfigGenerator(config, derived_args, schema) module_config = config_generator.get_module_config() @@ -173,7 +170,7 @@ def run_pipeline(log_type: str, train_moniter_stage = pipeline.add_stage( MonitorStage(config, description="DFP Training Pipeline rate", smoothing=0.001)) - + infer_moniter_stage = pipeline.add_stage( MonitorStage(config, description="DFP Inference Pipeline rate", smoothing=0.001)) diff --git a/morpheus/utils/module_utils.py b/morpheus/utils/module_utils.py index 4324768d85..d74bebc17a 100644 --- a/morpheus/utils/module_utils.py +++ b/morpheus/utils/module_utils.py @@ -134,6 +134,16 @@ def verify_module_meta_fields(config: typing.Dict): raise KeyError("Required attribute 'module_name' is missing in the module configuration.") +def get_config_with_overrides(config, module_id, module_name, module_namespace="morpheus"): + sub_config = config.get(module_id, None) + + sub_config.setdefault("module_id", module_id) + sub_config.setdefault("module_name", module_name) + sub_config.setdefault("namespace", module_namespace) + + return sub_config + + def get_module_config(module_id: str, builder: mrc.Builder): """ Returns the module configuration for the specified module id. From 3181adb3001b455d52389b0170f9794c10cfde3e Mon Sep 17 00:00:00 2001 From: Devin Robison Date: Tue, 28 Feb 2023 15:34:21 -0700 Subject: [PATCH 051/157] More config parameter pruning --- .../morpheus/dfp/utils/config_generator.py | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py b/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py index a7b37ca2bf..ff89d8a162 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py @@ -165,13 +165,7 @@ def infer_module_config(self): def train_module_config(self): module_config = { - "module_id": DFP_TRA, - "module_name": "dfp_tra", - "namespace": MODULE_NAMESPACE, DFP_ROLLING_WINDOW: { - "module_id": DFP_ROLLING_WINDOW, - "module_name": "dfp_rolling_window_tra", - "namespace": MODULE_NAMESPACE, "min_history": 300, "min_increment": 300, "max_history": self._derive_args.duration, @@ -180,9 +174,6 @@ def train_module_config(self): "task_type": "training" }, DFP_DATA_PREP: { - "module_id": DFP_DATA_PREP, - "module_name": "dfp_data_prep_tra", - "namespace": MODULE_NAMESPACE, "timestamp_column_name": self._config.ae.timestamp_column_name, "schema": { "schema_str": self._preprocess_schema_str, "encoding": self._encoding @@ -190,9 +181,6 @@ def train_module_config(self): "task_type": "training" }, DFP_TRAINING: { - "module_id": DFP_TRAINING, - "module_name": "dfp_training", - "namespace": MODULE_NAMESPACE, "model_kwargs": { "encoder_layers": [512, 500], # layers of the encoding part "decoder_layers": [512], # layers of the decoding part @@ -213,9 +201,6 @@ def train_module_config(self): "validation_size": 0.10 }, MLFLOW_MODEL_WRITER: { - "module_id": MLFLOW_MODEL_WRITER, - "module_name": "mlflow_model_writer", - "namespace": MODULE_NAMESPACE, "model_name_formatter": self._derive_args.model_name_formatter, "experiment_name_formatter": self._derive_args.experiment_name_formatter, "timestamp_column_name": self._config.ae.timestamp_column_name, From 3f1535e21b88354888712e2341a2127b547a33e3 Mon Sep 17 00:00:00 2001 From: Devin Robison Date: Tue, 28 Feb 2023 15:38:38 -0700 Subject: [PATCH 052/157] Remove debug code --- .../production/morpheus/dfp/modules/dfp_preproc.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_preproc.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_preproc.py index 2b7b5f1cd0..81f3329431 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_preproc.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_preproc.py @@ -63,8 +63,6 @@ def dfp_preproc(builder: mrc.Builder): dfp_split_users_conf = get_config_with_overrides(config, DFP_SPLIT_USERS, "dfp_split_users") # Load modules - import json - print(json.dumps(fsspec_data_loader_conf, indent=4, default=str)) fsspec_data_loader_module = load_module(fsspec_data_loader_conf, builder=builder) file_batcher_module = load_module(file_batcher_conf, builder=builder) file_to_df_data_loader_module = load_module(file_to_df_data_loader_conf, builder=builder) From 6246fda43cb49dfc6b3d287c3852cb10fa7e2953 Mon Sep 17 00:00:00 2001 From: Devin Robison Date: Tue, 28 Feb 2023 15:55:10 -0700 Subject: [PATCH 053/157] Cleanup file_batcher module --- morpheus/modules/file_batcher.py | 39 ++++++++++++++------------------ 1 file changed, 17 insertions(+), 22 deletions(-) diff --git a/morpheus/modules/file_batcher.py b/morpheus/modules/file_batcher.py index f8ad595ff6..1576d0b6c6 100644 --- a/morpheus/modules/file_batcher.py +++ b/morpheus/modules/file_batcher.py @@ -57,21 +57,7 @@ def file_batcher(builder: mrc.Builder): iso_date_regex = re.compile(iso_date_regex_pattern) - def on_data(control_message: MessageControl): - # Determine the date of the file, and apply the window filter if we have one - # This needs to be in the payload, not a task, because batcher isn't a data loader - # TODO(Devin) - # task = control_message.pop_task("load") - # files = task["files"] - - # TODO(Devin) - data_type = "streaming" - if (control_message.has_metadata("data_type")): - data_type = control_message.get_metadata("data_type") - - df = control_message.payload().df - files = df.files.to_arrow().to_pylist() - + def build_fs_filename_df(files): file_objects: fsspec.core.OpenFiles = fsspec.open_files(files) ts_and_files = [] @@ -87,7 +73,6 @@ def on_data(control_message: MessageControl): # sort the incoming data by date ts_and_files.sort(key=lambda x: x.timestamp) - # Create a dataframe with the incoming metadata if ((len(ts_and_files) > 1) and (sampling_rate_s > 0)): file_sampled_list = [] @@ -104,26 +89,36 @@ def on_data(control_message: MessageControl): else: ts_and_files = file_sampled_list - df = pd.DataFrame() - timestamps = [] full_names = [] for (ts, file_name) in ts_and_files: timestamps.append(ts) full_names.append(file_name) + df = pd.DataFrame() df["ts"] = timestamps df["key"] = full_names + return ts_and_files + + def on_data(control_message: MessageControl): + data_type = "streaming" + if (control_message.has_metadata("data_type")): + data_type = control_message.get_metadata("data_type") + + df = control_message.payload().df + files = df.files.to_arrow().to_pylist() + ts_filenames_df = build_fs_filename_df(files) + # TODO(Devin): Clean this up control_messages = [] - if len(df) > 0: + if len(ts_filenames_df) > 0: # Now split by the batching settings - df_period = df["ts"].dt.to_period(period) - period_gb = df.groupby(df_period) + df_period = ts_filenames_df["ts"].dt.to_period(period) + period_gb = ts_filenames_df.groupby(df_period) n_groups = len(period_gb) - logger.debug("Batching %d files => %d groups", len(df), n_groups) + logger.debug("Batching %d files => %d groups", len(ts_filenames_df), n_groups) for group in period_gb.groups: period_df = period_gb.get_group(group) filenames = period_df["key"].to_list() From a39de93d3897c5988dbf158aef392dbf56a6d3dc Mon Sep 17 00:00:00 2001 From: Devin Robison Date: Tue, 28 Feb 2023 15:56:57 -0700 Subject: [PATCH 054/157] Cleanup file_batcher module --- morpheus/modules/file_batcher.py | 75 +++++++++++++++++--------------- 1 file changed, 40 insertions(+), 35 deletions(-) diff --git a/morpheus/modules/file_batcher.py b/morpheus/modules/file_batcher.py index 1576d0b6c6..b6eef71b1e 100644 --- a/morpheus/modules/file_batcher.py +++ b/morpheus/modules/file_batcher.py @@ -99,18 +99,50 @@ def build_fs_filename_df(files): df["ts"] = timestamps df["key"] = full_names - return ts_and_files + return df - def on_data(control_message: MessageControl): - data_type = "streaming" - if (control_message.has_metadata("data_type")): - data_type = control_message.get_metadata("data_type") + def generate_cms_for_batch_periods(control_message: MessageControl, period_gb, n_groups): + data_type = control_message.get_metadata("data_type") + + control_messages = [] + for group in period_gb.groups: + period_df = period_gb.get_group(group) + filenames = period_df["key"].to_list() + + load_task = { + "loader_id": FILE_TO_DF_LOADER, + "strategy": "aggregate", + "files": filenames, + "n_groups": n_groups, + "batcher_config": { # TODO(Devin): remove this + "timestamp_column_name": config.get("timestamp_column_name"), + "schema": config.get("schema"), + "file_type": config.get("file_type"), + "filter_null": config.get("filter_null"), + "parser_kwargs": config.get("parser_kwargs"), + "cache_dir": config.get("cache_dir") + } + } + + if (data_type == "payload"): + control_message.add_task("load", load_task) + elif (data_type == "streaming"): + batch_control_message = control_message.copy() + batch_control_message.add_task("load", load_task) + control_messages.append(batch_control_message) + else: + raise Exception("Unknown data type") + + if (data_type == "payload"): + control_messages.append(control_message) + return control_messages + + def on_data(control_message: MessageControl): df = control_message.payload().df files = df.files.to_arrow().to_pylist() ts_filenames_df = build_fs_filename_df(files) - # TODO(Devin): Clean this up control_messages = [] if len(ts_filenames_df) > 0: # Now split by the batching settings @@ -119,36 +151,9 @@ def on_data(control_message: MessageControl): n_groups = len(period_gb) logger.debug("Batching %d files => %d groups", len(ts_filenames_df), n_groups) - for group in period_gb.groups: - period_df = period_gb.get_group(group) - filenames = period_df["key"].to_list() - - load_task = { - "loader_id": FILE_TO_DF_LOADER, - "strategy": "aggregate", - "files": filenames, - "n_groups": n_groups, - "batcher_config": { # TODO(Devin): remove this - "timestamp_column_name": config.get("timestamp_column_name"), - "schema": config.get("schema"), - "file_type": config.get("file_type"), - "filter_null": config.get("filter_null"), - "parser_kwargs": config.get("parser_kwargs"), - "cache_dir": config.get("cache_dir") - } - } - if (data_type == "payload"): - control_message.add_task("load", load_task) - elif (data_type == "streaming"): - batch_control_message = control_message.copy() - batch_control_message.add_task("load", load_task) - control_messages.append(batch_control_message) - else: - raise Exception("Unknown data type") - - if (data_type == "payload"): - control_messages.append(control_message) + control_messages = generate_cms_for_batch_periods(control_message, + period_gb, n_groups) return control_messages From 29eecf35d28ae06cadc1544bd8b6d0d2fbdf3b87 Mon Sep 17 00:00:00 2001 From: bsuryadevara Date: Tue, 28 Feb 2023 17:10:56 -0600 Subject: [PATCH 055/157] fix to dfp benchmarks --- .../production/morpheus/benchmarks/README.md | 24 +- .../morpheus/benchmarks/conftest.py | 30 ++ .../morpheus/benchmarks/dfp_config.py | 50 ++-- .../resource/control_message_azure.json | 46 ++++ .../control_message_azure_inference.json | 25 ++ .../control_message_azure_training.json | 25 ++ .../resource/control_message_duo.json | 46 ++++ .../control_message_duo_inference.json | 25 ++ .../control_message_duo_training.json | 25 ++ .../benchmarks/resource/modules_conf.json | 257 ++++++++++++------ .../benchmarks/resource/pipelines_conf.json | 40 ++- .../benchmarks/test_bench_e2e_dfp_pipeline.py | 256 ++++++++++++----- morpheus/utils/module_ids.py | 2 +- 13 files changed, 663 insertions(+), 188 deletions(-) create mode 100644 examples/digital_fingerprinting/production/morpheus/benchmarks/resource/control_message_azure.json create mode 100644 examples/digital_fingerprinting/production/morpheus/benchmarks/resource/control_message_azure_inference.json create mode 100644 examples/digital_fingerprinting/production/morpheus/benchmarks/resource/control_message_azure_training.json create mode 100644 examples/digital_fingerprinting/production/morpheus/benchmarks/resource/control_message_duo.json create mode 100644 examples/digital_fingerprinting/production/morpheus/benchmarks/resource/control_message_duo_inference.json create mode 100644 examples/digital_fingerprinting/production/morpheus/benchmarks/resource/control_message_duo_training.json diff --git a/examples/digital_fingerprinting/production/morpheus/benchmarks/README.md b/examples/digital_fingerprinting/production/morpheus/benchmarks/README.md index 6ce0770d6b..0d755782c2 100644 --- a/examples/digital_fingerprinting/production/morpheus/benchmarks/README.md +++ b/examples/digital_fingerprinting/production/morpheus/benchmarks/README.md @@ -35,13 +35,13 @@ To provide your own calibration or use other `pytest-benchmark` features with th Morpheus pipeline configurations for each workflow are managed using [pipelines_conf.json](./resource/pipelines_conf.json). For example, this is the Morpheus configuration for `duo_training_modules`: ``` -"test_dfp_training_duo_modules_e2e": { - "file_path": "../../../../data/dfp/duo-training-data/*.json", - "num_threads": 8, - "pipeline_batch_size": 1024, - "edge_buffer_size": 4, - "start_time": "2022-08-01", - "duration": "60d" +"test_dfp_modules_azure_training_e2e": { + "message_path": "./resource/control_message_azure_training.json", + "num_threads": 12, + "pipeline_batch_size": 256, + "edge_buffer_size": 128, + "start_time": "2022-08-01", + "duration": "60d" }, ... ``` @@ -61,14 +61,18 @@ The `--benchmark-warmup` and `--benchmark-warmup-iterations` options are used to `` is the name of the test to run benchmarks on. This can be one of the following: - `test_dfp_inference_azure_stages_e2e` - `test_dfp_inference_duo_stages_e2e` -- `test_dfp_training_azure_modules_e2e` - `test_dfp_training_azure_stages_e2e` -- `test_dfp_training_duo_modules_e2e` - `test_dfp_training_duo_stages_e2e` +- `test_dfp_modules_duo_training_e2e` +- `test_dfp_modules_azure_training_e2e` +- `test_dfp_modules_duo_inference_e2e` +- `test_dfp_modules_azure_inference_e2e` +- `test_dfp_modules_duo_e2e` +- `test_dfp_modules_azure_e2e` For example, to run E2E benchmarks on the DFP training (modules) workflow on the duo logs: ``` -pytest -s --benchmark-enable --benchmark-warmup=on --benchmark-warmup-iterations=1 --benchmark-autosave test_bench_e2e_dfp_pipeline.py::test_dfp_training_duo_modules_e2e +pytest -s --benchmark-enable --benchmark-warmup=on --benchmark-warmup-iterations=1 --benchmark-autosave test_bench_e2e_dfp_pipeline.py::test_dfp_modules_duo_training_e2e ``` To run E2E benchmarks on all workflows: diff --git a/examples/digital_fingerprinting/production/morpheus/benchmarks/conftest.py b/examples/digital_fingerprinting/production/morpheus/benchmarks/conftest.py index e082b0127c..23b069e9a6 100644 --- a/examples/digital_fingerprinting/production/morpheus/benchmarks/conftest.py +++ b/examples/digital_fingerprinting/production/morpheus/benchmarks/conftest.py @@ -61,6 +61,36 @@ def pytest_benchmark_update_json(config, benchmarks, output_json): for fn in glob.glob(source_files_glob): line_count += get_json_lines_count(fn) byte_count += path.getsize(fn) + elif "message_path" in PIPELINES_CONF[bench["name"]]: + source_message_glob = path.join(curr_dir, PIPELINES_CONF[bench["name"]]["message_path"]) + for message_fn in glob.glob(source_message_glob): + message_file = open(message_fn) + control_message = json.load(message_file) + inputs = control_message.get("inputs") + input_data = {} + # Iterating over inputs array + for input in inputs: + line_count_per_task = 0 + byte_count_per_task = 0 + tasks = input.get("tasks") + # Iterating tasks inputs array + for task in tasks: + if task.get("type") == "load": + files = task.get("properties").get("files") + for file_glob in files: + for fn in glob.glob(file_glob): + count = get_json_lines_count(fn) + size = path.getsize(fn) + line_count += count + byte_count += size + line_count_per_task += count + byte_count_per_task += size + else: + non_load_task = task.get("type") + bench['stats'][non_load_task] = {} + bench['stats'][non_load_task]["input_lines"] = line_count_per_task + bench['stats'][non_load_task]["input_bytes"] = byte_count_per_task + else: raise KeyError("Configuration requires either 'glob_path' or 'file_path' attribute.") diff --git a/examples/digital_fingerprinting/production/morpheus/benchmarks/dfp_config.py b/examples/digital_fingerprinting/production/morpheus/benchmarks/dfp_config.py index bac08ba44c..0ddac97ef1 100644 --- a/examples/digital_fingerprinting/production/morpheus/benchmarks/dfp_config.py +++ b/examples/digital_fingerprinting/production/morpheus/benchmarks/dfp_config.py @@ -26,10 +26,12 @@ import mlflow import pandas as pd +from dfp.utils.derive_args import pyobj2str from morpheus.config import Config from morpheus.config import ConfigAutoEncoder from morpheus.config import CppConfig +from morpheus.messages.multi_message import MultiMessage from morpheus.utils.column_info import BoolColumn from morpheus.utils.column_info import ColumnInfo from morpheus.utils.column_info import CustomColumn @@ -197,10 +199,10 @@ def feature_columns(self): def source(self): return self._source - def get_config(self) -> Config: + def get_config(self, use_cpp=True) -> Config: config = Config() - CppConfig.set_should_use_cpp(False) + CppConfig.set_should_use_cpp(use_cpp) config.ae = ConfigAutoEncoder() config.num_threads = self.pipeline_conf["num_threads"] @@ -238,29 +240,39 @@ def _get_start_stop_time(self) -> typing.Tuple[datetime, datetime]: return tuple((start_time, end_time)) def update_modules_conf(self, source_schema: DataFrameInputSchema, preprocess_schema: DataFrameInputSchema): - - start_stop_time = self._get_start_stop_time() - self.modules_conf["preprocessing"]["FileBatcher"]["start_time"] = start_stop_time[0] - self.modules_conf["preprocessing"]["FileBatcher"]["end_time"] = start_stop_time[0] - self.modules_conf["preprocessing"]["DFPRollingWindow"]["max_history"] = self.pipeline_conf["duration"] - encoding = "latin1" # Convert schema as a string source_schema_str = str(pickle.dumps(source_schema), encoding=encoding) preprocess_schema_str = str(pickle.dumps(preprocess_schema), encoding=encoding) - self.modules_conf["preprocessing"]["FileToDF"]["schema"]["schema_str"] = source_schema_str - self.modules_conf["preprocessing"]["FileToDF"]["schema"]["encoding"] = encoding - self.modules_conf["preprocessing"]["DFPDataPrep"]["schema"]["schema_str"] = preprocess_schema_str - self.modules_conf["preprocessing"]["DFPDataPrep"]["schema"]["encoding"] = encoding - self.modules_conf["train_deploy"]["DFPTraining"]["feature_columns"] = self.feature_columns - - self.modules_conf["train_deploy"]["MLFlowModelWriter"]["model_name_formatter"] = self._get_model_name_formatter( - ) - self.modules_conf["train_deploy"]["MLFlowModelWriter"][ + start_stop_time = self._get_start_stop_time() + self.modules_conf["DFPPreproc"]["FileBatcher"]["start_time"] = start_stop_time[0] + self.modules_conf["DFPPreproc"]["FileBatcher"]["end_time"] = start_stop_time[0] + self.modules_conf["DFPPreproc"]["FileBatcher"]["schema"]["schema_str"] = source_schema_str + self.modules_conf["DFPPreproc"]["FileBatcher"]["schema"]["encoding"] = encoding + + self.modules_conf["DFPTra"]["DFPDataPrep"]["schema"]["schema_str"] = preprocess_schema_str + self.modules_conf["DFPTra"]["DFPDataPrep"]["schema"]["encoding"] = encoding + + self.modules_conf["DFPTra"]["DFPRollingWindow"]["max_history"] = self.pipeline_conf["duration"] + self.modules_conf["DFPTra"]["DFPTraining"]["feature_columns"] = self.feature_columns + self.modules_conf["DFPTra"]["MLFlowModelWriter"]["model_name_formatter"] = self._get_model_name_formatter() + self.modules_conf["DFPTra"]["MLFlowModelWriter"][ "experiment_name_formatter"] = self._get_experiment_name_formatter() + self.modules_conf["DFPInf"]["DFPRollingWindow"]["max_history"] = "1d" + self.modules_conf["DFPInf"]["DFPDataPrep"]["schema"]["schema_str"] = preprocess_schema_str + self.modules_conf["DFPInf"]["DFPDataPrep"]["schema"]["encoding"] = encoding + self.modules_conf["DFPInf"]["DFPInference"]["model_name_formatter"] = self._get_model_name_formatter() + self.modules_conf["DFPInf"]["FilterDetections"]["schema"]["input_message_type"] = pyobj2str( + MultiMessage, encoding) + self.modules_conf["DFPInf"]["FilterDetections"]["schema"]["encoding"] = encoding + self.modules_conf["DFPInf"]["Serialize"]["use_cpp"] = True + self.modules_conf["DFPInf"]["WriteToFile"]["filename"] = "dfp_detections_{}.csv".format(self._source) + + self.modules_conf["output_port_count"] = 2 + def get_stages_conf(self) -> typing.Dict[str, any]: stages_conf = {} @@ -289,6 +301,10 @@ def get_filenames(self) -> typing.List[str]: file_path = self.pipeline_conf.get("file_path") full_file_path = path.join(THIS_DIR, file_path) filenames = [full_file_path] + elif "message_path" in self.pipeline_conf: + file_path = self.pipeline_conf.get("message_path") + full_file_path = path.join(THIS_DIR, file_path) + filenames = [full_file_path] else: raise KeyError("Configuration needs the glob path or file path attribute.") diff --git a/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/control_message_azure.json b/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/control_message_azure.json new file mode 100644 index 0000000000..4c6da153c1 --- /dev/null +++ b/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/control_message_azure.json @@ -0,0 +1,46 @@ +{ + "inputs": [ + { + "tasks": [ + { + "type": "load", + "properties": { + "loader_id": "fsspec", + "files": [ + "../../../../../examples/data/dfp/azure-training-data/*.json" + ] + } + }, + { + "type": "training", + "properties": { + } + } + ], + "metadata": { + "data_type": "payload" + } + }, + { + "tasks": [ + { + "type": "load", + "properties": { + "loader_id": "fsspec", + "files": [ + "../../../../../examples/data/dfp/azure-inference-data/*.json" + ] + } + }, + { + "type": "inference", + "properties": { + } + } + ], + "metadata": { + "data_type": "payload" + } + } + ] + } diff --git a/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/control_message_azure_inference.json b/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/control_message_azure_inference.json new file mode 100644 index 0000000000..1a2048e37d --- /dev/null +++ b/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/control_message_azure_inference.json @@ -0,0 +1,25 @@ +{ + "inputs": [ + { + "tasks": [ + { + "type": "load", + "properties": { + "loader_id": "fsspec", + "files": [ + "../../../../../examples/data/dfp/azure-inference-data/*.json" + ] + } + }, + { + "type": "inference", + "properties": { + } + } + ], + "metadata": { + "data_type": "payload" + } + } + ] + } diff --git a/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/control_message_azure_training.json b/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/control_message_azure_training.json new file mode 100644 index 0000000000..ad682ffbea --- /dev/null +++ b/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/control_message_azure_training.json @@ -0,0 +1,25 @@ +{ + "inputs": [ + { + "tasks": [ + { + "type": "load", + "properties": { + "loader_id": "fsspec", + "files": [ + "../../../../../examples/data/dfp/azure-training-data/*.json" + ] + } + }, + { + "type": "training", + "properties": { + } + } + ], + "metadata": { + "data_type": "payload" + } + } + ] + } diff --git a/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/control_message_duo.json b/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/control_message_duo.json new file mode 100644 index 0000000000..17bcc99b1e --- /dev/null +++ b/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/control_message_duo.json @@ -0,0 +1,46 @@ +{ + "inputs": [ + { + "tasks": [ + { + "type": "load", + "properties": { + "loader_id": "fsspec", + "files": [ + "../../../../../examples/data/dfp/duo-training-data/*.json" + ] + } + }, + { + "type": "training", + "properties": { + } + } + ], + "metadata": { + "data_type": "streaming" + } + }, + { + "tasks": [ + { + "type": "load", + "properties": { + "loader_id": "fsspec", + "files": [ + "../../../../../examples/data/dfp/duo-inference-data/*.json" + ] + } + }, + { + "type": "inference", + "properties": { + } + } + ], + "metadata": { + "data_type": "streaming" + } + } + ] + } diff --git a/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/control_message_duo_inference.json b/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/control_message_duo_inference.json new file mode 100644 index 0000000000..4e2d5a3893 --- /dev/null +++ b/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/control_message_duo_inference.json @@ -0,0 +1,25 @@ +{ + "inputs": [ + { + "tasks": [ + { + "type": "load", + "properties": { + "loader_id": "fsspec", + "files": [ + "../../../../../examples/data/dfp/duo-inference-data/*.json" + ] + } + }, + { + "type": "inference", + "properties": { + } + } + ], + "metadata": { + "data_type": "payload" + } + } + ] + } diff --git a/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/control_message_duo_training.json b/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/control_message_duo_training.json new file mode 100644 index 0000000000..deb358ff6c --- /dev/null +++ b/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/control_message_duo_training.json @@ -0,0 +1,25 @@ +{ + "inputs": [ + { + "tasks": [ + { + "type": "load", + "properties": { + "loader_id": "fsspec", + "files": [ + "../../../../../examples/data/dfp/duo-training-data/*.json" + ] + } + }, + { + "type": "training", + "properties": { + } + } + ], + "metadata": { + "data_type": "payload" + } + } + ] + } diff --git a/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/modules_conf.json b/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/modules_conf.json index 6168a6847b..87789121ff 100644 --- a/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/modules_conf.json +++ b/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/modules_conf.json @@ -1,22 +1,28 @@ { - "preprocessing": { - "module_id": "DFPPreprocessing", - "module_name": "dfp_preprocessing", - "namespace": "morpheus_modules", + "module_id": "DFPDeployment", + "module_name": "dfp_deployment", + "namespace": "morpheus", + "DFPPreproc": { + "module_id": "DFPPreproc", + "module_name": "dfp_preproc", + "namespace": "morpheus", + "fsspec": { + "module_id": "DataLoader", + "module_name": "fsspec_dataloader", + "namespace": "morpheus", + "loaders": [{ + "id": "fsspec" + }] + }, "FileBatcher": { "module_id": "FileBatcher", "module_name": "file_batcher", - "namespace": "morpheus_modules", + "namespace": "morpheus", "period": "D", "sampling_rate_s": 0, "start_time": null, "end_time": null, - "iso_date_regex_pattern": "(?P\\d{4})-(?P\\d{1,2})-(?P\\d{1,2})T(?P\\d{1,2})(:|_)(?P\\d{1,2})(:|_)(?P\\d{1,2})(?P\\.\\d{1,6})?Z" - }, - "FileToDF": { - "module_id": "FileToDF", - "module_name": "FILE_TO_DF", - "namespace": "morpheus_modules", + "iso_date_regex_pattern": "(?P\\d{4})-(?P\\d{1,2})-(?P\\d{1,2})T(?P\\d{1,2})(:|_)(?P\\d{1,2})(:|_)(?P\\d{1,2})(?P\\.\\d{1,6})?Z", "timestamp_column_name": "timestamp", "userid_column_name": "username", "parser_kwargs": { @@ -31,10 +37,18 @@ "encoding": null } }, + "file_to_df": { + "module_id": "DataLoader", + "module_name": "file_to_df_dataloader", + "namespace": "morpheus", + "loaders": [{ + "id": "file_to_df" + }] + }, "DFPSplitUsers": { "module_id": "DFPSplitUsers", "module_name": "dfp_split_users", - "namespace": "morpheus_modules", + "namespace": "morpheus", "include_generic": true, "include_individual": false, "skip_users": [], @@ -42,84 +56,153 @@ "timestamp_column_name": "timestamp", "userid_column_name": "username", "fallback_username": "generic_user" + } + }, + "DFPTra": { + "module_id": "DFPTra", + "module_name": "dfp_tra", + "namespace": "morpheus", + "DFPRollingWindow": { + "module_id": "DFPRollingWindow", + "module_name": "dfp_rolling_window", + "namespace": "morpheus", + "min_history": 300, + "min_increment": 300, + "max_history": null, + "cache_dir": "./.cache/dfp", + "timestamp_column_name": "timestamp", + "task_type": "training" + }, + "DFPDataPrep": { + "module_id": "DFPDataPrep", + "module_name": "dfp_data_prep_tra", + "namespace": "morpheus", + "timestamp_column_name": "timestamp", + "userid_column_name": "username", + "schema": { + "schema_str": null, + "encoding": null }, - "DFPRollingWindow": { - "module_id": "DFPRollingWindow", - "module_name": "dfp_rolling_window", - "namespace": "morpheus_modules", - "min_history": 300, - "min_increment": 300, - "max_history": null, - "cache_dir": "./.cache/dfp", - "timestamp_column_name": "timestamp" + "task_type": "training" + }, + "DFPTraining": { + "module_id": "DFPTraining", + "module_name": "dfp_training", + "namespace": "morpheus", + "model_kwargs": { + "encoder_layers": [ + 512, + 500 + ], + "decoder_layers": [ + 512 + ], + "activation": "relu", + "swap_p": 0.2, + "lr": 0.001, + "lr_decay": 0.99, + "batch_size": 512, + "verbose": false, + "optimizer": "sgd", + "scaler": "standard", + "min_cats": 1, + "progress_bar": false, + "device": "cuda" }, - "DFPDataPrep": { - "module_id": "DFPDataPrep", - "module_name": "dfp_data_prep", - "namespace": "morpheus_modules", - "timestamp_column_name": "timestamp", - "userid_column_name": "username", - "schema": { - "schema_str": null, - "encoding": null - } - } + "feature_columns": null, + "epochs": 30, + "validation_size": 0.1 }, - "train_deploy": { - "module_id": "DFPModelTrainDeploy", - "module_name": "dfp_model_train_deploy", - "namespace": "morpheus_modules", - "DFPTraining": { - "module_id": "DFPTraining", - "module_name": "dfp_training", - "namespace": "morpheus_modules", - "model_kwargs": { - "encoder_layers": [ - 512, - 500 - ], - "decoder_layers": [ - 512 - ], - "activation": "relu", - "swap_p": 0.2, - "lr": 0.001, - "lr_decay": 0.99, - "batch_size": 512, - "verbose": false, - "optimizer": "sgd", - "scaler": "standard", - "min_cats": 1, - "progress_bar": false, - "device": "cuda" - }, - "feature_columns": null, - "epochs": 30, - "validation_size": 0.1 + "MLFlowModelWriter": { + "module_id": "MLFlowModelWriter", + "module_name": "mlflow_model_writer", + "namespace": "morpheus", + "model_name_formatter": null, + "experiment_name_formatter": null, + "timestamp_column_name": "timestamp", + "conda_env": { + "channels": [ + "defaults", + "conda-forge" + ], + "dependencies": [ + "python=3.8", + "pip" + ], + "pip": [ + "mlflow", + "dfencoder" + ], + "name": "mlflow-env" }, - "MLFlowModelWriter": { - "module_id": "MLFlowModelWriter", - "module_name": "mlflow_model_writer", - "namespace": "morpheus_modules", - "model_name_formatter": null, - "experiment_name_formatter": null, - "timestamp_column_name": "timestamp", - "conda_env": { - "channels": [ - "defaults", - "conda-forge" - ], - "dependencies": [ - "python=3.8", - "pip" - ], - "pip": [ - "mlflow", - "dfencoder" - ], - "name": "mlflow-env" - }, - "databricks_permissions": null - } + "databricks_permissions": null } + }, + "DFPInf": { + "module_id": "DFPInf", + "module_name": "dfp_inf", + "namespace": "morpheus", + "DFPRollingWindow": { + "module_id": "DFPRollingWindow", + "module_name": "dfp_rolling_window", + "namespace": "morpheus", + "min_history": 1, + "min_increment": 0, + "max_history": null, + "cache_dir": "./.cache/dfp", + "timestamp_column_name": "timestamp", + "task_type": "inference" + }, + "DFPDataPrep": { + "module_id": "DFPDataPrep", + "module_name": "dfp_data_prep_tra", + "namespace": "morpheus", + "timestamp_column_name": "timestamp", + "userid_column_name": "username", + "schema": { + "schema_str": null, + "encoding": null + }, + "task_type": "inference" + }, + "DFPInference": { + "module_id": "DFPInference", + "module_name": "dfp_inference", + "namespace": "morpheus", + "model_name_formatter": null, + "fallback_username": "username", + "timestamp_column_name": "timestamp" + }, + "FilterDetections": { + "module_id": "FilterDetections", + "module_name": "filter_detections", + "namespace": "morpheus", + "field_name": "mean_abs_z", + "threshold": 2.0, + "filter_source": "DATAFRAME", + "schema": { + "input_message_type": null, "encoding": null + } + }, + "DFPPostProcessing": { + "module_id": "DFPPostProcessing", + "module_name": "dfp_post_processing", + "namespace": "morpheus", + "timestamp_column_name": "timestamp" + }, + "Serialize": { + "module_id": "Serialize", + "module_name": "serialize", + "namespace": "morpheus", + "exclude": ["batch_count", "origin_hash", "_row_hash", "_batch_id"], + "use_cpp": null + }, + "WriteToFile": { + "module_id": "WriteToFile", + "module_name": "write_to_file", + "namespace": "morpheus", + "filename": null, + "overwrite": true } +} +} diff --git a/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/pipelines_conf.json b/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/pipelines_conf.json index c6d5f13d55..d15783fc4b 100644 --- a/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/pipelines_conf.json +++ b/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/pipelines_conf.json @@ -1,15 +1,47 @@ { "tracking_uri": "http://mlflow:5000", - "test_dfp_training_azure_modules_e2e": { - "glob_path": "../../../../data/dfp/azure-training-data/*.json", + "test_dfp_modules_azure_training_e2e": { + "message_path": "./resource/control_message_azure_training.json", "num_threads": 12, "pipeline_batch_size": 256, "edge_buffer_size": 128, "start_time": "2022-08-01", "duration": "60d" }, - "test_dfp_training_duo_modules_e2e": { - "glob_path": "../../../../data/dfp/duo-training-data/*.json", + "test_dfp_modules_azure_inference_e2e": { + "message_path": "./resource/control_message_azure_inference.json", + "num_threads": 12, + "pipeline_batch_size": 256, + "edge_buffer_size": 128, + "start_time": "2022-08-01", + "duration": "60d" + }, + "test_dfp_modules_azure_e2e": { + "message_path": "./resource/control_message_azure.json", + "num_threads": 12, + "pipeline_batch_size": 256, + "edge_buffer_size": 128, + "start_time": "2022-08-01", + "duration": "60d" + }, + "test_dfp_modules_duo_training_e2e": { + "message_path": "./resource/control_message_duo_training.json", + "num_threads": 12, + "pipeline_batch_size": 256, + "edge_buffer_size": 128, + "start_time": "2022-08-01", + "duration": "60d" + }, + "test_dfp_modules_duo_inference_e2e": { + "message_path": "./resource/control_message_duo_inference.json", + "num_threads": 12, + "pipeline_batch_size": 256, + "edge_buffer_size": 128, + "start_time": "2022-08-01", + "duration": "60d" + }, + "test_dfp_modules_duo_e2e": { + "message_path": "./resource/control_message_duo.json", "num_threads": 12, "pipeline_batch_size": 256, "edge_buffer_size": 128, diff --git a/examples/digital_fingerprinting/production/morpheus/benchmarks/test_bench_e2e_dfp_pipeline.py b/examples/digital_fingerprinting/production/morpheus/benchmarks/test_bench_e2e_dfp_pipeline.py index 27c6230cea..3fa3d6f5a7 100644 --- a/examples/digital_fingerprinting/production/morpheus/benchmarks/test_bench_e2e_dfp_pipeline.py +++ b/examples/digital_fingerprinting/production/morpheus/benchmarks/test_bench_e2e_dfp_pipeline.py @@ -19,8 +19,8 @@ import typing # flake8 warnings are silenced by the addition of noqa. -import dfp.modules.dfp_model_train_deploy # noqa: F401 -import dfp.modules.dfp_preprocessing # noqa: F401 +import dfp.modules.dfp_deployment # noqa: F401 +import dfp.modules.dfp_preprocessing import pytest from dfp.messages.multi_dfp_message import MultiDFPMessage from dfp.stages.dfp_file_batcher_stage import DFPFileBatcherStage @@ -46,7 +46,10 @@ from morpheus._lib.common import FilterSource from morpheus.config import Config from morpheus.pipeline.linear_pipeline import LinearPipeline -from morpheus.stages.general.linear_modules_stage import LinearModulesStage +from morpheus.pipeline.pipeline import Pipeline # noqa: F401 +from morpheus.stages.general.monitor_stage import MonitorStage +from morpheus.stages.general.multi_port_module_stage import MultiPortModuleStage +from morpheus.stages.input.control_message_source_stage import ControlMessageSourceStage from morpheus.stages.output.write_to_file_stage import WriteToFileStage from morpheus.stages.postprocess.filter_detections_stage import FilterDetectionsStage from morpheus.stages.postprocess.serialize_stage import SerializeStage @@ -60,25 +63,24 @@ set_mlflow_tracking_uri(PIPELINES_CONF.get("tracking_uri")) -def dfp_training_pipeline_modules(config: Config, modules_conf: typing.Dict[str, any], filenames: typing.List[str]): +def dfp_modules_pipeline(config: Config, modules_conf: typing.Dict[str, any], filenames: typing.List[str]): configure_logging(log_level=logging.INFO) - pipeline = LinearPipeline(config) - pipeline.set_source(MultiFileSource(config, filenames=filenames)) - pipeline.add_stage( - LinearModulesStage(config, - modules_conf["preprocessing"], - input_port_name="input", - output_port_name="output", - output_type=MultiDFPMessage)) - pipeline.add_stage( - LinearModulesStage(config, - modules_conf["train_deploy"], - input_port_name="input", - output_port_name="output", - output_type=MultiDFPMessage)) - pipeline.build() + pipeline = Pipeline(config) + + source_stage = pipeline.add_stage(ControlMessageSourceStage(config, filenames=filenames)) + + # Here we add a wrapped module that implements the DFP Deployment + dfp_deployment_stage = pipeline.add_stage( + MultiPortModuleStage(config, + modules_conf, + input_port_name="input", + output_port_name_prefix="output", + output_port_count=modules_conf.get("output_port_count"))) + + pipeline.add_edge(source_stage, dfp_deployment_stage) + pipeline.run() @@ -180,7 +182,7 @@ def dfp_inference_pipeline_stages(config: Config, @pytest.mark.benchmark -def test_dfp_training_duo_modules_e2e(benchmark: typing.Any): +def test_dfp_training_duo_stages_e2e(benchmark: typing.Any): feature_columns = [ "accessdevicebrowser", @@ -192,50 +194,51 @@ def test_dfp_training_duo_modules_e2e(benchmark: typing.Any): "logcount", ] - pipeline_conf = PIPELINES_CONF.get("test_dfp_training_duo_modules_e2e") + pipeline_conf = PIPELINES_CONF.get("test_dfp_training_duo_stages_e2e") - dfp_config = DFPConfig(pipeline_conf, feature_columns, source="duo", modules_conf=MODULES_CONF) + dfp_config = DFPConfig(pipeline_conf, feature_columns, source="duo") config = dfp_config.get_config() + stages_conf = dfp_config.get_stages_conf() filenames = dfp_config.get_filenames() source_schema = get_duo_source_schema(config) preprocess_schema = get_duo_preprocess_schema(config) - dfp_config.update_modules_conf(source_schema, preprocess_schema) - - benchmark(dfp_training_pipeline_modules, config, dfp_config.modules_conf, filenames) + benchmark(dfp_training_pipeline_stages, config, stages_conf, source_schema, preprocess_schema, filenames) @pytest.mark.benchmark -def test_dfp_training_duo_stages_e2e(benchmark: typing.Any): +def test_dfp_training_azure_stages_e2e(benchmark: typing.Any): feature_columns = [ - "accessdevicebrowser", - "accessdeviceos", - "authdevicename", - "reason", - "result", + "appDisplayName", + "clientAppUsed", + "deviceDetailbrowser", + "deviceDetaildisplayName", + "deviceDetailoperatingSystem", + "statusfailureReason", + "appincrement", "locincrement", - "logcount", + "logcount" ] - pipeline_conf = PIPELINES_CONF.get("test_dfp_training_duo_stages_e2e") + pipeline_conf = PIPELINES_CONF.get("test_dfp_training_azure_stages_e2e") - dfp_config = DFPConfig(pipeline_conf, feature_columns, source="duo") + dfp_config = DFPConfig(pipeline_conf, feature_columns, source="azure") config = dfp_config.get_config() stages_conf = dfp_config.get_stages_conf() filenames = dfp_config.get_filenames() - source_schema = get_duo_source_schema(config) - preprocess_schema = get_duo_preprocess_schema(config) + source_schema = get_azure_source_schema(config) + preprocess_schema = get_azure_preprocess_schema(config) benchmark(dfp_training_pipeline_stages, config, stages_conf, source_schema, preprocess_schema, filenames) @pytest.mark.benchmark -def test_dfp_training_azure_modules_e2e(benchmark: typing.Any): +def test_dfp_inference_azure_stages_e2e(benchmark: typing.Any, tmp_path): feature_columns = [ "appDisplayName", @@ -249,23 +252,30 @@ def test_dfp_training_azure_modules_e2e(benchmark: typing.Any): "logcount" ] - pipeline_conf = PIPELINES_CONF.get("test_dfp_training_azure_modules_e2e") + pipeline_conf = PIPELINES_CONF.get("test_dfp_inference_azure_stages_e2e") - dfp_config = DFPConfig(pipeline_conf, feature_columns, source="azure", modules_conf=MODULES_CONF) + dfp_config = DFPConfig(pipeline_conf, feature_columns, source="azure") config = dfp_config.get_config() + stages_conf = dfp_config.get_stages_conf() filenames = dfp_config.get_filenames() source_schema = get_azure_source_schema(config) preprocess_schema = get_azure_preprocess_schema(config) - dfp_config.update_modules_conf(source_schema, preprocess_schema) + output_filepath = os.path.join(tmp_path, "detections_azure.csv") - benchmark(dfp_training_pipeline_modules, config, dfp_config.modules_conf, filenames) + benchmark(dfp_inference_pipeline_stages, + config, + stages_conf, + source_schema, + preprocess_schema, + filenames, + output_filepath) @pytest.mark.benchmark -def test_dfp_training_azure_stages_e2e(benchmark: typing.Any): +def test_dfp_inference_duo_stages_e2e(benchmark: typing.Any, tmp_path): feature_columns = [ "appDisplayName", @@ -279,9 +289,9 @@ def test_dfp_training_azure_stages_e2e(benchmark: typing.Any): "logcount" ] - pipeline_conf = PIPELINES_CONF.get("test_dfp_training_azure_stages_e2e") + pipeline_conf = PIPELINES_CONF.get("test_dfp_inference_duo_stages_e2e") - dfp_config = DFPConfig(pipeline_conf, feature_columns, source="azure") + dfp_config = DFPConfig(pipeline_conf, feature_columns, source="duo") config = dfp_config.get_config() stages_conf = dfp_config.get_stages_conf() @@ -290,11 +300,47 @@ def test_dfp_training_azure_stages_e2e(benchmark: typing.Any): source_schema = get_azure_source_schema(config) preprocess_schema = get_azure_preprocess_schema(config) - benchmark(dfp_training_pipeline_stages, config, stages_conf, source_schema, preprocess_schema, filenames) + output_filepath = os.path.join(tmp_path, "detections_duo.csv") + + benchmark(dfp_inference_pipeline_stages, + config, + stages_conf, + source_schema, + preprocess_schema, + filenames, + output_filepath) @pytest.mark.benchmark -def test_dfp_inference_azure_stages_e2e(benchmark: typing.Any, tmp_path): +def test_dfp_modules_duo_training_e2e(benchmark: typing.Any): + + feature_columns = [ + "accessdevicebrowser", + "accessdeviceos", + "authdevicename", + "reason", + "result", + "locincrement", + "logcount", + ] + + pipeline_conf = PIPELINES_CONF.get("test_dfp_modules_duo_training_e2e") + + dfp_config = DFPConfig(pipeline_conf, feature_columns, source="duo", modules_conf=MODULES_CONF) + + config = dfp_config.get_config() + filenames = dfp_config.get_filenames() + + source_schema = get_duo_source_schema(config) + preprocess_schema = get_duo_preprocess_schema(config) + + dfp_config.update_modules_conf(source_schema, preprocess_schema) + + benchmark(dfp_modules_pipeline, config, dfp_config.modules_conf, filenames) + + +@pytest.mark.benchmark +def test_dfp_modules_azure_training_e2e(benchmark: typing.Any): feature_columns = [ "appDisplayName", @@ -308,30 +354,51 @@ def test_dfp_inference_azure_stages_e2e(benchmark: typing.Any, tmp_path): "logcount" ] - pipeline_conf = PIPELINES_CONF.get("test_dfp_inference_azure_stages_e2e") + pipeline_conf = PIPELINES_CONF.get("test_dfp_modules_azure_training_e2e") - dfp_config = DFPConfig(pipeline_conf, feature_columns, source="azure") + dfp_config = DFPConfig(pipeline_conf, feature_columns, source="azure", modules_conf=MODULES_CONF) config = dfp_config.get_config() - stages_conf = dfp_config.get_stages_conf() filenames = dfp_config.get_filenames() source_schema = get_azure_source_schema(config) preprocess_schema = get_azure_preprocess_schema(config) - output_filepath = os.path.join(tmp_path, "detections_azure.csv") + dfp_config.update_modules_conf(source_schema, preprocess_schema) - benchmark(dfp_inference_pipeline_stages, - config, - stages_conf, - source_schema, - preprocess_schema, - filenames, - output_filepath) + benchmark(dfp_modules_pipeline, config, dfp_config.modules_conf, filenames) @pytest.mark.benchmark -def test_dfp_inference_duo_stages_e2e(benchmark: typing.Any, tmp_path): +def test_dfp_modules_duo_inference_e2e(benchmark: typing.Any): + + feature_columns = [ + "accessdevicebrowser", + "accessdeviceos", + "authdevicename", + "reason", + "result", + "locincrement", + "logcount", + ] + + pipeline_conf = PIPELINES_CONF.get("test_dfp_modules_duo_inference_e2e") + + dfp_config = DFPConfig(pipeline_conf, feature_columns, source="duo", modules_conf=MODULES_CONF) + + config = dfp_config.get_config() + filenames = dfp_config.get_filenames() + + source_schema = get_duo_source_schema(config) + preprocess_schema = get_duo_preprocess_schema(config) + + dfp_config.update_modules_conf(source_schema, preprocess_schema) + + benchmark(dfp_modules_pipeline, config, dfp_config.modules_conf, filenames) + + +@pytest.mark.benchmark +def test_dfp_modules_azure_inference_e2e(benchmark: typing.Any): feature_columns = [ "appDisplayName", @@ -345,23 +412,74 @@ def test_dfp_inference_duo_stages_e2e(benchmark: typing.Any, tmp_path): "logcount" ] - pipeline_conf = PIPELINES_CONF.get("test_dfp_inference_duo_stages_e2e") + pipeline_conf = PIPELINES_CONF.get("test_dfp_modules_azure_inference_e2e") - dfp_config = DFPConfig(pipeline_conf, feature_columns, source="duo") + dfp_config = DFPConfig(pipeline_conf, feature_columns, source="azure", modules_conf=MODULES_CONF) config = dfp_config.get_config() - stages_conf = dfp_config.get_stages_conf() filenames = dfp_config.get_filenames() source_schema = get_azure_source_schema(config) preprocess_schema = get_azure_preprocess_schema(config) - output_filepath = os.path.join(tmp_path, "detections_duo.csv") + dfp_config.update_modules_conf(source_schema, preprocess_schema) - benchmark(dfp_inference_pipeline_stages, - config, - stages_conf, - source_schema, - preprocess_schema, - filenames, - output_filepath) + benchmark(dfp_modules_pipeline, config, dfp_config.modules_conf, filenames) + + +@pytest.mark.benchmark +def test_dfp_modules_duo_e2e(benchmark: typing.Any): + + feature_columns = [ + "accessdevicebrowser", + "accessdeviceos", + "authdevicename", + "reason", + "result", + "locincrement", + "logcount", + ] + + pipeline_conf = PIPELINES_CONF.get("test_dfp_modules_duo_e2e") + + dfp_config = DFPConfig(pipeline_conf, feature_columns, source="duo", modules_conf=MODULES_CONF) + + config = dfp_config.get_config() + filenames = dfp_config.get_filenames() + + source_schema = get_duo_source_schema(config) + preprocess_schema = get_duo_preprocess_schema(config) + + dfp_config.update_modules_conf(source_schema, preprocess_schema) + + benchmark(dfp_modules_pipeline, config, dfp_config.modules_conf, filenames) + + +@pytest.mark.benchmark +def test_dfp_modules_azure_e2e(benchmark: typing.Any): + + feature_columns = [ + "appDisplayName", + "clientAppUsed", + "deviceDetailbrowser", + "deviceDetaildisplayName", + "deviceDetailoperatingSystem", + "statusfailureReason", + "appincrement", + "locincrement", + "logcount" + ] + + pipeline_conf = PIPELINES_CONF.get("test_dfp_modules_azure_e2e") + + dfp_config = DFPConfig(pipeline_conf, feature_columns, source="azure", modules_conf=MODULES_CONF) + + config = dfp_config.get_config() + filenames = dfp_config.get_filenames() + + source_schema = get_azure_source_schema(config) + preprocess_schema = get_azure_preprocess_schema(config) + + dfp_config.update_modules_conf(source_schema, preprocess_schema) + + benchmark(dfp_modules_pipeline, config, dfp_config.modules_conf, filenames) diff --git a/morpheus/utils/module_ids.py b/morpheus/utils/module_ids.py index 2b37cfe965..81208e968f 100644 --- a/morpheus/utils/module_ids.py +++ b/morpheus/utils/module_ids.py @@ -19,5 +19,5 @@ MLFLOW_MODEL_WRITER = "MLFlowModelWriter" SERIALIZE = "Serialize" WRITE_TO_FILE = "WriteToFile" -FILTER_DETECTIONS = "filter_detections" +FILTER_DETECTIONS = "FilterDetections" DATA_LOADER = "DataLoader" From 897d3617986b033cd8347e17a5cad874aab20cca Mon Sep 17 00:00:00 2001 From: bsuryadevara Date: Tue, 28 Feb 2023 17:23:43 -0600 Subject: [PATCH 056/157] fix to dfp benchmarks --- .../benchmarks/resource/modules_conf.json | 51 ------------------- .../morpheus/dfp/utils/config_generator.py | 3 -- 2 files changed, 54 deletions(-) diff --git a/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/modules_conf.json b/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/modules_conf.json index 87789121ff..9a73c0f387 100644 --- a/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/modules_conf.json +++ b/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/modules_conf.json @@ -3,21 +3,12 @@ "module_name": "dfp_deployment", "namespace": "morpheus", "DFPPreproc": { - "module_id": "DFPPreproc", - "module_name": "dfp_preproc", - "namespace": "morpheus", "fsspec": { - "module_id": "DataLoader", - "module_name": "fsspec_dataloader", - "namespace": "morpheus", "loaders": [{ "id": "fsspec" }] }, "FileBatcher": { - "module_id": "FileBatcher", - "module_name": "file_batcher", - "namespace": "morpheus", "period": "D", "sampling_rate_s": 0, "start_time": null, @@ -38,17 +29,11 @@ } }, "file_to_df": { - "module_id": "DataLoader", - "module_name": "file_to_df_dataloader", - "namespace": "morpheus", "loaders": [{ "id": "file_to_df" }] }, "DFPSplitUsers": { - "module_id": "DFPSplitUsers", - "module_name": "dfp_split_users", - "namespace": "morpheus", "include_generic": true, "include_individual": false, "skip_users": [], @@ -59,13 +44,7 @@ } }, "DFPTra": { - "module_id": "DFPTra", - "module_name": "dfp_tra", - "namespace": "morpheus", "DFPRollingWindow": { - "module_id": "DFPRollingWindow", - "module_name": "dfp_rolling_window", - "namespace": "morpheus", "min_history": 300, "min_increment": 300, "max_history": null, @@ -74,9 +53,6 @@ "task_type": "training" }, "DFPDataPrep": { - "module_id": "DFPDataPrep", - "module_name": "dfp_data_prep_tra", - "namespace": "morpheus", "timestamp_column_name": "timestamp", "userid_column_name": "username", "schema": { @@ -86,9 +62,6 @@ "task_type": "training" }, "DFPTraining": { - "module_id": "DFPTraining", - "module_name": "dfp_training", - "namespace": "morpheus", "model_kwargs": { "encoder_layers": [ 512, @@ -114,9 +87,6 @@ "validation_size": 0.1 }, "MLFlowModelWriter": { - "module_id": "MLFlowModelWriter", - "module_name": "mlflow_model_writer", - "namespace": "morpheus", "model_name_formatter": null, "experiment_name_formatter": null, "timestamp_column_name": "timestamp", @@ -139,9 +109,6 @@ } }, "DFPInf": { - "module_id": "DFPInf", - "module_name": "dfp_inf", - "namespace": "morpheus", "DFPRollingWindow": { "module_id": "DFPRollingWindow", "module_name": "dfp_rolling_window", @@ -154,9 +121,6 @@ "task_type": "inference" }, "DFPDataPrep": { - "module_id": "DFPDataPrep", - "module_name": "dfp_data_prep_tra", - "namespace": "morpheus", "timestamp_column_name": "timestamp", "userid_column_name": "username", "schema": { @@ -166,17 +130,11 @@ "task_type": "inference" }, "DFPInference": { - "module_id": "DFPInference", - "module_name": "dfp_inference", - "namespace": "morpheus", "model_name_formatter": null, "fallback_username": "username", "timestamp_column_name": "timestamp" }, "FilterDetections": { - "module_id": "FilterDetections", - "module_name": "filter_detections", - "namespace": "morpheus", "field_name": "mean_abs_z", "threshold": 2.0, "filter_source": "DATAFRAME", @@ -185,22 +143,13 @@ } }, "DFPPostProcessing": { - "module_id": "DFPPostProcessing", - "module_name": "dfp_post_processing", - "namespace": "morpheus", "timestamp_column_name": "timestamp" }, "Serialize": { - "module_id": "Serialize", - "module_name": "serialize", - "namespace": "morpheus", "exclude": ["batch_count", "origin_hash", "_row_hash", "_batch_id"], "use_cpp": null }, "WriteToFile": { - "module_id": "WriteToFile", - "module_name": "write_to_file", - "namespace": "morpheus", "filename": null, "overwrite": true } diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py b/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py index ff89d8a162..d3d9308740 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py @@ -117,9 +117,6 @@ def preproc_module_config(self): def infer_module_config(self): module_config = { - "module_id": DFP_INF, - "module_name": "dfp_inf", - "namespace": MODULE_NAMESPACE, DFP_ROLLING_WINDOW: { "min_history": 1, "min_increment": 0, From ba8734d5a7f95fd8e5003d0dde07859f3f20692c Mon Sep 17 00:00:00 2001 From: Bhargav Suryadevara Date: Wed, 1 Mar 2023 10:01:05 -0600 Subject: [PATCH 057/157] Update conftest.py --- .../production/morpheus/benchmarks/conftest.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/examples/digital_fingerprinting/production/morpheus/benchmarks/conftest.py b/examples/digital_fingerprinting/production/morpheus/benchmarks/conftest.py index 23b069e9a6..775e8004fd 100644 --- a/examples/digital_fingerprinting/production/morpheus/benchmarks/conftest.py +++ b/examples/digital_fingerprinting/production/morpheus/benchmarks/conftest.py @@ -67,17 +67,18 @@ def pytest_benchmark_update_json(config, benchmarks, output_json): message_file = open(message_fn) control_message = json.load(message_file) inputs = control_message.get("inputs") - input_data = {} - # Iterating over inputs array + # Iterating over inputs for input in inputs: line_count_per_task = 0 byte_count_per_task = 0 tasks = input.get("tasks") - # Iterating tasks inputs array + # Iterating over tasks for task in tasks: if task.get("type") == "load": files = task.get("properties").get("files") + # Iterating over files in a task for file_glob in files: + # Iterating over a file glob for fn in glob.glob(file_glob): count = get_json_lines_count(fn) size = path.getsize(fn) @@ -87,9 +88,11 @@ def pytest_benchmark_update_json(config, benchmarks, output_json): byte_count_per_task += size else: non_load_task = task.get("type") - bench['stats'][non_load_task] = {} - bench['stats'][non_load_task]["input_lines"] = line_count_per_task - bench['stats'][non_load_task]["input_bytes"] = byte_count_per_task + # Adding non-load task status here. + if non_load_task is not None: + bench['stats'][non_load_task] = {} + bench['stats'][non_load_task]["input_lines"] = line_count_per_task + bench['stats'][non_load_task]["input_bytes"] = byte_count_per_task else: raise KeyError("Configuration requires either 'glob_path' or 'file_path' attribute.") From af4ba0456343e3f3084ff718d8497d6d4f2284cb Mon Sep 17 00:00:00 2001 From: Devin Robison Date: Wed, 1 Mar 2023 11:25:19 -0700 Subject: [PATCH 058/157] Tweak to make benchmarks run --- .../benchmarks/resource/modules_conf.json | 118 ++++++++++-------- .../benchmarks/resource/pipelines_conf.json | 2 +- .../benchmarks/test_bench_e2e_dfp_pipeline.py | 20 +-- .../morpheus/dfp_modules_pipeline.py | 22 ++-- 4 files changed, 85 insertions(+), 77 deletions(-) diff --git a/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/modules_conf.json b/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/modules_conf.json index 9a73c0f387..e6c221be95 100644 --- a/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/modules_conf.json +++ b/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/modules_conf.json @@ -1,47 +1,57 @@ { - "module_id": "DFPDeployment", - "module_name": "dfp_deployment", - "namespace": "morpheus", - "DFPPreproc": { - "fsspec": { - "loaders": [{ - "id": "fsspec" - }] - }, - "FileBatcher": { - "period": "D", - "sampling_rate_s": 0, - "start_time": null, - "end_time": null, - "iso_date_regex_pattern": "(?P\\d{4})-(?P\\d{1,2})-(?P\\d{1,2})T(?P\\d{1,2})(:|_)(?P\\d{1,2})(:|_)(?P\\d{1,2})(?P\\.\\d{1,6})?Z", - "timestamp_column_name": "timestamp", - "userid_column_name": "username", - "parser_kwargs": { - "lines": false, - "orient": "records" - }, - "cache_dir": "./.cache/dfp", - "filter_null": true, - "file_type": "JSON", - "schema": { - "schema_str": null, - "encoding": null + "module_id": "DFPDeployment", + "module_name": "dfp_deployment", + "namespace": "morpheus", + "DFPPreproc": { + "fsspec": { + "module_id": "DataLoader", + "namespace": "morpheus", + "module_name": "fsspec", + "loaders": [ + { + "id": "fsspec" } + ] + }, + "FileBatcher": { + "sampling_rate_s": 0, + "start_time": null, + "end_time": null, + "iso_date_regex_pattern": "(?P\\d{4})-(?P\\d{1,2})-(?P\\d{1,2})T(?P\\d{1,2})(:|_)(?P\\d{1,2})(:|_)(?P\\d{1,2})(?P\\.\\d{1,6})?Z", + "timestamp_column_name": "timestamp", + "period": "D", + "userid_column_name": "username", + "parser_kwargs": { + "lines": false, + "orient": "records" }, - "file_to_df": { - "loaders": [{ - "id": "file_to_df" - }] - }, - "DFPSplitUsers": { - "include_generic": true, - "include_individual": false, - "skip_users": [], - "only_users": [], - "timestamp_column_name": "timestamp", - "userid_column_name": "username", - "fallback_username": "generic_user" + "cache_dir": "./.cache/dfp", + "filter_null": true, + "file_type": "JSON", + "schema": { + "schema_str": null, + "encoding": null } + }, + "file_to_df": { + "module_id": "DataLoader", + "namespace": "morpheus", + "module_name": "file_to_df", + "loaders": [ + { + "id": "file_to_df" + } + ] + }, + "DFPSplitUsers": { + "include_generic": true, + "include_individual": false, + "skip_users": [], + "only_users": [], + "timestamp_column_name": "timestamp", + "userid_column_name": "username", + "fallback_username": "generic_user" + } }, "DFPTra": { "DFPRollingWindow": { @@ -133,25 +143,31 @@ "model_name_formatter": null, "fallback_username": "username", "timestamp_column_name": "timestamp" - }, - "FilterDetections": { + }, + "FilterDetections": { "field_name": "mean_abs_z", "threshold": 2.0, "filter_source": "DATAFRAME", "schema": { - "input_message_type": null, "encoding": null + "input_message_type": null, + "encoding": null } - }, - "DFPPostProcessing": { + }, + "DFPPostProcessing": { "timestamp_column_name": "timestamp" - }, - "Serialize": { - "exclude": ["batch_count", "origin_hash", "_row_hash", "_batch_id"], + }, + "Serialize": { + "exclude": [ + "batch_count", + "origin_hash", + "_row_hash", + "_batch_id" + ], "use_cpp": null - }, - "WriteToFile": { + }, + "WriteToFile": { "filename": null, "overwrite": true + } } } -} diff --git a/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/pipelines_conf.json b/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/pipelines_conf.json index d15783fc4b..9456d261d2 100644 --- a/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/pipelines_conf.json +++ b/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/pipelines_conf.json @@ -1,5 +1,5 @@ { - "tracking_uri": "http://mlflow:5000", + "tracking_uri": "http://localhost:5000", "test_dfp_modules_azure_training_e2e": { "message_path": "./resource/control_message_azure_training.json", "num_threads": 12, diff --git a/examples/digital_fingerprinting/production/morpheus/benchmarks/test_bench_e2e_dfp_pipeline.py b/examples/digital_fingerprinting/production/morpheus/benchmarks/test_bench_e2e_dfp_pipeline.py index 3fa3d6f5a7..6c5d2cfc6e 100644 --- a/examples/digital_fingerprinting/production/morpheus/benchmarks/test_bench_e2e_dfp_pipeline.py +++ b/examples/digital_fingerprinting/production/morpheus/benchmarks/test_bench_e2e_dfp_pipeline.py @@ -20,9 +20,7 @@ # flake8 warnings are silenced by the addition of noqa. import dfp.modules.dfp_deployment # noqa: F401 -import dfp.modules.dfp_preprocessing import pytest -from dfp.messages.multi_dfp_message import MultiDFPMessage from dfp.stages.dfp_file_batcher_stage import DFPFileBatcherStage from dfp.stages.dfp_file_to_df import DFPFileToDataFrameStage from dfp.stages.dfp_inference_stage import DFPInferenceStage @@ -47,7 +45,6 @@ from morpheus.config import Config from morpheus.pipeline.linear_pipeline import LinearPipeline from morpheus.pipeline.pipeline import Pipeline # noqa: F401 -from morpheus.stages.general.monitor_stage import MonitorStage from morpheus.stages.general.multi_port_module_stage import MultiPortModuleStage from morpheus.stages.input.control_message_source_stage import ControlMessageSourceStage from morpheus.stages.output.write_to_file_stage import WriteToFileStage @@ -64,13 +61,16 @@ def dfp_modules_pipeline(config: Config, modules_conf: typing.Dict[str, any], filenames: typing.List[str]): - configure_logging(log_level=logging.INFO) pipeline = Pipeline(config) source_stage = pipeline.add_stage(ControlMessageSourceStage(config, filenames=filenames)) + import json + with open("modules_conf.json", "w") as f: + f.write(json.dumps(modules_conf, indent=3, default=str)) + # Here we add a wrapped module that implements the DFP Deployment dfp_deployment_stage = pipeline.add_stage( MultiPortModuleStage(config, @@ -89,7 +89,6 @@ def dfp_training_pipeline_stages(config: Config, source_schema: DataFrameInputSchema, preprocess_schema: DataFrameInputSchema, filenames: typing.List[str]): - configure_logging(log_level=logging.INFO) pipeline = LinearPipeline(config) @@ -137,7 +136,6 @@ def dfp_inference_pipeline_stages(config: Config, preprocess_schema: DataFrameInputSchema, filenames: typing.List[str], output_filepath: str): - configure_logging(log_level=logging.INFO) pipeline = LinearPipeline(config) @@ -183,7 +181,6 @@ def dfp_inference_pipeline_stages(config: Config, @pytest.mark.benchmark def test_dfp_training_duo_stages_e2e(benchmark: typing.Any): - feature_columns = [ "accessdevicebrowser", "accessdeviceos", @@ -210,7 +207,6 @@ def test_dfp_training_duo_stages_e2e(benchmark: typing.Any): @pytest.mark.benchmark def test_dfp_training_azure_stages_e2e(benchmark: typing.Any): - feature_columns = [ "appDisplayName", "clientAppUsed", @@ -239,7 +235,6 @@ def test_dfp_training_azure_stages_e2e(benchmark: typing.Any): @pytest.mark.benchmark def test_dfp_inference_azure_stages_e2e(benchmark: typing.Any, tmp_path): - feature_columns = [ "appDisplayName", "clientAppUsed", @@ -276,7 +271,6 @@ def test_dfp_inference_azure_stages_e2e(benchmark: typing.Any, tmp_path): @pytest.mark.benchmark def test_dfp_inference_duo_stages_e2e(benchmark: typing.Any, tmp_path): - feature_columns = [ "appDisplayName", "clientAppUsed", @@ -313,7 +307,6 @@ def test_dfp_inference_duo_stages_e2e(benchmark: typing.Any, tmp_path): @pytest.mark.benchmark def test_dfp_modules_duo_training_e2e(benchmark: typing.Any): - feature_columns = [ "accessdevicebrowser", "accessdeviceos", @@ -341,7 +334,6 @@ def test_dfp_modules_duo_training_e2e(benchmark: typing.Any): @pytest.mark.benchmark def test_dfp_modules_azure_training_e2e(benchmark: typing.Any): - feature_columns = [ "appDisplayName", "clientAppUsed", @@ -371,7 +363,6 @@ def test_dfp_modules_azure_training_e2e(benchmark: typing.Any): @pytest.mark.benchmark def test_dfp_modules_duo_inference_e2e(benchmark: typing.Any): - feature_columns = [ "accessdevicebrowser", "accessdeviceos", @@ -399,7 +390,6 @@ def test_dfp_modules_duo_inference_e2e(benchmark: typing.Any): @pytest.mark.benchmark def test_dfp_modules_azure_inference_e2e(benchmark: typing.Any): - feature_columns = [ "appDisplayName", "clientAppUsed", @@ -429,7 +419,6 @@ def test_dfp_modules_azure_inference_e2e(benchmark: typing.Any): @pytest.mark.benchmark def test_dfp_modules_duo_e2e(benchmark: typing.Any): - feature_columns = [ "accessdevicebrowser", "accessdeviceos", @@ -457,7 +446,6 @@ def test_dfp_modules_duo_e2e(benchmark: typing.Any): @pytest.mark.benchmark def test_dfp_modules_azure_e2e(benchmark: typing.Any): - feature_columns = [ "appDisplayName", "clientAppUsed", diff --git a/examples/digital_fingerprinting/production/morpheus/dfp_modules_pipeline.py b/examples/digital_fingerprinting/production/morpheus/dfp_modules_pipeline.py index d2eb7efbf0..cd52d23834 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp_modules_pipeline.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp_modules_pipeline.py @@ -124,15 +124,15 @@ def run_pipeline(log_type: str, logging.error("Option --skip_user and --only_user are mutually exclusive. Exiting") derived_args = DeriveArgs(skip_user, - only_user, - start_time, - log_level, - cache_dir, - sample_rate_s, - duration, - log_type, - tracking_uri, - train_users) + only_user, + start_time, + log_level, + cache_dir, + sample_rate_s, + duration, + log_type, + tracking_uri, + train_users) derived_args.init() @@ -161,6 +161,10 @@ def run_pipeline(log_type: str, source_stage = pipeline.add_stage(ControlMessageSourceStage(config, filenames=list(kwargs["input_file"]))) # Here we add a wrapped module that implements the DFP Deployment + import json + with open("good_config.json", "w") as f: + json.dump(module_config, f, indent=4, default=str) + dfp_deployment_stage = pipeline.add_stage( MultiPortModuleStage(config, module_config, From 01a6ecb7d4725b1cacc14130a003ea7b85f2c532 Mon Sep 17 00:00:00 2001 From: Devin Robison Date: Wed, 1 Mar 2023 14:54:32 -0700 Subject: [PATCH 059/157] Performance improvements -- reducing data copies --- .../benchmarks/resource/modules_conf.json | 6 -- .../benchmarks/test_bench_e2e_dfp_pipeline.py | 6 +- .../morpheus/dfp/modules/dfp_data_prep.py | 8 +- .../morpheus/dfp/modules/dfp_inference.py | 12 ++- .../dfp/modules/dfp_rolling_window.py | 4 +- .../morpheus/dfp/modules/dfp_split_users.py | 97 ++++++++++--------- .../morpheus/dfp/modules/dfp_training.py | 13 ++- .../morpheus/dfp_modules_pipeline.py | 5 - morpheus/loaders/file_to_df_loader.py | 1 + morpheus/modules/file_batcher.py | 7 +- morpheus/utils/column_info.py | 12 ++- 11 files changed, 86 insertions(+), 85 deletions(-) diff --git a/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/modules_conf.json b/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/modules_conf.json index e6c221be95..a283cb2b2c 100644 --- a/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/modules_conf.json +++ b/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/modules_conf.json @@ -4,9 +4,6 @@ "namespace": "morpheus", "DFPPreproc": { "fsspec": { - "module_id": "DataLoader", - "namespace": "morpheus", - "module_name": "fsspec", "loaders": [ { "id": "fsspec" @@ -34,9 +31,6 @@ } }, "file_to_df": { - "module_id": "DataLoader", - "namespace": "morpheus", - "module_name": "file_to_df", "loaders": [ { "id": "file_to_df" diff --git a/examples/digital_fingerprinting/production/morpheus/benchmarks/test_bench_e2e_dfp_pipeline.py b/examples/digital_fingerprinting/production/morpheus/benchmarks/test_bench_e2e_dfp_pipeline.py index 6c5d2cfc6e..a0050a4d6e 100644 --- a/examples/digital_fingerprinting/production/morpheus/benchmarks/test_bench_e2e_dfp_pipeline.py +++ b/examples/digital_fingerprinting/production/morpheus/benchmarks/test_bench_e2e_dfp_pipeline.py @@ -61,7 +61,7 @@ def dfp_modules_pipeline(config: Config, modules_conf: typing.Dict[str, any], filenames: typing.List[str]): - configure_logging(log_level=logging.INFO) + configure_logging(log_level=logging.CRITICAL) pipeline = Pipeline(config) @@ -89,7 +89,7 @@ def dfp_training_pipeline_stages(config: Config, source_schema: DataFrameInputSchema, preprocess_schema: DataFrameInputSchema, filenames: typing.List[str]): - configure_logging(log_level=logging.INFO) + configure_logging(log_level=logging.CRITICAL) pipeline = LinearPipeline(config) pipeline.set_source(MultiFileSource(config, filenames=filenames)) @@ -136,7 +136,7 @@ def dfp_inference_pipeline_stages(config: Config, preprocess_schema: DataFrameInputSchema, filenames: typing.List[str], output_filepath: str): - configure_logging(log_level=logging.INFO) + configure_logging(log_level=logging.CRITICAL) pipeline = LinearPipeline(config) pipeline.set_source(MultiFileSource(config, filenames=filenames)) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_data_prep.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_data_prep.py index 3125183204..21e4df4ae5 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_data_prep.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_data_prep.py @@ -63,13 +63,11 @@ def process_features(message: MessageControl): # Process the columns payload = message.payload() - # df_processed = process_dataframe(message.get_meta_dataframe(), schema) - df_processed_pandas = payload.df.to_pandas() - df_processed = process_dataframe(df_processed_pandas, schema) + with payload.mutable_dataframe() as dfm: + df_processed = process_dataframe(dfm, schema) # Apply the new dataframe, only the rows in the offset - # message.set_meta_dataframe(list(df_processed.columns), df_processed) - message.payload(MessageMeta(cudf.DataFrame(df_processed))) + message.payload(MessageMeta(df_processed)) if logger.isEnabledFor(logging.DEBUG): duration = (time.time() - start_time) * 1000.0 diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference.py index eddcb8ce3b..ea5fb38ae3 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference.py @@ -66,7 +66,8 @@ def process_task(control_message: MessageControl, task: dict): user_id = control_message.get_metadata("user_id") payload = control_message.payload() - df_user = payload.df.to_pandas() + with payload.mutable_dataframe() as dfm: + df_user = dfm.to_pandas() df_user[timestamp_column_name] = pd.to_datetime(df_user[timestamp_column_name], utc=True) try: @@ -90,8 +91,6 @@ def process_task(control_message: MessageControl, task: dict): for col in include_cols: results_df[col] = df_user[col].copy(True) - results_df = cudf.from_pandas(results_df) - # Create an output message to allow setting meta dfp_mm = DFPMessageMeta(results_df, user_id=user_id) multi_message = MultiDFPMessage(dfp_mm, mess_offset=0, mess_count=len(results_df)) @@ -120,12 +119,15 @@ def on_data(control_message: MessageControl): if (control_message is None): return None + task_results = [] while (control_message.has_task("inference")): task = control_message.pop_task("inference") - return process_task(control_message, task) + task_results.append(process_task(control_message, task)) + + return task_results def node_fn(obs: mrc.Observable, sub: mrc.Subscriber): - obs.pipe(ops.map(on_data)).subscribe(sub) + obs.pipe(ops.map(on_data), ops.flatten()).subscribe(sub) node = builder.make_node_full(DFP_INFERENCE, node_fn) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_rolling_window.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_rolling_window.py index 748156a9cc..9883a42f88 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_rolling_window.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_rolling_window.py @@ -80,7 +80,9 @@ def build_window(message: MessageMeta, user_id: str) -> MessageMeta: with get_user_cache(user_id) as user_cache: # incoming_df = message.get_df() - incoming_df = message.df.to_pandas() + with message.mutable_dataframe() as dfm: + incoming_df = dfm.to_pandas() + incoming_df[timestamp_column_name] = pd.to_datetime(incoming_df[timestamp_column_name], utc=True) if (not user_cache.append_dataframe(incoming_df=incoming_df)): diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_split_users.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_split_users.py index c9f628c90d..aacf6f3c80 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_split_users.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_split_users.py @@ -63,70 +63,73 @@ def extract_users(control_message: MessageControl): logger.debug("No message to extract users from") return [] - df = control_message.payload().df - with log_time(logger.debug) as log_info: + mm = control_message.payload() + with mm.mutable_dataframe() as dfm: + # df = control_message.payload().df + with log_time(logger.debug) as log_info: - if (isinstance(df, cudf.DataFrame)): - # Convert to pandas because cudf is slow at this - df = df.to_pandas() - df[timestamp_column_name] = pd.to_datetime(df[timestamp_column_name], utc=True) + if (isinstance(dfm, cudf.DataFrame)): + # Convert to pandas because cudf is slow at this + df = dfm.to_pandas() + df[timestamp_column_name] = pd.to_datetime(df[timestamp_column_name], utc=True) - split_dataframes: typing.Dict[str, cudf.DataFrame] = {} + split_dataframes: typing.Dict[str, cudf.DataFrame] = {} - # If we are skipping users, do that here - if (len(skip_users) > 0): - df = df[~df[userid_column_name].isin(skip_users)] + # If we are skipping users, do that here + if (len(skip_users) > 0): + df = df[~df[userid_column_name].isin(skip_users)] - if (len(only_users) > 0): - df = df[df[userid_column_name].isin(only_users)] + if (len(only_users) > 0): + df = df[df[userid_column_name].isin(only_users)] - # Split up the dataframes - if (include_generic): - split_dataframes[fallback_username] = df + # Split up the dataframes + if (include_generic): + split_dataframes[fallback_username] = df - if (include_individual): - split_dataframes.update({username: user_df for username, user_df in df.groupby("username", sort=False)}) + if (include_individual): + split_dataframes.update( + {username: user_df for username, user_df in df.groupby("username", sort=False)}) - output_messages: typing.List[MessageControl] = [] + output_messages: typing.List[MessageControl] = [] - for user_id in sorted(split_dataframes.keys()): - if (user_id in skip_users): - continue + for user_id in sorted(split_dataframes.keys()): + if (user_id in skip_users): + continue - user_df = split_dataframes[user_id] + user_df = split_dataframes[user_id] - current_user_count = user_index_map.get(user_id, 0) - logger.debug("Current user count: %s", current_user_count) + current_user_count = user_index_map.get(user_id, 0) + logger.debug("Current user count: %s", current_user_count) - # Reset the index so that users see monotonically increasing indexes - user_df.index = range(current_user_count, current_user_count + len(user_df)) - user_index_map[user_id] = current_user_count + len(user_df) + # Reset the index so that users see monotonically increasing indexes + user_df.index = range(current_user_count, current_user_count + len(user_df)) + user_index_map[user_id] = current_user_count + len(user_df) - user_control_message = control_message.copy() - user_control_message.set_metadata("user_id", user_id) + user_control_message = control_message.copy() + user_control_message.set_metadata("user_id", user_id) - user_cudf = cudf.from_pandas(user_df) - user_control_message.payload(MessageMeta(df=user_cudf)) + user_cudf = cudf.from_pandas(user_df) + user_control_message.payload(MessageMeta(df=user_cudf)) - # output_messages.append(DFPMessageMeta(df=user_df, user_id=user_id)) - output_messages.append(user_control_message) + # output_messages.append(DFPMessageMeta(df=user_df, user_id=user_id)) + output_messages.append(user_control_message) - rows_per_user = [len(msg.payload().df.to_pandas()) for msg in output_messages] + rows_per_user = [len(msg.payload().df.to_pandas()) for msg in output_messages] - if (len(output_messages) > 0): - log_info.set_log( - ("Batch split users complete. Input: %s rows from %s to %s. " - "Output: %s users, rows/user min: %s, max: %s, avg: %.2f. Duration: {duration:.2f} ms"), - len(df), - df[timestamp_column_name].min(), - df[timestamp_column_name].max(), - len(rows_per_user), - np.min(rows_per_user), - np.max(rows_per_user), - np.mean(rows_per_user), - ) + if (len(output_messages) > 0): + log_info.set_log( + ("Batch split users complete. Input: %s rows from %s to %s. " + "Output: %s users, rows/user min: %s, max: %s, avg: %.2f. Duration: {duration:.2f} ms"), + len(df), + df[timestamp_column_name].min(), + df[timestamp_column_name].max(), + len(rows_per_user), + np.min(rows_per_user), + np.max(rows_per_user), + np.mean(rows_per_user), + ) - return output_messages + return output_messages def node_fn(obs: mrc.Observable, sub: mrc.Subscriber): obs.pipe(ops.map(extract_users), ops.flatten()).subscribe(sub) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training.py index ef5a82ec09..db5ff46e74 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training.py @@ -58,14 +58,15 @@ def on_data(control_message: MessageControl): if (control_message is None): return None - output_message = None + output_messages = [] while (control_message.has_task("training")): task = control_message.pop_task("training") user_id = control_message.get_metadata("user_id") message_meta = control_message.payload() - final_df = message_meta.df.to_pandas() + with message_meta.mutable_dataframe() as dfm: + final_df = dfm.to_pandas() model = AutoEncoder(**model_kwargs) @@ -76,17 +77,19 @@ def on_data(control_message: MessageControl): model.fit(train_df, epochs=epochs) logger.debug("Training AE model for user: '%s'... Complete.", user_id) - dfp_mm = DFPMessageMeta(cudf.DataFrame(final_df), user_id=user_id) + dfp_mm = DFPMessageMeta(cudf.from_pandas(final_df), user_id=user_id) multi_message = MultiDFPMessage(dfp_mm, mess_offset=0, mess_count=len(final_df)) output_message = MultiAEMessage(multi_message.meta, mess_offset=multi_message.mess_offset, mess_count=multi_message.mess_count, model=model) - return output_message + output_messages.append(output_message) + + return output_messages def node_fn(obs: mrc.Observable, sub: mrc.Subscriber): - obs.pipe(ops.map(on_data), ops.filter(lambda x: x is not None)).subscribe(sub) + obs.pipe(ops.map(on_data), ops.flatten(), ops.filter(lambda x: x is not None)).subscribe(sub) node = builder.make_node_full(DFP_TRAINING, node_fn) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp_modules_pipeline.py b/examples/digital_fingerprinting/production/morpheus/dfp_modules_pipeline.py index cd52d23834..8567ed514e 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp_modules_pipeline.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp_modules_pipeline.py @@ -160,11 +160,6 @@ def run_pipeline(log_type: str, source_stage = pipeline.add_stage(ControlMessageSourceStage(config, filenames=list(kwargs["input_file"]))) - # Here we add a wrapped module that implements the DFP Deployment - import json - with open("good_config.json", "w") as f: - json.dump(module_config, f, indent=4, default=str) - dfp_deployment_stage = pipeline.add_stage( MultiPortModuleStage(config, module_config, diff --git a/morpheus/loaders/file_to_df_loader.py b/morpheus/loaders/file_to_df_loader.py index 60d37da656..52f026ee2e 100644 --- a/morpheus/loaders/file_to_df_loader.py +++ b/morpheus/loaders/file_to_df_loader.py @@ -51,6 +51,7 @@ def file_to_df_loader(control_message: MessageControl, task: dict): files = task.get("files", None) batcher_config = task["batcher_config"] + # TODO(Devin): Should be configured at loader creation time timestamp_column_name = batcher_config.get("timestamp_column_name", None) schema_batcher_config = batcher_config.get("schema", None) schema_str = schema_batcher_config.get("schema_str", None) diff --git a/morpheus/modules/file_batcher.py b/morpheus/modules/file_batcher.py index b6eef71b1e..96cf202fa5 100644 --- a/morpheus/modules/file_batcher.py +++ b/morpheus/modules/file_batcher.py @@ -139,9 +139,10 @@ def generate_cms_for_batch_periods(control_message: MessageControl, period_gb, n return control_messages def on_data(control_message: MessageControl): - df = control_message.payload().df - files = df.files.to_arrow().to_pylist() - ts_filenames_df = build_fs_filename_df(files) + mm = control_message.payload() + with mm.mutable_dataframe() as df: + files = df.files.to_arrow().to_pylist() + ts_filenames_df = build_fs_filename_df(files) control_messages = [] if len(ts_filenames_df) > 0: diff --git a/morpheus/utils/column_info.py b/morpheus/utils/column_info.py index db732420cc..9ed0345d53 100644 --- a/morpheus/utils/column_info.py +++ b/morpheus/utils/column_info.py @@ -109,7 +109,6 @@ class RenameColumn(ColumnInfo): input_name: str def _process_column(self, df: pd.DataFrame) -> pd.Series: - if (self.input_name not in df.columns): return pd.Series(None, index=df.index, dtype=self.get_pandas_dtype()) @@ -224,10 +223,12 @@ def __post_init__(self): self.preserve_columns = input_preserve_columns -def _process_columns(df_in: pd.DataFrame, input_schema: DataFrameInputSchema): - +def _process_columns(df_in, input_schema: DataFrameInputSchema): # TODO(MDD): See what causes this to have such a perf impact over using df_in output_df = pd.DataFrame() + if (isinstance(df_in, cudf.DataFrame)): + df_in = df_in.to_pandas() + convert_to_cudf = True # Iterate over the column info for ci in input_schema.column_info: @@ -246,11 +247,13 @@ def _process_columns(df_in: pd.DataFrame, input_schema: DataFrameInputSchema): output_df[match_columns] = df_in[match_columns] + if (convert_to_cudf): + return cudf.from_pandas(output_df) + return output_df def _normalize_dataframe(df_in: pd.DataFrame, input_schema: DataFrameInputSchema): - if (input_schema.json_columns is None or len(input_schema.json_columns) == 0): return df_in @@ -294,7 +297,6 @@ def _normalize_dataframe(df_in: pd.DataFrame, input_schema: DataFrameInputSchema def _filter_rows(df_in: pd.DataFrame, input_schema: DataFrameInputSchema): - if (input_schema.row_filter is None): return df_in From 3398a2007a459018f6ddd304a3a654d71217f6c2 Mon Sep 17 00:00:00 2001 From: Devin Robison Date: Wed, 1 Mar 2023 17:04:27 -0700 Subject: [PATCH 060/157] Additional performance optimizations, start removing pandas wherever possible --- .../control_message_duo_training.json | 48 +++++++++---------- morpheus/modules/file_batcher.py | 22 ++++++--- morpheus/utils/column_info.py | 3 ++ 3 files changed, 42 insertions(+), 31 deletions(-) diff --git a/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/control_message_duo_training.json b/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/control_message_duo_training.json index deb358ff6c..e331b33f98 100644 --- a/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/control_message_duo_training.json +++ b/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/control_message_duo_training.json @@ -1,25 +1,25 @@ { - "inputs": [ - { - "tasks": [ - { - "type": "load", - "properties": { - "loader_id": "fsspec", - "files": [ - "../../../../../examples/data/dfp/duo-training-data/*.json" - ] - } - }, - { - "type": "training", - "properties": { - } - } - ], - "metadata": { - "data_type": "payload" - } - } - ] - } + "inputs": [ + { + "tasks": [ + { + "type": "load", + "properties": { + "loader_id": "fsspec", + "files": [ + "../../../../../examples/data/dfp/duo-training-data/*.json" + ] + } + }, + { + "type": "training", + "properties": { + } + } + ], + "metadata": { + "data_type": "payload" + } + } + ] +} diff --git a/morpheus/modules/file_batcher.py b/morpheus/modules/file_batcher.py index 96cf202fa5..1961e0324c 100644 --- a/morpheus/modules/file_batcher.py +++ b/morpheus/modules/file_batcher.py @@ -19,6 +19,7 @@ import fsspec import fsspec.utils import mrc +import cudf import pandas as pd from mrc.core import operators as ops @@ -95,7 +96,8 @@ def build_fs_filename_df(files): timestamps.append(ts) full_names.append(file_name) - df = pd.DataFrame() + # df = pd.DataFrame() + df = cudf.DataFrame() df["ts"] = timestamps df["key"] = full_names @@ -140,21 +142,27 @@ def generate_cms_for_batch_periods(control_message: MessageControl, period_gb, n def on_data(control_message: MessageControl): mm = control_message.payload() - with mm.mutable_dataframe() as df: - files = df.files.to_arrow().to_pylist() + with mm.mutable_dataframe() as dfm: + files = dfm.files.to_arrow().to_pylist() ts_filenames_df = build_fs_filename_df(files) control_messages = [] if len(ts_filenames_df) > 0: # Now split by the batching settings - df_period = ts_filenames_df["ts"].dt.to_period(period) - period_gb = ts_filenames_df.groupby(df_period) - n_groups = len(period_gb) + df_test = cudf.from_pandas(ts_filenames_df) + df_test["period"] = df_test["ts"].dt.strftime("%Y-%m-%d") + test_period_gb = df_test.groupby("period") + # print("DF_TEST_PERIOD: \n", df_test["period"], flush=True) + # df_period = ts_filenames_df["ts"].dt.to_period(period) + # print("DF_PERIOD: \n", df_period, flush=True) + # period_gb = ts_filenames_df.groupby(df_period) + # n_groups = len(period_gb) + n_groups = len(test_period_gb) logger.debug("Batching %d files => %d groups", len(ts_filenames_df), n_groups) control_messages = generate_cms_for_batch_periods(control_message, - period_gb, n_groups) + test_period_gb, n_groups) return control_messages diff --git a/morpheus/utils/column_info.py b/morpheus/utils/column_info.py index 9ed0345d53..a65e4ac1ca 100644 --- a/morpheus/utils/column_info.py +++ b/morpheus/utils/column_info.py @@ -226,6 +226,9 @@ def __post_init__(self): def _process_columns(df_in, input_schema: DataFrameInputSchema): # TODO(MDD): See what causes this to have such a perf impact over using df_in output_df = pd.DataFrame() + + convert_to_cudf = False + if (isinstance(df_in, cudf.DataFrame)): df_in = df_in.to_pandas() convert_to_cudf = True From ef271b38c7df88cba61ef0bdc2bce1e5c415d824 Mon Sep 17 00:00:00 2001 From: bsuryadevara Date: Wed, 1 Mar 2023 19:26:40 -0600 Subject: [PATCH 061/157] dfp deployment module splitting --- .../morpheus/dfp/modules/dfp_deployment.py | 20 +++++++----- .../morpheus/dfp/modules/dfp_inf.py | 31 +++++++++++++++++-- .../morpheus/dfp/modules/dfp_preproc.py | 9 +----- .../morpheus/dfp/modules/dfp_tra.py | 27 ++++++++++++++-- .../morpheus/dfp/utils/config_generator.py | 20 ++++++------ .../control_messages/control_message.json | 4 +-- 6 files changed, 78 insertions(+), 33 deletions(-) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_deployment.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_deployment.py index 3f1431132d..9edad80f38 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_deployment.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_deployment.py @@ -15,20 +15,22 @@ import logging import dfp.modules.dfp_inf # noqa: F401 -import dfp.modules.dfp_preproc # noqa: F401 -import dfp.modules.dfp_tra # noqa: F401 +import dfp.modules.dfp_tra import mrc from mrc.core.node import Broadcast +import morpheus._lib.modules # noqa: F401 +import morpheus.loaders.fsspec_loader +from morpheus.utils.loader_ids import FSSPEC_LOADER # noqa: F401 +from morpheus.utils.module_ids import DATA_LOADER from morpheus.utils.module_ids import MODULE_NAMESPACE +from morpheus.utils.module_utils import get_config_with_overrides from morpheus.utils.module_utils import get_module_config from morpheus.utils.module_utils import load_module from morpheus.utils.module_utils import register_module -from morpheus.utils.module_utils import get_config_with_overrides from ..utils.module_ids import DFP_DEPLOYMENT from ..utils.module_ids import DFP_INF -from ..utils.module_ids import DFP_PREPROC from ..utils.module_ids import DFP_TRA logger = logging.getLogger("morpheus.{}".format(__name__)) @@ -38,7 +40,9 @@ def dfp_deployment(builder: mrc.Builder): module_config = get_module_config(DFP_DEPLOYMENT, builder) - preproc_conf = get_config_with_overrides(module_config, DFP_PREPROC, "dfp_preproc") + fsspec_data_loader_conf = get_config_with_overrides(module_config, FSSPEC_LOADER, "fsspec_dataloader") + fsspec_data_loader_conf["module_id"] = DATA_LOADER # Work around some naming issues. + infer_conf = get_config_with_overrides(module_config, DFP_INF, "dfp_inference") train_conf = get_config_with_overrides(module_config, DFP_TRA, "dfp_training") @@ -47,7 +51,7 @@ def dfp_deployment(builder: mrc.Builder): output_port_count = module_config.get("output_port_count") - preproc_module = load_module(preproc_conf, builder=builder) + fsspec_data_loader_module = load_module(fsspec_data_loader_conf, builder=builder) # Load module from registry. infer_module = load_module(infer_conf, builder=builder) @@ -57,14 +61,14 @@ def dfp_deployment(builder: mrc.Builder): boradcast_node = Broadcast(builder, "broadcast") # Make an edge between modules - builder.make_edge(preproc_module.output_port("output"), boradcast_node) + builder.make_edge(fsspec_data_loader_module.output_port("output"), boradcast_node) builder.make_edge(boradcast_node, infer_module.input_port("input")) builder.make_edge(boradcast_node, train_module.input_port("input")) out_streams = [train_module.output_port("output"), infer_module.output_port("output")] # Register input port for a module. - builder.register_module_input("input", preproc_module.input_port("input")) + builder.register_module_input("input", fsspec_data_loader_module.input_port("input")) # Register output ports for a module. for i in range(output_port_count): diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inf.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inf.py index aa028bf1d1..02bb13a98e 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inf.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inf.py @@ -17,18 +17,25 @@ import dfp.modules.dfp_data_prep # noqa: F401 import dfp.modules.dfp_inference # noqa: F401 import dfp.modules.dfp_postprocessing # noqa: F401 -import dfp.modules.dfp_rolling_window # noqa: F401 +import dfp.modules.dfp_preproc # noqa: F401 +import dfp.modules.dfp_rolling_window import mrc +import morpheus._lib.modules # noqa: F401 +import morpheus.loaders.file_to_df_loader # noqa: F401 +import morpheus.modules.file_batcher # noqa: F401 import morpheus.modules.filter_detections # noqa: F401 import morpheus.modules.serialize # noqa: F401 import morpheus.modules.write_to_file # noqa: F401 +from morpheus.utils.loader_ids import FILE_TO_DF_LOADER +from morpheus.utils.module_ids import DATA_LOADER +from morpheus.utils.module_ids import FILE_BATCHER from morpheus.utils.module_ids import FILTER_DETECTIONS from morpheus.utils.module_ids import MODULE_NAMESPACE from morpheus.utils.module_ids import SERIALIZE from morpheus.utils.module_ids import WRITE_TO_FILE -from morpheus.utils.module_utils import get_module_config from morpheus.utils.module_utils import get_config_with_overrides +from morpheus.utils.module_utils import get_module_config from morpheus.utils.module_utils import load_module from morpheus.utils.module_utils import register_module @@ -36,7 +43,9 @@ from ..utils.module_ids import DFP_INF from ..utils.module_ids import DFP_INFERENCE from ..utils.module_ids import DFP_POST_PROCESSING +from ..utils.module_ids import DFP_PREPROC from ..utils.module_ids import DFP_ROLLING_WINDOW +from ..utils.module_ids import DFP_SPLIT_USERS logger = logging.getLogger("morpheus.{}".format(__name__)) @@ -58,6 +67,18 @@ def dfp_inf(builder: mrc.Builder): config["namespace"] = MODULE_NAMESPACE config["module_name"] = "dfp_inf" + preproc_conf = get_config_with_overrides(config, DFP_PREPROC, "dfp_preproc") + + file_batcher_conf = get_config_with_overrides(preproc_conf, FILE_BATCHER, "file_batcher") + file_to_df_data_loader_conf = get_config_with_overrides(preproc_conf, FILE_TO_DF_LOADER, "file_to_df_dataloader") + file_to_df_data_loader_conf["module_id"] = DATA_LOADER # Work around some naming issues. + dfp_split_users_conf = get_config_with_overrides(preproc_conf, DFP_SPLIT_USERS, "dfp_split_users") + + # Load modules + file_batcher_module = load_module(file_batcher_conf, builder=builder) + file_to_df_data_loader_module = load_module(file_to_df_data_loader_conf, builder=builder) + dfp_split_users_module = load_module(dfp_split_users_conf, builder=builder) + dfp_rolling_window_conf = get_config_with_overrides(config, DFP_ROLLING_WINDOW, "dfp_rolling_window") dfp_data_prep_conf = get_config_with_overrides(config, DFP_DATA_PREP, "dfp_data_prep") dfp_inference_conf = get_config_with_overrides(config, DFP_INFERENCE, "dfp_inference") @@ -67,6 +88,7 @@ def dfp_inf(builder: mrc.Builder): write_to_file_conf = get_config_with_overrides(config, WRITE_TO_FILE, "write_to_file") # Load modules + # preproc_module = load_module(preproc_conf, builder=builder) dfp_rolling_window_module = load_module(dfp_rolling_window_conf, builder=builder) dfp_data_prep_module = load_module(dfp_data_prep_conf, builder=builder) dfp_inference_module = load_module(dfp_inference_conf, builder=builder) @@ -76,6 +98,9 @@ def dfp_inf(builder: mrc.Builder): write_to_file_module = load_module(write_to_file_conf, builder=builder) # Make an edge between the modules. + builder.make_edge(file_batcher_module.output_port("output"), file_to_df_data_loader_module.input_port("input")) + builder.make_edge(file_to_df_data_loader_module.output_port("output"), dfp_split_users_module.input_port("input")) + builder.make_edge(dfp_split_users_module.output_port("output"), dfp_rolling_window_module.input_port("input")) builder.make_edge(dfp_rolling_window_module.output_port("output"), dfp_data_prep_module.input_port("input")) builder.make_edge(dfp_data_prep_module.output_port("output"), dfp_inference_module.input_port("input")) builder.make_edge(dfp_inference_module.output_port("output"), filter_detections_module.input_port("input")) @@ -84,5 +109,5 @@ def dfp_inf(builder: mrc.Builder): builder.make_edge(serialize_module.output_port("output"), write_to_file_module.input_port("input")) # Register input and output port for a module. - builder.register_module_input("input", dfp_rolling_window_module.input_port("input")) + builder.register_module_input("input", file_batcher_module.input_port("input")) builder.register_module_output("output", write_to_file_module.output_port("output")) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_preproc.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_preproc.py index 81f3329431..249feb49c5 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_preproc.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_preproc.py @@ -19,11 +19,8 @@ import morpheus._lib.modules # noqa: F401 import morpheus.loaders.file_to_df_loader # noqa: F401 -import morpheus.loaders.fsspec_loader # noqa: F401 import morpheus.modules.file_batcher # noqa: F401 -import morpheus.modules.file_to_df # noqa: F401 from morpheus.utils.loader_ids import FILE_TO_DF_LOADER -from morpheus.utils.loader_ids import FSSPEC_LOADER from morpheus.utils.module_ids import DATA_LOADER from morpheus.utils.module_ids import FILE_BATCHER from morpheus.utils.module_ids import MODULE_NAMESPACE @@ -55,24 +52,20 @@ def dfp_preproc(builder: mrc.Builder): config["module_name"] = "dfp_preproc" config["namespace"] = MODULE_NAMESPACE - fsspec_data_loader_conf = get_config_with_overrides(config, FSSPEC_LOADER, "fsspec_dataloader") - fsspec_data_loader_conf["module_id"] = DATA_LOADER # Work around some naming issues. file_batcher_conf = get_config_with_overrides(config, FILE_BATCHER, "file_batcher") file_to_df_data_loader_conf = get_config_with_overrides(config, FILE_TO_DF_LOADER, "file_to_df_dataloader") file_to_df_data_loader_conf["module_id"] = DATA_LOADER # Work around some naming issues. dfp_split_users_conf = get_config_with_overrides(config, DFP_SPLIT_USERS, "dfp_split_users") # Load modules - fsspec_data_loader_module = load_module(fsspec_data_loader_conf, builder=builder) file_batcher_module = load_module(file_batcher_conf, builder=builder) file_to_df_data_loader_module = load_module(file_to_df_data_loader_conf, builder=builder) dfp_split_users_module = load_module(dfp_split_users_conf, builder=builder) # Make an edge between the modules. - builder.make_edge(fsspec_data_loader_module.output_port("output"), file_batcher_module.input_port("input")) builder.make_edge(file_batcher_module.output_port("output"), file_to_df_data_loader_module.input_port("input")) builder.make_edge(file_to_df_data_loader_module.output_port("output"), dfp_split_users_module.input_port("input")) # Register input and output port for a module. - builder.register_module_input("input", fsspec_data_loader_module.input_port("input")) + builder.register_module_input("input", file_batcher_module.input_port("input")) builder.register_module_output("output", dfp_split_users_module.output_port("output")) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_tra.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_tra.py index 8cbae39d29..15908b8c3a 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_tra.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_tra.py @@ -15,20 +15,26 @@ import logging import dfp.modules.dfp_data_prep # noqa: F401 +# import dfp.modules.dfp_preproc # noqa: F401 import dfp.modules.dfp_rolling_window # noqa: F401 import dfp.modules.dfp_training # noqa: F401 import mrc import morpheus.modules.mlflow_model_writer # noqa: F401 +from morpheus.utils.loader_ids import FILE_TO_DF_LOADER +from morpheus.utils.module_ids import DATA_LOADER +from morpheus.utils.module_ids import FILE_BATCHER from morpheus.utils.module_ids import MLFLOW_MODEL_WRITER from morpheus.utils.module_ids import MODULE_NAMESPACE +from morpheus.utils.module_utils import get_config_with_overrides from morpheus.utils.module_utils import get_module_config from morpheus.utils.module_utils import load_module from morpheus.utils.module_utils import register_module -from morpheus.utils.module_utils import get_config_with_overrides from ..utils.module_ids import DFP_DATA_PREP +from ..utils.module_ids import DFP_PREPROC from ..utils.module_ids import DFP_ROLLING_WINDOW +from ..utils.module_ids import DFP_SPLIT_USERS from ..utils.module_ids import DFP_TRA from ..utils.module_ids import DFP_TRAINING @@ -52,22 +58,39 @@ def dfp_tra(builder: mrc.Builder): config["namespace"] = MODULE_NAMESPACE config["module_name"] = "dfp_tra" + preproc_conf = get_config_with_overrides(config, DFP_PREPROC, "dfp_preproc") + + file_batcher_conf = get_config_with_overrides(preproc_conf, FILE_BATCHER, "file_batcher") + file_to_df_data_loader_conf = get_config_with_overrides(preproc_conf, + FILE_TO_DF_LOADER, + "file_to_df_dataloader_tra") + file_to_df_data_loader_conf["module_id"] = DATA_LOADER # Work around some naming issues. + dfp_split_users_conf = get_config_with_overrides(preproc_conf, DFP_SPLIT_USERS, "dfp_split_users") + + file_batcher_module = load_module(file_batcher_conf, builder=builder) + file_to_df_data_loader_module = load_module(file_to_df_data_loader_conf, builder=builder) + dfp_split_users_module = load_module(dfp_split_users_conf, builder=builder) + dfp_rolling_window_conf = get_config_with_overrides(config, DFP_ROLLING_WINDOW, "dfp_rolling_window") dfp_data_prep_conf = get_config_with_overrides(config, DFP_DATA_PREP, "dfp_data_prep") dfp_training_conf = get_config_with_overrides(config, DFP_TRAINING, "dfp_training") mlflow_model_writer_conf = get_config_with_overrides(config, MLFLOW_MODEL_WRITER, "mlflow_model_writer") # Load modules + #preproc_module = load_module(preproc_conf, builder=builder) dfp_rolling_window_module = load_module(dfp_rolling_window_conf, builder=builder) dfp_data_prep_module = load_module(dfp_data_prep_conf, builder=builder) dfp_training_module = load_module(dfp_training_conf, builder=builder) mlflow_model_writer_module = load_module(mlflow_model_writer_conf, builder=builder) # Make an edge between the modules. + builder.make_edge(file_batcher_module.output_port("output"), file_to_df_data_loader_module.input_port("input")) + builder.make_edge(file_to_df_data_loader_module.output_port("output"), dfp_split_users_module.input_port("input")) + builder.make_edge(dfp_split_users_module.output_port("output"), dfp_rolling_window_module.input_port("input")) builder.make_edge(dfp_rolling_window_module.output_port("output"), dfp_data_prep_module.input_port("input")) builder.make_edge(dfp_data_prep_module.output_port("output"), dfp_training_module.input_port("input")) builder.make_edge(dfp_training_module.output_port("output"), mlflow_model_writer_module.input_port("input")) # Register input and output port for a module. - builder.register_module_input("input", dfp_rolling_window_module.input_port("input")) + builder.register_module_input("input", file_batcher_module.input_port("input")) builder.register_module_output("output", mlflow_model_writer_module.output_port("output")) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py b/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py index d3d9308740..810c4e6e7c 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py @@ -66,20 +66,22 @@ def get_module_config(self): module_config["module_name"] = "dfp_deployment" module_config["namespace"] = MODULE_NAMESPACE - module_config[DFP_PREPROC] = self.preproc_module_config() + module_config[FSSPEC_LOADER] = self.fsspec_dataloader_module_config() + preproc_module_config = self.preproc_module_config() module_config[DFP_TRA] = self.train_module_config() module_config[DFP_INF] = self.infer_module_config() + module_config[DFP_TRA][DFP_PREPROC] = preproc_module_config + module_config[DFP_INF][DFP_PREPROC] = preproc_module_config module_config["output_port_count"] = 2 return module_config + def fsspec_dataloader_module_config(self): + module_config = {"loaders": [{"id": FSSPEC_LOADER}]} + return module_config + def preproc_module_config(self): module_config = { - FSSPEC_LOADER: { - "loaders": [{ - "id": FSSPEC_LOADER - }] - }, FILE_BATCHER: { "period": "D", "sampling_rate_s": self._derive_args.sample_rate_s, @@ -153,8 +155,7 @@ def infer_module_config(self): "use_cpp": CppConfig.get_should_use_cpp() }, WRITE_TO_FILE: { - "filename": "dfp_detections_{}.csv".format(self._derive_args.log_type), - "overwrite": True + "filename": "dfp_detections_{}.csv".format(self._derive_args.log_type), "overwrite": True } } @@ -276,8 +277,7 @@ def inf_pipe_module_config(self): "exclude": ['batch_count', 'origin_hash', '_row_hash', '_batch_id'] }, WRITE_TO_FILE: { - "filename": "dfp_detections_{}.csv".format(self._derive_args.log_type), - "overwrite": True + "filename": "dfp_detections_{}.csv".format(self._derive_args.log_type), "overwrite": True } } diff --git a/tests/tests_data/control_messages/control_message.json b/tests/tests_data/control_messages/control_message.json index e6641d6c01..23f9e7bf40 100644 --- a/tests/tests_data/control_messages/control_message.json +++ b/tests/tests_data/control_messages/control_message.json @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:325f94f2df7bc994d3422c31134a10801691a2dbc0f7e164cf95f6e7f569003f -size 588 +oid sha256:de1c242f3c0a33dc1757952a6be5bba0b972e8c1c5d2d00a2874f6baa6fca737 +size 324 From ca32d7c25652395042765c337f7d963008f03808 Mon Sep 17 00:00:00 2001 From: bsuryadevara Date: Wed, 1 Mar 2023 21:23:40 -0600 Subject: [PATCH 062/157] updates to dfp deployment module --- .../morpheus/benchmarks/dfp_config.py | 43 +++--- .../benchmarks/resource/modules_conf.json | 78 ++++++++--- .../morpheus/dfp/modules/dfp_deployment.py | 39 +++--- .../{dfp_inf.py => dfp_inference_pipe.py} | 36 ++--- .../morpheus/dfp/modules/dfp_preproc.py | 11 +- .../dfp/modules/dfp_rolling_window.py | 5 - .../{dfp_tra.py => dfp_training_pipe.py} | 37 ++---- .../morpheus/dfp/utils/config_generator.py | 123 ++++++++++-------- .../morpheus/dfp/utils/module_ids.py | 4 +- morpheus/modules/file_batcher.py | 13 +- morpheus/utils/module_utils.py | 8 +- .../control_messages/control_message_duo.json | 4 +- 12 files changed, 214 insertions(+), 187 deletions(-) rename examples/digital_fingerprinting/production/morpheus/dfp/modules/{dfp_inf.py => dfp_inference_pipe.py} (70%) rename examples/digital_fingerprinting/production/morpheus/dfp/modules/{dfp_tra.py => dfp_training_pipe.py} (64%) diff --git a/examples/digital_fingerprinting/production/morpheus/benchmarks/dfp_config.py b/examples/digital_fingerprinting/production/morpheus/benchmarks/dfp_config.py index 0ddac97ef1..748eca00b5 100644 --- a/examples/digital_fingerprinting/production/morpheus/benchmarks/dfp_config.py +++ b/examples/digital_fingerprinting/production/morpheus/benchmarks/dfp_config.py @@ -247,29 +247,32 @@ def update_modules_conf(self, source_schema: DataFrameInputSchema, preprocess_sc preprocess_schema_str = str(pickle.dumps(preprocess_schema), encoding=encoding) start_stop_time = self._get_start_stop_time() - self.modules_conf["DFPPreproc"]["FileBatcher"]["start_time"] = start_stop_time[0] - self.modules_conf["DFPPreproc"]["FileBatcher"]["end_time"] = start_stop_time[0] - self.modules_conf["DFPPreproc"]["FileBatcher"]["schema"]["schema_str"] = source_schema_str - self.modules_conf["DFPPreproc"]["FileBatcher"]["schema"]["encoding"] = encoding - - self.modules_conf["DFPTra"]["DFPDataPrep"]["schema"]["schema_str"] = preprocess_schema_str - self.modules_conf["DFPTra"]["DFPDataPrep"]["schema"]["encoding"] = encoding - - self.modules_conf["DFPTra"]["DFPRollingWindow"]["max_history"] = self.pipeline_conf["duration"] - self.modules_conf["DFPTra"]["DFPTraining"]["feature_columns"] = self.feature_columns - self.modules_conf["DFPTra"]["MLFlowModelWriter"]["model_name_formatter"] = self._get_model_name_formatter() - self.modules_conf["DFPTra"]["MLFlowModelWriter"][ + self.modules_conf["DFPTrainingPipe"]["DFPPreproc"]["FileBatcher"]["start_time"] = start_stop_time[0] + self.modules_conf["DFPTrainingPipe"]["DFPPreproc"]["FileBatcher"]["end_time"] = start_stop_time[0] + self.modules_conf["DFPTrainingPipe"]["DFPPreproc"]["FileBatcher"]["schema"]["schema_str"] = source_schema_str + self.modules_conf["DFPTrainingPipe"]["DFPPreproc"]["FileBatcher"]["schema"]["encoding"] = encoding + self.modules_conf["DFPTrainingPipe"]["DFPDataPrep"]["schema"]["schema_str"] = preprocess_schema_str + self.modules_conf["DFPTrainingPipe"]["DFPDataPrep"]["schema"]["encoding"] = encoding + self.modules_conf["DFPTrainingPipe"]["DFPRollingWindow"]["max_history"] = self.pipeline_conf["duration"] + self.modules_conf["DFPTrainingPipe"]["DFPTraining"]["feature_columns"] = self.feature_columns + self.modules_conf["DFPTrainingPipe"]["MLFlowModelWriter"][ + "model_name_formatter"] = self._get_model_name_formatter() + self.modules_conf["DFPTrainingPipe"]["MLFlowModelWriter"][ "experiment_name_formatter"] = self._get_experiment_name_formatter() - self.modules_conf["DFPInf"]["DFPRollingWindow"]["max_history"] = "1d" - self.modules_conf["DFPInf"]["DFPDataPrep"]["schema"]["schema_str"] = preprocess_schema_str - self.modules_conf["DFPInf"]["DFPDataPrep"]["schema"]["encoding"] = encoding - self.modules_conf["DFPInf"]["DFPInference"]["model_name_formatter"] = self._get_model_name_formatter() - self.modules_conf["DFPInf"]["FilterDetections"]["schema"]["input_message_type"] = pyobj2str( + self.modules_conf["DFPInferencePipe"]["DFPPreproc"]["FileBatcher"]["start_time"] = start_stop_time[0] + self.modules_conf["DFPInferencePipe"]["DFPPreproc"]["FileBatcher"]["end_time"] = start_stop_time[0] + self.modules_conf["DFPInferencePipe"]["DFPPreproc"]["FileBatcher"]["schema"]["schema_str"] = source_schema_str + self.modules_conf["DFPInferencePipe"]["DFPPreproc"]["FileBatcher"]["schema"]["encoding"] = encoding + self.modules_conf["DFPInferencePipe"]["DFPRollingWindow"]["max_history"] = "1d" + self.modules_conf["DFPInferencePipe"]["DFPDataPrep"]["schema"]["schema_str"] = preprocess_schema_str + self.modules_conf["DFPInferencePipe"]["DFPDataPrep"]["schema"]["encoding"] = encoding + self.modules_conf["DFPInferencePipe"]["DFPInference"]["model_name_formatter"] = self._get_model_name_formatter() + self.modules_conf["DFPInferencePipe"]["FilterDetections"]["schema"]["input_message_type"] = pyobj2str( MultiMessage, encoding) - self.modules_conf["DFPInf"]["FilterDetections"]["schema"]["encoding"] = encoding - self.modules_conf["DFPInf"]["Serialize"]["use_cpp"] = True - self.modules_conf["DFPInf"]["WriteToFile"]["filename"] = "dfp_detections_{}.csv".format(self._source) + self.modules_conf["DFPInferencePipe"]["FilterDetections"]["schema"]["encoding"] = encoding + self.modules_conf["DFPInferencePipe"]["Serialize"]["use_cpp"] = True + self.modules_conf["DFPInferencePipe"]["WriteToFile"]["filename"] = "dfp_detections_{}.csv".format(self._source) self.modules_conf["output_port_count"] = 2 diff --git a/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/modules_conf.json b/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/modules_conf.json index 9a73c0f387..13acccbf60 100644 --- a/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/modules_conf.json +++ b/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/modules_conf.json @@ -1,13 +1,14 @@ { - "module_id": "DFPDeployment", - "module_name": "dfp_deployment", - "namespace": "morpheus", + "module_id": "DFPDeployment", + "module_name": "dfp_deployment", + "namespace": "morpheus", + "fsspec": { + "loaders": [{ + "id": "fsspec" + }] + }, + "DFPTrainingPipe": { "DFPPreproc": { - "fsspec": { - "loaders": [{ - "id": "fsspec" - }] - }, "FileBatcher": { "period": "D", "sampling_rate_s": 0, @@ -26,12 +27,14 @@ "schema": { "schema_str": null, "encoding": null - } + }, + "task_type": "inference" }, "file_to_df": { "loaders": [{ "id": "file_to_df" - }] + }], + "module_name": "file_to_df_dataloader_tra" }, "DFPSplitUsers": { "include_generic": true, @@ -42,15 +45,13 @@ "userid_column_name": "username", "fallback_username": "generic_user" } - }, - "DFPTra": { + }, "DFPRollingWindow": { "min_history": 300, "min_increment": 300, "max_history": null, "cache_dir": "./.cache/dfp", - "timestamp_column_name": "timestamp", - "task_type": "training" + "timestamp_column_name": "timestamp" }, "DFPDataPrep": { "timestamp_column_name": "timestamp", @@ -58,8 +59,7 @@ "schema": { "schema_str": null, "encoding": null - }, - "task_type": "training" + } }, "DFPTraining": { "model_kwargs": { @@ -108,7 +108,45 @@ "databricks_permissions": null } }, - "DFPInf": { + "DFPInferencePipe": { + "DFPPreproc": { + "FileBatcher": { + "period": "D", + "sampling_rate_s": 0, + "start_time": null, + "end_time": null, + "iso_date_regex_pattern": "(?P\\d{4})-(?P\\d{1,2})-(?P\\d{1,2})T(?P\\d{1,2})(:|_)(?P\\d{1,2})(:|_)(?P\\d{1,2})(?P\\.\\d{1,6})?Z", + "timestamp_column_name": "timestamp", + "userid_column_name": "username", + "parser_kwargs": { + "lines": false, + "orient": "records" + }, + "cache_dir": "./.cache/dfp", + "filter_null": true, + "file_type": "JSON", + "schema": { + "schema_str": null, + "encoding": null + }, + "task_type": "inference" + }, + "file_to_df": { + "loaders": [{ + "id": "file_to_df" + }], + "module_name": "file_to_df_dataloader_inf" + }, + "DFPSplitUsers": { + "include_generic": true, + "include_individual": false, + "skip_users": [], + "only_users": [], + "timestamp_column_name": "timestamp", + "userid_column_name": "username", + "fallback_username": "generic_user" + } + }, "DFPRollingWindow": { "module_id": "DFPRollingWindow", "module_name": "dfp_rolling_window", @@ -117,8 +155,7 @@ "min_increment": 0, "max_history": null, "cache_dir": "./.cache/dfp", - "timestamp_column_name": "timestamp", - "task_type": "inference" + "timestamp_column_name": "timestamp" }, "DFPDataPrep": { "timestamp_column_name": "timestamp", @@ -126,8 +163,7 @@ "schema": { "schema_str": null, "encoding": null - }, - "task_type": "inference" + } }, "DFPInference": { "model_name_formatter": null, diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_deployment.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_deployment.py index 9edad80f38..2757830cb5 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_deployment.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_deployment.py @@ -14,14 +14,13 @@ import logging -import dfp.modules.dfp_inf # noqa: F401 -import dfp.modules.dfp_tra +import dfp.modules.dfp_inference_pipe # noqa: F401 +import dfp.modules.dfp_training_pipe # noqa: F401 import mrc from mrc.core.node import Broadcast -import morpheus._lib.modules # noqa: F401 import morpheus.loaders.fsspec_loader -from morpheus.utils.loader_ids import FSSPEC_LOADER # noqa: F401 +from morpheus.utils.loader_ids import FSSPEC_LOADER from morpheus.utils.module_ids import DATA_LOADER from morpheus.utils.module_ids import MODULE_NAMESPACE from morpheus.utils.module_utils import get_config_with_overrides @@ -30,8 +29,8 @@ from morpheus.utils.module_utils import register_module from ..utils.module_ids import DFP_DEPLOYMENT -from ..utils.module_ids import DFP_INF -from ..utils.module_ids import DFP_TRA +from ..utils.module_ids import DFP_INFERENCE_PIPE +from ..utils.module_ids import DFP_TRAINING_PIPE logger = logging.getLogger("morpheus.{}".format(__name__)) @@ -40,35 +39,35 @@ def dfp_deployment(builder: mrc.Builder): module_config = get_module_config(DFP_DEPLOYMENT, builder) - fsspec_data_loader_conf = get_config_with_overrides(module_config, FSSPEC_LOADER, "fsspec_dataloader") - fsspec_data_loader_conf["module_id"] = DATA_LOADER # Work around some naming issues. + fsspec_dataloader_conf = get_config_with_overrides(module_config, FSSPEC_LOADER, "fsspec_dataloader") + fsspec_dataloader_conf["module_id"] = DATA_LOADER # Work around some naming issues. - infer_conf = get_config_with_overrides(module_config, DFP_INF, "dfp_inference") - train_conf = get_config_with_overrides(module_config, DFP_TRA, "dfp_training") + dfp_training_pipe_conf = get_config_with_overrides(module_config, DFP_TRAINING_PIPE, "dfp_training_pipe") + dfp_inference_pipe_conf = get_config_with_overrides(module_config, DFP_INFERENCE_PIPE, "dfp_inference_pipe") if "output_port_count" not in module_config: - raise Exception("Missing required attribute 'output_port_count'") + raise KeyError("Missing required configuration 'output_port_count'") output_port_count = module_config.get("output_port_count") - fsspec_data_loader_module = load_module(fsspec_data_loader_conf, builder=builder) + fsspec_dataloader_module = load_module(fsspec_dataloader_conf, builder=builder) # Load module from registry. - infer_module = load_module(infer_conf, builder=builder) - train_module = load_module(train_conf, builder=builder) + dfp_training_pipe_module = load_module(dfp_training_pipe_conf, builder=builder) + dfp_inference_pipe_module = load_module(dfp_inference_pipe_conf, builder=builder) # Create broadcast node to fork the pipeline. - boradcast_node = Broadcast(builder, "broadcast") + boradcast = Broadcast(builder, "broadcast") # Make an edge between modules - builder.make_edge(fsspec_data_loader_module.output_port("output"), boradcast_node) - builder.make_edge(boradcast_node, infer_module.input_port("input")) - builder.make_edge(boradcast_node, train_module.input_port("input")) + builder.make_edge(fsspec_dataloader_module.output_port("output"), boradcast) + builder.make_edge(boradcast, dfp_training_pipe_module.input_port("input")) + builder.make_edge(boradcast, dfp_inference_pipe_module.input_port("input")) - out_streams = [train_module.output_port("output"), infer_module.output_port("output")] + out_streams = [dfp_training_pipe_module.output_port("output"), dfp_inference_pipe_module.output_port("output")] # Register input port for a module. - builder.register_module_input("input", fsspec_data_loader_module.input_port("input")) + builder.register_module_input("input", fsspec_dataloader_module.input_port("input")) # Register output ports for a module. for i in range(output_port_count): diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inf.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference_pipe.py similarity index 70% rename from examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inf.py rename to examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference_pipe.py index 02bb13a98e..b0fe7022ff 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inf.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference_pipe.py @@ -21,15 +21,9 @@ import dfp.modules.dfp_rolling_window import mrc -import morpheus._lib.modules # noqa: F401 -import morpheus.loaders.file_to_df_loader # noqa: F401 -import morpheus.modules.file_batcher # noqa: F401 import morpheus.modules.filter_detections # noqa: F401 import morpheus.modules.serialize # noqa: F401 import morpheus.modules.write_to_file # noqa: F401 -from morpheus.utils.loader_ids import FILE_TO_DF_LOADER -from morpheus.utils.module_ids import DATA_LOADER -from morpheus.utils.module_ids import FILE_BATCHER from morpheus.utils.module_ids import FILTER_DETECTIONS from morpheus.utils.module_ids import MODULE_NAMESPACE from morpheus.utils.module_ids import SERIALIZE @@ -40,18 +34,17 @@ from morpheus.utils.module_utils import register_module from ..utils.module_ids import DFP_DATA_PREP -from ..utils.module_ids import DFP_INF from ..utils.module_ids import DFP_INFERENCE +from ..utils.module_ids import DFP_INFERENCE_PIPE from ..utils.module_ids import DFP_POST_PROCESSING from ..utils.module_ids import DFP_PREPROC from ..utils.module_ids import DFP_ROLLING_WINDOW -from ..utils.module_ids import DFP_SPLIT_USERS logger = logging.getLogger("morpheus.{}".format(__name__)) -@register_module(DFP_INF, MODULE_NAMESPACE) -def dfp_inf(builder: mrc.Builder): +@register_module(DFP_INFERENCE_PIPE, MODULE_NAMESPACE) +def dfp_inference_pipe(builder: mrc.Builder): """ This module function allows for the consolidation of multiple dfp pipeline modules relevent to inference process into a single module. @@ -62,23 +55,12 @@ def dfp_inf(builder: mrc.Builder): Pipeline budler instance. """ - config = get_module_config(DFP_INF, builder) - config["module_id"] = DFP_INF + config = get_module_config(DFP_INFERENCE_PIPE, builder) + config["module_id"] = DFP_INFERENCE_PIPE config["namespace"] = MODULE_NAMESPACE config["module_name"] = "dfp_inf" preproc_conf = get_config_with_overrides(config, DFP_PREPROC, "dfp_preproc") - - file_batcher_conf = get_config_with_overrides(preproc_conf, FILE_BATCHER, "file_batcher") - file_to_df_data_loader_conf = get_config_with_overrides(preproc_conf, FILE_TO_DF_LOADER, "file_to_df_dataloader") - file_to_df_data_loader_conf["module_id"] = DATA_LOADER # Work around some naming issues. - dfp_split_users_conf = get_config_with_overrides(preproc_conf, DFP_SPLIT_USERS, "dfp_split_users") - - # Load modules - file_batcher_module = load_module(file_batcher_conf, builder=builder) - file_to_df_data_loader_module = load_module(file_to_df_data_loader_conf, builder=builder) - dfp_split_users_module = load_module(dfp_split_users_conf, builder=builder) - dfp_rolling_window_conf = get_config_with_overrides(config, DFP_ROLLING_WINDOW, "dfp_rolling_window") dfp_data_prep_conf = get_config_with_overrides(config, DFP_DATA_PREP, "dfp_data_prep") dfp_inference_conf = get_config_with_overrides(config, DFP_INFERENCE, "dfp_inference") @@ -88,7 +70,7 @@ def dfp_inf(builder: mrc.Builder): write_to_file_conf = get_config_with_overrides(config, WRITE_TO_FILE, "write_to_file") # Load modules - # preproc_module = load_module(preproc_conf, builder=builder) + preproc_module = load_module(preproc_conf, builder=builder) dfp_rolling_window_module = load_module(dfp_rolling_window_conf, builder=builder) dfp_data_prep_module = load_module(dfp_data_prep_conf, builder=builder) dfp_inference_module = load_module(dfp_inference_conf, builder=builder) @@ -98,9 +80,7 @@ def dfp_inf(builder: mrc.Builder): write_to_file_module = load_module(write_to_file_conf, builder=builder) # Make an edge between the modules. - builder.make_edge(file_batcher_module.output_port("output"), file_to_df_data_loader_module.input_port("input")) - builder.make_edge(file_to_df_data_loader_module.output_port("output"), dfp_split_users_module.input_port("input")) - builder.make_edge(dfp_split_users_module.output_port("output"), dfp_rolling_window_module.input_port("input")) + builder.make_edge(preproc_module.output_port("output"), dfp_rolling_window_module.input_port("input")) builder.make_edge(dfp_rolling_window_module.output_port("output"), dfp_data_prep_module.input_port("input")) builder.make_edge(dfp_data_prep_module.output_port("output"), dfp_inference_module.input_port("input")) builder.make_edge(dfp_inference_module.output_port("output"), filter_detections_module.input_port("input")) @@ -109,5 +89,5 @@ def dfp_inf(builder: mrc.Builder): builder.make_edge(serialize_module.output_port("output"), write_to_file_module.input_port("input")) # Register input and output port for a module. - builder.register_module_input("input", file_batcher_module.input_port("input")) + builder.register_module_input("input", preproc_module.input_port("input")) builder.register_module_output("output", write_to_file_module.output_port("output")) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_preproc.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_preproc.py index 249feb49c5..741cd3c5b1 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_preproc.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_preproc.py @@ -17,7 +17,6 @@ import dfp.modules.dfp_split_users # noqa: F401 import mrc -import morpheus._lib.modules # noqa: F401 import morpheus.loaders.file_to_df_loader # noqa: F401 import morpheus.modules.file_batcher # noqa: F401 from morpheus.utils.loader_ids import FILE_TO_DF_LOADER @@ -53,18 +52,18 @@ def dfp_preproc(builder: mrc.Builder): config["namespace"] = MODULE_NAMESPACE file_batcher_conf = get_config_with_overrides(config, FILE_BATCHER, "file_batcher") - file_to_df_data_loader_conf = get_config_with_overrides(config, FILE_TO_DF_LOADER, "file_to_df_dataloader") - file_to_df_data_loader_conf["module_id"] = DATA_LOADER # Work around some naming issues. + file_to_df_dataloader_conf = get_config_with_overrides(config, FILE_TO_DF_LOADER) + file_to_df_dataloader_conf["module_id"] = DATA_LOADER # Work around some naming issues. dfp_split_users_conf = get_config_with_overrides(config, DFP_SPLIT_USERS, "dfp_split_users") # Load modules file_batcher_module = load_module(file_batcher_conf, builder=builder) - file_to_df_data_loader_module = load_module(file_to_df_data_loader_conf, builder=builder) + file_to_df_dataloader_module = load_module(file_to_df_dataloader_conf, builder=builder) dfp_split_users_module = load_module(dfp_split_users_conf, builder=builder) # Make an edge between the modules. - builder.make_edge(file_batcher_module.output_port("output"), file_to_df_data_loader_module.input_port("input")) - builder.make_edge(file_to_df_data_loader_module.output_port("output"), dfp_split_users_module.input_port("input")) + builder.make_edge(file_batcher_module.output_port("output"), file_to_df_dataloader_module.input_port("input")) + builder.make_edge(file_to_df_dataloader_module.output_port("output"), dfp_split_users_module.input_port("input")) # Register input and output port for a module. builder.register_module_input("input", file_batcher_module.input_port("input")) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_rolling_window.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_rolling_window.py index 748156a9cc..4a1f154e29 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_rolling_window.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_rolling_window.py @@ -48,7 +48,6 @@ def dfp_rolling_window(builder: mrc.Builder): """ config = get_module_config(DFP_ROLLING_WINDOW, builder) - task_type = config.get("task_type", None) timestamp_column_name = config.get("timestamp_column_name", None) min_history = config.get("min_history", None) max_history = config.get("max_history", None) @@ -128,9 +127,6 @@ def build_window(message: MessageMeta, user_id: str) -> MessageMeta: def on_data(control_message: MessageControl): - if not control_message.has_task(task_type): - return None - payload = control_message.payload() user_id = control_message.get_metadata("user_id") @@ -166,7 +162,6 @@ def on_data(control_message: MessageControl): rw_control_message.payload(result) # TODO(Devin): Configure based on module config # TODO(Devin): Stop using dfp rolling window for inference, it makes zero sense - rw_control_message.add_task(task_type, {}) rw_control_message.set_metadata("user_id", user_id) rw_control_message.set_metadata("data_type", "payload") diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_tra.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training_pipe.py similarity index 64% rename from examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_tra.py rename to examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training_pipe.py index 15908b8c3a..d265394392 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_tra.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training_pipe.py @@ -15,15 +15,13 @@ import logging import dfp.modules.dfp_data_prep # noqa: F401 -# import dfp.modules.dfp_preproc # noqa: F401 +import dfp.modules.dfp_preproc # noqa: F401 import dfp.modules.dfp_rolling_window # noqa: F401 import dfp.modules.dfp_training # noqa: F401 import mrc +import morpheus._lib.modules # noqa: F401 import morpheus.modules.mlflow_model_writer # noqa: F401 -from morpheus.utils.loader_ids import FILE_TO_DF_LOADER -from morpheus.utils.module_ids import DATA_LOADER -from morpheus.utils.module_ids import FILE_BATCHER from morpheus.utils.module_ids import MLFLOW_MODEL_WRITER from morpheus.utils.module_ids import MODULE_NAMESPACE from morpheus.utils.module_utils import get_config_with_overrides @@ -34,15 +32,14 @@ from ..utils.module_ids import DFP_DATA_PREP from ..utils.module_ids import DFP_PREPROC from ..utils.module_ids import DFP_ROLLING_WINDOW -from ..utils.module_ids import DFP_SPLIT_USERS -from ..utils.module_ids import DFP_TRA from ..utils.module_ids import DFP_TRAINING +from ..utils.module_ids import DFP_TRAINING_PIPE logger = logging.getLogger("morpheus.{}".format(__name__)) -@register_module(DFP_TRA, MODULE_NAMESPACE) -def dfp_tra(builder: mrc.Builder): +@register_module(DFP_TRAINING_PIPE, MODULE_NAMESPACE) +def dfp_training_pipe(builder: mrc.Builder): """ This module function allows for the consolidation of multiple dfp pipeline modules relevent to training process into a single module. @@ -53,44 +50,30 @@ def dfp_tra(builder: mrc.Builder): Pipeline budler instance. """ - config = get_module_config(DFP_TRA, builder) - config["module_id"] = DFP_TRA + config = get_module_config(DFP_TRAINING_PIPE, builder) + config["module_id"] = DFP_TRAINING_PIPE config["namespace"] = MODULE_NAMESPACE config["module_name"] = "dfp_tra" preproc_conf = get_config_with_overrides(config, DFP_PREPROC, "dfp_preproc") - - file_batcher_conf = get_config_with_overrides(preproc_conf, FILE_BATCHER, "file_batcher") - file_to_df_data_loader_conf = get_config_with_overrides(preproc_conf, - FILE_TO_DF_LOADER, - "file_to_df_dataloader_tra") - file_to_df_data_loader_conf["module_id"] = DATA_LOADER # Work around some naming issues. - dfp_split_users_conf = get_config_with_overrides(preproc_conf, DFP_SPLIT_USERS, "dfp_split_users") - - file_batcher_module = load_module(file_batcher_conf, builder=builder) - file_to_df_data_loader_module = load_module(file_to_df_data_loader_conf, builder=builder) - dfp_split_users_module = load_module(dfp_split_users_conf, builder=builder) - dfp_rolling_window_conf = get_config_with_overrides(config, DFP_ROLLING_WINDOW, "dfp_rolling_window") dfp_data_prep_conf = get_config_with_overrides(config, DFP_DATA_PREP, "dfp_data_prep") dfp_training_conf = get_config_with_overrides(config, DFP_TRAINING, "dfp_training") mlflow_model_writer_conf = get_config_with_overrides(config, MLFLOW_MODEL_WRITER, "mlflow_model_writer") # Load modules - #preproc_module = load_module(preproc_conf, builder=builder) + preproc_module = load_module(preproc_conf, builder=builder) dfp_rolling_window_module = load_module(dfp_rolling_window_conf, builder=builder) dfp_data_prep_module = load_module(dfp_data_prep_conf, builder=builder) dfp_training_module = load_module(dfp_training_conf, builder=builder) mlflow_model_writer_module = load_module(mlflow_model_writer_conf, builder=builder) # Make an edge between the modules. - builder.make_edge(file_batcher_module.output_port("output"), file_to_df_data_loader_module.input_port("input")) - builder.make_edge(file_to_df_data_loader_module.output_port("output"), dfp_split_users_module.input_port("input")) - builder.make_edge(dfp_split_users_module.output_port("output"), dfp_rolling_window_module.input_port("input")) + builder.make_edge(preproc_module.output_port("output"), dfp_rolling_window_module.input_port("input")) builder.make_edge(dfp_rolling_window_module.output_port("output"), dfp_data_prep_module.input_port("input")) builder.make_edge(dfp_data_prep_module.output_port("output"), dfp_training_module.input_port("input")) builder.make_edge(dfp_training_module.output_port("output"), mlflow_model_writer_module.input_port("input")) # Register input and output port for a module. - builder.register_module_input("input", file_batcher_module.input_port("input")) + builder.register_module_input("input", preproc_module.input_port("input")) builder.register_module_output("output", mlflow_model_writer_module.output_port("output")) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py b/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py index 810c4e6e7c..8e27c524e1 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py @@ -18,16 +18,14 @@ from dfp.utils.derive_args import pyobj2str from dfp.utils.module_ids import DFP_DATA_PREP from dfp.utils.module_ids import DFP_DEPLOYMENT -from dfp.utils.module_ids import DFP_INF from dfp.utils.module_ids import DFP_INFERENCE -from dfp.utils.module_ids import DFP_INFERENCE_PIPELINE +from dfp.utils.module_ids import DFP_INFERENCE_PIPE from dfp.utils.module_ids import DFP_POST_PROCESSING from dfp.utils.module_ids import DFP_PREPROC from dfp.utils.module_ids import DFP_ROLLING_WINDOW from dfp.utils.module_ids import DFP_SPLIT_USERS -from dfp.utils.module_ids import DFP_TRA from dfp.utils.module_ids import DFP_TRAINING -from dfp.utils.module_ids import DFP_TRAINING_PIPELINE +from dfp.utils.module_ids import DFP_TRAINING_PIPE from dfp.utils.regex_utils import iso_date_regex_pattern from dfp.utils.schema_utils import Schema @@ -39,7 +37,6 @@ from morpheus.messages.multi_message import MultiMessage from morpheus.utils.loader_ids import FILE_TO_DF_LOADER from morpheus.utils.loader_ids import FSSPEC_LOADER -from morpheus.utils.module_ids import DATA_LOADER from morpheus.utils.module_ids import FILE_BATCHER from morpheus.utils.module_ids import FILE_TO_DF from morpheus.utils.module_ids import FILTER_DETECTIONS @@ -67,11 +64,8 @@ def get_module_config(self): module_config["namespace"] = MODULE_NAMESPACE module_config[FSSPEC_LOADER] = self.fsspec_dataloader_module_config() - preproc_module_config = self.preproc_module_config() - module_config[DFP_TRA] = self.train_module_config() - module_config[DFP_INF] = self.infer_module_config() - module_config[DFP_TRA][DFP_PREPROC] = preproc_module_config - module_config[DFP_INF][DFP_PREPROC] = preproc_module_config + module_config[DFP_TRAINING_PIPE] = self.train_module_config() + module_config[DFP_INFERENCE_PIPE] = self.infer_module_config() module_config["output_port_count"] = 2 return module_config @@ -80,59 +74,54 @@ def fsspec_dataloader_module_config(self): module_config = {"loaders": [{"id": FSSPEC_LOADER}]} return module_config - def preproc_module_config(self): + def infer_module_config(self): module_config = { - FILE_BATCHER: { - "period": "D", - "sampling_rate_s": self._derive_args.sample_rate_s, - "start_time": self._derive_args.time_fields.start_time, - "end_time": self._derive_args.time_fields.end_time, - "iso_date_regex_pattern": iso_date_regex_pattern, - "timestamp_column_name": self._config.ae.timestamp_column_name, - "parser_kwargs": { - "lines": False, "orient": "records" + DFP_PREPROC: { + FILE_BATCHER: { + "period": "D", + "sampling_rate_s": self._derive_args.sample_rate_s, + "start_time": self._derive_args.time_fields.start_time, + "end_time": self._derive_args.time_fields.end_time, + "iso_date_regex_pattern": iso_date_regex_pattern, + "timestamp_column_name": self._config.ae.timestamp_column_name, + "parser_kwargs": { + "lines": False, "orient": "records" + }, + "cache_dir": self._derive_args.cache_dir, + "filter_null": True, + "file_type": "JSON", + "schema": { + "schema_str": self._source_schema_str, "encoding": self._encoding + }, + "task_type": "inference" }, - "cache_dir": self._derive_args.cache_dir, - "filter_null": True, - "file_type": "JSON", - "schema": { - "schema_str": self._source_schema_str, "encoding": self._encoding + FILE_TO_DF_LOADER: { + "loaders": [{ + "id": FILE_TO_DF_LOADER + }], "module_name": "dfp_file_to_df_dataloader_inf" + }, + DFP_SPLIT_USERS: { + "include_generic": self._derive_args.include_generic, + "include_individual": self._derive_args.include_individual, + "skip_users": self._derive_args.skip_users, + "only_users": self._derive_args.only_users, + "timestamp_column_name": self._config.ae.timestamp_column_name, + "userid_column_name": self._config.ae.userid_column_name, + "fallback_username": self._config.ae.fallback_username } }, - FILE_TO_DF_LOADER: { - "loaders": [{ - "id": FILE_TO_DF_LOADER - }] - }, - DFP_SPLIT_USERS: { - "include_generic": self._derive_args.include_generic, - "include_individual": self._derive_args.include_individual, - "skip_users": self._derive_args.skip_users, - "only_users": self._derive_args.only_users, - "timestamp_column_name": self._config.ae.timestamp_column_name, - "userid_column_name": self._config.ae.userid_column_name, - "fallback_username": self._config.ae.fallback_username - } - } - - return module_config - - def infer_module_config(self): - module_config = { DFP_ROLLING_WINDOW: { "min_history": 1, "min_increment": 0, "max_history": "1d", "cache_dir": self._derive_args.cache_dir, "timestamp_column_name": self._config.ae.timestamp_column_name, - "task_type": "inference" }, DFP_DATA_PREP: { "timestamp_column_name": self._config.ae.timestamp_column_name, "schema": { "schema_str": self._preprocess_schema_str, "encoding": self._encoding }, - "task_type": "inference" }, DFP_INFERENCE: { "model_name_formatter": self._derive_args.model_name_formatter, @@ -163,20 +152,52 @@ def infer_module_config(self): def train_module_config(self): module_config = { + DFP_PREPROC: { + FILE_BATCHER: { + "period": "D", + "sampling_rate_s": self._derive_args.sample_rate_s, + "start_time": self._derive_args.time_fields.start_time, + "end_time": self._derive_args.time_fields.end_time, + "iso_date_regex_pattern": iso_date_regex_pattern, + "timestamp_column_name": self._config.ae.timestamp_column_name, + "parser_kwargs": { + "lines": False, "orient": "records" + }, + "cache_dir": self._derive_args.cache_dir, + "filter_null": True, + "file_type": "JSON", + "schema": { + "schema_str": self._source_schema_str, "encoding": self._encoding + }, + "task_type": "training" + }, + FILE_TO_DF_LOADER: { + "loaders": [{ + "id": FILE_TO_DF_LOADER + }], "module_name": "dfp_file_to_df_dataloader_tra" + }, + DFP_SPLIT_USERS: { + "include_generic": self._derive_args.include_generic, + "include_individual": self._derive_args.include_individual, + "skip_users": self._derive_args.skip_users, + "only_users": self._derive_args.only_users, + "timestamp_column_name": self._config.ae.timestamp_column_name, + "userid_column_name": self._config.ae.userid_column_name, + "fallback_username": self._config.ae.fallback_username + } + }, DFP_ROLLING_WINDOW: { "min_history": 300, "min_increment": 300, "max_history": self._derive_args.duration, "cache_dir": self._derive_args.cache_dir, - "timestamp_column_name": self._config.ae.timestamp_column_name, - "task_type": "training" + "timestamp_column_name": self._config.ae.timestamp_column_name }, DFP_DATA_PREP: { "timestamp_column_name": self._config.ae.timestamp_column_name, "schema": { "schema_str": self._preprocess_schema_str, "encoding": self._encoding - }, - "task_type": "training" + } }, DFP_TRAINING: { "model_kwargs": { diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/utils/module_ids.py b/examples/digital_fingerprinting/production/morpheus/dfp/utils/module_ids.py index d0af919394..2a703cada6 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/utils/module_ids.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/utils/module_ids.py @@ -23,6 +23,6 @@ DFP_INFERENCE = "DFPInference" DFP_POST_PROCESSING = "DFPPostProcessing" DFP_PREPROC = "DFPPreproc" -DFP_INF = "DFPInf" -DFP_TRA = "DFPTra" +DFP_INFERENCE_PIPE = "DFPInferencePipe" +DFP_TRAINING_PIPE = "DFPTrainingPipe" DFP_DEPLOYMENT = "DFPDeployment" diff --git a/morpheus/modules/file_batcher.py b/morpheus/modules/file_batcher.py index b6eef71b1e..ba50d60fd3 100644 --- a/morpheus/modules/file_batcher.py +++ b/morpheus/modules/file_batcher.py @@ -54,7 +54,7 @@ def file_batcher(builder: mrc.Builder): end_time = config.get("end_time", None) sampling_rate_s = config.get("sampling_rate_s", None) period = config.get("period", None) - + task_type = config.get("task_type", None) iso_date_regex = re.compile(iso_date_regex_pattern) def build_fs_filename_df(files): @@ -139,11 +139,17 @@ def generate_cms_for_batch_periods(control_message: MessageControl, period_gb, n return control_messages def on_data(control_message: MessageControl): + control_messages = [] + + data_type = control_message.get_metadata("data_type") + + if not control_message.has_task(task_type) and data_type != "streaming": + return control_messages + df = control_message.payload().df files = df.files.to_arrow().to_pylist() ts_filenames_df = build_fs_filename_df(files) - control_messages = [] if len(ts_filenames_df) > 0: # Now split by the batching settings df_period = ts_filenames_df["ts"].dt.to_period(period) @@ -152,8 +158,7 @@ def on_data(control_message: MessageControl): logger.debug("Batching %d files => %d groups", len(ts_filenames_df), n_groups) - control_messages = generate_cms_for_batch_periods(control_message, - period_gb, n_groups) + control_messages = generate_cms_for_batch_periods(control_message, period_gb, n_groups) return control_messages diff --git a/morpheus/utils/module_utils.py b/morpheus/utils/module_utils.py index d74bebc17a..74571b1d8f 100644 --- a/morpheus/utils/module_utils.py +++ b/morpheus/utils/module_utils.py @@ -134,9 +134,15 @@ def verify_module_meta_fields(config: typing.Dict): raise KeyError("Required attribute 'module_name' is missing in the module configuration.") -def get_config_with_overrides(config, module_id, module_name, module_namespace="morpheus"): +def get_config_with_overrides(config, module_id, module_name=None, module_namespace="morpheus"): sub_config = config.get(module_id, None) + try: + if module_name is None: + module_name = sub_config.get("module_name") + except Exception: + raise KeyError(f"'module_name' is not set in the '{module_id}' module configuration") + sub_config.setdefault("module_id", module_id) sub_config.setdefault("module_name", module_name) sub_config.setdefault("namespace", module_namespace) diff --git a/tests/tests_data/control_messages/control_message_duo.json b/tests/tests_data/control_messages/control_message_duo.json index 828561ef26..2d933f1a02 100644 --- a/tests/tests_data/control_messages/control_message_duo.json +++ b/tests/tests_data/control_messages/control_message_duo.json @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1fdff4456c9ed8e85ed7bd823b4901f6028f7f396ede3085acc0d2581515d95d -size 629 +oid sha256:c1ae8f972fac4fd7bfbb64f0a7ffc50db0b04f3b1c76110568cc128bf4f6a9e3 +size 633 From 8f665dd115f77a997446be4d3d7c60d44e827928 Mon Sep 17 00:00:00 2001 From: bsuryadevara Date: Thu, 2 Mar 2023 20:27:18 -0600 Subject: [PATCH 063/157] restructured benchmark tests --- .../production/morpheus/benchmarks/README.md | 31 +- .../benchmarks/benchmark_conf_generator.py | 154 +++++ .../morpheus/benchmarks/dfp_config.py | 316 ---------- .../morpheus/benchmarks/modules_conf.json | 207 +++++++ .../azure_payload_inference.json} | 0 .../azure_payload_lti.json} | 0 .../azure_payload_training.json} | 0 .../azure_streaming_inference.json | 25 + .../control_messages/azure_streaming_lti.json | 46 ++ .../azure_streaming_training.json | 25 + .../duo_payload_inference.json} | 0 .../control_messages/duo_payload_lti.json | 46 ++ .../duo_payload_only_load.json | 20 + .../duo_payload_training.json} | 0 .../duo_streaming_inference.json | 25 + .../duo_streaming_lti.json} | 0 .../duo_streaming_only_load.json | 20 + .../duo_streaming_payload.json | 46 ++ .../duo_streaming_training.json | 25 + .../benchmarks/resource/modules_conf.json | 199 ------- .../benchmarks/resource/pipelines_conf.json | 310 +++++++--- .../benchmarks/test_bench_e2e_dfp_pipeline.py | 538 +++++++++--------- .../morpheus/dfp/modules/dfp_data_prep.py | 3 +- .../morpheus/dfp/modules/dfp_deployment.py | 2 +- .../dfp/modules/dfp_rolling_window.py | 5 +- .../morpheus/dfp/modules/dfp_training.py | 11 +- .../morpheus/dfp/utils/config_generator.py | 164 +++--- .../{derive_args.py => dfp_arg_parser.py} | 37 +- .../morpheus/dfp/utils/schema_utils.py | 10 +- .../morpheus/dfp_azure_modules_inference.py | 28 +- .../morpheus/dfp_azure_modules_training.py | 30 +- .../morpheus/dfp_duo_modules_inference.py | 28 +- .../morpheus/dfp_duo_modules_training.py | 30 +- .../morpheus/dfp_modules_pipeline.py | 38 +- morpheus/modules/file_batcher.py | 58 +- .../stages/general/multi_port_module_stage.py | 8 +- 36 files changed, 1393 insertions(+), 1092 deletions(-) create mode 100644 examples/digital_fingerprinting/production/morpheus/benchmarks/benchmark_conf_generator.py delete mode 100644 examples/digital_fingerprinting/production/morpheus/benchmarks/dfp_config.py create mode 100644 examples/digital_fingerprinting/production/morpheus/benchmarks/modules_conf.json rename examples/digital_fingerprinting/production/morpheus/benchmarks/resource/{control_message_azure_inference.json => control_messages/azure_payload_inference.json} (100%) rename examples/digital_fingerprinting/production/morpheus/benchmarks/resource/{control_message_azure.json => control_messages/azure_payload_lti.json} (100%) rename examples/digital_fingerprinting/production/morpheus/benchmarks/resource/{control_message_azure_training.json => control_messages/azure_payload_training.json} (100%) create mode 100644 examples/digital_fingerprinting/production/morpheus/benchmarks/resource/control_messages/azure_streaming_inference.json create mode 100644 examples/digital_fingerprinting/production/morpheus/benchmarks/resource/control_messages/azure_streaming_lti.json create mode 100644 examples/digital_fingerprinting/production/morpheus/benchmarks/resource/control_messages/azure_streaming_training.json rename examples/digital_fingerprinting/production/morpheus/benchmarks/resource/{control_message_duo_inference.json => control_messages/duo_payload_inference.json} (100%) create mode 100644 examples/digital_fingerprinting/production/morpheus/benchmarks/resource/control_messages/duo_payload_lti.json create mode 100644 examples/digital_fingerprinting/production/morpheus/benchmarks/resource/control_messages/duo_payload_only_load.json rename examples/digital_fingerprinting/production/morpheus/benchmarks/resource/{control_message_duo_training.json => control_messages/duo_payload_training.json} (100%) create mode 100644 examples/digital_fingerprinting/production/morpheus/benchmarks/resource/control_messages/duo_streaming_inference.json rename examples/digital_fingerprinting/production/morpheus/benchmarks/resource/{control_message_duo.json => control_messages/duo_streaming_lti.json} (100%) create mode 100644 examples/digital_fingerprinting/production/morpheus/benchmarks/resource/control_messages/duo_streaming_only_load.json create mode 100644 examples/digital_fingerprinting/production/morpheus/benchmarks/resource/control_messages/duo_streaming_payload.json create mode 100644 examples/digital_fingerprinting/production/morpheus/benchmarks/resource/control_messages/duo_streaming_training.json delete mode 100644 examples/digital_fingerprinting/production/morpheus/benchmarks/resource/modules_conf.json rename examples/digital_fingerprinting/production/morpheus/dfp/utils/{derive_args.py => dfp_arg_parser.py} (84%) diff --git a/examples/digital_fingerprinting/production/morpheus/benchmarks/README.md b/examples/digital_fingerprinting/production/morpheus/benchmarks/README.md index 0d755782c2..932365bc4e 100644 --- a/examples/digital_fingerprinting/production/morpheus/benchmarks/README.md +++ b/examples/digital_fingerprinting/production/morpheus/benchmarks/README.md @@ -59,20 +59,29 @@ The `-s` option allows outputs of pipeline execution to be displayed so you can The `--benchmark-warmup` and `--benchmark-warmup-iterations` options are used to run the workflow(s) once before starting measurements. This is because, if it does not already exist, the preprocessed data is cached during the initial run. `` is the name of the test to run benchmarks on. This can be one of the following: -- `test_dfp_inference_azure_stages_e2e` -- `test_dfp_inference_duo_stages_e2e` -- `test_dfp_training_azure_stages_e2e` -- `test_dfp_training_duo_stages_e2e` -- `test_dfp_modules_duo_training_e2e` -- `test_dfp_modules_azure_training_e2e` -- `test_dfp_modules_duo_inference_e2e` -- `test_dfp_modules_azure_inference_e2e` -- `test_dfp_modules_duo_e2e` -- `test_dfp_modules_azure_e2e` +- `test_dfp_modules_azure_payload_inference_e2e` +- `test_dfp_modules_azure_payload_lti_e2e` +- `test_dfp_modules_azure_payload_training_e2e` +- `test_dfp_modules_azure_streaming_inference_e2e` +- `test_dfp_modules_azure_streaming_lti_e2e` +- `test_dfp_modules_azure_streaming_training_e2e` +- `test_dfp_modules_duo_payload_inference_e2e` +- `test_dfp_modules_duo_payload_lti_e2e` +- `test_dfp_modules_duo_payload_only_load_e2e` +- `test_dfp_modules_duo_payload_training_e2e` +- `test_dfp_modules_duo_streaming_inference_e2e` +- `test_dfp_modules_duo_streaming_lti_e2e` +- `test_dfp_modules_duo_streaming_only_load_e2e` +- `test_dfp_modules_duo_streaming_payload_e2e` +- `test_dfp_modules_duo_streaming_training_e2e` +- `test_dfp_stages_azure_training_e2e` +- `test_dfp_stages_azure_inference_e2e` +- `test_dfp_stages_duo_training_e2e` +- `test_dfp_stages_duo_inference_e2e` For example, to run E2E benchmarks on the DFP training (modules) workflow on the duo logs: ``` -pytest -s --benchmark-enable --benchmark-warmup=on --benchmark-warmup-iterations=1 --benchmark-autosave test_bench_e2e_dfp_pipeline.py::test_dfp_modules_duo_training_e2e +pytest -s --benchmark-enable --benchmark-warmup=on --benchmark-warmup-iterations=1 --benchmark-autosave test_bench_e2e_dfp_pipeline.py::test_dfp_modules_azure_payload_lti_e2e ``` To run E2E benchmarks on all workflows: diff --git a/examples/digital_fingerprinting/production/morpheus/benchmarks/benchmark_conf_generator.py b/examples/digital_fingerprinting/production/morpheus/benchmarks/benchmark_conf_generator.py new file mode 100644 index 0000000000..32796801ba --- /dev/null +++ b/examples/digital_fingerprinting/production/morpheus/benchmarks/benchmark_conf_generator.py @@ -0,0 +1,154 @@ +# SPDX-FileCopyrightText: Copyright (c) 2023, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import glob +import json +import logging +import typing +from datetime import datetime +from datetime import timedelta +from datetime import timezone +from os import path + +import mlflow +import pandas as pd +from dfp.utils.config_generator import ConfigGenerator +from dfp.utils.config_generator import generate_ae_config +from dfp.utils.dfp_arg_parser import DFPArgParser +from dfp.utils.schema_utils import SchemaBuilder + +THIS_DIR = path.dirname(path.abspath(__file__)) + + +def set_mlflow_tracking_uri(tracking_uri): + mlflow.set_tracking_uri(tracking_uri) + logging.getLogger('mlflow').setLevel(logging.WARN) + + +def load_json(filepath: str): + full_filepath = path.join(THIS_DIR, filepath) + with open(full_filepath, 'r') as (f): + json_dict = json.load(f) + return json_dict + + +class BenchmarkConfGenerator: + + def __init__(self, pipe_conf: typing.Dict[(str, any)], tracking_uri: str, log_level=logging.ERROR): + self._pipe_conf = pipe_conf + self._tracking_uri = tracking_uri + self._log_level = log_level + self._config = self._create_config() + + @property + def pipe_config(self): + return self._config + + @property + def log_level(self): + return self._log_level + + @property + def source(self): + return self._pipe_conf.get('source') + + def _get_model_name_formatter(self) -> str: + model_name_formatter = 'DFP-{}-'.format(self.source) + '{user_id}' + return model_name_formatter + + def _get_experiment_name_formatter(self) -> str: + experiment_name_formatter = 'dfp/{}/training/'.format(self.source) + '{reg_model_name}' + return experiment_name_formatter + + def _get_start_stop_time(self) -> typing.Tuple[(datetime, datetime)]: + start_time = self._pipe_conf.get('start_time') + start_time = datetime.strptime(start_time, '%Y-%m-%d') + duration = self._pipe_conf.get('duration') + duration = timedelta(seconds=(pd.Timedelta(duration).total_seconds())) + if start_time is None: + end_time = datetime.now(tz=(timezone.utc)) + start_time = end_time - duration + else: + if start_time.tzinfo is None: + start_time = start_time.replace(tzinfo=(timezone.utc)) + end_time = start_time + duration + return tuple((start_time, end_time)) + + def _create_config(self): + config = generate_ae_config(source=(self._pipe_conf.get('source')), + userid_column_name=(self._pipe_conf.get('userid_column_name')), + timestamp_column_name=(self._pipe_conf.get('timestamp_column_name')), + use_cpp=(self._pipe_conf.get('use_cpp')), + pipeline_batch_size=(self._pipe_conf.get('pipeline_batch_size')), + edge_buffer_size=(self._pipe_conf.get('edge_buffer_size')), + num_threads=(self._pipe_conf.get('num_threads'))) + return config + + def get_stages_conf(self) -> typing.Dict[(str, any)]: + stages_conf = {} + start_stop_time = self._get_start_stop_time() + stages_conf['start_time'] = start_stop_time[0] + stages_conf['end_time'] = start_stop_time[1] + stages_conf['duration'] = self._pipe_conf.get('duration') + stages_conf['sampling_rate_s'] = 0 + stages_conf['cache_dir'] = './.cache/dfp' + stages_conf['include_generic'] = True + stages_conf['include_individual'] = False + stages_conf['skip_users'] = [] + stages_conf['only_users'] = [] + stages_conf['model_name_formatter'] = self._get_model_name_formatter() + stages_conf['experiment_name_formatter'] = self._get_experiment_name_formatter() + return stages_conf + + def get_filenames(self) -> typing.List[str]: + if 'glob_path' in self._pipe_conf: + input_glob = self._pipe_conf.get('glob_path') + input_glob = path.join(THIS_DIR, input_glob) + filenames = glob.glob(input_glob) + else: + if 'file_path' in self._pipe_conf: + file_path = self._pipe_conf.get('file_path') + full_file_path = path.join(THIS_DIR, file_path) + filenames = [full_file_path] + else: + if 'message_path' in self._pipe_conf: + file_path = self._pipe_conf.get('message_path') + full_file_path = path.join(THIS_DIR, file_path) + filenames = [full_file_path] + else: + raise KeyError('Configuration needs the glob path or file path attribute.') + assert len(filenames) > 0 + return filenames + + def get_schema(self): + schema_builder = SchemaBuilder((self.pipe_config), source=(self.source)) + schema = schema_builder.build_schema() + return schema + + def get_module_conf(self): + dfp_arg_parser = DFPArgParser(skip_user=[], + only_user=[], + start_time=(datetime.strptime(self._pipe_conf.get('start_time'), '%Y-%m-%d')), + log_level=(self._log_level), + cache_dir='./.cache/dfp', + sample_rate_s=0, + duration=(self._pipe_conf.get('duration')), + source=(self.source), + tracking_uri=(self._tracking_uri), + train_users='generic') + dfp_arg_parser.init() + config_generator = ConfigGenerator(self.pipe_config, dfp_arg_parser, self.get_schema()) + module_conf = config_generator.get_module_conf() + return module_conf diff --git a/examples/digital_fingerprinting/production/morpheus/benchmarks/dfp_config.py b/examples/digital_fingerprinting/production/morpheus/benchmarks/dfp_config.py deleted file mode 100644 index 748eca00b5..0000000000 --- a/examples/digital_fingerprinting/production/morpheus/benchmarks/dfp_config.py +++ /dev/null @@ -1,316 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2023, NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import glob -import json -import logging -import pickle -import typing -from datetime import datetime -from datetime import timedelta -from datetime import timezone -from functools import partial -from os import path - -import mlflow -import pandas as pd -from dfp.utils.derive_args import pyobj2str - -from morpheus.config import Config -from morpheus.config import ConfigAutoEncoder -from morpheus.config import CppConfig -from morpheus.messages.multi_message import MultiMessage -from morpheus.utils.column_info import BoolColumn -from morpheus.utils.column_info import ColumnInfo -from morpheus.utils.column_info import CustomColumn -from morpheus.utils.column_info import DataFrameInputSchema -from morpheus.utils.column_info import DateTimeColumn -from morpheus.utils.column_info import IncrementColumn -from morpheus.utils.column_info import RenameColumn -from morpheus.utils.column_info import StringCatColumn -from morpheus.utils.column_info import create_increment_col - -THIS_DIR = path.dirname(path.abspath(__file__)) - - -def set_mlflow_tracking_uri(tracking_uri): - mlflow.set_tracking_uri(tracking_uri) - logging.getLogger("mlflow").setLevel(logging.WARN) - - -def load_json(filepath: str): - full_filepath = path.join(THIS_DIR, filepath) - with open(full_filepath, 'r') as f: - json_dict = json.load(f) - return json_dict - - -def get_duo_source_schema(config: Config) -> DataFrameInputSchema: - - # Source schema - source_column_info = [ - DateTimeColumn(name=config.ae.timestamp_column_name, dtype=datetime, input_name="timestamp"), - RenameColumn(name=config.ae.userid_column_name, dtype=str, input_name="user.name"), - RenameColumn(name="accessdevicebrowser", dtype=str, input_name="access_device.browser"), - RenameColumn(name="accessdeviceos", dtype=str, input_name="access_device.os"), - StringCatColumn(name="location", - dtype=str, - input_columns=[ - "access_device.location.city", - "access_device.location.state", - "access_device.location.country" - ], - sep=", "), - RenameColumn(name="authdevicename", dtype=str, input_name="auth_device.name"), - BoolColumn(name="result", - dtype=bool, - input_name="result", - true_values=["success", "SUCCESS"], - false_values=["denied", "DENIED", "FRAUD"]), - ColumnInfo(name="reason", dtype=str), - ] - - schema = DataFrameInputSchema(json_columns=["access_device", "application", "auth_device", "user"], - column_info=source_column_info) - - return schema - - -def get_duo_preprocess_schema(config: Config) -> DataFrameInputSchema: - - # Preprocessing schema - preprocess_column_info = [ - ColumnInfo(name=config.ae.timestamp_column_name, dtype=datetime), - ColumnInfo(name=config.ae.userid_column_name, dtype=str), - ColumnInfo(name="accessdevicebrowser", dtype=str), - ColumnInfo(name="accessdeviceos", dtype=str), - ColumnInfo(name="authdevicename", dtype=str), - ColumnInfo(name="result", dtype=bool), - ColumnInfo(name="reason", dtype=str), - IncrementColumn(name="logcount", - dtype=int, - input_name=config.ae.timestamp_column_name, - groupby_column=config.ae.userid_column_name), - CustomColumn(name="locincrement", - dtype=int, - process_column_fn=partial(create_increment_col, column_name="location")), - ] - - schema = DataFrameInputSchema(column_info=preprocess_column_info, preserve_columns=["_batch_id"]) - - return schema - - -def get_azure_source_schema(config: Config) -> DataFrameInputSchema: - - # Source schema - source_column_info = [ - DateTimeColumn(name=config.ae.timestamp_column_name, dtype=datetime, input_name="time"), - RenameColumn(name=config.ae.userid_column_name, dtype=str, input_name="properties.userPrincipalName"), - RenameColumn(name="appDisplayName", dtype=str, input_name="properties.appDisplayName"), - ColumnInfo(name="category", dtype=str), - RenameColumn(name="clientAppUsed", dtype=str, input_name="properties.clientAppUsed"), - RenameColumn(name="deviceDetailbrowser", dtype=str, input_name="properties.deviceDetail.browser"), - RenameColumn(name="deviceDetaildisplayName", dtype=str, input_name="properties.deviceDetail.displayName"), - RenameColumn(name="deviceDetailoperatingSystem", - dtype=str, - input_name="properties.deviceDetail.operatingSystem"), - StringCatColumn(name="location", - dtype=str, - input_columns=[ - "properties.location.city", - "properties.location.countryOrRegion", - ], - sep=", "), - RenameColumn(name="statusfailureReason", dtype=str, input_name="properties.status.failureReason"), - ] - - schema = DataFrameInputSchema(json_columns=["properties"], column_info=source_column_info) - - return schema - - -def get_azure_preprocess_schema(config: Config) -> DataFrameInputSchema: - - # Preprocessing schema - preprocess_column_info = [ - ColumnInfo(name=config.ae.timestamp_column_name, dtype=datetime), - ColumnInfo(name=config.ae.userid_column_name, dtype=str), - ColumnInfo(name="appDisplayName", dtype=str), - ColumnInfo(name="clientAppUsed", dtype=str), - ColumnInfo(name="deviceDetailbrowser", dtype=str), - ColumnInfo(name="deviceDetaildisplayName", dtype=str), - ColumnInfo(name="deviceDetailoperatingSystem", dtype=str), - ColumnInfo(name="statusfailureReason", dtype=str), - IncrementColumn(name="logcount", - dtype=int, - input_name=config.ae.timestamp_column_name, - groupby_column=config.ae.userid_column_name), - CustomColumn(name="locincrement", - dtype=int, - process_column_fn=partial(create_increment_col, column_name="location")), - CustomColumn(name="appincrement", - dtype=int, - process_column_fn=partial(create_increment_col, column_name="appDisplayName")), - ] - - schema = DataFrameInputSchema(column_info=preprocess_column_info, preserve_columns=["_batch_id"]) - - return schema - - -class DFPConfig(): - - def __init__(self, - pipeline_conf: typing.Dict[str, any], - feature_columns: typing.List[str], - source: str, - modules_conf: typing.Dict[str, any] = None): - self._pipeline_conf = pipeline_conf - self._modules_conf = modules_conf - self._feature_columns = feature_columns - self._source = source - - @property - def pipeline_conf(self): - return self._pipeline_conf - - @property - def modules_conf(self): - return self._modules_conf - - @property - def feature_columns(self): - return self._feature_columns - - @property - def source(self): - return self._source - - def get_config(self, use_cpp=True) -> Config: - - config = Config() - CppConfig.set_should_use_cpp(use_cpp) - config.ae = ConfigAutoEncoder() - - config.num_threads = self.pipeline_conf["num_threads"] - config.pipeline_batch_size = self.pipeline_conf["pipeline_batch_size"] - config.edge_buffer_size = self.pipeline_conf["edge_buffer_size"] - config.ae.userid_column_name = "username" - config.ae.timestamp_column_name = "timestamp" - config.ae.feature_columns = self.feature_columns - - return config - - def _get_model_name_formatter(self) -> str: - model_name_formatter = "DFP-{}-".format(self.source) + "{user_id}" - return model_name_formatter - - def _get_experiment_name_formatter(self) -> str: - experiment_name_formatter = "dfp/{}/training/".format(self.source) + "{reg_model_name}" - return experiment_name_formatter - - def _get_start_stop_time(self) -> typing.Tuple[datetime, datetime]: - start_time = self.pipeline_conf["start_time"] - start_time = datetime.strptime(start_time, "%Y-%m-%d") - - duration = self.pipeline_conf["duration"] - duration = timedelta(seconds=pd.Timedelta(duration).total_seconds()) - - if start_time is None: - end_time = datetime.now(tz=timezone.utc) - start_time = end_time - duration - else: - if start_time.tzinfo is None: - start_time = start_time.replace(tzinfo=timezone.utc) - - end_time = start_time + duration - return tuple((start_time, end_time)) - - def update_modules_conf(self, source_schema: DataFrameInputSchema, preprocess_schema: DataFrameInputSchema): - encoding = "latin1" - - # Convert schema as a string - source_schema_str = str(pickle.dumps(source_schema), encoding=encoding) - preprocess_schema_str = str(pickle.dumps(preprocess_schema), encoding=encoding) - - start_stop_time = self._get_start_stop_time() - self.modules_conf["DFPTrainingPipe"]["DFPPreproc"]["FileBatcher"]["start_time"] = start_stop_time[0] - self.modules_conf["DFPTrainingPipe"]["DFPPreproc"]["FileBatcher"]["end_time"] = start_stop_time[0] - self.modules_conf["DFPTrainingPipe"]["DFPPreproc"]["FileBatcher"]["schema"]["schema_str"] = source_schema_str - self.modules_conf["DFPTrainingPipe"]["DFPPreproc"]["FileBatcher"]["schema"]["encoding"] = encoding - self.modules_conf["DFPTrainingPipe"]["DFPDataPrep"]["schema"]["schema_str"] = preprocess_schema_str - self.modules_conf["DFPTrainingPipe"]["DFPDataPrep"]["schema"]["encoding"] = encoding - self.modules_conf["DFPTrainingPipe"]["DFPRollingWindow"]["max_history"] = self.pipeline_conf["duration"] - self.modules_conf["DFPTrainingPipe"]["DFPTraining"]["feature_columns"] = self.feature_columns - self.modules_conf["DFPTrainingPipe"]["MLFlowModelWriter"][ - "model_name_formatter"] = self._get_model_name_formatter() - self.modules_conf["DFPTrainingPipe"]["MLFlowModelWriter"][ - "experiment_name_formatter"] = self._get_experiment_name_formatter() - - self.modules_conf["DFPInferencePipe"]["DFPPreproc"]["FileBatcher"]["start_time"] = start_stop_time[0] - self.modules_conf["DFPInferencePipe"]["DFPPreproc"]["FileBatcher"]["end_time"] = start_stop_time[0] - self.modules_conf["DFPInferencePipe"]["DFPPreproc"]["FileBatcher"]["schema"]["schema_str"] = source_schema_str - self.modules_conf["DFPInferencePipe"]["DFPPreproc"]["FileBatcher"]["schema"]["encoding"] = encoding - self.modules_conf["DFPInferencePipe"]["DFPRollingWindow"]["max_history"] = "1d" - self.modules_conf["DFPInferencePipe"]["DFPDataPrep"]["schema"]["schema_str"] = preprocess_schema_str - self.modules_conf["DFPInferencePipe"]["DFPDataPrep"]["schema"]["encoding"] = encoding - self.modules_conf["DFPInferencePipe"]["DFPInference"]["model_name_formatter"] = self._get_model_name_formatter() - self.modules_conf["DFPInferencePipe"]["FilterDetections"]["schema"]["input_message_type"] = pyobj2str( - MultiMessage, encoding) - self.modules_conf["DFPInferencePipe"]["FilterDetections"]["schema"]["encoding"] = encoding - self.modules_conf["DFPInferencePipe"]["Serialize"]["use_cpp"] = True - self.modules_conf["DFPInferencePipe"]["WriteToFile"]["filename"] = "dfp_detections_{}.csv".format(self._source) - - self.modules_conf["output_port_count"] = 2 - - def get_stages_conf(self) -> typing.Dict[str, any]: - - stages_conf = {} - start_stop_time = self._get_start_stop_time() - stages_conf["start_time"] = start_stop_time[0] - stages_conf["end_time"] = start_stop_time[1] - stages_conf["duration"] = self.pipeline_conf["duration"] - stages_conf["sampling_rate_s"] = 0 - stages_conf["cache_dir"] = "./.cache/dfp" - stages_conf["include_generic"] = True - stages_conf["include_individual"] = False - stages_conf["skip_users"] = [] - stages_conf["only_users"] = [] - stages_conf["model_name_formatter"] = self._get_model_name_formatter() - stages_conf["experiment_name_formatter"] = self._get_experiment_name_formatter() - - return stages_conf - - def get_filenames(self) -> typing.List[str]: - - if "glob_path" in self.pipeline_conf: - input_glob = self.pipeline_conf.get("glob_path") - input_glob = path.join(THIS_DIR, input_glob) - filenames = glob.glob(input_glob) - elif "file_path" in self.pipeline_conf: - file_path = self.pipeline_conf.get("file_path") - full_file_path = path.join(THIS_DIR, file_path) - filenames = [full_file_path] - elif "message_path" in self.pipeline_conf: - file_path = self.pipeline_conf.get("message_path") - full_file_path = path.join(THIS_DIR, file_path) - filenames = [full_file_path] - else: - raise KeyError("Configuration needs the glob path or file path attribute.") - - assert len(filenames) > 0 # List empty throw error - - return filenames diff --git a/examples/digital_fingerprinting/production/morpheus/benchmarks/modules_conf.json b/examples/digital_fingerprinting/production/morpheus/benchmarks/modules_conf.json new file mode 100644 index 0000000000..d8b1342a0c --- /dev/null +++ b/examples/digital_fingerprinting/production/morpheus/benchmarks/modules_conf.json @@ -0,0 +1,207 @@ +{ + "module_id": "DFPDeployment", + "module_name": "dfp_deployment", + "namespace": "morpheus", + "fsspec": { + "loaders": [ + { + "id": "fsspec" + } + ] + }, + "DFPTrainingPipe": { + "DFPPreproc": { + "FileBatcher": { + "period": "D", + "sampling_rate_s": 0, + "start_time": "2022-08-01 00:00:00+00:00", + "end_time": "2022-09-30 00:00:00+00:00", + "iso_date_regex_pattern": "(?P\\d{4})-(?P\\d{1,2})-(?P\\d{1,2})T(?P\\d{1,2})(:|_)(?P\\d{1,2})(:|_)(?P\\d{1,2})(?P\\.\\d{1,6})?Z", + "timestamp_column_name": "timestamp", + "parser_kwargs": { + "lines": false, + "orient": "records" + }, + "cache_dir": "./.cache/dfp", + "filter_null": true, + "file_type": "JSON", + "schema": { + "schema_str": "\u0080\u0004\u0095e\u0003\u0000\u0000\u0000\u0000\u0000\u0000\u008c\u001amorpheus.utils.column_info\u0094\u008c\u0014DataFrameInputSchema\u0094\u0093\u0094)\u0081\u0094}\u0094(\u008c\fjson_columns\u0094]\u0094(\u008c\raccess_device\u0094\u008c\u000bapplication\u0094\u008c\u000bauth_device\u0094\u008c\u0004user\u0094e\u008c\u000bcolumn_info\u0094]\u0094(h\u0000\u008c\u000eDateTimeColumn\u0094\u0093\u0094)\u0081\u0094}\u0094(\u008c\u0004name\u0094\u008c\ttimestamp\u0094\u008c\u0005dtype\u0094\u008c\bdatetime\u0094\u008c\bdatetime\u0094\u0093\u0094\u008c\ninput_name\u0094\u008c\ttimestamp\u0094ubh\u0000\u008c\fRenameColumn\u0094\u0093\u0094)\u0081\u0094}\u0094(h\u0011\u008c\busername\u0094h\u0013\u008c\bbuiltins\u0094\u008c\u0003str\u0094\u0093\u0094h\u0017\u008c\tuser.name\u0094ubh\u001a)\u0081\u0094}\u0094(h\u0011\u008c\u0013accessdevicebrowser\u0094h\u0013h h\u0017\u008c\u0015access_device.browser\u0094ubh\u001a)\u0081\u0094}\u0094(h\u0011\u008c\u000eaccessdeviceos\u0094h\u0013h h\u0017\u008c\u0010access_device.os\u0094ubh\u0000\u008c\u000fStringCatColumn\u0094\u0093\u0094)\u0081\u0094}\u0094(h\u0011\u008c\blocation\u0094h\u0013h \u008c\rinput_columns\u0094]\u0094(\u008c\u001baccess_device.location.city\u0094\u008c\u001caccess_device.location.state\u0094\u008c\u001eaccess_device.location.country\u0094e\u008c\u0003sep\u0094\u008c\u0002, \u0094ubh\u001a)\u0081\u0094}\u0094(h\u0011\u008c\u000eauthdevicename\u0094h\u0013h h\u0017\u008c\u0010auth_device.name\u0094ubh\u0000\u008c\nBoolColumn\u0094\u0093\u0094)\u0081\u0094}\u0094(h\u0011\u008c\u0006result\u0094h\u0013h\u001e\u008c\u0004bool\u0094\u0093\u0094h\u0017h>\u008c\tvalue_map\u0094}\u0094(\u008c\u0007success\u0094\u0088\u008c\u0007SUCCESS\u0094\u0088\u008c\u0006denied\u0094\u0089\u008c\u0006DENIED\u0094\u0089\u008c\u0005FRAUD\u0094\u0089uubh\u0000\u008c\nColumnInfo\u0094\u0093\u0094)\u0081\u0094}\u0094(h\u0011\u008c\u0006reason\u0094h\u0013h ube\u008c\u0010preserve_columns\u0094N\u008c\nrow_filter\u0094Nub.", + "encoding": "latin1" + }, + "task_type": "training" + }, + "file_to_df": { + "loaders": [ + { + "id": "file_to_df" + } + ], + "module_name": "dfp_file_to_df_dataloader_tra" + }, + "DFPSplitUsers": { + "include_generic": true, + "include_individual": false, + "skip_users": [], + "only_users": [], + "timestamp_column_name": "timestamp", + "userid_column_name": "username", + "fallback_username": "generic_user" + } + }, + "DFPRollingWindow": { + "min_history": 300, + "min_increment": 300, + "max_history": "60d", + "cache_dir": "./.cache/dfp", + "timestamp_column_name": "timestamp" + }, + "DFPDataPrep": { + "timestamp_column_name": "timestamp", + "schema": { + "schema_str": "\u0080\u0004\u0095\u00c9\u0002\u0000\u0000\u0000\u0000\u0000\u0000\u008c\u001amorpheus.utils.column_info\u0094\u008c\u0014DataFrameInputSchema\u0094\u0093\u0094)\u0081\u0094}\u0094(\u008c\fjson_columns\u0094]\u0094\u008c\u000bcolumn_info\u0094]\u0094(h\u0000\u008c\nColumnInfo\u0094\u0093\u0094)\u0081\u0094}\u0094(\u008c\u0004name\u0094\u008c\ttimestamp\u0094\u008c\u0005dtype\u0094\u008c\bdatetime\u0094\u008c\bdatetime\u0094\u0093\u0094ubh\n)\u0081\u0094}\u0094(h\r\u008c\busername\u0094h\u000f\u008c\bbuiltins\u0094\u008c\u0003str\u0094\u0093\u0094ubh\n)\u0081\u0094}\u0094(h\r\u008c\u0013accessdevicebrowser\u0094h\u000fh\u0018ubh\n)\u0081\u0094}\u0094(h\r\u008c\u000eaccessdeviceos\u0094h\u000fh\u0018ubh\n)\u0081\u0094}\u0094(h\r\u008c\u000eauthdevicename\u0094h\u000fh\u0018ubh\n)\u0081\u0094}\u0094(h\r\u008c\u0006result\u0094h\u000fh\u0016\u008c\u0004bool\u0094\u0093\u0094ubh\n)\u0081\u0094}\u0094(h\r\u008c\u0006reason\u0094h\u000fh\u0018ubh\u0000\u008c\u000fIncrementColumn\u0094\u0093\u0094)\u0081\u0094}\u0094(h\r\u008c\blogcount\u0094h\u000fh\u0016\u008c\u0003int\u0094\u0093\u0094\u008c\ninput_name\u0094h\u000e\u008c\u000egroupby_column\u0094h\u0015\u008c\u0006period\u0094\u008c\u0001D\u0094ubh\u0000\u008c\fCustomColumn\u0094\u0093\u0094)\u0081\u0094}\u0094(h\r\u008c\flocincrement\u0094h\u000fh0\u008c\u0011process_column_fn\u0094\u008c\tfunctools\u0094\u008c\u0007partial\u0094\u0093\u0094h\u0000\u008c\u0014create_increment_col\u0094\u0093\u0094\u0085\u0094R\u0094(h?)}\u0094\u008c\u000bcolumn_name\u0094\u008c\blocation\u0094sNt\u0094bube\u008c\u0010preserve_columns\u0094\u008c\u0002re\u0094\u008c\b_compile\u0094\u0093\u0094\u008c\u000b(_batch_id)\u0094K \u0086\u0094R\u0094\u008c\nrow_filter\u0094Nub.", + "encoding": "latin1" + } + }, + "DFPTraining": { + "model_kwargs": { + "encoder_layers": [ + 512, + 500 + ], + "decoder_layers": [ + 512 + ], + "activation": "relu", + "swap_p": 0.2, + "lr": 0.001, + "lr_decay": 0.99, + "batch_size": 512, + "verbose": false, + "optimizer": "sgd", + "scaler": "standard", + "min_cats": 1, + "progress_bar": false, + "device": "cuda" + }, + "feature_columns": [ + "accessdevicebrowser", + "accessdeviceos", + "authdevicename", + "reason", + "result", + "locincrement", + "logcount" + ], + "epochs": 30, + "validation_size": 0.1 + }, + "MLFlowModelWriter": { + "model_name_formatter": "DFP-duo-{user_id}", + "experiment_name_formatter": "dfp/duo/training/{reg_model_name}", + "timestamp_column_name": "timestamp", + "conda_env": { + "channels": [ + "defaults", + "conda-forge" + ], + "dependencies": [ + "python=3.8", + "pip" + ], + "pip": [ + "mlflow", + "dfencoder" + ], + "name": "mlflow-env" + }, + "databricks_permissions": null + } + }, + "DFPInferencePipe": { + "DFPPreproc": { + "FileBatcher": { + "period": "D", + "sampling_rate_s": 0, + "start_time": "2022-08-01 00:00:00+00:00", + "end_time": "2022-09-30 00:00:00+00:00", + "iso_date_regex_pattern": "(?P\\d{4})-(?P\\d{1,2})-(?P\\d{1,2})T(?P\\d{1,2})(:|_)(?P\\d{1,2})(:|_)(?P\\d{1,2})(?P\\.\\d{1,6})?Z", + "timestamp_column_name": "timestamp", + "parser_kwargs": { + "lines": false, + "orient": "records" + }, + "cache_dir": "./.cache/dfp", + "filter_null": true, + "file_type": "JSON", + "schema": { + "schema_str": "\u0080\u0004\u0095e\u0003\u0000\u0000\u0000\u0000\u0000\u0000\u008c\u001amorpheus.utils.column_info\u0094\u008c\u0014DataFrameInputSchema\u0094\u0093\u0094)\u0081\u0094}\u0094(\u008c\fjson_columns\u0094]\u0094(\u008c\raccess_device\u0094\u008c\u000bapplication\u0094\u008c\u000bauth_device\u0094\u008c\u0004user\u0094e\u008c\u000bcolumn_info\u0094]\u0094(h\u0000\u008c\u000eDateTimeColumn\u0094\u0093\u0094)\u0081\u0094}\u0094(\u008c\u0004name\u0094\u008c\ttimestamp\u0094\u008c\u0005dtype\u0094\u008c\bdatetime\u0094\u008c\bdatetime\u0094\u0093\u0094\u008c\ninput_name\u0094\u008c\ttimestamp\u0094ubh\u0000\u008c\fRenameColumn\u0094\u0093\u0094)\u0081\u0094}\u0094(h\u0011\u008c\busername\u0094h\u0013\u008c\bbuiltins\u0094\u008c\u0003str\u0094\u0093\u0094h\u0017\u008c\tuser.name\u0094ubh\u001a)\u0081\u0094}\u0094(h\u0011\u008c\u0013accessdevicebrowser\u0094h\u0013h h\u0017\u008c\u0015access_device.browser\u0094ubh\u001a)\u0081\u0094}\u0094(h\u0011\u008c\u000eaccessdeviceos\u0094h\u0013h h\u0017\u008c\u0010access_device.os\u0094ubh\u0000\u008c\u000fStringCatColumn\u0094\u0093\u0094)\u0081\u0094}\u0094(h\u0011\u008c\blocation\u0094h\u0013h \u008c\rinput_columns\u0094]\u0094(\u008c\u001baccess_device.location.city\u0094\u008c\u001caccess_device.location.state\u0094\u008c\u001eaccess_device.location.country\u0094e\u008c\u0003sep\u0094\u008c\u0002, \u0094ubh\u001a)\u0081\u0094}\u0094(h\u0011\u008c\u000eauthdevicename\u0094h\u0013h h\u0017\u008c\u0010auth_device.name\u0094ubh\u0000\u008c\nBoolColumn\u0094\u0093\u0094)\u0081\u0094}\u0094(h\u0011\u008c\u0006result\u0094h\u0013h\u001e\u008c\u0004bool\u0094\u0093\u0094h\u0017h>\u008c\tvalue_map\u0094}\u0094(\u008c\u0007success\u0094\u0088\u008c\u0007SUCCESS\u0094\u0088\u008c\u0006denied\u0094\u0089\u008c\u0006DENIED\u0094\u0089\u008c\u0005FRAUD\u0094\u0089uubh\u0000\u008c\nColumnInfo\u0094\u0093\u0094)\u0081\u0094}\u0094(h\u0011\u008c\u0006reason\u0094h\u0013h ube\u008c\u0010preserve_columns\u0094N\u008c\nrow_filter\u0094Nub.", + "encoding": "latin1" + }, + "task_type": "inference" + }, + "file_to_df": { + "loaders": [ + { + "id": "file_to_df" + } + ], + "module_name": "dfp_file_to_df_dataloader_inf" + }, + "DFPSplitUsers": { + "include_generic": true, + "include_individual": false, + "skip_users": [], + "only_users": [], + "timestamp_column_name": "timestamp", + "userid_column_name": "username", + "fallback_username": "generic_user" + } + }, + "DFPRollingWindow": { + "min_history": 1, + "min_increment": 0, + "max_history": "1d", + "cache_dir": "./.cache/dfp", + "timestamp_column_name": "timestamp" + }, + "DFPDataPrep": { + "timestamp_column_name": "timestamp", + "schema": { + "schema_str": "\u0080\u0004\u0095\u00c9\u0002\u0000\u0000\u0000\u0000\u0000\u0000\u008c\u001amorpheus.utils.column_info\u0094\u008c\u0014DataFrameInputSchema\u0094\u0093\u0094)\u0081\u0094}\u0094(\u008c\fjson_columns\u0094]\u0094\u008c\u000bcolumn_info\u0094]\u0094(h\u0000\u008c\nColumnInfo\u0094\u0093\u0094)\u0081\u0094}\u0094(\u008c\u0004name\u0094\u008c\ttimestamp\u0094\u008c\u0005dtype\u0094\u008c\bdatetime\u0094\u008c\bdatetime\u0094\u0093\u0094ubh\n)\u0081\u0094}\u0094(h\r\u008c\busername\u0094h\u000f\u008c\bbuiltins\u0094\u008c\u0003str\u0094\u0093\u0094ubh\n)\u0081\u0094}\u0094(h\r\u008c\u0013accessdevicebrowser\u0094h\u000fh\u0018ubh\n)\u0081\u0094}\u0094(h\r\u008c\u000eaccessdeviceos\u0094h\u000fh\u0018ubh\n)\u0081\u0094}\u0094(h\r\u008c\u000eauthdevicename\u0094h\u000fh\u0018ubh\n)\u0081\u0094}\u0094(h\r\u008c\u0006result\u0094h\u000fh\u0016\u008c\u0004bool\u0094\u0093\u0094ubh\n)\u0081\u0094}\u0094(h\r\u008c\u0006reason\u0094h\u000fh\u0018ubh\u0000\u008c\u000fIncrementColumn\u0094\u0093\u0094)\u0081\u0094}\u0094(h\r\u008c\blogcount\u0094h\u000fh\u0016\u008c\u0003int\u0094\u0093\u0094\u008c\ninput_name\u0094h\u000e\u008c\u000egroupby_column\u0094h\u0015\u008c\u0006period\u0094\u008c\u0001D\u0094ubh\u0000\u008c\fCustomColumn\u0094\u0093\u0094)\u0081\u0094}\u0094(h\r\u008c\flocincrement\u0094h\u000fh0\u008c\u0011process_column_fn\u0094\u008c\tfunctools\u0094\u008c\u0007partial\u0094\u0093\u0094h\u0000\u008c\u0014create_increment_col\u0094\u0093\u0094\u0085\u0094R\u0094(h?)}\u0094\u008c\u000bcolumn_name\u0094\u008c\blocation\u0094sNt\u0094bube\u008c\u0010preserve_columns\u0094\u008c\u0002re\u0094\u008c\b_compile\u0094\u0093\u0094\u008c\u000b(_batch_id)\u0094K \u0086\u0094R\u0094\u008c\nrow_filter\u0094Nub.", + "encoding": "latin1" + } + }, + "DFPInference": { + "model_name_formatter": "DFP-duo-{user_id}", + "fallback_username": "generic_user", + "timestamp_column_name": "timestamp" + }, + "FilterDetections": { + "field_name": "mean_abs_z", + "threshold": 2.0, + "filter_source": "DATAFRAME", + "schema": { + "input_message_type": "\u0080\u0004\u00954\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u008c\u001fmorpheus.messages.multi_message\u0094\u008c\fMultiMessage\u0094\u0093\u0094.", + "encoding": "latin1" + } + }, + "DFPPostProcessing": { + "timestamp_column_name": "timestamp" + }, + "Serialize": { + "exclude": [ + "batch_count", + "origin_hash", + "_row_hash", + "_batch_id" + ], + "use_cpp": true + }, + "WriteToFile": { + "filename": "dfp_detections_duo.csv", + "overwrite": true + } + }, + "output_port_count": 2 +} \ No newline at end of file diff --git a/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/control_message_azure_inference.json b/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/control_messages/azure_payload_inference.json similarity index 100% rename from examples/digital_fingerprinting/production/morpheus/benchmarks/resource/control_message_azure_inference.json rename to examples/digital_fingerprinting/production/morpheus/benchmarks/resource/control_messages/azure_payload_inference.json diff --git a/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/control_message_azure.json b/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/control_messages/azure_payload_lti.json similarity index 100% rename from examples/digital_fingerprinting/production/morpheus/benchmarks/resource/control_message_azure.json rename to examples/digital_fingerprinting/production/morpheus/benchmarks/resource/control_messages/azure_payload_lti.json diff --git a/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/control_message_azure_training.json b/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/control_messages/azure_payload_training.json similarity index 100% rename from examples/digital_fingerprinting/production/morpheus/benchmarks/resource/control_message_azure_training.json rename to examples/digital_fingerprinting/production/morpheus/benchmarks/resource/control_messages/azure_payload_training.json diff --git a/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/control_messages/azure_streaming_inference.json b/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/control_messages/azure_streaming_inference.json new file mode 100644 index 0000000000..f78740c918 --- /dev/null +++ b/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/control_messages/azure_streaming_inference.json @@ -0,0 +1,25 @@ +{ + "inputs": [ + { + "tasks": [ + { + "type": "load", + "properties": { + "loader_id": "fsspec", + "files": [ + "../../../../../examples/data/dfp/azure-inference-data/*.json" + ] + } + }, + { + "type": "inference", + "properties": { + } + } + ], + "metadata": { + "data_type": "streaming" + } + } + ] + } diff --git a/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/control_messages/azure_streaming_lti.json b/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/control_messages/azure_streaming_lti.json new file mode 100644 index 0000000000..878153b95b --- /dev/null +++ b/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/control_messages/azure_streaming_lti.json @@ -0,0 +1,46 @@ +{ + "inputs": [ + { + "tasks": [ + { + "type": "load", + "properties": { + "loader_id": "fsspec", + "files": [ + "../../../../../examples/data/dfp/azure-training-data/*.json" + ] + } + }, + { + "type": "training", + "properties": { + } + } + ], + "metadata": { + "data_type": "streaming" + } + }, + { + "tasks": [ + { + "type": "load", + "properties": { + "loader_id": "fsspec", + "files": [ + "../../../../../examples/data/dfp/azure-inference-data/*.json" + ] + } + }, + { + "type": "inference", + "properties": { + } + } + ], + "metadata": { + "data_type": "streaming" + } + } + ] + } diff --git a/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/control_messages/azure_streaming_training.json b/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/control_messages/azure_streaming_training.json new file mode 100644 index 0000000000..935f950cfb --- /dev/null +++ b/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/control_messages/azure_streaming_training.json @@ -0,0 +1,25 @@ +{ + "inputs": [ + { + "tasks": [ + { + "type": "load", + "properties": { + "loader_id": "fsspec", + "files": [ + "../../../../../examples/data/dfp/azure-training-data/*.json" + ] + } + }, + { + "type": "training", + "properties": { + } + } + ], + "metadata": { + "data_type": "streaming" + } + } + ] + } diff --git a/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/control_message_duo_inference.json b/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/control_messages/duo_payload_inference.json similarity index 100% rename from examples/digital_fingerprinting/production/morpheus/benchmarks/resource/control_message_duo_inference.json rename to examples/digital_fingerprinting/production/morpheus/benchmarks/resource/control_messages/duo_payload_inference.json diff --git a/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/control_messages/duo_payload_lti.json b/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/control_messages/duo_payload_lti.json new file mode 100644 index 0000000000..d8639c4aaf --- /dev/null +++ b/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/control_messages/duo_payload_lti.json @@ -0,0 +1,46 @@ +{ + "inputs": [ + { + "tasks": [ + { + "type": "load", + "properties": { + "loader_id": "fsspec", + "files": [ + "../../../../../examples/data/dfp/duo-training-data/*.json" + ] + } + }, + { + "type": "training", + "properties": { + } + } + ], + "metadata": { + "data_type": "payload" + } + }, + { + "tasks": [ + { + "type": "load", + "properties": { + "loader_id": "fsspec", + "files": [ + "../../../../../examples/data/dfp/duo-inference-data/*.json" + ] + } + }, + { + "type": "inference", + "properties": { + } + } + ], + "metadata": { + "data_type": "payload" + } + } + ] + } diff --git a/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/control_messages/duo_payload_only_load.json b/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/control_messages/duo_payload_only_load.json new file mode 100644 index 0000000000..9593faa515 --- /dev/null +++ b/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/control_messages/duo_payload_only_load.json @@ -0,0 +1,20 @@ +{ + "inputs": [ + { + "tasks": [ + { + "type": "load", + "properties": { + "loader_id": "fsspec", + "files": [ + "../../../../../examples/data/dfp/duo-training-data/*.json" + ] + } + } + ], + "metadata": { + "data_type": "payload" + } + } + ] + } diff --git a/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/control_message_duo_training.json b/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/control_messages/duo_payload_training.json similarity index 100% rename from examples/digital_fingerprinting/production/morpheus/benchmarks/resource/control_message_duo_training.json rename to examples/digital_fingerprinting/production/morpheus/benchmarks/resource/control_messages/duo_payload_training.json diff --git a/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/control_messages/duo_streaming_inference.json b/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/control_messages/duo_streaming_inference.json new file mode 100644 index 0000000000..d0e82f31c0 --- /dev/null +++ b/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/control_messages/duo_streaming_inference.json @@ -0,0 +1,25 @@ +{ + "inputs": [ + { + "tasks": [ + { + "type": "load", + "properties": { + "loader_id": "fsspec", + "files": [ + "../../../../../examples/data/dfp/duo-inference-data/*.json" + ] + } + }, + { + "type": "inference", + "properties": { + } + } + ], + "metadata": { + "data_type": "streaming" + } + } + ] + } diff --git a/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/control_message_duo.json b/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/control_messages/duo_streaming_lti.json similarity index 100% rename from examples/digital_fingerprinting/production/morpheus/benchmarks/resource/control_message_duo.json rename to examples/digital_fingerprinting/production/morpheus/benchmarks/resource/control_messages/duo_streaming_lti.json diff --git a/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/control_messages/duo_streaming_only_load.json b/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/control_messages/duo_streaming_only_load.json new file mode 100644 index 0000000000..b6666d6ab7 --- /dev/null +++ b/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/control_messages/duo_streaming_only_load.json @@ -0,0 +1,20 @@ +{ + "inputs": [ + { + "tasks": [ + { + "type": "load", + "properties": { + "loader_id": "fsspec", + "files": [ + "../../../../../examples/data/dfp/duo-training-data/*.json" + ] + } + } + ], + "metadata": { + "data_type": "streaming" + } + } + ] + } diff --git a/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/control_messages/duo_streaming_payload.json b/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/control_messages/duo_streaming_payload.json new file mode 100644 index 0000000000..38ca26d73f --- /dev/null +++ b/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/control_messages/duo_streaming_payload.json @@ -0,0 +1,46 @@ +{ + "inputs": [ + { + "tasks": [ + { + "type": "load", + "properties": { + "loader_id": "fsspec", + "files": [ + "../../../../../examples/data/dfp/duo-training-data/*.json" + ] + } + }, + { + "type": "training", + "properties": { + } + } + ], + "metadata": { + "data_type": "streaming" + } + }, + { + "tasks": [ + { + "type": "load", + "properties": { + "loader_id": "fsspec", + "files": [ + "../../../../../examples/data/dfp/duo-inference-data/*.json" + ] + } + }, + { + "type": "inference", + "properties": { + } + } + ], + "metadata": { + "data_type": "payload" + } + } + ] + } diff --git a/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/control_messages/duo_streaming_training.json b/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/control_messages/duo_streaming_training.json new file mode 100644 index 0000000000..844b3c9d86 --- /dev/null +++ b/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/control_messages/duo_streaming_training.json @@ -0,0 +1,25 @@ +{ + "inputs": [ + { + "tasks": [ + { + "type": "load", + "properties": { + "loader_id": "fsspec", + "files": [ + "../../../../../examples/data/dfp/duo-training-data/*.json" + ] + } + }, + { + "type": "training", + "properties": { + } + } + ], + "metadata": { + "data_type": "streaming" + } + } + ] +} diff --git a/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/modules_conf.json b/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/modules_conf.json deleted file mode 100644 index 513409b93b..0000000000 --- a/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/modules_conf.json +++ /dev/null @@ -1,199 +0,0 @@ -{ - "module_id": "DFPDeployment", - "module_name": "dfp_deployment", - "namespace": "morpheus", - "fsspec": { - "loaders": [{ - "id": "fsspec" - }] - }, - "DFPTrainingPipe": { - "DFPPreproc": { - "FileBatcher": { - "period": "D", - "sampling_rate_s": 0, - "start_time": null, - "end_time": null, - "iso_date_regex_pattern": "(?P\\d{4})-(?P\\d{1,2})-(?P\\d{1,2})T(?P\\d{1,2})(:|_)(?P\\d{1,2})(:|_)(?P\\d{1,2})(?P\\.\\d{1,6})?Z", - "timestamp_column_name": "timestamp", - "userid_column_name": "username", - "parser_kwargs": { - "lines": false, - "orient": "records" - }, - "cache_dir": "./.cache/dfp", - "filter_null": true, - "file_type": "JSON", - "schema": { - "schema_str": null, - "encoding": null - }, - "task_type": "inference" - }, - "file_to_df": { - "loaders": [{ - "id": "file_to_df" - }], - "module_name": "file_to_df_dataloader_tra" - }, - "DFPSplitUsers": { - "include_generic": true, - "include_individual": false, - "skip_users": [], - "only_users": [], - "timestamp_column_name": "timestamp", - "userid_column_name": "username", - "fallback_username": "generic_user" - } - }, - "DFPRollingWindow": { - "min_history": 300, - "min_increment": 300, - "max_history": null, - "cache_dir": "./.cache/dfp", - "timestamp_column_name": "timestamp" - }, - "DFPDataPrep": { - "timestamp_column_name": "timestamp", - "userid_column_name": "username", - "schema": { - "schema_str": null, - "encoding": null - } - }, - "DFPTraining": { - "model_kwargs": { - "encoder_layers": [ - 512, - 500 - ], - "decoder_layers": [ - 512 - ], - "activation": "relu", - "swap_p": 0.2, - "lr": 0.001, - "lr_decay": 0.99, - "batch_size": 512, - "verbose": false, - "optimizer": "sgd", - "scaler": "standard", - "min_cats": 1, - "progress_bar": false, - "device": "cuda" - }, - "feature_columns": null, - "epochs": 30, - "validation_size": 0.1 - }, - "MLFlowModelWriter": { - "model_name_formatter": null, - "experiment_name_formatter": null, - "timestamp_column_name": "timestamp", - "conda_env": { - "channels": [ - "defaults", - "conda-forge" - ], - "dependencies": [ - "python=3.8", - "pip" - ], - "pip": [ - "mlflow", - "dfencoder" - ], - "name": "mlflow-env" - }, - "databricks_permissions": null - } - }, - "DFPInferencePipe": { - "DFPPreproc": { - "FileBatcher": { - "period": "D", - "sampling_rate_s": 0, - "start_time": null, - "end_time": null, - "iso_date_regex_pattern": "(?P\\d{4})-(?P\\d{1,2})-(?P\\d{1,2})T(?P\\d{1,2})(:|_)(?P\\d{1,2})(:|_)(?P\\d{1,2})(?P\\.\\d{1,6})?Z", - "timestamp_column_name": "timestamp", - "userid_column_name": "username", - "parser_kwargs": { - "lines": false, - "orient": "records" - }, - "cache_dir": "./.cache/dfp", - "filter_null": true, - "file_type": "JSON", - "schema": { - "schema_str": null, - "encoding": null - }, - "task_type": "inference" - }, - "file_to_df": { - "loaders": [{ - "id": "file_to_df" - }], - "module_name": "file_to_df_dataloader_inf" - }, - "DFPSplitUsers": { - "include_generic": true, - "include_individual": false, - "skip_users": [], - "only_users": [], - "timestamp_column_name": "timestamp", - "userid_column_name": "username", - "fallback_username": "generic_user" - } - }, - "DFPRollingWindow": { - "module_id": "DFPRollingWindow", - "module_name": "dfp_rolling_window", - "namespace": "morpheus", - "min_history": 1, - "min_increment": 0, - "max_history": null, - "cache_dir": "./.cache/dfp", - "timestamp_column_name": "timestamp" - }, - "DFPDataPrep": { - "timestamp_column_name": "timestamp", - "userid_column_name": "username", - "schema": { - "schema_str": null, - "encoding": null - } - }, - "DFPInference": { - "model_name_formatter": null, - "fallback_username": "username", - "timestamp_column_name": "timestamp" - }, - "FilterDetections": { - "field_name": "mean_abs_z", - "threshold": 2.0, - "filter_source": "DATAFRAME", - "schema": { - "input_message_type": null, - "encoding": null - } - }, - "DFPPostProcessing": { - "timestamp_column_name": "timestamp" - }, - "Serialize": { - "exclude": [ - "batch_count", - "origin_hash", - "_row_hash", - "_batch_id" - ], - "use_cpp": null - }, - "WriteToFile": { - "filename": null, - "overwrite": true - } - } -} diff --git a/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/pipelines_conf.json b/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/pipelines_conf.json index 9456d261d2..73a6d5e268 100644 --- a/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/pipelines_conf.json +++ b/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/pipelines_conf.json @@ -1,83 +1,231 @@ { - "tracking_uri": "http://localhost:5000", - "test_dfp_modules_azure_training_e2e": { - "message_path": "./resource/control_message_azure_training.json", - "num_threads": 12, - "pipeline_batch_size": 256, - "edge_buffer_size": 128, - "start_time": "2022-08-01", - "duration": "60d" - }, - "test_dfp_modules_azure_inference_e2e": { - "message_path": "./resource/control_message_azure_inference.json", - "num_threads": 12, - "pipeline_batch_size": 256, - "edge_buffer_size": 128, - "start_time": "2022-08-01", - "duration": "60d" - }, - "test_dfp_modules_azure_e2e": { - "message_path": "./resource/control_message_azure.json", - "num_threads": 12, - "pipeline_batch_size": 256, - "edge_buffer_size": 128, - "start_time": "2022-08-01", - "duration": "60d" - }, - "test_dfp_modules_duo_training_e2e": { - "message_path": "./resource/control_message_duo_training.json", - "num_threads": 12, - "pipeline_batch_size": 256, - "edge_buffer_size": 128, - "start_time": "2022-08-01", - "duration": "60d" - }, - "test_dfp_modules_duo_inference_e2e": { - "message_path": "./resource/control_message_duo_inference.json", - "num_threads": 12, - "pipeline_batch_size": 256, - "edge_buffer_size": 128, - "start_time": "2022-08-01", - "duration": "60d" - }, - "test_dfp_modules_duo_e2e": { - "message_path": "./resource/control_message_duo.json", - "num_threads": 12, - "pipeline_batch_size": 256, - "edge_buffer_size": 128, - "start_time": "2022-08-01", - "duration": "60d" - }, - "test_dfp_training_azure_stages_e2e": { - "glob_path": "../../../../data/dfp/azure-training-data/*.json", - "num_threads": 12, - "pipeline_batch_size": 256, - "edge_buffer_size": 128, - "start_time": "2022-08-01", - "duration": "60d" - }, - "test_dfp_training_duo_stages_e2e": { - "glob_path": "../../../../data/dfp/duo-training-data/*.json", - "num_threads": 12, - "pipeline_batch_size": 256, - "edge_buffer_size": 128, - "start_time": "2022-08-01", - "duration": "60d" - }, - "test_dfp_inference_azure_stages_e2e": { - "glob_path": "../../../../data/dfp/azure-inference-data/*.json", - "num_threads": 12, - "pipeline_batch_size": 256, - "edge_buffer_size": 128, - "start_time": "2022-08-01", - "duration": "1d" - }, - "test_dfp_inference_duo_stages_e2e": { - "glob_path": "../../../../data/dfp/duo-inference-data/*.json", - "num_threads": 12, - "pipeline_batch_size": 256, - "edge_buffer_size": 128, - "start_time": "2022-08-01", - "duration": "1d" - } + "tracking_uri": "http://localhost:8000", + "test_dfp_modules_azure_payload_inference_e2e": { + "message_path": "./resource/control_messages/azure_payload_inference.json", + "num_threads": 12, + "pipeline_batch_size": 256, + "edge_buffer_size": 128, + "start_time": "2022-08-01", + "duration": "60d", + "userid_column_name": "username", + "timestamp_column_name": "timestamp", + "source": "azure", + "use_cpp": true + }, + "test_dfp_modules_azure_payload_lti_e2e": { + "message_path": "./resource/control_messages/azure_payload_lti.json", + "num_threads": 12, + "pipeline_batch_size": 256, + "edge_buffer_size": 128, + "start_time": "2022-08-01", + "duration": "60d", + "userid_column_name": "username", + "timestamp_column_name": "timestamp", + "source": "azure", + "use_cpp": true + }, + "test_dfp_modules_azure_payload_training_e2e": { + "message_path": "./resource/control_messages/azure_payload_training.json", + "num_threads": 12, + "pipeline_batch_size": 256, + "edge_buffer_size": 128, + "start_time": "2022-08-01", + "duration": "60d", + "userid_column_name": "username", + "timestamp_column_name": "timestamp", + "source": "azure", + "use_cpp": true + }, + "test_dfp_modules_azure_streaming_inference_e2e": { + "message_path": "./resource/control_messages/azure_streaming_inference.json", + "num_threads": 12, + "pipeline_batch_size": 256, + "edge_buffer_size": 128, + "start_time": "2022-08-01", + "duration": "60d", + "userid_column_name": "username", + "timestamp_column_name": "timestamp", + "source": "azure", + "use_cpp": true + }, + "test_dfp_modules_azure_streaming_lti_e2e": { + "message_path": "./resource/control_messages/azure_streaming_lti.json", + "num_threads": 12, + "pipeline_batch_size": 256, + "edge_buffer_size": 128, + "start_time": "2022-08-01", + "duration": "60d", + "userid_column_name": "username", + "timestamp_column_name": "timestamp", + "source": "azure", + "use_cpp": true + }, + "test_dfp_modules_azure_streaming_training_e2e": { + "message_path": "./resource/control_messages/azure_streaming_training.json", + "num_threads": 12, + "pipeline_batch_size": 256, + "edge_buffer_size": 128, + "start_time": "2022-08-01", + "duration": "60d", + "userid_column_name": "username", + "timestamp_column_name": "timestamp", + "source": "azure", + "use_cpp": true + }, + "test_dfp_modules_duo_payload_inference_e2e": { + "message_path": "./resource/control_messages/duo_payload_inference.json", + "num_threads": 12, + "pipeline_batch_size": 256, + "edge_buffer_size": 128, + "start_time": "2022-08-01", + "duration": "60d", + "userid_column_name": "username", + "timestamp_column_name": "timestamp", + "source": "duo", + "use_cpp": true + }, + "test_dfp_modules_duo_payload_lti_e2e": { + "message_path": "./resource/control_messages/duo_payload_lti.json", + "num_threads": 12, + "pipeline_batch_size": 256, + "edge_buffer_size": 128, + "start_time": "2022-08-01", + "duration": "60d", + "userid_column_name": "username", + "timestamp_column_name": "timestamp", + "source": "duo", + "use_cpp": true + }, + "test_dfp_modules_duo_payload_only_load_e2e": { + "message_path": "./resource/control_messages/duo_payload_only_load.json", + "num_threads": 12, + "pipeline_batch_size": 256, + "edge_buffer_size": 128, + "start_time": "2022-08-01", + "duration": "60d", + "userid_column_name": "username", + "timestamp_column_name": "timestamp", + "source": "duo", + "use_cpp": true + }, + "test_dfp_modules_duo_payload_training_e2e": { + "message_path": "./resource/control_messages/duo_payload_training.json", + "num_threads": 12, + "pipeline_batch_size": 256, + "edge_buffer_size": 128, + "start_time": "2022-08-01", + "duration": "60d", + "userid_column_name": "username", + "timestamp_column_name": "timestamp", + "source": "duo", + "use_cpp": true + }, + "test_dfp_modules_duo_streaming_inference_e2e": { + "message_path": "./resource/control_messages/duo_streaming_inference.json", + "num_threads": 12, + "pipeline_batch_size": 256, + "edge_buffer_size": 128, + "start_time": "2022-08-01", + "duration": "60d", + "userid_column_name": "username", + "timestamp_column_name": "timestamp", + "source": "duo", + "use_cpp": true + }, + "test_dfp_modules_duo_streaming_lti_e2e": { + "message_path": "./resource/control_messages/duo_streaming_lti.json", + "num_threads": 12, + "pipeline_batch_size": 256, + "edge_buffer_size": 128, + "start_time": "2022-08-01", + "duration": "60d", + "userid_column_name": "username", + "timestamp_column_name": "timestamp", + "source": "duo", + "use_cpp": true + }, + "test_dfp_modules_duo_streaming_only_load_e2e": { + "message_path": "./resource/control_messages/duo_streaming_only_load.json", + "num_threads": 12, + "pipeline_batch_size": 256, + "edge_buffer_size": 128, + "start_time": "2022-08-01", + "duration": "60d", + "userid_column_name": "username", + "timestamp_column_name": "timestamp", + "source": "duo", + "use_cpp": true + }, + "test_dfp_modules_duo_streaming_payload_e2e": { + "message_path": "./resource/control_messages/duo_streaming_payload.json", + "num_threads": 12, + "pipeline_batch_size": 256, + "edge_buffer_size": 128, + "start_time": "2022-08-01", + "duration": "60d", + "userid_column_name": "username", + "timestamp_column_name": "timestamp", + "source": "duo", + "use_cpp": true + }, + "test_dfp_modules_duo_streaming_training_e2e": { + "message_path": "./resource/control_messages/duo_streaming_training.json", + "num_threads": 12, + "pipeline_batch_size": 256, + "edge_buffer_size": 128, + "start_time": "2022-08-01", + "duration": "60d", + "userid_column_name": "username", + "timestamp_column_name": "timestamp", + "source": "duo", + "use_cpp": true + }, + "test_dfp_stages_azure_training_e2e": { + "glob_path": "../../../../data/dfp/azure-training-data/*.json", + "num_threads": 12, + "pipeline_batch_size": 256, + "edge_buffer_size": 128, + "start_time": "2022-08-01", + "duration": "60d", + "userid_column_name": "username", + "timestamp_column_name": "timestamp", + "source": "azure", + "use_cpp": false + }, + "test_dfp_stages_azure_inference_e2e": { + "glob_path": "../../../../data/dfp/azure-inference-data/*.json", + "num_threads": 12, + "pipeline_batch_size": 256, + "edge_buffer_size": 128, + "start_time": "2022-08-01", + "duration": "60d", + "userid_column_name": "username", + "timestamp_column_name": "timestamp", + "source": "azure", + "use_cpp": false + }, + "test_dfp_stages_duo_training_e2e": { + "glob_path": "../../../../data/dfp/duo-training-data/*.json", + "num_threads": 12, + "pipeline_batch_size": 256, + "edge_buffer_size": 128, + "start_time": "2022-08-01", + "duration": "60d", + "userid_column_name": "username", + "timestamp_column_name": "timestamp", + "source": "duo", + "use_cpp": false + }, + "test_dfp_stages_duo_inference_e2e": { + "glob_path": "../../../../data/dfp/duo-inference-data/*.json", + "num_threads": 12, + "pipeline_batch_size": 256, + "edge_buffer_size": 128, + "start_time": "2022-08-01", + "duration": "60d", + "userid_column_name": "username", + "timestamp_column_name": "timestamp", + "source": "duo", + "use_cpp": false + } } diff --git a/examples/digital_fingerprinting/production/morpheus/benchmarks/test_bench_e2e_dfp_pipeline.py b/examples/digital_fingerprinting/production/morpheus/benchmarks/test_bench_e2e_dfp_pipeline.py index a0050a4d6e..92785c435a 100644 --- a/examples/digital_fingerprinting/production/morpheus/benchmarks/test_bench_e2e_dfp_pipeline.py +++ b/examples/digital_fingerprinting/production/morpheus/benchmarks/test_bench_e2e_dfp_pipeline.py @@ -16,6 +16,7 @@ import functools import logging import os +import shutil import typing # flake8 warnings are silenced by the addition of noqa. @@ -32,19 +33,16 @@ from dfp.stages.dfp_training import DFPTraining from dfp.stages.multi_file_source import MultiFileSource from dfp.utils.regex_utils import iso_date_regex +from dfp.utils.schema_utils import Schema -from benchmarks.dfp_config import DFPConfig -from benchmarks.dfp_config import get_azure_preprocess_schema -from benchmarks.dfp_config import get_azure_source_schema -from benchmarks.dfp_config import get_duo_preprocess_schema -from benchmarks.dfp_config import get_duo_source_schema -from benchmarks.dfp_config import load_json -from benchmarks.dfp_config import set_mlflow_tracking_uri +from benchmarks.benchmark_conf_generator import BenchmarkConfGenerator +from benchmarks.benchmark_conf_generator import load_json +from benchmarks.benchmark_conf_generator import set_mlflow_tracking_uri from morpheus._lib.common import FileTypes from morpheus._lib.common import FilterSource from morpheus.config import Config from morpheus.pipeline.linear_pipeline import LinearPipeline -from morpheus.pipeline.pipeline import Pipeline # noqa: F401 +from morpheus.pipeline.pipeline import Pipeline from morpheus.stages.general.multi_port_module_stage import MultiPortModuleStage from morpheus.stages.input.control_message_source_stage import ControlMessageSourceStage from morpheus.stages.output.write_to_file_stage import WriteToFileStage @@ -54,54 +52,68 @@ from morpheus.utils.file_utils import date_extractor from morpheus.utils.logger import configure_logging -MODULES_CONF = load_json("resource/modules_conf.json") +logger = logging.getLogger("morpheus.{}".format(__name__)) + PIPELINES_CONF = load_json("resource/pipelines_conf.json") +TRACKING_URI = PIPELINES_CONF.get("tracking_uri") + set_mlflow_tracking_uri(PIPELINES_CONF.get("tracking_uri")) -def dfp_modules_pipeline(config: Config, modules_conf: typing.Dict[str, any], filenames: typing.List[str]): - configure_logging(log_level=logging.CRITICAL) +def remove_cache(dir: str): + logger.debug(f"Cleaning up cache `{dir}` directory...") + shutil.rmtree(dir, ignore_errors=True) + logger.debug(f"Cleaning up cache `{dir}` directory... Done") + - pipeline = Pipeline(config) +def dfp_modules_pipeline(pipe_config: Config, + modules_conf: typing.Dict[str, any], + filenames: typing.List[str], + reuse_cache=False): - source_stage = pipeline.add_stage(ControlMessageSourceStage(config, filenames=filenames)) + pipeline = Pipeline(pipe_config) - import json - with open("modules_conf.json", "w") as f: - f.write(json.dumps(modules_conf, indent=3, default=str)) + source_stage = pipeline.add_stage(ControlMessageSourceStage(pipe_config, filenames=filenames)) # Here we add a wrapped module that implements the DFP Deployment dfp_deployment_stage = pipeline.add_stage( - MultiPortModuleStage(config, + MultiPortModuleStage(pipe_config, modules_conf, input_port_name="input", output_port_name_prefix="output", - output_port_count=modules_conf.get("output_port_count"))) + output_port_count=modules_conf["output_port_count"])) pipeline.add_edge(source_stage, dfp_deployment_stage) pipeline.run() + if not reuse_cache: + cache_dir = modules_conf["DFPInferencePipe"]["DFPPreproc"]["FileBatcher"]["cache_dir"] + remove_cache(dir=cache_dir) -def dfp_training_pipeline_stages(config: Config, + +def dfp_training_pipeline_stages(pipe_config: Config, stages_conf: typing.Dict[str, any], source_schema: DataFrameInputSchema, preprocess_schema: DataFrameInputSchema, - filenames: typing.List[str]): - configure_logging(log_level=logging.CRITICAL) + filenames: typing.List[str], + log_level: int, + reuse_cache=False): + + configure_logging(log_level) - pipeline = LinearPipeline(config) - pipeline.set_source(MultiFileSource(config, filenames=filenames)) + pipeline = LinearPipeline(pipe_config) + pipeline.set_source(MultiFileSource(pipe_config, filenames=filenames)) pipeline.add_stage( - DFPFileBatcherStage(config, + DFPFileBatcherStage(pipe_config, period="D", sampling_rate_s=stages_conf["sampling_rate_s"], date_conversion_func=functools.partial(date_extractor, filename_regex=iso_date_regex), start_time=stages_conf["start_time"], end_time=stages_conf["end_time"])) pipeline.add_stage( - DFPFileToDataFrameStage(config, + DFPFileToDataFrameStage(pipe_config, schema=source_schema, file_type=FileTypes.JSON, parser_kwargs={ @@ -109,46 +121,52 @@ def dfp_training_pipeline_stages(config: Config, }, cache_dir=stages_conf["cache_dir"])) pipeline.add_stage( - DFPSplitUsersStage(config, + DFPSplitUsersStage(pipe_config, include_generic=stages_conf["include_generic"], include_individual=stages_conf["include_individual"], skip_users=stages_conf["skip_users"], only_users=stages_conf["only_users"])) pipeline.add_stage( - DFPRollingWindowStage(config, + DFPRollingWindowStage(pipe_config, min_history=300, min_increment=300, max_history=stages_conf["duration"], cache_dir=stages_conf["cache_dir"])) - pipeline.add_stage(DFPPreprocessingStage(config, input_schema=preprocess_schema)) - pipeline.add_stage(DFPTraining(config, validation_size=0.10)) + pipeline.add_stage(DFPPreprocessingStage(pipe_config, input_schema=preprocess_schema)) + pipeline.add_stage(DFPTraining(pipe_config, validation_size=0.10)) pipeline.add_stage( - DFPMLFlowModelWriterStage(config, + DFPMLFlowModelWriterStage(pipe_config, model_name_formatter=stages_conf["model_name_formatter"], experiment_name_formatter=stages_conf["experiment_name_formatter"])) pipeline.build() pipeline.run() + if not reuse_cache: + remove_cache(dir=stages_conf["cache_dir"]) -def dfp_inference_pipeline_stages(config: Config, + +def dfp_inference_pipeline_stages(pipe_config: Config, stages_conf: typing.Dict[str, any], source_schema: DataFrameInputSchema, preprocess_schema: DataFrameInputSchema, filenames: typing.List[str], - output_filepath: str): - configure_logging(log_level=logging.CRITICAL) + output_filepath: str, + log_level: int, + reuse_cache=False): + + configure_logging(log_level) - pipeline = LinearPipeline(config) - pipeline.set_source(MultiFileSource(config, filenames=filenames)) + pipeline = LinearPipeline(pipe_config) + pipeline.set_source(MultiFileSource(pipe_config, filenames=filenames)) pipeline.add_stage( - DFPFileBatcherStage(config, + DFPFileBatcherStage(pipe_config, period="D", sampling_rate_s=stages_conf["sampling_rate_s"], date_conversion_func=functools.partial(date_extractor, filename_regex=iso_date_regex), start_time=stages_conf["start_time"], end_time=stages_conf["end_time"])) pipeline.add_stage( - DFPFileToDataFrameStage(config, + DFPFileToDataFrameStage(pipe_config, schema=source_schema, file_type=FileTypes.JSON, parser_kwargs={ @@ -156,318 +174,328 @@ def dfp_inference_pipeline_stages(config: Config, }, cache_dir=stages_conf["cache_dir"])) pipeline.add_stage( - DFPSplitUsersStage(config, + DFPSplitUsersStage(pipe_config, include_generic=stages_conf["include_generic"], include_individual=stages_conf["include_individual"], skip_users=stages_conf["skip_users"], only_users=stages_conf["only_users"])) pipeline.add_stage( - DFPRollingWindowStage(config, + DFPRollingWindowStage(pipe_config, min_history=1, min_increment=0, max_history=stages_conf["duration"], cache_dir=stages_conf["cache_dir"])) - pipeline.add_stage(DFPPreprocessingStage(config, input_schema=preprocess_schema)) - pipeline.add_stage(DFPInferenceStage(config, model_name_formatter=stages_conf["model_name_formatter"])) + pipeline.add_stage(DFPPreprocessingStage(pipe_config, input_schema=preprocess_schema)) + pipeline.add_stage(DFPInferenceStage(pipe_config, model_name_formatter=stages_conf["model_name_formatter"])) pipeline.add_stage( - FilterDetectionsStage(config, threshold=2.0, filter_source=FilterSource.DATAFRAME, field_name='mean_abs_z')) - pipeline.add_stage(DFPPostprocessingStage(config)) - pipeline.add_stage(SerializeStage(config, exclude=['batch_count', 'origin_hash', '_row_hash', '_batch_id'])) - pipeline.add_stage(WriteToFileStage(config, filename=output_filepath, overwrite=True)) + FilterDetectionsStage(pipe_config, threshold=2.0, filter_source=FilterSource.DATAFRAME, + field_name='mean_abs_z')) + pipeline.add_stage(DFPPostprocessingStage(pipe_config)) + pipeline.add_stage(SerializeStage(pipe_config, exclude=['batch_count', 'origin_hash', '_row_hash', '_batch_id'])) + pipeline.add_stage(WriteToFileStage(pipe_config, filename=output_filepath, overwrite=True)) pipeline.build() pipeline.run() + if not reuse_cache: + remove_cache(dir=stages_conf["cache_dir"]) -@pytest.mark.benchmark -def test_dfp_training_duo_stages_e2e(benchmark: typing.Any): - feature_columns = [ - "accessdevicebrowser", - "accessdeviceos", - "authdevicename", - "reason", - "result", - "locincrement", - "logcount", - ] - pipeline_conf = PIPELINES_CONF.get("test_dfp_training_duo_stages_e2e") +@pytest.mark.benchmark +def test_dfp_stages_duo_training_e2e(benchmark: typing.Any): - dfp_config = DFPConfig(pipeline_conf, feature_columns, source="duo") + pipe_conf = PIPELINES_CONF.get("test_dfp_stages_duo_training_e2e") - config = dfp_config.get_config() - stages_conf = dfp_config.get_stages_conf() - filenames = dfp_config.get_filenames() + bcg = BenchmarkConfGenerator(pipe_conf=pipe_conf, tracking_uri=TRACKING_URI) - source_schema = get_duo_source_schema(config) - preprocess_schema = get_duo_preprocess_schema(config) + pipe_config = bcg.pipe_config + stages_conf = bcg.get_stages_conf() + input_filenames = bcg.get_filenames() + schema: Schema = bcg.get_schema() - benchmark(dfp_training_pipeline_stages, config, stages_conf, source_schema, preprocess_schema, filenames) + benchmark(dfp_training_pipeline_stages, + pipe_config, + stages_conf, + schema.source, + schema.preprocess, + input_filenames, + bcg.log_level) @pytest.mark.benchmark -def test_dfp_training_azure_stages_e2e(benchmark: typing.Any): - feature_columns = [ - "appDisplayName", - "clientAppUsed", - "deviceDetailbrowser", - "deviceDetaildisplayName", - "deviceDetailoperatingSystem", - "statusfailureReason", - "appincrement", - "locincrement", - "logcount" - ] - - pipeline_conf = PIPELINES_CONF.get("test_dfp_training_azure_stages_e2e") +def test_dfp_stages_azure_training_e2e(benchmark: typing.Any): - dfp_config = DFPConfig(pipeline_conf, feature_columns, source="azure") + pipe_conf = PIPELINES_CONF.get("test_dfp_stages_azure_training_e2e") - config = dfp_config.get_config() - stages_conf = dfp_config.get_stages_conf() - filenames = dfp_config.get_filenames() + bcg = BenchmarkConfGenerator(pipe_conf=pipe_conf, tracking_uri=TRACKING_URI) - source_schema = get_azure_source_schema(config) - preprocess_schema = get_azure_preprocess_schema(config) + pipe_config = bcg.pipe_config + stages_conf = bcg.get_stages_conf() + input_filenames = bcg.get_filenames() + schema: Schema = bcg.get_schema() - benchmark(dfp_training_pipeline_stages, config, stages_conf, source_schema, preprocess_schema, filenames) + benchmark(dfp_training_pipeline_stages, + pipe_config, + stages_conf, + schema.source, + schema.preprocess, + input_filenames, + bcg.log_level) @pytest.mark.benchmark -def test_dfp_inference_azure_stages_e2e(benchmark: typing.Any, tmp_path): - feature_columns = [ - "appDisplayName", - "clientAppUsed", - "deviceDetailbrowser", - "deviceDetaildisplayName", - "deviceDetailoperatingSystem", - "statusfailureReason", - "appincrement", - "locincrement", - "logcount" - ] - - pipeline_conf = PIPELINES_CONF.get("test_dfp_inference_azure_stages_e2e") - - dfp_config = DFPConfig(pipeline_conf, feature_columns, source="azure") - - config = dfp_config.get_config() - stages_conf = dfp_config.get_stages_conf() - filenames = dfp_config.get_filenames() - - source_schema = get_azure_source_schema(config) - preprocess_schema = get_azure_preprocess_schema(config) +def test_dfp_stages_azure_inference_e2e(benchmark: typing.Any, tmp_path): + + pipe_conf = PIPELINES_CONF.get("test_dfp_stages_azure_inference_e2e") + + bcg = BenchmarkConfGenerator(pipe_conf=pipe_conf, tracking_uri=TRACKING_URI) + + pipe_config = bcg.pipe_config + stages_conf = bcg.get_stages_conf() + input_filenames = bcg.get_filenames() + schema: Schema = bcg.get_schema() output_filepath = os.path.join(tmp_path, "detections_azure.csv") benchmark(dfp_inference_pipeline_stages, - config, + pipe_config, stages_conf, - source_schema, - preprocess_schema, - filenames, - output_filepath) + schema.source, + schema.preprocess, + input_filenames, + output_filepath, + bcg.log_level) @pytest.mark.benchmark -def test_dfp_inference_duo_stages_e2e(benchmark: typing.Any, tmp_path): - feature_columns = [ - "appDisplayName", - "clientAppUsed", - "deviceDetailbrowser", - "deviceDetaildisplayName", - "deviceDetailoperatingSystem", - "statusfailureReason", - "appincrement", - "locincrement", - "logcount" - ] - - pipeline_conf = PIPELINES_CONF.get("test_dfp_inference_duo_stages_e2e") - - dfp_config = DFPConfig(pipeline_conf, feature_columns, source="duo") - - config = dfp_config.get_config() - stages_conf = dfp_config.get_stages_conf() - filenames = dfp_config.get_filenames() - - source_schema = get_azure_source_schema(config) - preprocess_schema = get_azure_preprocess_schema(config) +def test_dfp_stages_duo_inference_e2e(benchmark: typing.Any, tmp_path): + + pipe_conf = PIPELINES_CONF.get("test_dfp_stages_duo_inference_e2e") + + bcg = BenchmarkConfGenerator(pipe_conf=pipe_conf, tracking_uri=TRACKING_URI) + + pipe_config = bcg.pipe_config + stages_conf = bcg.get_stages_conf() + input_filenames = bcg.get_filenames() + schema: Schema = bcg.get_schema() output_filepath = os.path.join(tmp_path, "detections_duo.csv") benchmark(dfp_inference_pipeline_stages, - config, + pipe_config, stages_conf, - source_schema, - preprocess_schema, - filenames, - output_filepath) + schema.source, + schema.preprocess, + input_filenames, + output_filepath, + bcg.log_level) @pytest.mark.benchmark -def test_dfp_modules_duo_training_e2e(benchmark: typing.Any): - feature_columns = [ - "accessdevicebrowser", - "accessdeviceos", - "authdevicename", - "reason", - "result", - "locincrement", - "logcount", - ] +def test_dfp_modules_azure_payload_inference_e2e(benchmark: typing.Any): - pipeline_conf = PIPELINES_CONF.get("test_dfp_modules_duo_training_e2e") + pipe_conf = PIPELINES_CONF.get("test_dfp_modules_azure_payload_inference_e2e") - dfp_config = DFPConfig(pipeline_conf, feature_columns, source="duo", modules_conf=MODULES_CONF) + bcg = BenchmarkConfGenerator(pipe_conf=pipe_conf, tracking_uri=TRACKING_URI) - config = dfp_config.get_config() - filenames = dfp_config.get_filenames() + pipe_config = bcg.pipe_config + module_config = bcg.get_module_conf() + input_filenames = bcg.get_filenames() - source_schema = get_duo_source_schema(config) - preprocess_schema = get_duo_preprocess_schema(config) + benchmark(dfp_modules_pipeline, pipe_config, module_config, filenames=input_filenames) - dfp_config.update_modules_conf(source_schema, preprocess_schema) - benchmark(dfp_modules_pipeline, config, dfp_config.modules_conf, filenames) +@pytest.mark.benchmark +def test_dfp_modules_azure_payload_lti_e2e(benchmark: typing.Any): + + pipe_conf = PIPELINES_CONF.get("test_dfp_modules_azure_payload_lti_e2e") + + bcg = BenchmarkConfGenerator(pipe_conf=pipe_conf, tracking_uri=TRACKING_URI) + + pipe_config = bcg.pipe_config + module_config = bcg.get_module_conf() + input_filenames = bcg.get_filenames() + + benchmark(dfp_modules_pipeline, pipe_config, module_config, filenames=input_filenames) @pytest.mark.benchmark -def test_dfp_modules_azure_training_e2e(benchmark: typing.Any): - feature_columns = [ - "appDisplayName", - "clientAppUsed", - "deviceDetailbrowser", - "deviceDetaildisplayName", - "deviceDetailoperatingSystem", - "statusfailureReason", - "appincrement", - "locincrement", - "logcount" - ] +def test_dfp_modules_azure_payload_training_e2e(benchmark: typing.Any): - pipeline_conf = PIPELINES_CONF.get("test_dfp_modules_azure_training_e2e") + pipe_conf = PIPELINES_CONF.get("test_dfp_modules_azure_payload_training_e2e") - dfp_config = DFPConfig(pipeline_conf, feature_columns, source="azure", modules_conf=MODULES_CONF) + bcg = BenchmarkConfGenerator(pipe_conf=pipe_conf, tracking_uri=TRACKING_URI) - config = dfp_config.get_config() - filenames = dfp_config.get_filenames() + pipe_config = bcg.pipe_config + module_config = bcg.get_module_conf() + input_filenames = bcg.get_filenames() + + benchmark(dfp_modules_pipeline, pipe_config, module_config, filenames=input_filenames) + + +@pytest.mark.benchmark +def test_dfp_modules_azure_streaming_inference_e2e(benchmark: typing.Any): - source_schema = get_azure_source_schema(config) - preprocess_schema = get_azure_preprocess_schema(config) + pipe_conf = PIPELINES_CONF.get("test_dfp_modules_azure_streaming_inference_e2e") - dfp_config.update_modules_conf(source_schema, preprocess_schema) + bcg = BenchmarkConfGenerator(pipe_conf=pipe_conf, tracking_uri=TRACKING_URI) - benchmark(dfp_modules_pipeline, config, dfp_config.modules_conf, filenames) + pipe_config = bcg.pipe_config + module_config = bcg.get_module_conf() + input_filenames = bcg.get_filenames() + + benchmark(dfp_modules_pipeline, pipe_config, module_config, filenames=input_filenames) @pytest.mark.benchmark -def test_dfp_modules_duo_inference_e2e(benchmark: typing.Any): - feature_columns = [ - "accessdevicebrowser", - "accessdeviceos", - "authdevicename", - "reason", - "result", - "locincrement", - "logcount", - ] +def test_dfp_modules_azure_streaming_lti_e2e(benchmark: typing.Any): + + pipe_conf = PIPELINES_CONF.get("test_dfp_modules_azure_streaming_lti_e2e") + + bcg = BenchmarkConfGenerator(pipe_conf=pipe_conf, tracking_uri=TRACKING_URI) + + pipe_config = bcg.pipe_config + module_config = bcg.get_module_conf() + input_filenames = bcg.get_filenames() - pipeline_conf = PIPELINES_CONF.get("test_dfp_modules_duo_inference_e2e") + benchmark(dfp_modules_pipeline, pipe_config, module_config, filenames=input_filenames) - dfp_config = DFPConfig(pipeline_conf, feature_columns, source="duo", modules_conf=MODULES_CONF) - config = dfp_config.get_config() - filenames = dfp_config.get_filenames() +@pytest.mark.benchmark +def test_dfp_modules_azure_streaming_training_e2e(benchmark: typing.Any): + + pipe_conf = PIPELINES_CONF.get("test_dfp_modules_azure_streaming_training_e2e") - source_schema = get_duo_source_schema(config) - preprocess_schema = get_duo_preprocess_schema(config) + bcg = BenchmarkConfGenerator(pipe_conf=pipe_conf, tracking_uri=TRACKING_URI) - dfp_config.update_modules_conf(source_schema, preprocess_schema) + pipe_config = bcg.pipe_config + module_config = bcg.get_module_conf() + input_filenames = bcg.get_filenames() - benchmark(dfp_modules_pipeline, config, dfp_config.modules_conf, filenames) + benchmark(dfp_modules_pipeline, pipe_config, module_config, filenames=input_filenames) @pytest.mark.benchmark -def test_dfp_modules_azure_inference_e2e(benchmark: typing.Any): - feature_columns = [ - "appDisplayName", - "clientAppUsed", - "deviceDetailbrowser", - "deviceDetaildisplayName", - "deviceDetailoperatingSystem", - "statusfailureReason", - "appincrement", - "locincrement", - "logcount" - ] +def test_dfp_modules_duo_payload_inference_e2e(benchmark: typing.Any): - pipeline_conf = PIPELINES_CONF.get("test_dfp_modules_azure_inference_e2e") + pipe_conf = PIPELINES_CONF.get("test_dfp_modules_duo_payload_inference_e2e") - dfp_config = DFPConfig(pipeline_conf, feature_columns, source="azure", modules_conf=MODULES_CONF) + bcg = BenchmarkConfGenerator(pipe_conf=pipe_conf, tracking_uri=TRACKING_URI) - config = dfp_config.get_config() - filenames = dfp_config.get_filenames() + pipe_config = bcg.pipe_config + module_config = bcg.get_module_conf() + input_filenames = bcg.get_filenames() - source_schema = get_azure_source_schema(config) - preprocess_schema = get_azure_preprocess_schema(config) + benchmark(dfp_modules_pipeline, pipe_config, module_config, filenames=input_filenames) - dfp_config.update_modules_conf(source_schema, preprocess_schema) - benchmark(dfp_modules_pipeline, config, dfp_config.modules_conf, filenames) +@pytest.mark.benchmark +def test_dfp_modules_duo_payload_lti_e2e(benchmark: typing.Any): + + pipe_conf = PIPELINES_CONF.get("test_dfp_modules_duo_payload_lti_e2e") + + bcg = BenchmarkConfGenerator(pipe_conf=pipe_conf, tracking_uri=TRACKING_URI) + + pipe_config = bcg.pipe_config + module_config = bcg.get_module_conf() + input_filenames = bcg.get_filenames() + + benchmark(dfp_modules_pipeline, pipe_config, module_config, filenames=input_filenames) + + +@pytest.mark.benchmark +def test_dfp_modules_duo_payload_only_load_e2e(benchmark: typing.Any): + + pipe_conf = PIPELINES_CONF.get("test_dfp_modules_duo_payload_only_load_e2e") + + bcg = BenchmarkConfGenerator(pipe_conf=pipe_conf, tracking_uri=TRACKING_URI) + + pipe_config = bcg.pipe_config + module_config = bcg.get_module_conf() + input_filenames = bcg.get_filenames() + + benchmark(dfp_modules_pipeline, pipe_config, module_config, filenames=input_filenames) + + +@pytest.mark.benchmark +def test_dfp_modules_duo_payload_training_e2e(benchmark: typing.Any): + + pipe_conf = PIPELINES_CONF.get("test_dfp_modules_duo_payload_training_e2e") + + bcg = BenchmarkConfGenerator(pipe_conf=pipe_conf, tracking_uri=TRACKING_URI) + + pipe_config = bcg.pipe_config + module_config = bcg.get_module_conf() + input_filenames = bcg.get_filenames() + + benchmark(dfp_modules_pipeline, pipe_config, module_config, filenames=input_filenames) @pytest.mark.benchmark -def test_dfp_modules_duo_e2e(benchmark: typing.Any): - feature_columns = [ - "accessdevicebrowser", - "accessdeviceos", - "authdevicename", - "reason", - "result", - "locincrement", - "logcount", - ] +def test_dfp_modules_duo_streaming_inference_e2e(benchmark: typing.Any): - pipeline_conf = PIPELINES_CONF.get("test_dfp_modules_duo_e2e") + pipe_conf = PIPELINES_CONF.get("test_dfp_modules_duo_streaming_inference_e2e") - dfp_config = DFPConfig(pipeline_conf, feature_columns, source="duo", modules_conf=MODULES_CONF) + bcg = BenchmarkConfGenerator(pipe_conf=pipe_conf, tracking_uri=TRACKING_URI) - config = dfp_config.get_config() - filenames = dfp_config.get_filenames() + pipe_config = bcg.pipe_config + module_config = bcg.get_module_conf() + input_filenames = bcg.get_filenames() - source_schema = get_duo_source_schema(config) - preprocess_schema = get_duo_preprocess_schema(config) + benchmark(dfp_modules_pipeline, pipe_config, module_config, filenames=input_filenames) - dfp_config.update_modules_conf(source_schema, preprocess_schema) - benchmark(dfp_modules_pipeline, config, dfp_config.modules_conf, filenames) +@pytest.mark.benchmark +def test_dfp_modules_duo_streaming_lti_e2e(benchmark: typing.Any): + + pipe_conf = PIPELINES_CONF.get("test_dfp_modules_duo_streaming_lti_e2e") + + bcg = BenchmarkConfGenerator(pipe_conf=pipe_conf, tracking_uri=TRACKING_URI) + + pipe_config = bcg.pipe_config + module_config = bcg.get_module_conf() + input_filenames = bcg.get_filenames() + + benchmark(dfp_modules_pipeline, pipe_config, module_config, filenames=input_filenames) @pytest.mark.benchmark -def test_dfp_modules_azure_e2e(benchmark: typing.Any): - feature_columns = [ - "appDisplayName", - "clientAppUsed", - "deviceDetailbrowser", - "deviceDetaildisplayName", - "deviceDetailoperatingSystem", - "statusfailureReason", - "appincrement", - "locincrement", - "logcount" - ] +def test_dfp_modules_duo_streaming_only_load_e2e(benchmark: typing.Any): + + pipe_conf = PIPELINES_CONF.get("test_dfp_modules_duo_streaming_only_load_e2e") + + bcg = BenchmarkConfGenerator(pipe_conf=pipe_conf, tracking_uri=TRACKING_URI) + + pipe_config = bcg.pipe_config + module_config = bcg.get_module_conf() + input_filenames = bcg.get_filenames() + + benchmark(dfp_modules_pipeline, pipe_config, module_config, filenames=input_filenames) - pipeline_conf = PIPELINES_CONF.get("test_dfp_modules_azure_e2e") - dfp_config = DFPConfig(pipeline_conf, feature_columns, source="azure", modules_conf=MODULES_CONF) +@pytest.mark.benchmark +def test_dfp_modules_duo_streaming_payload_e2e(benchmark: typing.Any): + + pipe_conf = PIPELINES_CONF.get("test_dfp_modules_duo_streaming_payload_e2e") + + bcg = BenchmarkConfGenerator(pipe_conf=pipe_conf, tracking_uri=TRACKING_URI) + + pipe_config = bcg.pipe_config + module_config = bcg.get_module_conf() + input_filenames = bcg.get_filenames() + + benchmark(dfp_modules_pipeline, pipe_config, module_config, filenames=input_filenames) + + +@pytest.mark.benchmark +def test_dfp_modules_duo_streaming_training_e2e(benchmark: typing.Any): - config = dfp_config.get_config() - filenames = dfp_config.get_filenames() + pipe_conf = PIPELINES_CONF.get("test_dfp_modules_duo_streaming_training_e2e") - source_schema = get_azure_source_schema(config) - preprocess_schema = get_azure_preprocess_schema(config) + bcg = BenchmarkConfGenerator(pipe_conf=pipe_conf, tracking_uri=TRACKING_URI) - dfp_config.update_modules_conf(source_schema, preprocess_schema) + pipe_config = bcg.pipe_config + module_config = bcg.get_module_conf() + input_filenames = bcg.get_filenames() - benchmark(dfp_modules_pipeline, config, dfp_config.modules_conf, filenames) + benchmark(dfp_modules_pipeline, pipe_config, module_config, filenames=input_filenames) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_data_prep.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_data_prep.py index 21e4df4ae5..1f95402299 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_data_prep.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_data_prep.py @@ -45,7 +45,6 @@ def dfp_data_prep(builder: mrc.Builder): """ config = get_module_config(DFP_DATA_PREP, builder) - task_type = config.get("task_type", None) schema_config = config.get("schema", None) schema_str = schema_config.get("schema_str", None) encoding = schema_config.get("encoding", None) @@ -56,7 +55,7 @@ def dfp_data_prep(builder: mrc.Builder): # def process_features(message: MultiDFPMessage): def process_features(message: MessageControl): - if (message is None or not message.has_task(task_type)): + if (message is None): return None start_time = time.time() diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_deployment.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_deployment.py index 2757830cb5..81040d2d96 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_deployment.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_deployment.py @@ -19,7 +19,7 @@ import mrc from mrc.core.node import Broadcast -import morpheus.loaders.fsspec_loader +import morpheus.loaders.fsspec_loader # noqa: F401 from morpheus.utils.loader_ids import FSSPEC_LOADER from morpheus.utils.module_ids import DATA_LOADER from morpheus.utils.module_ids import MODULE_NAMESPACE diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_rolling_window.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_rolling_window.py index eb7363eb0f..1784175bf4 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_rolling_window.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_rolling_window.py @@ -159,8 +159,9 @@ def on_data(control_message: MessageControl): # Dont print anything log_info.disable() return None - - rw_control_message = MessageControl() + # TODO (bhargav) Check if we need to pass control_message config to data_prep module. + # If no config is passed there won't be any tasks to perform in the DataPrep stage. + rw_control_message = MessageControl(control_message.config()) rw_control_message.payload(result) # TODO(Devin): Configure based on module config # TODO(Devin): Stop using dfp rolling window for inference, it makes zero sense diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training.py index db5ff46e74..499707cb09 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training.py @@ -14,17 +14,20 @@ import logging -import cudf import mrc from dfencoder import AutoEncoder from mrc.core import operators as ops -from ..messages.multi_dfp_message import MultiDFPMessage, DFPMessageMeta +import cudf + +from morpheus.messages.message_control import MessageControl from morpheus.messages.multi_ae_message import MultiAEMessage from morpheus.utils.module_ids import MODULE_NAMESPACE from morpheus.utils.module_utils import get_module_config from morpheus.utils.module_utils import register_module -from morpheus.messages.message_control import MessageControl + +from ..messages.multi_dfp_message import DFPMessageMeta +from ..messages.multi_dfp_message import MultiDFPMessage from ..utils.module_ids import DFP_TRAINING logger = logging.getLogger("morpheus.{}".format(__name__)) @@ -60,7 +63,7 @@ def on_data(control_message: MessageControl): output_messages = [] while (control_message.has_task("training")): - task = control_message.pop_task("training") + control_message.pop_task("training") user_id = control_message.get_metadata("user_id") message_meta = control_message.payload() diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py b/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py index 8e27c524e1..6bbe076b1f 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py @@ -14,8 +14,8 @@ import os -from dfp.utils.derive_args import DeriveArgs -from dfp.utils.derive_args import pyobj2str +from dfp.utils.dfp_arg_parser import DFPArgParser +from dfp.utils.dfp_arg_parser import pyobj2str from dfp.utils.module_ids import DFP_DATA_PREP from dfp.utils.module_ids import DFP_DEPLOYMENT from dfp.utils.module_ids import DFP_INFERENCE @@ -48,46 +48,46 @@ class ConfigGenerator: - def __init__(self, config: Config, derive_args: DeriveArgs, schema: Schema, encoding: str = "latin1"): + def __init__(self, config: Config, dfp_arg_parser: DFPArgParser, schema: Schema, encoding: str = "latin1"): self._config = config - self._derive_args = derive_args + self._dfp_arg_parser = dfp_arg_parser self._encoding = encoding self._source_schema_str = pyobj2str(schema.source, encoding=encoding) self._preprocess_schema_str = pyobj2str(schema.preprocess, encoding=encoding) self._input_message_type = pyobj2str(MultiMessage, encoding) - def get_module_config(self): - module_config = {} + def get_module_conf(self): + module_conf = {} - module_config["module_id"] = DFP_DEPLOYMENT - module_config["module_name"] = "dfp_deployment" - module_config["namespace"] = MODULE_NAMESPACE + module_conf["module_id"] = DFP_DEPLOYMENT + module_conf["module_name"] = "dfp_deployment" + module_conf["namespace"] = MODULE_NAMESPACE - module_config[FSSPEC_LOADER] = self.fsspec_dataloader_module_config() - module_config[DFP_TRAINING_PIPE] = self.train_module_config() - module_config[DFP_INFERENCE_PIPE] = self.infer_module_config() - module_config["output_port_count"] = 2 + module_conf[FSSPEC_LOADER] = self.fsspec_dataloader_module_conf() + module_conf[DFP_TRAINING_PIPE] = self.train_module_conf() + module_conf[DFP_INFERENCE_PIPE] = self.infer_module_conf() + module_conf["output_port_count"] = 2 - return module_config + return module_conf - def fsspec_dataloader_module_config(self): - module_config = {"loaders": [{"id": FSSPEC_LOADER}]} - return module_config + def fsspec_dataloader_module_conf(self): + module_conf = {"loaders": [{"id": FSSPEC_LOADER}]} + return module_conf - def infer_module_config(self): - module_config = { + def infer_module_conf(self): + module_conf = { DFP_PREPROC: { FILE_BATCHER: { "period": "D", - "sampling_rate_s": self._derive_args.sample_rate_s, - "start_time": self._derive_args.time_fields.start_time, - "end_time": self._derive_args.time_fields.end_time, + "sampling_rate_s": self._dfp_arg_parser.sample_rate_s, + "start_time": self._dfp_arg_parser.time_fields.start_time, + "end_time": self._dfp_arg_parser.time_fields.end_time, "iso_date_regex_pattern": iso_date_regex_pattern, "timestamp_column_name": self._config.ae.timestamp_column_name, "parser_kwargs": { "lines": False, "orient": "records" }, - "cache_dir": self._derive_args.cache_dir, + "cache_dir": self._dfp_arg_parser.cache_dir, "filter_null": True, "file_type": "JSON", "schema": { @@ -101,10 +101,10 @@ def infer_module_config(self): }], "module_name": "dfp_file_to_df_dataloader_inf" }, DFP_SPLIT_USERS: { - "include_generic": self._derive_args.include_generic, - "include_individual": self._derive_args.include_individual, - "skip_users": self._derive_args.skip_users, - "only_users": self._derive_args.only_users, + "include_generic": self._dfp_arg_parser.include_generic, + "include_individual": self._dfp_arg_parser.include_individual, + "skip_users": self._dfp_arg_parser.skip_users, + "only_users": self._dfp_arg_parser.only_users, "timestamp_column_name": self._config.ae.timestamp_column_name, "userid_column_name": self._config.ae.userid_column_name, "fallback_username": self._config.ae.fallback_username @@ -114,7 +114,7 @@ def infer_module_config(self): "min_history": 1, "min_increment": 0, "max_history": "1d", - "cache_dir": self._derive_args.cache_dir, + "cache_dir": self._dfp_arg_parser.cache_dir, "timestamp_column_name": self._config.ae.timestamp_column_name, }, DFP_DATA_PREP: { @@ -124,7 +124,7 @@ def infer_module_config(self): }, }, DFP_INFERENCE: { - "model_name_formatter": self._derive_args.model_name_formatter, + "model_name_formatter": self._dfp_arg_parser.model_name_formatter, "fallback_username": self._config.ae.fallback_username, "timestamp_column_name": self._config.ae.timestamp_column_name }, @@ -144,26 +144,26 @@ def infer_module_config(self): "use_cpp": CppConfig.get_should_use_cpp() }, WRITE_TO_FILE: { - "filename": "dfp_detections_{}.csv".format(self._derive_args.log_type), "overwrite": True + "filename": "dfp_detections_{}.csv".format(self._dfp_arg_parser.source), "overwrite": True } } - return module_config + return module_conf - def train_module_config(self): - module_config = { + def train_module_conf(self): + module_conf = { DFP_PREPROC: { FILE_BATCHER: { "period": "D", - "sampling_rate_s": self._derive_args.sample_rate_s, - "start_time": self._derive_args.time_fields.start_time, - "end_time": self._derive_args.time_fields.end_time, + "sampling_rate_s": self._dfp_arg_parser.sample_rate_s, + "start_time": self._dfp_arg_parser.time_fields.start_time, + "end_time": self._dfp_arg_parser.time_fields.end_time, "iso_date_regex_pattern": iso_date_regex_pattern, "timestamp_column_name": self._config.ae.timestamp_column_name, "parser_kwargs": { "lines": False, "orient": "records" }, - "cache_dir": self._derive_args.cache_dir, + "cache_dir": self._dfp_arg_parser.cache_dir, "filter_null": True, "file_type": "JSON", "schema": { @@ -177,10 +177,10 @@ def train_module_config(self): }], "module_name": "dfp_file_to_df_dataloader_tra" }, DFP_SPLIT_USERS: { - "include_generic": self._derive_args.include_generic, - "include_individual": self._derive_args.include_individual, - "skip_users": self._derive_args.skip_users, - "only_users": self._derive_args.only_users, + "include_generic": self._dfp_arg_parser.include_generic, + "include_individual": self._dfp_arg_parser.include_individual, + "skip_users": self._dfp_arg_parser.skip_users, + "only_users": self._dfp_arg_parser.only_users, "timestamp_column_name": self._config.ae.timestamp_column_name, "userid_column_name": self._config.ae.userid_column_name, "fallback_username": self._config.ae.fallback_username @@ -189,8 +189,8 @@ def train_module_config(self): DFP_ROLLING_WINDOW: { "min_history": 300, "min_increment": 300, - "max_history": self._derive_args.duration, - "cache_dir": self._derive_args.cache_dir, + "max_history": self._dfp_arg_parser.duration, + "cache_dir": self._dfp_arg_parser.cache_dir, "timestamp_column_name": self._config.ae.timestamp_column_name }, DFP_DATA_PREP: { @@ -220,8 +220,8 @@ def train_module_config(self): "validation_size": 0.10 }, MLFLOW_MODEL_WRITER: { - "model_name_formatter": self._derive_args.model_name_formatter, - "experiment_name_formatter": self._derive_args.experiment_name_formatter, + "model_name_formatter": self._dfp_arg_parser.model_name_formatter, + "experiment_name_formatter": self._dfp_arg_parser.experiment_name_formatter, "timestamp_column_name": self._config.ae.timestamp_column_name, "conda_env": { 'channels': ['defaults', 'conda-forge'], @@ -233,15 +233,15 @@ def train_module_config(self): } } - return module_config + return module_conf - def inf_pipe_module_config(self): - module_config = { + def inf_pipe_module_conf(self): + module_conf = { FILE_BATCHER: { "period": "D", - "sampling_rate_s": self._derive_args.sample_rate_s, - "start_time": self._derive_args.time_fields.start_time, - "end_time": self._derive_args.time_fields.end_time, + "sampling_rate_s": self._dfp_arg_parser.sample_rate_s, + "start_time": self._dfp_arg_parser.time_fields.start_time, + "end_time": self._dfp_arg_parser.time_fields.end_time, "iso_date_regex_pattern": iso_date_regex_pattern }, FILE_TO_DF: { @@ -249,7 +249,7 @@ def inf_pipe_module_config(self): "parser_kwargs": { "lines": False, "orient": "records" }, - "cache_dir": self._derive_args.cache_dir, + "cache_dir": self._dfp_arg_parser.cache_dir, "filter_null": True, "file_type": "JSON", "schema": { @@ -257,10 +257,10 @@ def inf_pipe_module_config(self): } }, DFP_SPLIT_USERS: { - "include_generic": self._derive_args.include_generic, - "include_individual": self._derive_args.include_individual, - "skip_users": self._derive_args.skip_users, - "only_users": self._derive_args.only_users, + "include_generic": self._dfp_arg_parser.include_generic, + "include_individual": self._dfp_arg_parser.include_individual, + "skip_users": self._dfp_arg_parser.skip_users, + "only_users": self._dfp_arg_parser.only_users, "timestamp_column_name": self._config.ae.timestamp_column_name, "userid_column_name": self._config.ae.userid_column_name, "fallback_username": self._config.ae.fallback_username @@ -269,7 +269,7 @@ def inf_pipe_module_config(self): "min_history": 1, "min_increment": 0, "max_history": "1d", - "cache_dir": self._derive_args.cache_dir, + "cache_dir": self._dfp_arg_parser.cache_dir, "timestamp_column_name": self._config.ae.timestamp_column_name }, DFP_DATA_PREP: { @@ -279,13 +279,13 @@ def inf_pipe_module_config(self): } }, DFP_INFERENCE: { - "model_name_formatter": self._derive_args.model_name_formatter, + "model_name_formatter": self._dfp_arg_parser.model_name_formatter, "fallback_username": self._config.ae.fallback_username, "timestamp_column_name": self._config.ae.timestamp_column_name }, FILTER_DETECTIONS: { "field_name": "mean_abs_z", - "threshold": 1.0, + "threshold": 2.0, "filter_source": "DATAFRAME", "schema": { "input_message_type": self._input_message_type, "encoding": self._encoding @@ -298,19 +298,19 @@ def inf_pipe_module_config(self): "exclude": ['batch_count', 'origin_hash', '_row_hash', '_batch_id'] }, WRITE_TO_FILE: { - "filename": "dfp_detections_{}.csv".format(self._derive_args.log_type), "overwrite": True + "filename": "dfp_detections_{}.csv".format(self._dfp_arg_parser.source), "overwrite": True } } - return module_config + return module_conf - def tra_pipe_module_config(self): - module_config = { + def tra_pipe_module_conf(self): + module_conf = { FILE_BATCHER: { "period": "D", - "sampling_rate_s": self._derive_args.sample_rate_s, - "start_time": self._derive_args.time_fields.start_time, - "end_time": self._derive_args.time_fields.end_time, + "sampling_rate_s": self._dfp_arg_parser.sample_rate_s, + "start_time": self._dfp_arg_parser.time_fields.start_time, + "end_time": self._dfp_arg_parser.time_fields.end_time, "iso_date_regex_pattern": iso_date_regex_pattern }, FILE_TO_DF: { @@ -318,7 +318,7 @@ def tra_pipe_module_config(self): "parser_kwargs": { "lines": False, "orient": "records" }, - "cache_dir": self._derive_args.cache_dir, + "cache_dir": self._dfp_arg_parser.cache_dir, "filter_null": True, "file_type": "JSON", "schema": { @@ -326,10 +326,10 @@ def tra_pipe_module_config(self): } }, DFP_SPLIT_USERS: { - "include_generic": self._derive_args.include_generic, - "include_individual": self._derive_args.include_individual, - "skip_users": self._derive_args.skip_users, - "only_users": self._derive_args.only_users, + "include_generic": self._dfp_arg_parser.include_generic, + "include_individual": self._dfp_arg_parser.include_individual, + "skip_users": self._dfp_arg_parser.skip_users, + "only_users": self._dfp_arg_parser.only_users, "timestamp_column_name": self._config.ae.timestamp_column_name, "userid_column_name": self._config.ae.userid_column_name, "fallback_username": self._config.ae.fallback_username @@ -337,8 +337,8 @@ def tra_pipe_module_config(self): DFP_ROLLING_WINDOW: { "min_history": 300, "min_increment": 300, - "max_history": self._derive_args.duration, - "cache_dir": self._derive_args.cache_dir, + "max_history": self._dfp_arg_parser.duration, + "cache_dir": self._dfp_arg_parser.cache_dir, "timestamp_column_name": self._config.ae.timestamp_column_name }, DFP_DATA_PREP: { @@ -368,8 +368,8 @@ def tra_pipe_module_config(self): "validation_size": 0.10 }, MLFLOW_MODEL_WRITER: { - "model_name_formatter": self._derive_args.model_name_formatter, - "experiment_name_formatter": self._derive_args.experiment_name_formatter, + "model_name_formatter": self._dfp_arg_parser.model_name_formatter, + "experiment_name_formatter": self._dfp_arg_parser.experiment_name_formatter, "timestamp_column_name": self._config.ae.timestamp_column_name, "conda_env": { 'channels': ['defaults', 'conda-forge'], @@ -381,12 +381,14 @@ def tra_pipe_module_config(self): } } - return module_config + return module_conf -def generate_ae_config(log_type: str, +def generate_ae_config(source: str, userid_column_name: str, timestamp_column_name: str, + pipeline_batch_size: int = 0, + edge_buffer_size: int = 0, use_cpp: bool = False, num_threads: int = os.cpu_count()): config = Config() @@ -395,9 +397,15 @@ def generate_ae_config(log_type: str, config.num_threads = num_threads + if pipeline_batch_size > 0: + config.pipeline_batch_size = pipeline_batch_size + + if edge_buffer_size > 0: + config.edge_buffer_size = edge_buffer_size + config.ae = ConfigAutoEncoder() - labels_file = "data/columns_ae_{}.txt".format(log_type) + labels_file = "data/columns_ae_{}.txt".format(source) config.ae.feature_columns = load_labels_file(get_package_relative_file(labels_file)) config.ae.userid_column_name = userid_column_name config.ae.timestamp_column_name = timestamp_column_name diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/utils/derive_args.py b/examples/digital_fingerprinting/production/morpheus/dfp/utils/dfp_arg_parser.py similarity index 84% rename from examples/digital_fingerprinting/production/morpheus/dfp/utils/derive_args.py rename to examples/digital_fingerprinting/production/morpheus/dfp/utils/dfp_arg_parser.py index 6093fb6a04..5c2b8ef108 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/utils/derive_args.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/utils/dfp_arg_parser.py @@ -33,7 +33,7 @@ class TimeFields: end_time: datetime -class DeriveArgs: +class DFPArgParser: def __init__(self, skip_user: str, @@ -43,7 +43,7 @@ def __init__(self, cache_dir: str, sample_rate_s: str, duration: str, - log_type: str, + source: str, tracking_uri: str, train_users: str = None): @@ -57,18 +57,14 @@ def __init__(self, self._initialized = False self._tracking_uri = tracking_uri self._sample_rate_s = sample_rate_s - self._log_type = log_type + self._source = source self._include_generic = None self._include_individual = None self._time_fields: TimeFields = None - self._model_name_formatter = "DFP-%s-{user_id}" % (log_type) - self._experiment_name_formatter = "dfp/%s/training/{reg_model_name}" % (log_type) - - self._is_training = True - self._is_train_and_infer = True - self._is_inference = True + self._model_name_formatter = "DFP-%s-{user_id}" % (source) + self._experiment_name_formatter = "dfp/%s/training/{reg_model_name}" % (source) def verify_init(func): @@ -97,11 +93,6 @@ def include_generic(self): def duration(self): return self._duration - @property - @verify_init - def is_train_and_infer(self): - return self._is_train_and_infer - @property @verify_init def include_individual(self): @@ -124,21 +115,13 @@ def cache_dir(self): return self._cache_dir @property - def log_type(self): - return self._log_type + def source(self): + return self._source @property def model_name_formatter(self): return self._model_name_formatter - @property - def is_training(self): - return self._is_training - - @property - def is_inference(self): - return self._is_inference - @property def experiment_name_formatter(self): return self._experiment_name_formatter @@ -172,11 +155,7 @@ def _set_mlflow_tracking_uri(self): logger.info("Tracking URI: %s", mlflow.get_tracking_uri()) def _set_time_fields(self): - if self._is_train_and_infer or self._is_training or self._is_inference: - self._time_fields = self._create_time_fields(self._duration) - else: - raise Exception( - "Invalid arguments, when --workload_type is 'train' or 'train_and_infer' --train_users must be passed.") + self._time_fields = self._create_time_fields(self._duration) def init(self): self._configure_logging() diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/utils/schema_utils.py b/examples/digital_fingerprinting/production/morpheus/dfp/utils/schema_utils.py index 8edade2028..2c479e7ee8 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/utils/schema_utils.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/utils/schema_utils.py @@ -36,18 +36,18 @@ class Schema: class SchemaBuilder: - def __init__(self, config: Config, log_type: str): + def __init__(self, config: Config, source: str): self._config = config - self._log_type = log_type + self._source = source def build_schema(self): - if self._log_type == "duo": + if self._source == "duo": return self._build_duo_schema() - elif self._log_type == "azure": + elif self._source == "azure": return self._build_azure_schema() else: - raise Exception("No matching schema found for log type : {}".format(self._log_type)) + raise Exception("No matching schema found for log type : {}".format(self._source)) def _build_azure_schema(self) -> Schema: # Specify the column names to ensure all data is uniform diff --git a/examples/digital_fingerprinting/production/morpheus/dfp_azure_modules_inference.py b/examples/digital_fingerprinting/production/morpheus/dfp_azure_modules_inference.py index 674c4ec132..8cca845ebd 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp_azure_modules_inference.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp_azure_modules_inference.py @@ -21,7 +21,7 @@ from dfp.stages.multi_file_source import MultiFileSource from dfp.utils.config_generator import ConfigGenerator from dfp.utils.config_generator import generate_ae_config -from dfp.utils.derive_args import DeriveArgs +from dfp.utils.dfp_arg_parser import DFPArgParser from dfp.utils.schema_utils import Schema from dfp.utils.schema_utils import SchemaBuilder @@ -99,26 +99,26 @@ def run_pipeline(skip_user: typing.Tuple[str], sample_rate_s, **kwargs): - derive_args = DeriveArgs(skip_user, - only_user, - start_time, - log_level, - cache_dir, - sample_rate_s, - duration, - log_type="azure", - tracking_uri=kwargs["tracking_uri"]) + dfp_arg_parser = DFPArgParser(skip_user, + only_user, + start_time, + log_level, + cache_dir, + sample_rate_s, + duration, + log_type="azure", + tracking_uri=kwargs["tracking_uri"]) - derive_args.init() + dfp_arg_parser.init() - config: Config = generate_ae_config(derive_args.log_type, + config: Config = generate_ae_config(dfp_arg_parser.log_type, userid_column_name="username", timestamp_column_name="timestamp") - schema_builder = SchemaBuilder(config, derive_args.log_type) + schema_builder = SchemaBuilder(config, dfp_arg_parser.log_type) schema: Schema = schema_builder.build_schema() - config_generator = ConfigGenerator(config, derive_args, schema) + config_generator = ConfigGenerator(config, dfp_arg_parser, schema) module_config = config_generator.inf_pipe_module_config() diff --git a/examples/digital_fingerprinting/production/morpheus/dfp_azure_modules_training.py b/examples/digital_fingerprinting/production/morpheus/dfp_azure_modules_training.py index 349247bf3b..203e665df9 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp_azure_modules_training.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp_azure_modules_training.py @@ -21,7 +21,7 @@ from dfp.stages.multi_file_source import MultiFileSource from dfp.utils.config_generator import ConfigGenerator from dfp.utils.config_generator import generate_ae_config -from dfp.utils.derive_args import DeriveArgs +from dfp.utils.dfp_arg_parser import DFPArgParser from dfp.utils.schema_utils import Schema from dfp.utils.schema_utils import SchemaBuilder @@ -108,27 +108,27 @@ def run_pipeline(train_users, sample_rate_s, **kwargs): - derive_args = DeriveArgs(skip_user, - only_user, - start_time, - log_level, - cache_dir, - sample_rate_s, - duration, - log_type="azure", - tracking_uri=kwargs["tracking_uri"], - train_users=train_users) + dfp_arg_parser = DeriveArgs(skip_user, + only_user, + start_time, + log_level, + cache_dir, + sample_rate_s, + duration, + log_type="azure", + tracking_uri=kwargs["tracking_uri"], + train_users=train_users) - derive_args.init() + dfp_arg_parser.init() - config: Config = generate_ae_config(derive_args.log_type, + config: Config = generate_ae_config(dfp_arg_parser.log_type, userid_column_name="username", timestamp_column_name="timestamp") - schema_builder = SchemaBuilder(config, derive_args.log_type) + schema_builder = SchemaBuilder(config, dfp_arg_parser.log_type) schema: Schema = schema_builder.build_schema() - config_generator = ConfigGenerator(config, derive_args, schema) + config_generator = ConfigGenerator(config, dfp_arg_parser, schema) module_config = config_generator.tra_pipe_module_config() diff --git a/examples/digital_fingerprinting/production/morpheus/dfp_duo_modules_inference.py b/examples/digital_fingerprinting/production/morpheus/dfp_duo_modules_inference.py index d80e6e4da6..e79458c3e5 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp_duo_modules_inference.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp_duo_modules_inference.py @@ -22,7 +22,7 @@ from dfp.stages.multi_file_source import MultiFileSource from dfp.utils.config_generator import ConfigGenerator from dfp.utils.config_generator import generate_ae_config -from dfp.utils.derive_args import DeriveArgs +from dfp.utils.dfp_arg_parser import DFPArgParser from dfp.utils.schema_utils import Schema from dfp.utils.schema_utils import SchemaBuilder @@ -100,26 +100,26 @@ def run_pipeline(skip_user: typing.Tuple[str], sample_rate_s, **kwargs): - derive_args = DeriveArgs(skip_user, - only_user, - start_time, - log_level, - cache_dir, - sample_rate_s, - duration, - log_type="duo", - tracking_uri=kwargs["tracking_uri"]) + dfp_arg_parser = DeriveArgs(skip_user, + only_user, + start_time, + log_level, + cache_dir, + sample_rate_s, + duration, + log_type="duo", + tracking_uri=kwargs["tracking_uri"]) - derive_args.init() + dfp_arg_parser.init() - config: Config = generate_ae_config(derive_args.log_type, + config: Config = generate_ae_config(dfp_arg_parser.log_type, userid_column_name="username", timestamp_column_name="timestamp") - schema_builder = SchemaBuilder(config, derive_args.log_type) + schema_builder = SchemaBuilder(config, dfp_arg_parser.log_type) schema: Schema = schema_builder.build_schema() - config_generator = ConfigGenerator(config, derive_args, schema) + config_generator = ConfigGenerator(config, dfp_arg_parser, schema) module_config = config_generator.inf_pipe_module_config() diff --git a/examples/digital_fingerprinting/production/morpheus/dfp_duo_modules_training.py b/examples/digital_fingerprinting/production/morpheus/dfp_duo_modules_training.py index f2c1b83948..3a84ea8a86 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp_duo_modules_training.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp_duo_modules_training.py @@ -21,7 +21,7 @@ from dfp.stages.multi_file_source import MultiFileSource from dfp.utils.config_generator import ConfigGenerator from dfp.utils.config_generator import generate_ae_config -from dfp.utils.derive_args import DeriveArgs +from dfp.utils.dfp_arg_parser import DFPArgParser from dfp.utils.schema_utils import Schema from dfp.utils.schema_utils import SchemaBuilder @@ -108,27 +108,27 @@ def run_pipeline(train_users, sample_rate_s, **kwargs): - derive_args = DeriveArgs(skip_user, - only_user, - start_time, - log_level, - cache_dir, - sample_rate_s, - duration, - log_type="duo", - tracking_uri=kwargs["tracking_uri"], - train_users=train_users) + dfp_arg_parser = DeriveArgs(skip_user, + only_user, + start_time, + log_level, + cache_dir, + sample_rate_s, + duration, + log_type="duo", + tracking_uri=kwargs["tracking_uri"], + train_users=train_users) - derive_args.init() + dfp_arg_parser.init() - config: Config = generate_ae_config(derive_args.log_type, + config: Config = generate_ae_config(dfp_arg_parser.log_type, userid_column_name="username", timestamp_column_name="timestamp") - schema_builder = SchemaBuilder(config, derive_args.log_type) + schema_builder = SchemaBuilder(config, dfp_arg_parser.log_type) schema: Schema = schema_builder.build_schema() - config_generator = ConfigGenerator(config, derive_args, schema) + config_generator = ConfigGenerator(config, dfp_arg_parser, schema) module_config = config_generator.tra_pipe_module_config() diff --git a/examples/digital_fingerprinting/production/morpheus/dfp_modules_pipeline.py b/examples/digital_fingerprinting/production/morpheus/dfp_modules_pipeline.py index 8567ed514e..22621d94eb 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp_modules_pipeline.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp_modules_pipeline.py @@ -20,7 +20,7 @@ import dfp.modules.dfp_deployment # noqa: F401 from dfp.utils.config_generator import ConfigGenerator from dfp.utils.config_generator import generate_ae_config -from dfp.utils.derive_args import DeriveArgs +from dfp.utils.dfp_arg_parser import DFPArgParser from dfp.utils.schema_utils import Schema from dfp.utils.schema_utils import SchemaBuilder @@ -108,7 +108,7 @@ type=str, default="http://mlflow:5000", help=("The MLflow tracking URI to connect to the tracking backend.")) -def run_pipeline(log_type: str, +def run_pipeline(source: str, train_users: str, skip_user: typing.Tuple[str], only_user: typing.Tuple[str], @@ -123,37 +123,37 @@ def run_pipeline(log_type: str, if (skip_user and only_user): logging.error("Option --skip_user and --only_user are mutually exclusive. Exiting") - derived_args = DeriveArgs(skip_user, - only_user, - start_time, - log_level, - cache_dir, - sample_rate_s, - duration, - log_type, - tracking_uri, - train_users) + dfp_arg_parser = DFPArgParser(skip_user, + only_user, + start_time, + log_level, + cache_dir, + sample_rate_s, + duration, + source, + tracking_uri, + train_users) - derived_args.init() + dfp_arg_parser.init() # Default user_id column -- override with ControlMessage userid_column_name = "username" # Default timestamp column -- override with ControlMessage timestamp_column_name = "timestamp" - config: Config = generate_ae_config(log_type, userid_column_name, timestamp_column_name, use_cpp=use_cpp) + config: Config = generate_ae_config(source, userid_column_name, timestamp_column_name, use_cpp=use_cpp) # Construct the data frame Schema used to normalize incoming data - schema_builder = SchemaBuilder(config, log_type) + schema_builder = SchemaBuilder(config, source) schema: Schema = schema_builder.build_schema() # Create config helper used to generate config parameters for the DFP module # This will populate to the minimum configuration parameters with intelligent default values - config_generator = ConfigGenerator(config, derived_args, schema) + config_generator = ConfigGenerator(config, dfp_arg_parser, schema) - module_config = config_generator.get_module_config() + module_conf = config_generator.get_module_conf() - output_port_count = module_config.get("output_port_count") + output_port_count = module_conf.get("output_port_count") # Create a pipeline object pipeline = Pipeline(config) @@ -162,7 +162,7 @@ def run_pipeline(log_type: str, dfp_deployment_stage = pipeline.add_stage( MultiPortModuleStage(config, - module_config, + module_conf, input_port_name="input", output_port_name_prefix="output", output_port_count=output_port_count)) diff --git a/morpheus/modules/file_batcher.py b/morpheus/modules/file_batcher.py index d95c50fbbb..aacc899378 100644 --- a/morpheus/modules/file_batcher.py +++ b/morpheus/modules/file_batcher.py @@ -52,11 +52,12 @@ def file_batcher(builder: mrc.Builder): TimestampFileObj = namedtuple("TimestampFileObj", ["timestamp", "file_name"]) iso_date_regex_pattern = config.get("iso_date_regex_pattern", None) + task_type = config.get("task_type", None) start_time = config.get("start_time", None) end_time = config.get("end_time", None) sampling_rate_s = config.get("sampling_rate_s", None) period = config.get("period", None) - task_type = config.get("task_type", None) + iso_date_regex = re.compile(iso_date_regex_pattern) def build_fs_filename_df(files): @@ -97,8 +98,8 @@ def build_fs_filename_df(files): timestamps.append(ts) full_names.append(file_name) - # df = pd.DataFrame() - df = cudf.DataFrame() + df = pd.DataFrame() + # df = cudf.DataFrame() df["ts"] = timestamps df["key"] = full_names @@ -142,33 +143,34 @@ def generate_cms_for_batch_periods(control_message: MessageControl, period_gb, n return control_messages def on_data(control_message: MessageControl): - control_messages = [] - data_type = control_message.get_metadata("data_type") - if not control_message.has_task(task_type) and data_type != "streaming": - return control_messages - - mm = control_message.payload() - with mm.mutable_dataframe() as dfm: - files = dfm.files.to_arrow().to_pylist() - ts_filenames_df = build_fs_filename_df(files) - - if len(ts_filenames_df) > 0: - # Now split by the batching settings - df_test = cudf.from_pandas(ts_filenames_df) - df_test["period"] = df_test["ts"].dt.strftime("%Y-%m-%d") - test_period_gb = df_test.groupby("period") - # print("DF_TEST_PERIOD: \n", df_test["period"], flush=True) - # df_period = ts_filenames_df["ts"].dt.to_period(period) - # print("DF_PERIOD: \n", df_period, flush=True) - # period_gb = ts_filenames_df.groupby(df_period) - # n_groups = len(period_gb) - n_groups = len(test_period_gb) - - logger.debug("Batching %d files => %d groups", len(ts_filenames_df), n_groups) - - control_messages = generate_cms_for_batch_periods(control_message, test_period_gb, n_groups) + control_messages = [] + tasks = control_message.config().get("tasks") + # Checking for task_type (`inference` or `training`) or (no task at all and data_type is streaming) + # to proceed further. If not dispose the message. + if control_message.has_task(task_type) or (not tasks and data_type == "streaming"): + + mm = control_message.payload() + with mm.mutable_dataframe() as dfm: + files = dfm.files.to_arrow().to_pylist() + ts_filenames_df = build_fs_filename_df(files) + + if len(ts_filenames_df) > 0: + # Now split by the batching settings + # df_test = cudf.from_pandas(ts_filenames_df) + # df_test["period"] = df_test["ts"].dt.strftime("%Y-%m-%d") + # test_period_gb = df_test.groupby("period") + # print("DF_TEST_PERIOD: \n", df_test["period"], flush=True) + df_period = ts_filenames_df["ts"].dt.to_period(period) + # print("DF_PERIOD: \n", df_period, flush=True) + period_gb = ts_filenames_df.groupby(df_period) + n_groups = len(period_gb) + # n_groups = len(test_period_gb) + + logger.debug("Batching %d files => %d groups", len(ts_filenames_df), n_groups) + + control_messages = generate_cms_for_batch_periods(control_message, period_gb, n_groups) return control_messages diff --git a/morpheus/stages/general/multi_port_module_stage.py b/morpheus/stages/general/multi_port_module_stage.py index b03221ea52..95c842cebe 100644 --- a/morpheus/stages/general/multi_port_module_stage.py +++ b/morpheus/stages/general/multi_port_module_stage.py @@ -50,7 +50,7 @@ class MultiPortModuleStage(Stage): def __init__(self, c: Config, - module_config: typing.Dict, + module_conf: typing.Dict[str, any], input_port_name: str, output_port_name_prefix: str, output_port_count: int, @@ -61,7 +61,7 @@ def __init__(self, self._input_type = input_type self._ouput_type = output_type - self._module_config = module_config + self._module_conf = module_conf self._input_port_name = input_port_name self._output_port_name_prefix = output_port_name_prefix @@ -72,7 +72,7 @@ def __init__(self, @property def name(self) -> str: - return self._module_config.get("module_name", "non_linear_module") + return self._module_conf.get("module_name", "non_linear_module") def supports_cpp_node(self): return False @@ -103,7 +103,7 @@ def _build(self, builder: mrc.Builder, in_stream_pairs: typing.List[StreamPair]) in_stream_node = in_stream_pairs[0][0] # Laod module from registry. - module = load_module(self._module_config, builder=builder) + module = load_module(self._module_conf, builder=builder) mod_in_stream = module.input_port(self._input_port_name) builder.make_edge(in_stream_node, mod_in_stream) From 8c39fe563502ac024a8a0ee541e3417d01f08a40 Mon Sep 17 00:00:00 2001 From: bsuryadevara Date: Thu, 2 Mar 2023 20:44:25 -0600 Subject: [PATCH 064/157] restructured benchmark tests --- .../production/morpheus/benchmarks/README.md | 33 +++++++++++++------ 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/examples/digital_fingerprinting/production/morpheus/benchmarks/README.md b/examples/digital_fingerprinting/production/morpheus/benchmarks/README.md index 932365bc4e..df5cd4c4a1 100644 --- a/examples/digital_fingerprinting/production/morpheus/benchmarks/README.md +++ b/examples/digital_fingerprinting/production/morpheus/benchmarks/README.md @@ -91,16 +91,29 @@ pytest -s --benchmark-enable --benchmark-warmup=on --benchmark-warmup-iterations The console output should look like this: ``` ------------------------------------------------------------------------------------------------------ benchmark: 6 tests ----------------------------------------------------------------------------------------------------- -Name (time in ms) Min Max Mean StdDev Median IQR Outliers OPS Rounds Iterations ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- -test_dfp_inference_duo_stages_e2e 308.4402 (1.0) 441.9953 (1.0) 385.4835 (1.0) 52.7466 (1.0) 374.8979 (1.0) 73.1232 (1.0) 2;0 2.5941 (1.0) 5 1 -test_dfp_inference_azure_stages_e2e 454.2198 (1.47) 625.3723 (1.41) 539.4551 (1.40) 77.5497 (1.47) 556.1858 (1.48) 143.2852 (1.96) 2;0 1.8537 (0.71) 5 1 -test_dfp_training_duo_modules_e2e 13,701.4709 (44.42) 15,542.6684 (35.16) 14,604.7726 (37.89) 806.8470 (15.30) 14,486.1345 (38.64) 1,461.3735 (19.99) 2;0 0.0685 (0.03) 5 1 -test_dfp_training_duo_stages_e2e 14,617.3350 (47.39) 15,589.4445 (35.27) 14,941.8147 (38.76) 403.5400 (7.65) 14,717.5218 (39.26) 526.5890 (7.20) 1;0 0.0669 (0.03) 5 1 -test_dfp_training_azure_stages_e2e 26,091.4968 (84.59) 27,554.4906 (62.34) 27,014.1010 (70.08) 558.1178 (10.58) 27,148.0393 (72.41) 612.2293 (8.37) 1;0 0.0370 (0.01) 5 1 -test_dfp_training_azure_modules_e2e 26,228.4464 (85.04) 29,457.1970 (66.65) 28,156.9607 (73.04) 1,252.0302 (23.74) 28,241.6172 (75.33) 1,698.1469 (23.22) 2;0 0.0355 (0.01) 5 1 ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- +------------------------------------------------------------------------------------------------------- benchmark: 19 tests ------------------------------------------------------------------------------------------------------- +Name (time in ms) Min Max Mean StdDev Median IQR Outliers OPS Rounds Iterations +----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- +test_dfp_modules_duo_payload_only_load_e2e 221.7548 (1.0) 313.8652 (1.0) 263.8946 (1.0) 35.5942 (inf) 251.0703 (1.0) 49.3962 (inf) 2;0 3.7894 (1.0) 5 1 +test_dfp_modules_duo_payload_inference_e2e 1,010.4983 (4.56) 1,010.4983 (3.22) 1,010.4983 (3.83) 0.0000 (1.0) 1,010.4983 (4.02) 0.0000 (1.0) 0;0 0.9896 (0.26) 1 1 +test_dfp_modules_azure_payload_inference_e2e 1,160.3311 (5.23) 1,160.3311 (3.70) 1,160.3311 (4.40) 0.0000 (1.0) 1,160.3311 (4.62) 0.0000 (1.0) 0;0 0.8618 (0.23) 1 1 +test_dfp_stages_duo_inference_e2e 1,221.0156 (5.51) 1,221.0156 (3.89) 1,221.0156 (4.63) 0.0000 (1.0) 1,221.0156 (4.86) 0.0000 (1.0) 0;0 0.8190 (0.22) 1 1 +test_dfp_stages_azure_inference_e2e 1,462.4917 (6.60) 1,462.4917 (4.66) 1,462.4917 (5.54) 0.0000 (1.0) 1,462.4917 (5.83) 0.0000 (1.0) 0;0 0.6838 (0.18) 1 1 +test_dfp_modules_azure_streaming_inference_e2e 1,562.7886 (7.05) 1,562.7886 (4.98) 1,562.7886 (5.92) 0.0000 (1.0) 1,562.7886 (6.22) 0.0000 (1.0) 0;0 0.6399 (0.17) 1 1 +test_dfp_modules_duo_streaming_inference_e2e 1,626.7846 (7.34) 1,626.7846 (5.18) 1,626.7846 (6.16) 0.0000 (1.0) 1,626.7846 (6.48) 0.0000 (1.0) 0;0 0.6147 (0.16) 1 1 +test_dfp_modules_duo_payload_training_e2e 9,909.2326 (44.69) 9,909.2326 (31.57) 9,909.2326 (37.55) 0.0000 (1.0) 9,909.2326 (39.47) 0.0000 (1.0) 0;0 0.1009 (0.03) 1 1 +test_dfp_modules_duo_payload_lti_e2e 11,283.7325 (50.88) 11,283.7325 (35.95) 11,283.7325 (42.76) 0.0000 (1.0) 11,283.7325 (44.94) 0.0000 (1.0) 0;0 0.0886 (0.02) 1 1 +test_dfp_modules_azure_payload_training_e2e 12,097.5285 (54.55) 12,097.5285 (38.54) 12,097.5285 (45.84) 0.0000 (1.0) 12,097.5285 (48.18) 0.0000 (1.0) 0;0 0.0827 (0.02) 1 1 +test_dfp_modules_azure_payload_lti_e2e 13,467.1761 (60.73) 13,467.1761 (42.91) 13,467.1761 (51.03) 0.0000 (1.0) 13,467.1761 (53.64) 0.0000 (1.0) 0;0 0.0743 (0.02) 1 1 +test_dfp_stages_duo_training_e2e 18,871.9930 (85.10) 18,871.9930 (60.13) 18,871.9930 (71.51) 0.0000 (1.0) 18,871.9930 (75.17) 0.0000 (1.0) 0;0 0.0530 (0.01) 1 1 +test_dfp_stages_azure_training_e2e 30,399.7126 (137.09) 30,399.7126 (96.86) 30,399.7126 (115.20) 0.0000 (1.0) 30,399.7126 (121.08) 0.0000 (1.0) 0;0 0.0329 (0.01) 1 1 +test_dfp_modules_duo_streaming_payload_e2e 33,018.3594 (148.90) 33,018.3594 (105.20) 33,018.3594 (125.12) 0.0000 (1.0) 33,018.3594 (131.51) 0.0000 (1.0) 0;0 0.0303 (0.01) 1 1 +test_dfp_modules_duo_streaming_training_e2e 33,672.9700 (151.85) 33,672.9700 (107.28) 33,672.9700 (127.60) 0.0000 (1.0) 33,672.9700 (134.12) 0.0000 (1.0) 0;0 0.0297 (0.01) 1 1 +test_dfp_modules_duo_streaming_only_load_e2e 35,410.0752 (159.68) 35,410.0752 (112.82) 35,410.0752 (134.18) 0.0000 (1.0) 35,410.0752 (141.04) 0.0000 (1.0) 0;0 0.0282 (0.01) 1 1 +test_dfp_modules_duo_streaming_lti_e2e 36,251.7741 (163.48) 36,251.7741 (115.50) 36,251.7741 (137.37) 0.0000 (1.0) 36,251.7741 (144.39) 0.0000 (1.0) 0;0 0.0276 (0.01) 1 1 +test_dfp_modules_azure_streaming_training_e2e 54,888.6326 (247.52) 54,888.6326 (174.88) 54,888.6326 (207.99) 0.0000 (1.0) 54,888.6326 (218.62) 0.0000 (1.0) 0;0 0.0182 (0.00) 1 1 +test_dfp_modules_azure_streaming_lti_e2e 57,296.2454 (258.38) 57,296.2454 (182.55) 57,296.2454 (217.12) 0.0000 (1.0) 57,296.2454 (228.21) 0.0000 (1.0) 0;0 0.0175 (0.00) 1 1 +----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- ``` ### Benchmarks Report From dcd272f12841ba343231a38a2cc0217474d7f3ff Mon Sep 17 00:00:00 2001 From: bsuryadevara Date: Fri, 3 Mar 2023 13:04:17 -0600 Subject: [PATCH 065/157] updates to dfp benchmarks --- .../production/morpheus/benchmarks/README.md | 80 ++++++++++-------- .../benchmarks/benchmark_conf_generator.py | 16 ++-- .../duo_payload_only_load.json | 2 +- .../duo_streaming_only_load.json | 2 +- .../benchmarks/test_bench_e2e_dfp_pipeline.py | 82 ++++++++----------- .../stages/general/linear_modules_stage.py | 2 +- .../stages/general/multi_port_module_stage.py | 11 ++- .../input/control_message_source_stage.py | 3 +- 8 files changed, 97 insertions(+), 101 deletions(-) diff --git a/examples/digital_fingerprinting/production/morpheus/benchmarks/README.md b/examples/digital_fingerprinting/production/morpheus/benchmarks/README.md index df5cd4c4a1..2741318038 100644 --- a/examples/digital_fingerprinting/production/morpheus/benchmarks/README.md +++ b/examples/digital_fingerprinting/production/morpheus/benchmarks/README.md @@ -33,26 +33,36 @@ Benchmarks are run using `pytest-benchmark`. By default, there are five rounds o To provide your own calibration or use other `pytest-benchmark` features with these workflows, please refer to their [documentation](https://pytest-benchmark.readthedocs.io/en/latest/). -Morpheus pipeline configurations for each workflow are managed using [pipelines_conf.json](./resource/pipelines_conf.json). For example, this is the Morpheus configuration for `duo_training_modules`: -``` -"test_dfp_modules_azure_training_e2e": { - "message_path": "./resource/control_message_azure_training.json", - "num_threads": 12, - "pipeline_batch_size": 256, - "edge_buffer_size": 128, - "start_time": "2022-08-01", - "duration": "60d" +Morpheus pipeline configurations for each workflow are managed using [pipelines_conf.json](./resource/pipelines_conf.json). For example, this is the Morpheus configuration for `dfp_modules_duo_payload_inference`: +``` +"test_dfp_modules_duo_payload_inference_e2e": { + "message_path": "./resource/control_messages/duo_payload_inference.json", + "num_threads": 12, + "pipeline_batch_size": 256, + "edge_buffer_size": 128, + "start_time": "2022-08-01", + "duration": "60d", + "userid_column_name": "username", + "timestamp_column_name": "timestamp", + "source": "duo", + "use_cpp": true }, ... ``` In addition to the Morpheus pipeline settings, we also have a configuration file called [modules_conf.json](./resource/modules_conf.json) that is specific to modules. When using MRC SegmentModule, pipelines need this configuration file. Additional information is included in the [Morpheus Pipeline with Modules](../../../../../docs/source/developer_guide/guides/6_digital_fingerprinting_reference.md#morpheus-pipeline-with-modules) +To ensure that the [file_to_df_loader.py](../../../../../morpheus/loaders/file_to_df_loader.py) utilizes the same type of downloading mechanism, set `MORPHEUS FILE DOWNLOAD TYPE` environment variable with any one of given choices (`multiprocess`, `dask`, `dask thread`, `single thread`). + +``` +export MORPHEUS_FILE_DOWNLOAD_TYPE=multiprocess +``` + Benchmarks for an individual workflow can be run using the following: ``` -pytest -s --benchmark-enable --benchmark-warmup=on --benchmark-warmup-iterations=1 --benchmark-autosave test_bench_e2e_dfp_pipeline.py:: +pytest -s --log-level=WARN --benchmark-enable --benchmark-warmup=on --benchmark-warmup-iterations=1 --benchmark-autosave test_bench_e2e_dfp_pipeline.py:: ``` The `-s` option allows outputs of pipeline execution to be displayed so you can ensure there are no errors while running your benchmarks. @@ -91,29 +101,29 @@ pytest -s --benchmark-enable --benchmark-warmup=on --benchmark-warmup-iterations The console output should look like this: ``` -------------------------------------------------------------------------------------------------------- benchmark: 19 tests ------------------------------------------------------------------------------------------------------- -Name (time in ms) Min Max Mean StdDev Median IQR Outliers OPS Rounds Iterations ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ -test_dfp_modules_duo_payload_only_load_e2e 221.7548 (1.0) 313.8652 (1.0) 263.8946 (1.0) 35.5942 (inf) 251.0703 (1.0) 49.3962 (inf) 2;0 3.7894 (1.0) 5 1 -test_dfp_modules_duo_payload_inference_e2e 1,010.4983 (4.56) 1,010.4983 (3.22) 1,010.4983 (3.83) 0.0000 (1.0) 1,010.4983 (4.02) 0.0000 (1.0) 0;0 0.9896 (0.26) 1 1 -test_dfp_modules_azure_payload_inference_e2e 1,160.3311 (5.23) 1,160.3311 (3.70) 1,160.3311 (4.40) 0.0000 (1.0) 1,160.3311 (4.62) 0.0000 (1.0) 0;0 0.8618 (0.23) 1 1 -test_dfp_stages_duo_inference_e2e 1,221.0156 (5.51) 1,221.0156 (3.89) 1,221.0156 (4.63) 0.0000 (1.0) 1,221.0156 (4.86) 0.0000 (1.0) 0;0 0.8190 (0.22) 1 1 -test_dfp_stages_azure_inference_e2e 1,462.4917 (6.60) 1,462.4917 (4.66) 1,462.4917 (5.54) 0.0000 (1.0) 1,462.4917 (5.83) 0.0000 (1.0) 0;0 0.6838 (0.18) 1 1 -test_dfp_modules_azure_streaming_inference_e2e 1,562.7886 (7.05) 1,562.7886 (4.98) 1,562.7886 (5.92) 0.0000 (1.0) 1,562.7886 (6.22) 0.0000 (1.0) 0;0 0.6399 (0.17) 1 1 -test_dfp_modules_duo_streaming_inference_e2e 1,626.7846 (7.34) 1,626.7846 (5.18) 1,626.7846 (6.16) 0.0000 (1.0) 1,626.7846 (6.48) 0.0000 (1.0) 0;0 0.6147 (0.16) 1 1 -test_dfp_modules_duo_payload_training_e2e 9,909.2326 (44.69) 9,909.2326 (31.57) 9,909.2326 (37.55) 0.0000 (1.0) 9,909.2326 (39.47) 0.0000 (1.0) 0;0 0.1009 (0.03) 1 1 -test_dfp_modules_duo_payload_lti_e2e 11,283.7325 (50.88) 11,283.7325 (35.95) 11,283.7325 (42.76) 0.0000 (1.0) 11,283.7325 (44.94) 0.0000 (1.0) 0;0 0.0886 (0.02) 1 1 -test_dfp_modules_azure_payload_training_e2e 12,097.5285 (54.55) 12,097.5285 (38.54) 12,097.5285 (45.84) 0.0000 (1.0) 12,097.5285 (48.18) 0.0000 (1.0) 0;0 0.0827 (0.02) 1 1 -test_dfp_modules_azure_payload_lti_e2e 13,467.1761 (60.73) 13,467.1761 (42.91) 13,467.1761 (51.03) 0.0000 (1.0) 13,467.1761 (53.64) 0.0000 (1.0) 0;0 0.0743 (0.02) 1 1 -test_dfp_stages_duo_training_e2e 18,871.9930 (85.10) 18,871.9930 (60.13) 18,871.9930 (71.51) 0.0000 (1.0) 18,871.9930 (75.17) 0.0000 (1.0) 0;0 0.0530 (0.01) 1 1 -test_dfp_stages_azure_training_e2e 30,399.7126 (137.09) 30,399.7126 (96.86) 30,399.7126 (115.20) 0.0000 (1.0) 30,399.7126 (121.08) 0.0000 (1.0) 0;0 0.0329 (0.01) 1 1 -test_dfp_modules_duo_streaming_payload_e2e 33,018.3594 (148.90) 33,018.3594 (105.20) 33,018.3594 (125.12) 0.0000 (1.0) 33,018.3594 (131.51) 0.0000 (1.0) 0;0 0.0303 (0.01) 1 1 -test_dfp_modules_duo_streaming_training_e2e 33,672.9700 (151.85) 33,672.9700 (107.28) 33,672.9700 (127.60) 0.0000 (1.0) 33,672.9700 (134.12) 0.0000 (1.0) 0;0 0.0297 (0.01) 1 1 -test_dfp_modules_duo_streaming_only_load_e2e 35,410.0752 (159.68) 35,410.0752 (112.82) 35,410.0752 (134.18) 0.0000 (1.0) 35,410.0752 (141.04) 0.0000 (1.0) 0;0 0.0282 (0.01) 1 1 -test_dfp_modules_duo_streaming_lti_e2e 36,251.7741 (163.48) 36,251.7741 (115.50) 36,251.7741 (137.37) 0.0000 (1.0) 36,251.7741 (144.39) 0.0000 (1.0) 0;0 0.0276 (0.01) 1 1 -test_dfp_modules_azure_streaming_training_e2e 54,888.6326 (247.52) 54,888.6326 (174.88) 54,888.6326 (207.99) 0.0000 (1.0) 54,888.6326 (218.62) 0.0000 (1.0) 0;0 0.0182 (0.00) 1 1 -test_dfp_modules_azure_streaming_lti_e2e 57,296.2454 (258.38) 57,296.2454 (182.55) 57,296.2454 (217.12) 0.0000 (1.0) 57,296.2454 (228.21) 0.0000 (1.0) 0;0 0.0175 (0.00) 1 1 ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ +-------------------------------------------------------------------------------------------------------- benchmark: 19 tests -------------------------------------------------------------------------------------------------------- +Name (time in ms) Min Max Mean StdDev Median IQR Outliers OPS Rounds Iterations +------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- +test_dfp_modules_duo_payload_only_load_e2e 226.3854 (1.0) 283.0055 (1.0) 259.3731 (1.0) 24.3098 (1.0) 269.2701 (1.0) 40.5459 (1.0) 1;0 3.8554 (1.0) 5 1 +test_dfp_modules_duo_payload_inference_e2e 976.1599 (4.31) 1,147.7819 (4.06) 1,067.5186 (4.12) 65.2043 (2.68) 1,088.5716 (4.04) 86.9582 (2.14) 2;0 0.9368 (0.24) 5 1 +test_dfp_stages_duo_inference_e2e 1,040.1275 (4.59) 1,328.9118 (4.70) 1,158.5368 (4.47) 127.0640 (5.23) 1,127.6553 (4.19) 223.5278 (5.51) 1;0 0.8632 (0.22) 5 1 +test_dfp_modules_azure_payload_inference_e2e 1,075.9931 (4.75) 1,313.8863 (4.64) 1,163.2758 (4.48) 90.5340 (3.72) 1,142.0053 (4.24) 95.3948 (2.35) 1;0 0.8596 (0.22) 5 1 +test_dfp_stages_azure_inference_e2e 1,102.1970 (4.87) 1,436.8655 (5.08) 1,243.6478 (4.79) 147.9676 (6.09) 1,164.8561 (4.33) 246.8259 (6.09) 1;0 0.8041 (0.21) 5 1 +test_dfp_modules_duo_streaming_inference_e2e 1,261.8304 (5.57) 1,406.6397 (4.97) 1,333.9344 (5.14) 52.9789 (2.18) 1,324.8074 (4.92) 62.6631 (1.55) 2;0 0.7497 (0.19) 5 1 +test_dfp_modules_azure_streaming_inference_e2e 1,332.5694 (5.89) 1,506.8211 (5.32) 1,415.3912 (5.46) 67.6594 (2.78) 1,417.5592 (5.26) 101.9428 (2.51) 2;0 0.7065 (0.18) 5 1 +test_dfp_modules_duo_streaming_only_load_e2e 1,805.8288 (7.98) 2,354.6001 (8.32) 2,045.9313 (7.89) 199.3942 (8.20) 2,045.7892 (7.60) 202.2794 (4.99) 2;0 0.4888 (0.13) 5 1 +test_dfp_modules_duo_payload_training_e2e 9,037.7003 (39.92) 9,836.9510 (34.76) 9,367.2792 (36.12) 330.3668 (13.59) 9,207.2873 (34.19) 502.7229 (12.40) 1;0 0.1068 (0.03) 5 1 +test_dfp_modules_duo_payload_lti_e2e 9,954.3053 (43.97) 10,534.4838 (37.22) 10,247.6966 (39.51) 246.8732 (10.16) 10,224.6111 (37.97) 434.5221 (10.72) 2;0 0.0976 (0.03) 5 1 +test_dfp_modules_azure_payload_training_e2e 11,542.1990 (50.98) 11,704.6100 (41.36) 11,625.2338 (44.82) 72.5717 (2.99) 11,648.4413 (43.26) 130.2369 (3.21) 2;0 0.0860 (0.02) 5 1 +test_dfp_modules_azure_payload_lti_e2e 12,414.6397 (54.84) 13,634.3140 (48.18) 13,112.0041 (50.55) 492.8452 (20.27) 13,270.1088 (49.28) 763.9778 (18.84) 2;0 0.0763 (0.02) 5 1 +test_dfp_stages_duo_training_e2e 15,892.6129 (70.20) 16,538.2125 (58.44) 16,301.0573 (62.85) 242.4913 (9.98) 16,351.5376 (60.73) 212.1910 (5.23) 1;1 0.0613 (0.02) 5 1 +test_dfp_modules_duo_streaming_training_e2e 27,783.2057 (122.73) 28,387.4788 (100.31) 27,956.0751 (107.78) 249.0318 (10.24) 27,853.2863 (103.44) 253.7971 (6.26) 1;0 0.0358 (0.01) 5 1 +test_dfp_stages_azure_training_e2e 28,264.0585 (124.85) 29,443.4046 (104.04) 28,879.5257 (111.34) 476.5615 (19.60) 28,900.8030 (107.33) 781.7848 (19.28) 2;0 0.0346 (0.01) 5 1 +test_dfp_modules_duo_streaming_payload_e2e 29,466.8204 (130.16) 30,338.3991 (107.20) 29,855.7080 (115.11) 377.8633 (15.54) 29,864.8365 (110.91) 669.7878 (16.52) 2;0 0.0335 (0.01) 5 1 +test_dfp_modules_duo_streaming_lti_e2e 30,443.9077 (134.48) 31,385.2542 (110.90) 30,875.1344 (119.04) 334.9455 (13.78) 30,853.1295 (114.58) 258.4034 (6.37) 2;1 0.0324 (0.01) 5 1 +test_dfp_modules_azure_streaming_training_e2e 51,950.9638 (229.48) 52,498.6271 (185.50) 52,257.2178 (201.48) 259.6411 (10.68) 52,317.4839 (194.29) 494.9443 (12.21) 1;0 0.0191 (0.00) 5 1 +test_dfp_modules_azure_streaming_lti_e2e 54,148.7980 (239.19) 54,953.7450 (194.18) 54,525.3318 (210.22) 313.7135 (12.90) 54,540.2730 (202.55) 473.5052 (11.68) 2;0 0.0183 (0.00) 5 1 +------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- ``` ### Benchmarks Report @@ -145,6 +155,10 @@ Morpheus config for each workflow: - edge_buffer_size - start_time - duration +- userid_column_name +- timestamp_column_name +- source +- use_cpp Additional benchmark stats for each workflow: - input_lines diff --git a/examples/digital_fingerprinting/production/morpheus/benchmarks/benchmark_conf_generator.py b/examples/digital_fingerprinting/production/morpheus/benchmarks/benchmark_conf_generator.py index 32796801ba..7cdd74f56b 100644 --- a/examples/digital_fingerprinting/production/morpheus/benchmarks/benchmark_conf_generator.py +++ b/examples/digital_fingerprinting/production/morpheus/benchmarks/benchmark_conf_generator.py @@ -29,12 +29,14 @@ from dfp.utils.dfp_arg_parser import DFPArgParser from dfp.utils.schema_utils import SchemaBuilder +logger = logging.getLogger(__name__) + THIS_DIR = path.dirname(path.abspath(__file__)) def set_mlflow_tracking_uri(tracking_uri): mlflow.set_tracking_uri(tracking_uri) - logging.getLogger('mlflow').setLevel(logging.WARN) + logging.getLogger('mlflow').setLevel(logger.level) def load_json(filepath: str): @@ -46,20 +48,14 @@ def load_json(filepath: str): class BenchmarkConfGenerator: - def __init__(self, pipe_conf: typing.Dict[(str, any)], tracking_uri: str, log_level=logging.ERROR): + def __init__(self, pipe_conf: typing.Dict[(str, any)]): self._pipe_conf = pipe_conf - self._tracking_uri = tracking_uri - self._log_level = log_level self._config = self._create_config() @property def pipe_config(self): return self._config - @property - def log_level(self): - return self._log_level - @property def source(self): return self._pipe_conf.get('source') @@ -141,12 +137,12 @@ def get_module_conf(self): dfp_arg_parser = DFPArgParser(skip_user=[], only_user=[], start_time=(datetime.strptime(self._pipe_conf.get('start_time'), '%Y-%m-%d')), - log_level=(self._log_level), + log_level=logger.level, cache_dir='./.cache/dfp', sample_rate_s=0, duration=(self._pipe_conf.get('duration')), source=(self.source), - tracking_uri=(self._tracking_uri), + tracking_uri=mlflow.get_tracking_uri(), train_users='generic') dfp_arg_parser.init() config_generator = ConfigGenerator(self.pipe_config, dfp_arg_parser, self.get_schema()) diff --git a/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/control_messages/duo_payload_only_load.json b/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/control_messages/duo_payload_only_load.json index 9593faa515..6233282d61 100644 --- a/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/control_messages/duo_payload_only_load.json +++ b/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/control_messages/duo_payload_only_load.json @@ -7,7 +7,7 @@ "properties": { "loader_id": "fsspec", "files": [ - "../../../../../examples/data/dfp/duo-training-data/*.json" + "../../../../../examples/data/dfp/duo-inference-data/*.json" ] } } diff --git a/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/control_messages/duo_streaming_only_load.json b/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/control_messages/duo_streaming_only_load.json index b6666d6ab7..2d0ff5b186 100644 --- a/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/control_messages/duo_streaming_only_load.json +++ b/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/control_messages/duo_streaming_only_load.json @@ -7,7 +7,7 @@ "properties": { "loader_id": "fsspec", "files": [ - "../../../../../examples/data/dfp/duo-training-data/*.json" + "../../../../../examples/data/dfp/duo-inference-data/*.json" ] } } diff --git a/examples/digital_fingerprinting/production/morpheus/benchmarks/test_bench_e2e_dfp_pipeline.py b/examples/digital_fingerprinting/production/morpheus/benchmarks/test_bench_e2e_dfp_pipeline.py index 92785c435a..c3116560da 100644 --- a/examples/digital_fingerprinting/production/morpheus/benchmarks/test_bench_e2e_dfp_pipeline.py +++ b/examples/digital_fingerprinting/production/morpheus/benchmarks/test_bench_e2e_dfp_pipeline.py @@ -52,19 +52,17 @@ from morpheus.utils.file_utils import date_extractor from morpheus.utils.logger import configure_logging -logger = logging.getLogger("morpheus.{}".format(__name__)) +logger = logging.getLogger(__name__) PIPELINES_CONF = load_json("resource/pipelines_conf.json") -TRACKING_URI = PIPELINES_CONF.get("tracking_uri") - set_mlflow_tracking_uri(PIPELINES_CONF.get("tracking_uri")) -def remove_cache(dir: str): - logger.debug(f"Cleaning up cache `{dir}` directory...") +def purge_cache(dir: str): + logger.debug(f"Purging cache `{dir}` directory...") shutil.rmtree(dir, ignore_errors=True) - logger.debug(f"Cleaning up cache `{dir}` directory... Done") + logger.debug(f"Purging cache `{dir}` directory... Done") def dfp_modules_pipeline(pipe_config: Config, @@ -90,7 +88,7 @@ def dfp_modules_pipeline(pipe_config: Config, if not reuse_cache: cache_dir = modules_conf["DFPInferencePipe"]["DFPPreproc"]["FileBatcher"]["cache_dir"] - remove_cache(dir=cache_dir) + purge_cache(dir=cache_dir) def dfp_training_pipeline_stages(pipe_config: Config, @@ -98,10 +96,9 @@ def dfp_training_pipeline_stages(pipe_config: Config, source_schema: DataFrameInputSchema, preprocess_schema: DataFrameInputSchema, filenames: typing.List[str], - log_level: int, reuse_cache=False): - configure_logging(log_level) + configure_logging(logger.level) pipeline = LinearPipeline(pipe_config) pipeline.set_source(MultiFileSource(pipe_config, filenames=filenames)) @@ -142,7 +139,7 @@ def dfp_training_pipeline_stages(pipe_config: Config, pipeline.run() if not reuse_cache: - remove_cache(dir=stages_conf["cache_dir"]) + purge_cache(dir=stages_conf["cache_dir"]) def dfp_inference_pipeline_stages(pipe_config: Config, @@ -151,10 +148,9 @@ def dfp_inference_pipeline_stages(pipe_config: Config, preprocess_schema: DataFrameInputSchema, filenames: typing.List[str], output_filepath: str, - log_level: int, reuse_cache=False): - configure_logging(log_level) + configure_logging(logger.level) pipeline = LinearPipeline(pipe_config) pipeline.set_source(MultiFileSource(pipe_config, filenames=filenames)) @@ -198,7 +194,7 @@ def dfp_inference_pipeline_stages(pipe_config: Config, pipeline.run() if not reuse_cache: - remove_cache(dir=stages_conf["cache_dir"]) + purge_cache(dir=stages_conf["cache_dir"]) @pytest.mark.benchmark @@ -206,20 +202,14 @@ def test_dfp_stages_duo_training_e2e(benchmark: typing.Any): pipe_conf = PIPELINES_CONF.get("test_dfp_stages_duo_training_e2e") - bcg = BenchmarkConfGenerator(pipe_conf=pipe_conf, tracking_uri=TRACKING_URI) + bcg = BenchmarkConfGenerator(pipe_conf=pipe_conf) pipe_config = bcg.pipe_config stages_conf = bcg.get_stages_conf() input_filenames = bcg.get_filenames() schema: Schema = bcg.get_schema() - benchmark(dfp_training_pipeline_stages, - pipe_config, - stages_conf, - schema.source, - schema.preprocess, - input_filenames, - bcg.log_level) + benchmark(dfp_training_pipeline_stages, pipe_config, stages_conf, schema.source, schema.preprocess, input_filenames) @pytest.mark.benchmark @@ -227,20 +217,14 @@ def test_dfp_stages_azure_training_e2e(benchmark: typing.Any): pipe_conf = PIPELINES_CONF.get("test_dfp_stages_azure_training_e2e") - bcg = BenchmarkConfGenerator(pipe_conf=pipe_conf, tracking_uri=TRACKING_URI) + bcg = BenchmarkConfGenerator(pipe_conf=pipe_conf) pipe_config = bcg.pipe_config stages_conf = bcg.get_stages_conf() input_filenames = bcg.get_filenames() schema: Schema = bcg.get_schema() - benchmark(dfp_training_pipeline_stages, - pipe_config, - stages_conf, - schema.source, - schema.preprocess, - input_filenames, - bcg.log_level) + benchmark(dfp_training_pipeline_stages, pipe_config, stages_conf, schema.source, schema.preprocess, input_filenames) @pytest.mark.benchmark @@ -248,7 +232,7 @@ def test_dfp_stages_azure_inference_e2e(benchmark: typing.Any, tmp_path): pipe_conf = PIPELINES_CONF.get("test_dfp_stages_azure_inference_e2e") - bcg = BenchmarkConfGenerator(pipe_conf=pipe_conf, tracking_uri=TRACKING_URI) + bcg = BenchmarkConfGenerator(pipe_conf=pipe_conf) pipe_config = bcg.pipe_config stages_conf = bcg.get_stages_conf() @@ -263,8 +247,7 @@ def test_dfp_stages_azure_inference_e2e(benchmark: typing.Any, tmp_path): schema.source, schema.preprocess, input_filenames, - output_filepath, - bcg.log_level) + output_filepath) @pytest.mark.benchmark @@ -272,7 +255,7 @@ def test_dfp_stages_duo_inference_e2e(benchmark: typing.Any, tmp_path): pipe_conf = PIPELINES_CONF.get("test_dfp_stages_duo_inference_e2e") - bcg = BenchmarkConfGenerator(pipe_conf=pipe_conf, tracking_uri=TRACKING_URI) + bcg = BenchmarkConfGenerator(pipe_conf=pipe_conf) pipe_config = bcg.pipe_config stages_conf = bcg.get_stages_conf() @@ -287,8 +270,7 @@ def test_dfp_stages_duo_inference_e2e(benchmark: typing.Any, tmp_path): schema.source, schema.preprocess, input_filenames, - output_filepath, - bcg.log_level) + output_filepath) @pytest.mark.benchmark @@ -296,7 +278,7 @@ def test_dfp_modules_azure_payload_inference_e2e(benchmark: typing.Any): pipe_conf = PIPELINES_CONF.get("test_dfp_modules_azure_payload_inference_e2e") - bcg = BenchmarkConfGenerator(pipe_conf=pipe_conf, tracking_uri=TRACKING_URI) + bcg = BenchmarkConfGenerator(pipe_conf=pipe_conf) pipe_config = bcg.pipe_config module_config = bcg.get_module_conf() @@ -310,7 +292,7 @@ def test_dfp_modules_azure_payload_lti_e2e(benchmark: typing.Any): pipe_conf = PIPELINES_CONF.get("test_dfp_modules_azure_payload_lti_e2e") - bcg = BenchmarkConfGenerator(pipe_conf=pipe_conf, tracking_uri=TRACKING_URI) + bcg = BenchmarkConfGenerator(pipe_conf=pipe_conf) pipe_config = bcg.pipe_config module_config = bcg.get_module_conf() @@ -324,7 +306,7 @@ def test_dfp_modules_azure_payload_training_e2e(benchmark: typing.Any): pipe_conf = PIPELINES_CONF.get("test_dfp_modules_azure_payload_training_e2e") - bcg = BenchmarkConfGenerator(pipe_conf=pipe_conf, tracking_uri=TRACKING_URI) + bcg = BenchmarkConfGenerator(pipe_conf=pipe_conf) pipe_config = bcg.pipe_config module_config = bcg.get_module_conf() @@ -338,7 +320,7 @@ def test_dfp_modules_azure_streaming_inference_e2e(benchmark: typing.Any): pipe_conf = PIPELINES_CONF.get("test_dfp_modules_azure_streaming_inference_e2e") - bcg = BenchmarkConfGenerator(pipe_conf=pipe_conf, tracking_uri=TRACKING_URI) + bcg = BenchmarkConfGenerator(pipe_conf=pipe_conf) pipe_config = bcg.pipe_config module_config = bcg.get_module_conf() @@ -352,7 +334,7 @@ def test_dfp_modules_azure_streaming_lti_e2e(benchmark: typing.Any): pipe_conf = PIPELINES_CONF.get("test_dfp_modules_azure_streaming_lti_e2e") - bcg = BenchmarkConfGenerator(pipe_conf=pipe_conf, tracking_uri=TRACKING_URI) + bcg = BenchmarkConfGenerator(pipe_conf=pipe_conf) pipe_config = bcg.pipe_config module_config = bcg.get_module_conf() @@ -366,7 +348,7 @@ def test_dfp_modules_azure_streaming_training_e2e(benchmark: typing.Any): pipe_conf = PIPELINES_CONF.get("test_dfp_modules_azure_streaming_training_e2e") - bcg = BenchmarkConfGenerator(pipe_conf=pipe_conf, tracking_uri=TRACKING_URI) + bcg = BenchmarkConfGenerator(pipe_conf=pipe_conf) pipe_config = bcg.pipe_config module_config = bcg.get_module_conf() @@ -380,7 +362,7 @@ def test_dfp_modules_duo_payload_inference_e2e(benchmark: typing.Any): pipe_conf = PIPELINES_CONF.get("test_dfp_modules_duo_payload_inference_e2e") - bcg = BenchmarkConfGenerator(pipe_conf=pipe_conf, tracking_uri=TRACKING_URI) + bcg = BenchmarkConfGenerator(pipe_conf=pipe_conf) pipe_config = bcg.pipe_config module_config = bcg.get_module_conf() @@ -394,7 +376,7 @@ def test_dfp_modules_duo_payload_lti_e2e(benchmark: typing.Any): pipe_conf = PIPELINES_CONF.get("test_dfp_modules_duo_payload_lti_e2e") - bcg = BenchmarkConfGenerator(pipe_conf=pipe_conf, tracking_uri=TRACKING_URI) + bcg = BenchmarkConfGenerator(pipe_conf=pipe_conf) pipe_config = bcg.pipe_config module_config = bcg.get_module_conf() @@ -408,7 +390,7 @@ def test_dfp_modules_duo_payload_only_load_e2e(benchmark: typing.Any): pipe_conf = PIPELINES_CONF.get("test_dfp_modules_duo_payload_only_load_e2e") - bcg = BenchmarkConfGenerator(pipe_conf=pipe_conf, tracking_uri=TRACKING_URI) + bcg = BenchmarkConfGenerator(pipe_conf=pipe_conf) pipe_config = bcg.pipe_config module_config = bcg.get_module_conf() @@ -422,7 +404,7 @@ def test_dfp_modules_duo_payload_training_e2e(benchmark: typing.Any): pipe_conf = PIPELINES_CONF.get("test_dfp_modules_duo_payload_training_e2e") - bcg = BenchmarkConfGenerator(pipe_conf=pipe_conf, tracking_uri=TRACKING_URI) + bcg = BenchmarkConfGenerator(pipe_conf=pipe_conf) pipe_config = bcg.pipe_config module_config = bcg.get_module_conf() @@ -436,7 +418,7 @@ def test_dfp_modules_duo_streaming_inference_e2e(benchmark: typing.Any): pipe_conf = PIPELINES_CONF.get("test_dfp_modules_duo_streaming_inference_e2e") - bcg = BenchmarkConfGenerator(pipe_conf=pipe_conf, tracking_uri=TRACKING_URI) + bcg = BenchmarkConfGenerator(pipe_conf=pipe_conf) pipe_config = bcg.pipe_config module_config = bcg.get_module_conf() @@ -450,7 +432,7 @@ def test_dfp_modules_duo_streaming_lti_e2e(benchmark: typing.Any): pipe_conf = PIPELINES_CONF.get("test_dfp_modules_duo_streaming_lti_e2e") - bcg = BenchmarkConfGenerator(pipe_conf=pipe_conf, tracking_uri=TRACKING_URI) + bcg = BenchmarkConfGenerator(pipe_conf=pipe_conf) pipe_config = bcg.pipe_config module_config = bcg.get_module_conf() @@ -464,7 +446,7 @@ def test_dfp_modules_duo_streaming_only_load_e2e(benchmark: typing.Any): pipe_conf = PIPELINES_CONF.get("test_dfp_modules_duo_streaming_only_load_e2e") - bcg = BenchmarkConfGenerator(pipe_conf=pipe_conf, tracking_uri=TRACKING_URI) + bcg = BenchmarkConfGenerator(pipe_conf=pipe_conf) pipe_config = bcg.pipe_config module_config = bcg.get_module_conf() @@ -478,7 +460,7 @@ def test_dfp_modules_duo_streaming_payload_e2e(benchmark: typing.Any): pipe_conf = PIPELINES_CONF.get("test_dfp_modules_duo_streaming_payload_e2e") - bcg = BenchmarkConfGenerator(pipe_conf=pipe_conf, tracking_uri=TRACKING_URI) + bcg = BenchmarkConfGenerator(pipe_conf=pipe_conf) pipe_config = bcg.pipe_config module_config = bcg.get_module_conf() @@ -492,7 +474,7 @@ def test_dfp_modules_duo_streaming_training_e2e(benchmark: typing.Any): pipe_conf = PIPELINES_CONF.get("test_dfp_modules_duo_streaming_training_e2e") - bcg = BenchmarkConfGenerator(pipe_conf=pipe_conf, tracking_uri=TRACKING_URI) + bcg = BenchmarkConfGenerator(pipe_conf=pipe_conf) pipe_config = bcg.pipe_config module_config = bcg.get_module_conf() diff --git a/morpheus/stages/general/linear_modules_stage.py b/morpheus/stages/general/linear_modules_stage.py index 2a9b3ed6ea..ada95b3358 100644 --- a/morpheus/stages/general/linear_modules_stage.py +++ b/morpheus/stages/general/linear_modules_stage.py @@ -93,7 +93,7 @@ def _get_cpp_module_node(self, builder: mrc.Builder) -> mrc.SegmentObject: def _build_single(self, builder: mrc.Builder, input_stream: StreamPair) -> StreamPair: - # Laod module from registry. + # Load module from the registry. module = load_module(self._module_config, builder=builder) mod_in_stream = module.input_port(self._input_port_name) diff --git a/morpheus/stages/general/multi_port_module_stage.py b/morpheus/stages/general/multi_port_module_stage.py index 95c842cebe..3ea0237b33 100644 --- a/morpheus/stages/general/multi_port_module_stage.py +++ b/morpheus/stages/general/multi_port_module_stage.py @@ -65,14 +65,15 @@ def __init__(self, self._input_port_name = input_port_name self._output_port_name_prefix = output_port_name_prefix - assert output_port_count > 0, "Output port count must be >= 1" + if output_port_count < 1: + raise ValueError(f"The `output_port_count` must be >= 1, but received {output_port_count}.") self._create_ports(1, output_port_count) self._output_port_count = output_port_count @property def name(self) -> str: - return self._module_conf.get("module_name", "non_linear_module") + return self._module_conf.get("module_name", "multi_port_module") def supports_cpp_node(self): return False @@ -98,11 +99,13 @@ def accepted_types(self) -> typing.Tuple: def _build(self, builder: mrc.Builder, in_stream_pairs: typing.List[StreamPair]) -> typing.List[StreamPair]: - assert len(in_stream_pairs) == 1, "Only 1 input is supported" + in_ports_len = len(in_stream_pairs) + if in_ports_len != 1: + raise ValueError(f"Only 1 input is supported, but recieved {in_ports_len}.") in_stream_node = in_stream_pairs[0][0] - # Laod module from registry. + # Load module from theregistry. module = load_module(self._module_conf, builder=builder) mod_in_stream = module.input_port(self._input_port_name) diff --git a/morpheus/stages/input/control_message_source_stage.py b/morpheus/stages/input/control_message_source_stage.py index 30040a3f5a..c8e2c19ef1 100644 --- a/morpheus/stages/input/control_message_source_stage.py +++ b/morpheus/stages/input/control_message_source_stage.py @@ -12,13 +12,14 @@ # See the License for the specific language governing permissions and # limitations under the License. +import json import logging import typing import fsspec import fsspec.utils import mrc -import json + from morpheus.config import Config from morpheus.messages.message_control import MessageControl from morpheus.pipeline.single_output_source import SingleOutputSource From 76fa276fb15758f8451067fd97eb463371b7a1c3 Mon Sep 17 00:00:00 2001 From: Devin Robison Date: Fri, 3 Mar 2023 12:47:09 -0700 Subject: [PATCH 066/157] Update file_batcher.py --- .../production/morpheus/test_input.json | 27 +------------- morpheus/modules/file_batcher.py | 35 ++++++++++++------- 2 files changed, 24 insertions(+), 38 deletions(-) diff --git a/examples/digital_fingerprinting/production/morpheus/test_input.json b/examples/digital_fingerprinting/production/morpheus/test_input.json index f1872e5638..49c5feeb76 100644 --- a/examples/digital_fingerprinting/production/morpheus/test_input.json +++ b/examples/digital_fingerprinting/production/morpheus/test_input.json @@ -1,30 +1,5 @@ { "inputs": [ - { - "tasks": [ - { - "type": "load", - "properties": { - "loader_id": "fsspec", - "files": [ - "../../../../examples/data/dfp/duo-training-data/*.json" - ] - } - }, - { - "type": "training", - "properties": { - } - }, - { - "type": "inference", - "properties": {} - } - ], - "metadata": { - "data_type": "payload" - } - }, { "tasks": [ { @@ -42,7 +17,7 @@ } ], "metadata": { - "data_type": "streaming" + "data_type": "payload" } } ] diff --git a/morpheus/modules/file_batcher.py b/morpheus/modules/file_batcher.py index 1961e0324c..7923f844f0 100644 --- a/morpheus/modules/file_batcher.py +++ b/morpheus/modules/file_batcher.py @@ -20,7 +20,6 @@ import fsspec.utils import mrc import cudf -import pandas as pd from mrc.core import operators as ops from morpheus.messages import MessageControl @@ -109,7 +108,7 @@ def generate_cms_for_batch_periods(control_message: MessageControl, period_gb, n control_messages = [] for group in period_gb.groups: period_df = period_gb.get_group(group) - filenames = period_df["key"].to_list() + filenames = period_df["key"].to_arrow().to_pylist() load_task = { "loader_id": FILE_TO_DF_LOADER, @@ -140,6 +139,23 @@ def generate_cms_for_batch_periods(control_message: MessageControl, period_gb, n return control_messages + def add_ts_period(df): + # TODO(Devin): Rough approximation of pandas '.dt.to_period()' method, which is not yet supported by cudf + if (period == "s"): + df["period"] = df["ts"].dt.strftime("%Y-%m-%d %H:%M:%S").astype("datetime64[s]").astype('int') + elif (period == "m"): + df["period"] = df["ts"].dt.strftime("%Y-%m-%d %H:%M").astype("datetime64[s]").astype('int') + elif (period == "H"): + df["period"] = df["ts"].dt.strftime("%Y-%m-%d %H").astype("datetime64[s]").astype('int') + elif (period == "D"): + df["period"] = df["ts"].dt.strftime("%Y-%m-%d").astype("datetime64[s]").astype('int') + elif (period == "M"): + df["period"] = df["ts"].dt.strftime("%Y-%m").astype("datetime64[s]").astype('int') + elif (period == "Y"): + df["period"] = df["ts"].dt.strftime("%Y").astype("datetime64[s]").astype('int') + else: + raise Exception("Unknown period") + def on_data(control_message: MessageControl): mm = control_message.payload() with mm.mutable_dataframe() as dfm: @@ -149,20 +165,15 @@ def on_data(control_message: MessageControl): control_messages = [] if len(ts_filenames_df) > 0: # Now split by the batching settings - df_test = cudf.from_pandas(ts_filenames_df) - df_test["period"] = df_test["ts"].dt.strftime("%Y-%m-%d") - test_period_gb = df_test.groupby("period") - # print("DF_TEST_PERIOD: \n", df_test["period"], flush=True) - # df_period = ts_filenames_df["ts"].dt.to_period(period) - # print("DF_PERIOD: \n", df_period, flush=True) - # period_gb = ts_filenames_df.groupby(df_period) - # n_groups = len(period_gb) - n_groups = len(test_period_gb) + + add_ts_period(ts_filenames_df) + period_gb = ts_filenames_df.groupby("period") + n_groups = len(period_gb.groups) logger.debug("Batching %d files => %d groups", len(ts_filenames_df), n_groups) control_messages = generate_cms_for_batch_periods(control_message, - test_period_gb, n_groups) + period_gb, n_groups) return control_messages From 2f255761edf467bbdd3ee1bae11f335bd14dae00 Mon Sep 17 00:00:00 2001 From: Devin Robison Date: Fri, 3 Mar 2023 17:47:51 -0700 Subject: [PATCH 067/157] Merge 23.03 --- .clang-format | 7 +- .github/workflows/pull_request.yml | 4 +- ci/runner/Dockerfile | 1 + ci/scripts/common.sh | 2 +- ci/scripts/copyright.py | 63 +- ci/scripts/fix_all.sh | 69 +- ci/scripts/gitutils.py | 57 +- ci/scripts/run-clang-format.py | 7 +- ci/scripts/run_iwyu_for_ci.sh | 4 + docker/Dockerfile | 2 +- .../3_simple_cpp_stage/_lib/pass_thru.hpp | 6 +- .../morpheus/dfp/modules/dfp_inference.py | 2 +- .../morpheus/dfp/stages/dfp_training.py | 6 +- .../stages/create_features.py | 2 +- examples/sid_visualization/run.py | 2 +- external/utilities | 2 +- models/mlflow/docker/Dockerfile | 4 +- .../abp-models/abp-nvsmi-xgb-20210310.py | 2 +- .../root-cause-models/root-cause-bert.py | 10 +- .../sid-minibert-20211021-script.py | 12 +- morpheus.code-workspace | 3 +- .../include/morpheus/io/deserializers.hpp | 1 + .../messages/memory/response_memory.hpp | 8 +- .../messages/memory/response_memory_probs.hpp | 10 +- .../messages/memory/tensor_memory.hpp | 6 +- .../_lib/include/morpheus/messages/meta.hpp | 1 + .../_lib/include/morpheus/messages/multi.hpp | 66 +- .../morpheus/messages/multi_inference.hpp | 20 +- .../morpheus/messages/multi_response.hpp | 18 +- .../messages/multi_response_probs.hpp | 12 +- .../morpheus/messages/multi_tensor.hpp | 14 +- .../include/morpheus/objects/data_table.hpp | 3 + .../include/morpheus/objects/dev_mem_info.hpp | 1 + .../_lib/include/morpheus/objects/dtype.hpp | 5 +- .../morpheus/objects/factory_registry.hpp | 1 + .../include/morpheus/objects/fiber_queue.hpp | 10 +- .../objects/mutable_table_ctx_mgr.hpp | 4 +- .../morpheus/objects/python_data_table.hpp | 4 +- .../include/morpheus/objects/rmm_tensor.hpp | 17 +- .../include/morpheus/objects/table_info.hpp | 4 +- .../_lib/include/morpheus/objects/tensor.hpp | 2 +- .../morpheus/objects/wrapped_tensor.hpp | 2 +- .../morpheus/stages/add_classification.hpp | 8 +- .../include/morpheus/stages/add_scores.hpp | 8 +- .../include/morpheus/stages/deserialize.hpp | 9 +- .../include/morpheus/stages/file_source.hpp | 9 +- .../morpheus/stages/filter_detection.hpp | 8 +- .../include/morpheus/stages/kafka_source.hpp | 17 +- .../include/morpheus/stages/preallocate.hpp | 4 - .../morpheus/stages/preprocess_fil.hpp | 11 +- .../morpheus/stages/preprocess_nlp.hpp | 9 +- .../include/morpheus/stages/serialize.hpp | 22 +- .../morpheus/stages/triton_inference.hpp | 10 +- .../include/morpheus/stages/write_to_file.hpp | 9 +- .../include/morpheus/utilities/cudf_util.hpp | 11 +- .../include/morpheus/utilities/cupy_util.hpp | 2 +- .../include/morpheus/utilities/matx_util.hpp | 2 +- .../include/morpheus/utilities/stage_util.hpp | 2 +- .../include/morpheus/utilities/table_util.hpp | 2 +- morpheus/_lib/src/io/data_loader.cpp | 2 + morpheus/_lib/src/io/deserializers.cpp | 7 + morpheus/_lib/src/io/loaders/file.cpp | 1 + morpheus/_lib/src/io/loaders/file_list.cpp | 1 + morpheus/_lib/src/io/loaders/grpc.cpp | 1 + morpheus/_lib/src/io/loaders/lambda.cpp | 1 + morpheus/_lib/src/io/loaders/payload.cpp | 1 + morpheus/_lib/src/io/loaders/rest.cpp | 1 + morpheus/_lib/src/io/serializers.cpp | 1 + morpheus/_lib/src/messages/control.cpp | 18 +- .../messages/memory/inference_memory_fil.cpp | 16 +- .../messages/memory/inference_memory_nlp.cpp | 20 +- .../src/messages/memory/response_memory.cpp | 8 +- .../messages/memory/response_memory_probs.cpp | 12 +- .../src/messages/memory/tensor_memory.cpp | 8 +- morpheus/_lib/src/messages/meta.cpp | 5 +- morpheus/_lib/src/messages/multi.cpp | 41 +- .../_lib/src/messages/multi_inference.cpp | 21 +- .../_lib/src/messages/multi_inference_fil.cpp | 11 +- .../_lib/src/messages/multi_inference_nlp.cpp | 19 +- morpheus/_lib/src/messages/multi_response.cpp | 16 +- .../src/messages/multi_response_probs.cpp | 11 +- morpheus/_lib/src/messages/multi_tensor.cpp | 16 +- morpheus/_lib/src/objects/data_table.cpp | 3 + morpheus/_lib/src/objects/dev_mem_info.cpp | 5 +- morpheus/_lib/src/objects/dtype.cpp | 1 + morpheus/_lib/src/objects/fiber_queue.cpp | 10 +- morpheus/_lib/src/objects/file_types.cpp | 4 +- .../src/objects/mutable_table_ctx_mgr.cpp | 14 +- .../_lib/src/objects/python_data_table.cpp | 2 +- morpheus/_lib/src/objects/table_info.cpp | 3 +- morpheus/_lib/src/objects/tensor.cpp | 4 +- morpheus/_lib/src/objects/wrapped_tensor.cpp | 6 +- morpheus/_lib/src/python_modules/common.cpp | 1 + morpheus/_lib/src/python_modules/messages.cpp | 7 + morpheus/_lib/src/python_modules/stages.cpp | 1 - .../_lib/src/stages/add_classification.cpp | 5 +- morpheus/_lib/src/stages/add_scores.cpp | 10 +- morpheus/_lib/src/stages/deserialize.cpp | 2 +- morpheus/_lib/src/stages/file_source.cpp | 15 +- morpheus/_lib/src/stages/filter_detection.cpp | 9 +- morpheus/_lib/src/stages/kafka_source.cpp | 10 +- morpheus/_lib/src/stages/preprocess_fil.cpp | 7 +- morpheus/_lib/src/stages/preprocess_nlp.cpp | 2 +- morpheus/_lib/src/stages/serialize.cpp | 25 +- morpheus/_lib/src/stages/triton_inference.cpp | 383 ++++++---- morpheus/_lib/src/stages/write_to_file.cpp | 3 +- morpheus/_lib/src/utilities/cudf_util.cpp | 4 +- morpheus/_lib/src/utilities/matx_util.cu | 678 ++++++++++-------- morpheus/_lib/src/utilities/string_util.cpp | 2 +- morpheus/_lib/src/utilities/table_util.cpp | 2 +- morpheus/_lib/src/utilities/tensor_util.cpp | 5 +- morpheus/_lib/tests/CMakeLists.txt | 2 + morpheus/_lib/tests/test_cuda.cu | 17 +- morpheus/_lib/tests/test_matx_util.cpp | 134 +++- morpheus/_lib/tests/test_morpheus.cpp | 5 + morpheus/_lib/tests/test_multi_slices.cpp | 11 - morpheus/_lib/tests/test_tensor.cpp | 41 +- morpheus/modules/filter_detections.py | 2 +- .../inference/triton_inference_stage.py | 64 +- tests/messages/test_control_message.py | 86 ++- 120 files changed, 1436 insertions(+), 1006 deletions(-) diff --git a/.clang-format b/.clang-format index 72452caa72..623180c234 100644 --- a/.clang-format +++ b/.clang-format @@ -1,6 +1,7 @@ ---- # Refer to the following link for the explanation of each params: # https://releases.llvm.org/14.0.0/tools/clang/docs/ClangFormatStyleOptions.html + +--- Language: Cpp # BasedOnStyle: Google AccessModifierOffset: -2 @@ -164,3 +165,7 @@ StatementMacros: # Be consistent with indent-width, even for people who use tab for indentation! TabWidth: 4 UseTab: Never + +--- +Language: Json +TabWidth: 2 diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index d8276ee111..aab4a49ce5 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -31,8 +31,8 @@ jobs: uses: ./.github/workflows/ci_pipe.yml with: run_check: ${{ startsWith(github.ref_name, 'pull-request/') }} - container: nvcr.io/ea-nvidia-morpheus/morpheus:morpheus-ci-driver-230213 - test_container: nvcr.io/ea-nvidia-morpheus/morpheus:morpheus-ci-test-230213 + container: nvcr.io/ea-nvidia-morpheus/morpheus:morpheus-ci-driver-230214 + test_container: nvcr.io/ea-nvidia-morpheus/morpheus:morpheus-ci-test-230214 secrets: GHA_AWS_ACCESS_KEY_ID: ${{ secrets.GHA_AWS_ACCESS_KEY_ID }} GHA_AWS_SECRET_ACCESS_KEY: ${{ secrets.GHA_AWS_SECRET_ACCESS_KEY }} diff --git a/ci/runner/Dockerfile b/ci/runner/Dockerfile index 80800a6588..1d3c75c5dc 100644 --- a/ci/runner/Dockerfile +++ b/ci/runner/Dockerfile @@ -58,6 +58,7 @@ ARG CUDA_PKG_VER RUN apt update && \ DEBIAN_FRONTEND=noninteractive TZ=Etc/UTC \ apt install --no-install-recommends -y \ + cuda-nvtx-${CUDA_PKG_VER} \ libcublas-dev-${CUDA_PKG_VER} \ libcufft-dev-${CUDA_PKG_VER} \ libcurand-dev-${CUDA_PKG_VER} \ diff --git a/ci/scripts/common.sh b/ci/scripts/common.sh index 75ce1a13d7..dff534dfcd 100644 --- a/ci/scripts/common.sh +++ b/ci/scripts/common.sh @@ -29,7 +29,7 @@ export BASE_SHA=${CHANGE_TARGET:-${BASE_SHA:-$(${SCRIPT_DIR}/gitutils.py get_mer export COMMIT_SHA=${GIT_COMMIT:-${COMMIT_SHA:-HEAD}} export CPP_FILE_REGEX='^(\.\/)?(morpheus|tests)\/.*\.(cc|cpp|h|hpp)$' -export PYTHON_FILE_REGEX='^(\.\/)?(?!\.|build).*\.(py|pyx|pxd)$' +export PYTHON_FILE_REGEX='^(\.\/)?(?!\.|build|external).*\.(py|pyx|pxd)$' # Use these options to skip any of the checks export SKIP_COPYRIGHT=${SKIP_COPYRIGHT:-""} diff --git a/ci/scripts/copyright.py b/ci/scripts/copyright.py index 4ed1730f79..3c42aae7aa 100755 --- a/ci/scripts/copyright.py +++ b/ci/scripts/copyright.py @@ -22,6 +22,7 @@ import os import re import sys +import typing # Now import gitutils. Ignore flake8 error here since there is no other way to # set up imports @@ -39,10 +40,11 @@ ] # Nothing in a build folder or .cache -ExemptFiles = [ - r"_version\.py", - r"^[^ \/\n]*\.cache[^ \/\n]*\/.*$", - r"^[^ \/\n]*build[^ \/\n]*\/.*$", +ExemptFiles: typing.List[re.Pattern] = [ + r"(_version|versioneer)\.py", # Skip versioning files + r"^[^ \/\n]*\.cache[^ \/\n]*\/.*$", # Ignore .cache folder + r"^[^ \/\n]*build[^ \/\n]*\/.*$", # Ignore any build*/ folder + r"^external\/.*$", # Ignore external r"[^ \/\n]*docs/source/(_lib|_modules|_templates)/.*$" ] @@ -198,7 +200,9 @@ def checkCopyright(f, if update_current_year or update_start_year or insert_license: errs_update = [x for x in errs if x[-1] is not None] if len(errs_update) > 0: - print("File: {}. Changing line(s) {}".format(f, ', '.join(str(x[1]) for x in errs if x[-1] is not None))) + logging.info("File: {}. Changing line(s) {}".format(f, + ', '.join(str(x[1]) for x in errs + if x[-1] is not None))) for _, lineNum, __, replacement in errs_update: lines[lineNum - 1] = replacement with io.open(f, "w", encoding="utf-8") as out_file: @@ -213,16 +217,6 @@ def checkCopyright(f, return errs -def getAllFilesUnderDir(root, pathFilter=None): - retList = [] - for (dirpath, dirnames, filenames) in os.walk(root): - for fn in filenames: - filePath = os.path.join(dirpath, fn) - if pathFilter(filePath): - retList.append(filePath) - return retList - - def checkCopyright_main(): """ Checks for copyright headers in all the modified files. In case of local @@ -320,27 +314,24 @@ def checkCopyright_main(): ExemptFiles = ExemptFiles + [pathName for pathName in args.exclude] ExemptFiles = [re.compile(file) for file in ExemptFiles] except re.error as reException: - print("Regular expression error:") - print(reException) + logging.exception("Regular expression error: %s", reException, exc_info=True) return 1 if args.git_modified_only: - files = gitutils.modifiedFiles(pathFilter=checkThisFile) + files = gitutils.modifiedFiles() elif args.git_diff_commits: - files = gitutils.changedFilesBetweenCommits(*args.git_diff_commits, pathFilter=checkThisFile) + files = gitutils.changedFilesBetweenCommits(*args.git_diff_commits) elif args.git_diff_staged: - files = gitutils.stagedFiles(args.git_diff_staged, pathFilter=checkThisFile) + files = gitutils.stagedFiles(args.git_diff_staged) else: - files = [] - for d in [os.path.abspath(d) for d in dirs]: - if not (os.path.isdir(d)): - raise ValueError(f"{d} is not a directory.") - files += getAllFilesUnderDir(d, pathFilter=checkThisFile) + files = gitutils.list_files_under_source_control(ref="HEAD", *dirs) - print("Checking files ({}): ".format(len(files))) + logging.debug("File count before filter(): %s", len(files)) - for f in files: - print(" {}".format(f)) + # Now filter the files down based on the exclude/include + files = gitutils.filter_files(files, path_filter=checkThisFile) + + logging.info("Checking files (%s):\n %s", len(files), "\n ".join(files)) errors = [] for f in files: @@ -352,20 +343,20 @@ def checkCopyright_main(): git_add=args.git_add) if len(errors) > 0: - print("Copyright headers incomplete in some of the files!") + logging.info("Copyright headers incomplete in some of the files!") for e in errors: - print(" %s:%d Issue: %s" % (e[0], e[1], e[2])) - print("") + logging.error(" %s:%d Issue: %s", e[0], e[1], e[2]) + logging.info("") n_fixable = sum(1 for e in errors if e[-1] is not None) path_parts = os.path.abspath(__file__).split(os.sep) file_from_repo = os.sep.join(path_parts[path_parts.index("ci"):]) if n_fixable > 0: - print(("You can run `python {} --git-modified-only " - "--update-current-year --insert` to fix {} of these " - "errors.\n").format(file_from_repo, n_fixable)) + logging.info(("You can run `python {} --git-modified-only " + "--update-current-year --insert` to fix {} of these " + "errors.\n").format(file_from_repo, n_fixable)) retVal = 1 else: - print("Copyright check passed") + logging.info("Copyright check passed") return retVal @@ -462,5 +453,5 @@ def checkCopyright_main(): } if __name__ == "__main__": - logging.basicConfig(level=logging.DEBUG) + logging.basicConfig(format="%(levelname)s:%(message)s", level=logging.INFO) sys.exit(checkCopyright_main()) diff --git a/ci/scripts/fix_all.sh b/ci/scripts/fix_all.sh index 5fd7b64cca..449902cafe 100755 --- a/ci/scripts/fix_all.sh +++ b/ci/scripts/fix_all.sh @@ -19,40 +19,48 @@ SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" source ${SCRIPT_DIR}/../scripts/common.sh -# Get the list of modified files to check -get_modified_files ${PYTHON_FILE_REGEX} PY_MODIFIED_FILES -get_modified_files ${CPP_FILE_REGEX} CPP_MODIFIED_FILES +# If IGNORE_GIT_DIFF is enabled, use all files +if [[ "${IGNORE_GIT_DIFF}" == "1" ]]; then + PY_MODIFIED_FILES=$(find ./ -name '*' | grep -P "${PYTHON_FILE_REGEX}") + CPP_MODIFIED_FILES=$(find ./ -name '*' | grep -P "${CPP_FILE_REGEX}") +else + # Get the list of modified files to check + get_modified_files ${PYTHON_FILE_REGEX} PY_MODIFIED_FILES + get_modified_files ${CPP_FILE_REGEX} CPP_MODIFIED_FILES +fi # Run copyright fix if [[ "${SKIP_COPYRIGHT}" == "" ]]; then echo "Running copyright check..." - python3 ./ci/scripts/copyright.py --fix-all --git-modified-only ./ 2>&1 -fi - -# Run clang-format -if [[ "${SKIP_CLANG_FORMAT}" == "" ]]; then - CLANG_FORMAT_DIFF=$(find_clang_format_diff) - - if [[ -x "${CLANG_FORMAT_DIFF}" ]]; then - echo "Running clang-format from '${CLANG_FORMAT_DIFF}'..." - get_unified_diff ${CPP_FILE_REGEX} | ${CLANG_FORMAT_DIFF} -p1 -i -sort-includes 2>&1 + # If IGNORE_GIT_DIFF is enabled, use all files + if [[ "${IGNORE_GIT_DIFF}" == "1" ]]; then + python3 ./ci/scripts/copyright.py --fix-all ./ 2>&1 else - echo "Skipping clang-format. Could not find clang-format-diff at '${CLANG_FORMAT_DIFF}'" + python3 ./ci/scripts/copyright.py --fix-all --git-modified-only ./ 2>&1 fi fi # Run clang-tidy if [[ "${SKIP_CLANG_TIDY}" == "" ]]; then - CLANG_TIDY_DIFF=$(find_clang_tidy_diff) + # If IGNORE_GIT_DIFF is enabled, use all files + if [[ "${IGNORE_GIT_DIFF}" == "1" ]]; then + # Now find clang-tidy + export CLANG_TIDY=$(find_clang_tidy) - # Use -n here since the output could be multiple commands - if [[ -n "${CLANG_TIDY_DIFF}" ]]; then - echo "Running clang-tidy from '${CLANG_TIDY_DIFF}'..." - get_unified_diff ${CPP_FILE_REGEX} | ${CLANG_TIDY_DIFF} -p1 -j 0 -path ${BUILD_DIR} -fix -quiet 2>&1 + echo "Running clang-tidy from '${CLANG_TIDY}'..." + run-clang-tidy -clang-tidy-binary ${SCRIPT_DIR}/run_clang_tidy_for_ci.sh -j 0 -p ${BUILD_DIR} -fix -quiet ${CPP_MODIFIED_FILES[@]} 2>&1 else - echo "Skipping clang-tidy. Could not find clang-tidy-diff.py at '${CLANG_TIDY_DIFF}'" + CLANG_TIDY_DIFF=$(find_clang_tidy_diff) + + # Use -n here since the output could be multiple commands + if [[ -n "${CLANG_TIDY_DIFF}" ]]; then + echo "Running clang-tidy from '${CLANG_TIDY_DIFF}'..." + get_unified_diff ${CPP_FILE_REGEX} | ${CLANG_TIDY_DIFF} -p1 -j 0 -path ${BUILD_DIR} -fix -quiet 2>&1 + else + echo "Skipping clang-tidy. Could not find clang-tidy-diff.py at '${CLANG_TIDY_DIFF}'" + fi fi fi @@ -63,12 +71,31 @@ if [[ "${SKIP_IWYU}" == "" && "${CPP_MODIFIED_FILES}" != "" ]]; then if [[ -x "${IWYU_TOOL}" ]]; then echo "Running include-what-you-use from '${IWYU_TOOL}'..." - ${IWYU_TOOL} -j $(nproc) -p ${BUILD_DIR} ${CPP_MODIFIED_FILES[@]} 2>&1 + ${IWYU_TOOL} -j $(nproc) -p ${BUILD_DIR} morpheus | fix_includes.py --nosafe_headers --nocomments 2>&1 else echo "Skipping include-what-you-use. Could not find iwyu_tool.py at '${IWYU_TOOL}'" fi fi +# Run clang-format (Should be after IWYU because that messes up include ordering) +if [[ "${SKIP_CLANG_FORMAT}" == "" ]]; then + + # If IGNORE_GIT_DIFF is enabled, use all files + if [[ "${IGNORE_GIT_DIFF}" == "1" ]]; then + echo "Running clang-format from '${SCRIPT_DIR}/run-clang-format.py'..." + python3 ${SCRIPT_DIR}/run-clang-format.py -inplace -regex "${CPP_FILE_REGEX}" ./ 2>&1 + else + CLANG_FORMAT_DIFF=$(find_clang_format_diff) + + if [[ -x "${CLANG_FORMAT_DIFF}" ]]; then + echo "Running clang-format from '${CLANG_FORMAT_DIFF}'..." + get_unified_diff ${CPP_FILE_REGEX} | ${CLANG_FORMAT_DIFF} -p1 -i -sort-includes 2>&1 + else + echo "Skipping clang-format. Could not find clang-format-diff at '${CLANG_FORMAT_DIFF}'" + fi + fi +fi + # Run isort if [[ "${SKIP_ISORT}" == "" ]]; then echo "Running isort..." diff --git a/ci/scripts/gitutils.py b/ci/scripts/gitutils.py index 92326170af..779215ea13 100755 --- a/ci/scripts/gitutils.py +++ b/ci/scripts/gitutils.py @@ -21,6 +21,7 @@ import os import re import subprocess +import typing def isFileEmpty(f): @@ -39,6 +40,13 @@ def __gitdiff(*opts): return __git("--no-pager", "diff", *opts) +def top_level_dir(): + """ + Returns the top level directory for this git repo + """ + return __git("rev-parse", "--show-toplevel") + + def branch(): """Returns the name of the current branch""" name = __git("rev-parse", "--abbrev-ref", "HEAD") @@ -196,13 +204,33 @@ def changedFilesBetween(baseName, branchName, commitHash): return files.splitlines() -def _filterOutputFiles(output, pathFilter=None): - lines = [] - for line in output.splitlines(): - if pathFilter is None or pathFilter(line): - lines.append(line) +def is_repo_relative(f: str, git_root: str = None): + if (git_root is None): + git_root = top_level_dir() + + abs_f = os.path.abspath(f) + + rel_path = os.path.relpath(abs_f, git_root) + + return not rel_path.startswith("../") + + +def filter_files(files: typing.Union[str, typing.List[str]], path_filter=None): + # Convert all to array of strings + if (isinstance(files, str)): + files = files.splitlines() - return lines + git_root = top_level_dir() + + ret_files = [] + for fn in files: + # Check that we are relative to the git repo + assert is_repo_relative(fn, git_root=git_root), f"Path {fn} must be relative to git root: {git_root}" + + if (path_filter is None or path_filter(fn)): + ret_files.append(fn) + + return ret_files def changesInFileBetween(file, b1, b2, pathFilter=None): @@ -212,7 +240,7 @@ def changesInFileBetween(file, b1, b2, pathFilter=None): __git("checkout", "--quiet", b2) diffs = __gitdiff("--ignore-submodules", "-w", "--minimal", "-U0", "%s...%s" % (b1, b2), "--", file) __git("checkout", "--quiet", current) - return _filterOutputFiles(diffs, pathFilter) + return filter_files(diffs, pathFilter) def modifiedFiles(pathFilter=None): @@ -266,12 +294,23 @@ def modifiedFiles(pathFilter=None): def changedFilesBetweenCommits(base_commit, commit, pathFilter=None): diffs = __gitdiff("--name-only", f"{base_commit}...{commit}") - return _filterOutputFiles(diffs, pathFilter) + return filter_files(diffs, pathFilter) def stagedFiles(base='HEAD', pathFilter=None): diffs = __gitdiff("--cached", "--name-only", base) - return _filterOutputFiles(diffs, pathFilter) + return filter_files(diffs, pathFilter) + + +def list_files_under_source_control(*paths: str, ref: str = None): + + # Use HEAD if no ref is supplied + if (ref is None): + ref = "HEAD" + + git_args = ["ls-tree", "-r", "--name-only", ref] + list(paths) + + return __git(*git_args).split("\n") def listAllFilesInDir(folder): diff --git a/ci/scripts/run-clang-format.py b/ci/scripts/run-clang-format.py index 990a67647a..d7439ada80 100755 --- a/ci/scripts/run-clang-format.py +++ b/ci/scripts/run-clang-format.py @@ -1,3 +1,4 @@ +#!/usr/bin/env python3 # SPDX-FileCopyrightText: Copyright (c) 2019-2023, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # @@ -131,8 +132,8 @@ def list_all_src_files(file_regex, ignore_regex, srcfiles, srcdirs, dstdir, inpl for srcdir in srcdirs: for root, dirs, files in os.walk(srcdir): for f in files: - if re.search(file_regex, f): - src = os.path.join(root, f) + src = os.path.join(root, f) + if re.search(file_regex, src): if ignore_regex is not None and re.search(ignore_regex, src): continue if inplace: @@ -197,7 +198,7 @@ def main(): print(" 1. Look at formatting differences above and fix them manually") print(" 2. Or run the below command to bulk-fix all these at once") print("Bulk-fix command: ") - print(" python scripts/run-clang-format.py %s -inplace" % " ".join(sys.argv[1:])) + print(" python %s -inplace" % " ".join(sys.argv)) sys.exit(-1) return diff --git a/ci/scripts/run_iwyu_for_ci.sh b/ci/scripts/run_iwyu_for_ci.sh index 805f1d304a..509625ddae 100755 --- a/ci/scripts/run_iwyu_for_ci.sh +++ b/ci/scripts/run_iwyu_for_ci.sh @@ -18,6 +18,9 @@ SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" +# Its possible to fix violations using this script. If any errors are reported, run the following from the repo root: +# ci/scripts/run_iwyu_for_ci.sh -j 6 -p ./build morpheus | fix_includes.py --nosafe_headers --nocomments + # Call iwyu_tool.py and append IWYU arguments onto the end ${IWYU_TOOL_PY:-iwyu_tool.py} "$@" -- \ -Xiwyu --mapping_file=${REPO_DIR:-${SCRIPT_DIR}/../..}/ci/iwyu/mappings.imp \ @@ -26,4 +29,5 @@ ${IWYU_TOOL_PY:-iwyu_tool.py} "$@" -- \ -Xiwyu --quoted_includes_first \ -Xiwyu --cxx17ns \ -Xiwyu --max_line_length=120 \ + -Xiwyu --error=1 \ --driver-mode=g++ diff --git a/docker/Dockerfile b/docker/Dockerfile index 81d44a3dd8..7ee6282bc9 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -158,7 +158,7 @@ COPY "./scripts" "./scripts" COPY ["*.md", "LICENSE", "./"] # remedy for CVE-2015-20107 -RUN find / -name '*mailcap*.*py*' | xargs rm +RUN find / -name '*mailcap*.*py*' -delete # Use morpheus by default CMD [ "morpheus" ] diff --git a/examples/developer_guide/3_simple_cpp_stage/_lib/pass_thru.hpp b/examples/developer_guide/3_simple_cpp_stage/_lib/pass_thru.hpp index a4a8a6df72..12db26362f 100644 --- a/examples/developer_guide/3_simple_cpp_stage/_lib/pass_thru.hpp +++ b/examples/developer_guide/3_simple_cpp_stage/_lib/pass_thru.hpp @@ -18,9 +18,9 @@ #pragma once #include // for MultiMessage -#include // for PythonNode #include // for Segment Builder #include // for Segment Object +#include // for PythonNode #include #include @@ -47,8 +47,8 @@ class PassThruStage : public mrc::pymrc::PythonNode> init(mrc::segment::Builder &builder, - const std::string &name); + static std::shared_ptr> init(mrc::segment::Builder& builder, + const std::string& name); }; #pragma GCC visibility pop diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference.py index ea5fb38ae3..39668a62e7 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference.py @@ -1,4 +1,4 @@ -# Copyright (c) 2023, NVIDIA CORPORATION. +# Copyright (c) 2022-2023, NVIDIA CORPORATION. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/stages/dfp_training.py b/examples/digital_fingerprinting/production/morpheus/dfp/stages/dfp_training.py index aed2eec61f..757d0cb42a 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/stages/dfp_training.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/stages/dfp_training.py @@ -56,7 +56,7 @@ def __init__(self, c: Config, model_kwargs: dict = None, epochs=30, validation_s self._epochs = epochs - if (validation_size > 0.0 and validation_size < 1.0): + if (validation_size >= 0.0 and validation_size < 1.0): self._validation_size = validation_size else: raise ValueError("validation_size={0} should be a positive float in the " @@ -85,13 +85,15 @@ def on_data(self, message: MultiDFPMessage): # Only train on the feature columns train_df = train_df[train_df.columns.intersection(self._config.ae.feature_columns)] validation_df = None + run_validation = False # Split into training and validation sets if self._validation_size > 0.0: train_df, validation_df = train_test_split(train_df, test_size=self._validation_size, shuffle=False) + run_validation = True logger.debug("Training AE model for user: '%s'...", user_id) - model.fit(train_df, epochs=self._epochs, val=validation_df) + model.fit(train_df, epochs=self._epochs, val=validation_df, run_validation=run_validation) logger.debug("Training AE model for user: '%s'... Complete.", user_id) output_message = MultiAEMessage(message.meta, diff --git a/examples/ransomware_detection/stages/create_features.py b/examples/ransomware_detection/stages/create_features.py index 81e2fcef47..c26059305c 100644 --- a/examples/ransomware_detection/stages/create_features.py +++ b/examples/ransomware_detection/stages/create_features.py @@ -21,10 +21,10 @@ from dask.distributed import Client -from morpheus._lib.messages import MessageMeta from morpheus.cli.register_stage import register_stage from morpheus.config import Config from morpheus.config import PipelineModes +from morpheus.messages import MessageMeta from morpheus.messages import MultiMessage from morpheus.pipeline.multi_message_stage import MultiMessageStage from morpheus.pipeline.stream_pair import StreamPair diff --git a/examples/sid_visualization/run.py b/examples/sid_visualization/run.py index c1703ddb3a..dd275698d9 100644 --- a/examples/sid_visualization/run.py +++ b/examples/sid_visualization/run.py @@ -20,11 +20,11 @@ import mrc from morpheus._lib.common import FileTypes -from morpheus._lib.messages import MessageMeta from morpheus.config import Config from morpheus.config import CppConfig from morpheus.config import PipelineModes from morpheus.io.deserializers import read_file_to_df +from morpheus.messages import MessageMeta from morpheus.pipeline.linear_pipeline import LinearPipeline from morpheus.pipeline.preallocator_mixin import PreallocatorMixin from morpheus.pipeline.single_output_source import SingleOutputSource diff --git a/external/utilities b/external/utilities index 1df6920352..fedba7fd5d 160000 --- a/external/utilities +++ b/external/utilities @@ -1 +1 @@ -Subproject commit 1df6920352b1e76b1af075fc5ecf358c523c4221 +Subproject commit fedba7fd5d646fa742cb62aac45be5265f6cc206 diff --git a/models/mlflow/docker/Dockerfile b/models/mlflow/docker/Dockerfile index e5f901e813..8a8b1f124a 100644 --- a/models/mlflow/docker/Dockerfile +++ b/models/mlflow/docker/Dockerfile @@ -33,7 +33,7 @@ RUN sed -i 's/conda activate base/conda activate mlflow/g' ~/.bashrc SHELL ["/opt/conda/bin/conda", "run", "-n", "mlflow", "/bin/bash", "-c"] ARG TRITON_DIR=/mlflow/triton-inference-server -ARG TRITON_VER=r22.10 +ARG TRITON_VER=r23.01 RUN mkdir ${TRITON_DIR} && \ cd ${TRITON_DIR} && \ @@ -46,7 +46,7 @@ RUN ln -sf ${TRITON_DIR}/server/deploy/mlflow-triton-plugin/scripts/publish_mode mkdir /mlflow/artifacts # remedy for CVE-2015-20107 -RUN find / -name '*mailcap*.*py*' | xargs rm +RUN find / -name '*mailcap*.*py*' -delete # remedy for CVE-2022-42919 RUN find / -path '*multiprocessing/util.py' -exec sed -i 's/=\s*_platform_supports_abstract_sockets()/= False/g' {} + diff --git a/models/training-tuning-scripts/abp-models/abp-nvsmi-xgb-20210310.py b/models/training-tuning-scripts/abp-models/abp-nvsmi-xgb-20210310.py index 512ce26b3c..1df9dc049e 100644 --- a/models/training-tuning-scripts/abp-models/abp-nvsmi-xgb-20210310.py +++ b/models/training-tuning-scripts/abp-models/abp-nvsmi-xgb-20210310.py @@ -22,9 +22,9 @@ import argparse import xgboost as xgb +from sklearn.model_selection import train_test_split import cudf -from sklearn.model_selection import train_test_split def preprocess(trainingdata): diff --git a/models/training-tuning-scripts/root-cause-models/root-cause-bert.py b/models/training-tuning-scripts/root-cause-models/root-cause-bert.py index 254f23b809..e47ca2cbbe 100644 --- a/models/training-tuning-scripts/root-cause-models/root-cause-bert.py +++ b/models/training-tuning-scripts/root-cause-models/root-cause-bert.py @@ -20,14 +20,16 @@ """ import argparse -import cudf +import time + +import numpy as np +import pandas as pd import torch from binary_sequence_classifier import BinarySequenceClassifier from sklearn.metrics import f1_score from sklearn.model_selection import train_test_split -import pandas as pd -import numpy as np -import time + +import cudf def train(trainingdata, unseenerrors): diff --git a/models/training-tuning-scripts/sid-models/sid-minibert-20211021-script.py b/models/training-tuning-scripts/sid-models/sid-minibert-20211021-script.py index 263d7d95af..7ebf5a9c93 100644 --- a/models/training-tuning-scripts/sid-models/sid-minibert-20211021-script.py +++ b/models/training-tuning-scripts/sid-models/sid-minibert-20211021-script.py @@ -21,14 +21,20 @@ """ import argparse + import torch +from sklearn.metrics import accuracy_score +from sklearn.metrics import f1_score +from sklearn.metrics import multilabel_confusion_matrix from torch.nn import BCEWithLogitsLoss -from transformers import AutoModelForSequenceClassification, AdamW -from torch.utils.data import TensorDataset, DataLoader +from torch.utils.data import DataLoader +from torch.utils.data import TensorDataset from torch.utils.data.dataset import random_split from torch.utils.dlpack import from_dlpack -from sklearn.metrics import (f1_score, accuracy_score, multilabel_confusion_matrix) from tqdm import trange +from transformers import AdamW +from transformers import AutoModelForSequenceClassification + import cudf from cudf.core.subword_tokenizer import SubwordTokenizer diff --git a/morpheus.code-workspace b/morpheus.code-workspace index 7318d7d016..62b62c5c88 100644 --- a/morpheus.code-workspace +++ b/morpheus.code-workspace @@ -44,7 +44,8 @@ ], "python.linting.flake8Enabled": true, "python.linting.pylintArgs": [ - "--rcfile=${workspaceFolder}/.pylintrc" + "--rcfile=${workspaceFolder}/.pylintrc", + "--init-hook=import sys; sys.path.append(\"${workspaceFolder}\")", ], "python.linting.pylintEnabled": true, "rewrap.wrappingColumn": 120, diff --git a/morpheus/_lib/include/morpheus/io/deserializers.hpp b/morpheus/_lib/include/morpheus/io/deserializers.hpp index 9f427db57f..f66880dc05 100644 --- a/morpheus/_lib/include/morpheus/io/deserializers.hpp +++ b/morpheus/_lib/include/morpheus/io/deserializers.hpp @@ -21,6 +21,7 @@ #include #include +#include namespace morpheus { #pragma GCC visibility push(default) diff --git a/morpheus/_lib/include/morpheus/messages/memory/response_memory.hpp b/morpheus/_lib/include/morpheus/messages/memory/response_memory.hpp index da4e38d06d..14fdfbf41d 100644 --- a/morpheus/_lib/include/morpheus/messages/memory/response_memory.hpp +++ b/morpheus/_lib/include/morpheus/messages/memory/response_memory.hpp @@ -53,7 +53,7 @@ class ResponseMemory : public TensorMemory * @param count * @param tensors */ - ResponseMemory(size_t count, tensor_map_t &&tensors); + ResponseMemory(size_t count, tensor_map_t&& tensors); /** * @brief Checks if a tensor named `name` exists in `tensors` @@ -62,7 +62,7 @@ class ResponseMemory : public TensorMemory * @return true * @return false */ - bool has_output(const std::string &name) const; + bool has_output(const std::string& name) const; }; /****** ResponseMemoryInterfaceProxy *************************/ @@ -80,7 +80,7 @@ struct ResponseMemoryInterfaceProxy * @param name * @return pybind11::object */ - static pybind11::object get_output(ResponseMemory &self, const std::string &name); + static pybind11::object get_output(ResponseMemory& self, const std::string& name); /** * @brief Get the output tensor object @@ -89,7 +89,7 @@ struct ResponseMemoryInterfaceProxy * @param name * @return TensorObject */ - static TensorObject get_output_tensor(ResponseMemory &self, const std::string &name); + static TensorObject get_output_tensor(ResponseMemory& self, const std::string& name); }; #pragma GCC visibility pop diff --git a/morpheus/_lib/include/morpheus/messages/memory/response_memory_probs.hpp b/morpheus/_lib/include/morpheus/messages/memory/response_memory_probs.hpp index ef32dcdc3f..3944188bf2 100644 --- a/morpheus/_lib/include/morpheus/messages/memory/response_memory_probs.hpp +++ b/morpheus/_lib/include/morpheus/messages/memory/response_memory_probs.hpp @@ -57,14 +57,14 @@ class ResponseMemoryProbs : public ResponseMemory * @param count * @param tensors */ - ResponseMemoryProbs(size_t count, tensor_map_t &&tensors); + ResponseMemoryProbs(size_t count, tensor_map_t&& tensors); /** * @brief Returns the tensor named 'probs', throws a `std::runtime_error` if it does not exist * * @return const TensorObject& */ - const TensorObject &get_probs() const; + const TensorObject& get_probs() const; /** * @brief Update the tensor named 'probs' @@ -96,7 +96,7 @@ struct ResponseMemoryProbsInterfaceProxy * @param self * @return std::size_t */ - static std::size_t count(ResponseMemoryProbs &self); + static std::size_t count(ResponseMemoryProbs& self); /** * @brief Get the response memory probs object @@ -104,7 +104,7 @@ struct ResponseMemoryProbsInterfaceProxy * @param self * @return pybind11::object */ - static pybind11::object get_probs(ResponseMemoryProbs &self); + static pybind11::object get_probs(ResponseMemoryProbs& self); /** * @brief Set the response memory probs object @@ -112,7 +112,7 @@ struct ResponseMemoryProbsInterfaceProxy * @param self * @param cupy_values */ - static void set_probs(ResponseMemoryProbs &self, pybind11::object cupy_values); + static void set_probs(ResponseMemoryProbs& self, pybind11::object cupy_values); }; #pragma GCC visibility pop diff --git a/morpheus/_lib/include/morpheus/messages/memory/tensor_memory.hpp b/morpheus/_lib/include/morpheus/messages/memory/tensor_memory.hpp index cd4e92eb1f..bd1077ec0e 100644 --- a/morpheus/_lib/include/morpheus/messages/memory/tensor_memory.hpp +++ b/morpheus/_lib/include/morpheus/messages/memory/tensor_memory.hpp @@ -58,7 +58,7 @@ class TensorMemory * @param count * @param tensors */ - TensorMemory(size_t count, tensor_map_t &&tensors); + TensorMemory(size_t count, tensor_map_t&& tensors); virtual ~TensorMemory() = default; size_t count{0}; @@ -71,7 +71,7 @@ class TensorMemory * @return true * @return false */ - bool has_tensor(const std::string &name) const; + bool has_tensor(const std::string& name) const; /** * @brief Copy tensor ranges @@ -80,7 +80,7 @@ class TensorMemory * @param num_selected_rows * @return tensor_map_t */ - tensor_map_t copy_tensor_ranges(const std::vector> &ranges, + tensor_map_t copy_tensor_ranges(const std::vector>& ranges, size_t num_selected_rows) const; }; diff --git a/morpheus/_lib/include/morpheus/messages/meta.hpp b/morpheus/_lib/include/morpheus/messages/meta.hpp index a95fea42f2..da38723aba 100644 --- a/morpheus/_lib/include/morpheus/messages/meta.hpp +++ b/morpheus/_lib/include/morpheus/messages/meta.hpp @@ -27,6 +27,7 @@ #include // for size_t #include #include +#include namespace morpheus { #pragma GCC visibility push(default) diff --git a/morpheus/_lib/include/morpheus/messages/multi.hpp b/morpheus/_lib/include/morpheus/messages/multi.hpp index 1fc04d8425..cd8ebf9e63 100644 --- a/morpheus/_lib/include/morpheus/messages/multi.hpp +++ b/morpheus/_lib/include/morpheus/messages/multi.hpp @@ -92,7 +92,7 @@ class DerivedMultiMessage : public BasesT... * @param num_selected_rows * @return std::shared_ptr */ - std::shared_ptr copy_ranges(const std::vector> &ranges, + std::shared_ptr copy_ranges(const std::vector>& ranges, size_t num_selected_rows) const { std::shared_ptr new_message = this->clone_impl(); @@ -125,14 +125,14 @@ class DerivedMultiMessage : public BasesT... * @param num_selected_rows */ virtual void copy_ranges_impl(std::shared_ptr new_message, - const std::vector> &ranges, + const std::vector>& ranges, size_t num_selected_rows) const = 0; private: virtual std::shared_ptr clone_impl() const { // Cast `this` to the derived type - auto derived_this = static_cast(this); + auto derived_this = static_cast(this); // Use copy constructor to make a clone return std::make_shared(*derived_this); @@ -145,7 +145,7 @@ class DerivedMultiMessage : public BaseT { public: using BaseT::BaseT; - virtual ~DerivedMultiMessage() = default; + ~DerivedMultiMessage() override = default; std::shared_ptr get_slice(std::size_t start, std::size_t stop) const { @@ -156,7 +156,7 @@ class DerivedMultiMessage : public BaseT return DCHECK_NOTNULL(std::dynamic_pointer_cast(new_message)); } - std::shared_ptr copy_ranges(const std::vector> &ranges, + std::shared_ptr copy_ranges(const std::vector>& ranges, size_t num_selected_rows) const { std::shared_ptr new_message = this->clone_impl(); @@ -167,23 +167,23 @@ class DerivedMultiMessage : public BaseT } protected: - virtual void get_slice_impl(std::shared_ptr new_message, std::size_t start, std::size_t stop) const + void get_slice_impl(std::shared_ptr new_message, std::size_t start, std::size_t stop) const override { return BaseT::get_slice_impl(new_message, start, stop); } - virtual void copy_ranges_impl(std::shared_ptr new_message, - const std::vector> &ranges, - size_t num_selected_rows) const + void copy_ranges_impl(std::shared_ptr new_message, + const std::vector>& ranges, + size_t num_selected_rows) const override { return BaseT::copy_ranges_impl(new_message, ranges, num_selected_rows); } private: - virtual std::shared_ptr clone_impl() const + std::shared_ptr clone_impl() const override { // Cast `this` to the derived type - auto derived_this = static_cast(this); + auto derived_this = static_cast(this); // Use copy constructor to make a clone return std::make_shared(*derived_this); @@ -206,7 +206,7 @@ class DerivedMultiMessage return DCHECK_NOTNULL(std::dynamic_pointer_cast(new_message)); } - std::shared_ptr copy_ranges(const std::vector> &ranges, + std::shared_ptr copy_ranges(const std::vector>& ranges, size_t num_selected_rows) const { std::shared_ptr new_message = this->clone_impl(); @@ -222,14 +222,14 @@ class DerivedMultiMessage std::size_t stop) const = 0; virtual void copy_ranges_impl(std::shared_ptr new_message, - const std::vector> &ranges, + const std::vector>& ranges, size_t num_selected_rows) const = 0; private: virtual std::shared_ptr clone_impl() const { // Cast `this` to the derived type - auto derived_this = static_cast(this); + auto derived_this = static_cast(this); // Use copy constructor to make a clone return std::make_shared(*derived_this); @@ -247,7 +247,7 @@ class MultiMessage : public DerivedMultiMessage /** * @brief Default copy constructor */ - MultiMessage(const MultiMessage &other) = default; + MultiMessage(const MultiMessage& other) = default; /** * @brief Construct a new Multi Message object * @@ -276,7 +276,7 @@ class MultiMessage : public DerivedMultiMessage * @throws std::runtime_error * @return TableInfo */ - TableInfo get_meta(const std::string &col_name); + TableInfo get_meta(const std::string& col_name); /** * @brief Returns columns value from a meta object. When `columns_names` is empty all columns are returned. @@ -285,7 +285,7 @@ class MultiMessage : public DerivedMultiMessage * @throws std::runtime_error * @return TableInfo */ - TableInfo get_meta(const std::vector &column_names); + TableInfo get_meta(const std::vector& column_names); /** * @brief Set the meta object with a given column name @@ -293,7 +293,7 @@ class MultiMessage : public DerivedMultiMessage * @param col_name * @param tensor */ - void set_meta(const std::string &col_name, TensorObject tensor); + void set_meta(const std::string& col_name, TensorObject tensor); /** * @brief Set the meta object with a given column names @@ -301,13 +301,13 @@ class MultiMessage : public DerivedMultiMessage * @param column_names * @param tensors */ - void set_meta(const std::vector &column_names, const std::vector &tensors); + void set_meta(const std::vector& column_names, const std::vector& tensors); protected: void get_slice_impl(std::shared_ptr new_message, std::size_t start, std::size_t stop) const override; void copy_ranges_impl(std::shared_ptr new_message, - const std::vector> &ranges, + const std::vector>& ranges, size_t num_selected_rows) const override; /** @@ -316,7 +316,7 @@ class MultiMessage : public DerivedMultiMessage * @param ranges * @return std::shared_ptr */ - virtual std::shared_ptr copy_meta_ranges(const std::vector> &ranges) const; + virtual std::shared_ptr copy_meta_ranges(const std::vector>& ranges) const; /** * @brief Applies the message offset to the elements in `ranges` casting the results to `TensorIndex` @@ -326,7 +326,7 @@ class MultiMessage : public DerivedMultiMessage * @return std::vector> */ std::vector> apply_offset_to_ranges( - std::size_t offset, const std::vector> &ranges) const; + std::size_t offset, const std::vector>& ranges) const; }; /****** MultiMessageInterfaceProxy**************************/ @@ -345,47 +345,47 @@ struct MultiMessageInterfaceProxy /** * TODO(Documentation) */ - static std::shared_ptr meta(const MultiMessage &self); + static std::shared_ptr meta(const MultiMessage& self); /** * TODO(Documentation) */ - static std::size_t mess_offset(const MultiMessage &self); + static std::size_t mess_offset(const MultiMessage& self); /** * TODO(Documentation) */ - static std::size_t mess_count(const MultiMessage &self); + static std::size_t mess_count(const MultiMessage& self); /** * TODO(Documentation) */ - static pybind11::object get_meta(MultiMessage &self); + static pybind11::object get_meta(MultiMessage& self); /** * TODO(Documentation) */ - static pybind11::object get_meta(MultiMessage &self, std::string col_name); + static pybind11::object get_meta(MultiMessage& self, std::string col_name); /** * TODO(Documentation) */ - static pybind11::object get_meta(MultiMessage &self, std::vector columns); + static pybind11::object get_meta(MultiMessage& self, std::vector columns); - static pybind11::object get_meta_list(MultiMessage &self, pybind11::object col_name); + static pybind11::object get_meta_list(MultiMessage& self, pybind11::object col_name); /** * TODO(Documentation) */ - static void set_meta(MultiMessage &self, pybind11::object columns, pybind11::object value); + static void set_meta(MultiMessage& self, pybind11::object columns, pybind11::object value); /** * TODO(Documentation) */ - static std::shared_ptr get_slice(MultiMessage &self, std::size_t start, std::size_t stop); + static std::shared_ptr get_slice(MultiMessage& self, std::size_t start, std::size_t stop); - static std::shared_ptr copy_ranges(MultiMessage &self, - const std::vector> &ranges, + static std::shared_ptr copy_ranges(MultiMessage& self, + const std::vector>& ranges, pybind11::object num_selected_rows); }; diff --git a/morpheus/_lib/include/morpheus/messages/multi_inference.hpp b/morpheus/_lib/include/morpheus/messages/multi_inference.hpp index ec987ebd91..e1732a83b8 100644 --- a/morpheus/_lib/include/morpheus/messages/multi_inference.hpp +++ b/morpheus/_lib/include/morpheus/messages/multi_inference.hpp @@ -29,8 +29,6 @@ #include #include #include -#include // for pair -#include namespace morpheus { /****** Component public implementations********************/ @@ -54,7 +52,7 @@ class MultiInferenceMessage : public DerivedMultiMessage */ - static std::shared_ptr memory(MultiInferenceMessage &self); + static std::shared_ptr memory(MultiInferenceMessage& self); /** * @brief Get message offset @@ -134,7 +132,7 @@ struct MultiInferenceMessageInterfaceProxy * @param self * @return std::size_t */ - static std::size_t offset(MultiInferenceMessage &self); + static std::size_t offset(MultiInferenceMessage& self); /** * @brief Get messages count @@ -142,7 +140,7 @@ struct MultiInferenceMessageInterfaceProxy * @param self * @return std::size_t */ - static std::size_t count(MultiInferenceMessage &self); + static std::size_t count(MultiInferenceMessage& self); /** * @brief Get 'input_id' tensor as a python object, throws a `std::runtime_error` if it does not exist @@ -151,7 +149,7 @@ struct MultiInferenceMessageInterfaceProxy * @param name * @return pybind11::object */ - static pybind11::object get_input(MultiInferenceMessage &self, const std::string &name); + static pybind11::object get_input(MultiInferenceMessage& self, const std::string& name); /** * @brief Get the shared pointer of a sliced batches based on offsets supplied. Automatically calculates the correct @@ -162,7 +160,7 @@ struct MultiInferenceMessageInterfaceProxy * @param stop : Stop offset address * @return std::shared_ptr */ - static std::shared_ptr get_slice(MultiInferenceMessage &self, + static std::shared_ptr get_slice(MultiInferenceMessage& self, std::size_t start, std::size_t stop); }; diff --git a/morpheus/_lib/include/morpheus/messages/multi_response.hpp b/morpheus/_lib/include/morpheus/messages/multi_response.hpp index 846a559c9c..a48e25597f 100644 --- a/morpheus/_lib/include/morpheus/messages/multi_response.hpp +++ b/morpheus/_lib/include/morpheus/messages/multi_response.hpp @@ -29,8 +29,6 @@ #include #include #include -#include // for pair -#include namespace morpheus { /****** Component public implementations *******************/ @@ -54,7 +52,7 @@ class MultiResponseMessage : public DerivedMultiMessage */ - static std::shared_ptr memory(MultiResponseMessage &self); + static std::shared_ptr memory(MultiResponseMessage& self); /** * @brief Message offset in response memory probs object @@ -138,7 +136,7 @@ struct MultiResponseMessageInterfaceProxy * @param self * @return std::size_t */ - static std::size_t offset(MultiResponseMessage &self); + static std::size_t offset(MultiResponseMessage& self); /** * @brief Messages count in response memory probs object @@ -146,7 +144,7 @@ struct MultiResponseMessageInterfaceProxy * @param self * @return std::size_t */ - static std::size_t count(MultiResponseMessage &self); + static std::size_t count(MultiResponseMessage& self); /** * @brief Returns the output tensor for a given name @@ -155,7 +153,7 @@ struct MultiResponseMessageInterfaceProxy * @param name : Tensor name * @return pybind11::object */ - static pybind11::object get_output(MultiResponseMessage &self, const std::string &name); + static pybind11::object get_output(MultiResponseMessage& self, const std::string& name); }; #pragma GCC visibility pop /** @} */ // end of group diff --git a/morpheus/_lib/include/morpheus/messages/multi_response_probs.hpp b/morpheus/_lib/include/morpheus/messages/multi_response_probs.hpp index d2edb7cdbc..2b6b252402 100644 --- a/morpheus/_lib/include/morpheus/messages/multi_response_probs.hpp +++ b/morpheus/_lib/include/morpheus/messages/multi_response_probs.hpp @@ -51,7 +51,7 @@ class MultiResponseProbsMessage : public DerivedMultiMessage */ - static std::shared_ptr memory(MultiResponseProbsMessage &self); + static std::shared_ptr memory(MultiResponseProbsMessage& self); /** * @brief Message offset in response memory probs object @@ -125,7 +125,7 @@ struct MultiResponseProbsMessageInterfaceProxy * @param self * @return std::size_t */ - static std::size_t offset(MultiResponseProbsMessage &self); + static std::size_t offset(MultiResponseProbsMessage& self); /** * @brief Messages count in response memory probs object @@ -133,7 +133,7 @@ struct MultiResponseProbsMessageInterfaceProxy * @param self * @return std::size_t */ - static std::size_t count(MultiResponseProbsMessage &self); + static std::size_t count(MultiResponseProbsMessage& self); /** * @brief Return the `probs` (probabilities) output tensor @@ -141,7 +141,7 @@ struct MultiResponseProbsMessageInterfaceProxy * @param self * @return pybind11::object */ - static pybind11::object probs(MultiResponseProbsMessage &self); + static pybind11::object probs(MultiResponseProbsMessage& self); }; #pragma GCC visibility pop /** @} */ // end of group diff --git a/morpheus/_lib/include/morpheus/messages/multi_tensor.hpp b/morpheus/_lib/include/morpheus/messages/multi_tensor.hpp index a3b1973e9b..043fc2081b 100644 --- a/morpheus/_lib/include/morpheus/messages/multi_tensor.hpp +++ b/morpheus/_lib/include/morpheus/messages/multi_tensor.hpp @@ -58,7 +58,7 @@ class MultiTensorMessage : public DerivedMultiMessage new_message, std::size_t start, std::size_t stop) const override; void copy_ranges_impl(std::shared_ptr new_message, - const std::vector> &ranges, + const std::vector>& ranges, size_t num_selected_rows) const override; std::shared_ptr copy_input_ranges( - const std::vector> &ranges, std::size_t num_selected_rows) const; + const std::vector>& ranges, std::size_t num_selected_rows) const; - TensorObject get_tensor_impl(const std::string &name) const; + TensorObject get_tensor_impl(const std::string& name) const; }; #pragma GCC visibility pop diff --git a/morpheus/_lib/include/morpheus/objects/data_table.hpp b/morpheus/_lib/include/morpheus/objects/data_table.hpp index 7bf7d5c2c4..05c236fe41 100644 --- a/morpheus/_lib/include/morpheus/objects/data_table.hpp +++ b/morpheus/_lib/include/morpheus/objects/data_table.hpp @@ -20,7 +20,10 @@ #include #include +#include #include +#include +#include namespace morpheus { diff --git a/morpheus/_lib/include/morpheus/objects/dev_mem_info.hpp b/morpheus/_lib/include/morpheus/objects/dev_mem_info.hpp index 0a29afb7eb..e6d473536a 100644 --- a/morpheus/_lib/include/morpheus/objects/dev_mem_info.hpp +++ b/morpheus/_lib/include/morpheus/objects/dev_mem_info.hpp @@ -23,6 +23,7 @@ #include // for size_t #include // for shared_ptr, unique_ptr & make_unique +#include namespace morpheus { /****** Component public implementations *******************/ diff --git a/morpheus/_lib/include/morpheus/objects/dtype.hpp b/morpheus/_lib/include/morpheus/objects/dtype.hpp index d5a5bac202..8000a8ab30 100644 --- a/morpheus/_lib/include/morpheus/objects/dtype.hpp +++ b/morpheus/_lib/include/morpheus/objects/dtype.hpp @@ -18,14 +18,11 @@ #pragma once #include -#include #include // for CHAR_BIT #include // for size_t #include // for int32_t -#include -#include -#include // for string +#include // for string namespace morpheus { diff --git a/morpheus/_lib/include/morpheus/objects/factory_registry.hpp b/morpheus/_lib/include/morpheus/objects/factory_registry.hpp index fbfda0265e..b0ec5a9af4 100644 --- a/morpheus/_lib/include/morpheus/objects/factory_registry.hpp +++ b/morpheus/_lib/include/morpheus/objects/factory_registry.hpp @@ -19,6 +19,7 @@ #include "morpheus/io/data_loader.hpp" +#include #include #include diff --git a/morpheus/_lib/include/morpheus/objects/fiber_queue.hpp b/morpheus/_lib/include/morpheus/objects/fiber_queue.hpp index a401b87562..bcad0a9d5a 100644 --- a/morpheus/_lib/include/morpheus/objects/fiber_queue.hpp +++ b/morpheus/_lib/include/morpheus/objects/fiber_queue.hpp @@ -53,7 +53,7 @@ class FiberQueue * @param timeout * @return boost::fibers::channel_op_status */ - boost::fibers::channel_op_status put(pybind11::object &&item, bool block = true, float timeout = 0.0); + boost::fibers::channel_op_status put(pybind11::object&& item, bool block = true, float timeout = 0.0); /** * @brief Retrieves item from head of the queue. @@ -63,7 +63,7 @@ class FiberQueue * @param timeout * @return boost::fibers::channel_op_status */ - boost::fibers::channel_op_status get(pybind11::object &item, bool block = true, float timeout = 0.0); + boost::fibers::channel_op_status get(pybind11::object& item, bool block = true, float timeout = 0.0); /** * TODO(Documentation) @@ -102,17 +102,17 @@ struct FiberQueueInterfaceProxy /** * TODO(Documentation) */ - static void put(morpheus::FiberQueue &self, pybind11::object item, bool block = true, float timeout = 0.0); + static void put(morpheus::FiberQueue& self, pybind11::object item, bool block = true, float timeout = 0.0); /** * TODO(Documentation) */ - static pybind11::object get(morpheus::FiberQueue &self, bool block = true, float timeout = 0.0); + static pybind11::object get(morpheus::FiberQueue& self, bool block = true, float timeout = 0.0); /** * TODO(Documentation) */ - static void close(morpheus::FiberQueue &self); + static void close(morpheus::FiberQueue& self); }; #pragma GCC visibility pop /** @} */ // end of group diff --git a/morpheus/_lib/include/morpheus/objects/mutable_table_ctx_mgr.hpp b/morpheus/_lib/include/morpheus/objects/mutable_table_ctx_mgr.hpp index eedc7e69ca..a21e2e09fb 100644 --- a/morpheus/_lib/include/morpheus/objects/mutable_table_ctx_mgr.hpp +++ b/morpheus/_lib/include/morpheus/objects/mutable_table_ctx_mgr.hpp @@ -18,11 +18,11 @@ #pragma once #include "morpheus/messages/meta.hpp" +#include "morpheus/objects/table_info.hpp" #include // for object -#include // for unique_ptr -#include // for move +#include // for unique_ptr namespace morpheus { /** diff --git a/morpheus/_lib/include/morpheus/objects/python_data_table.hpp b/morpheus/_lib/include/morpheus/objects/python_data_table.hpp index 358e50950f..a2b561758f 100644 --- a/morpheus/_lib/include/morpheus/objects/python_data_table.hpp +++ b/morpheus/_lib/include/morpheus/objects/python_data_table.hpp @@ -43,7 +43,7 @@ struct PyDataTable : public IDataTable * * @param py_table */ - PyDataTable(pybind11::object &&py_table); + PyDataTable(pybind11::object&& py_table); ~PyDataTable(); /** @@ -58,7 +58,7 @@ struct PyDataTable : public IDataTable * * @return TableInfo */ - const pybind11::object &get_py_object() const override; + const pybind11::object& get_py_object() const override; private: TableInfoData get_table_data() const override; diff --git a/morpheus/_lib/include/morpheus/objects/rmm_tensor.hpp b/morpheus/_lib/include/morpheus/objects/rmm_tensor.hpp index d8d7ce1ce1..1568c73a27 100644 --- a/morpheus/_lib/include/morpheus/objects/rmm_tensor.hpp +++ b/morpheus/_lib/include/morpheus/objects/rmm_tensor.hpp @@ -28,6 +28,7 @@ #include namespace morpheus { +#pragma GCC visibility push(default) /****** Component public implementations *******************/ /****** RMMTensor****************************************/ @@ -74,13 +75,13 @@ class RMMTensor : public ITensor /** * TODO(Documentation) */ - std::shared_ptr reshape(const std::vector &dims) const override; + std::shared_ptr reshape(const std::vector& dims) const override; /** * TODO(Documentation) */ - std::shared_ptr slice(const std::vector &min_dims, - const std::vector &max_dims) const override; + std::shared_ptr slice(const std::vector& min_dims, + const std::vector& max_dims) const override; /** * @brief Creates a depp copy of the specified rows specified as vector> not inclusive @@ -90,7 +91,7 @@ class RMMTensor : public ITensor * @param num_rows * @return std::shared_ptr */ - std::shared_ptr copy_rows(const std::vector> &selected_rows, + std::shared_ptr copy_rows(const std::vector>& selected_rows, TensorIndex num_rows) const override; /** @@ -121,17 +122,17 @@ class RMMTensor : public ITensor /** * TODO(Documentation) */ - void *data() const override; + void* data() const override; /** * TODO(Documentation) */ - void get_shape(std::vector &s) const; + void get_shape(std::vector& s) const; /** * TODO(Documentation) */ - void get_stride(std::vector &s) const; + void get_stride(std::vector& s) const; // Tensor reshape(std::vector shape) // { @@ -164,5 +165,7 @@ class RMMTensor : public ITensor std::vector m_shape; std::vector m_stride; }; + +#pragma GCC visibility pop /** @} */ // end of group } // namespace morpheus diff --git a/morpheus/_lib/include/morpheus/objects/table_info.hpp b/morpheus/_lib/include/morpheus/objects/table_info.hpp index 90ccc3fa47..47e5454879 100644 --- a/morpheus/_lib/include/morpheus/objects/table_info.hpp +++ b/morpheus/_lib/include/morpheus/objects/table_info.hpp @@ -22,14 +22,14 @@ #include // for column_view #include -#include // for size_type -#include +#include // for size_type #include // for object #include #include #include #include +#include #include namespace morpheus { diff --git a/morpheus/_lib/include/morpheus/objects/tensor.hpp b/morpheus/_lib/include/morpheus/objects/tensor.hpp index 5ed21c355d..d7adc0212d 100644 --- a/morpheus/_lib/include/morpheus/objects/tensor.hpp +++ b/morpheus/_lib/include/morpheus/objects/tensor.hpp @@ -61,7 +61,7 @@ class Tensor /** * TODO(Documentation) */ - void *data() const; + void* data() const; /** * TODO(Documentation) diff --git a/morpheus/_lib/include/morpheus/objects/wrapped_tensor.hpp b/morpheus/_lib/include/morpheus/objects/wrapped_tensor.hpp index 7cfae56fc8..3c3f241238 100644 --- a/morpheus/_lib/include/morpheus/objects/wrapped_tensor.hpp +++ b/morpheus/_lib/include/morpheus/objects/wrapped_tensor.hpp @@ -39,7 +39,7 @@ namespace morpheus { */ struct TensorObjectInterfaceProxy { - static pybind11::dict cuda_array_interface(TensorObject &self); + static pybind11::dict cuda_array_interface(TensorObject& self); }; #pragma GCC visibility pop /** @} */ // end of group diff --git a/morpheus/_lib/include/morpheus/stages/add_classification.hpp b/morpheus/_lib/include/morpheus/stages/add_classification.hpp index 666e811725..c1fbf0d29d 100644 --- a/morpheus/_lib/include/morpheus/stages/add_classification.hpp +++ b/morpheus/_lib/include/morpheus/stages/add_classification.hpp @@ -19,11 +19,11 @@ #include "morpheus/messages/multi_response_probs.hpp" -#include // for Status -#include // for SinkProperties<>::sink_type_t -#include // for SourceProperties<>::source_type_t +#include +#include +#include #include -#include // for Object +#include #include #include diff --git a/morpheus/_lib/include/morpheus/stages/add_scores.hpp b/morpheus/_lib/include/morpheus/stages/add_scores.hpp index 86ebca7aba..c33eaf6d54 100644 --- a/morpheus/_lib/include/morpheus/stages/add_scores.hpp +++ b/morpheus/_lib/include/morpheus/stages/add_scores.hpp @@ -19,11 +19,11 @@ #include "morpheus/messages/multi_response_probs.hpp" -#include // for Status -#include // for SinkProperties<>::sink_type_t -#include // for SourceProperties<>::source_type_t +#include +#include +#include #include -#include // for Object +#include #include #include // for apply, make_subscriber, observable_member, is_on_error<>::not_void, is_on_next_of<>::not_void, trace_activity diff --git a/morpheus/_lib/include/morpheus/stages/deserialize.hpp b/morpheus/_lib/include/morpheus/stages/deserialize.hpp index 06861f4c0a..ed8142a0da 100644 --- a/morpheus/_lib/include/morpheus/stages/deserialize.hpp +++ b/morpheus/_lib/include/morpheus/stages/deserialize.hpp @@ -20,15 +20,16 @@ #include "morpheus/messages/meta.hpp" #include "morpheus/messages/multi.hpp" -#include // for Status -#include // for SinkProperties<>::sink_type_t -#include // for SourceProperties<>::source_type_t +#include +#include +#include #include -#include // for Object +#include #include #include #include // for size_t +#include #include #include #include diff --git a/morpheus/_lib/include/morpheus/stages/file_source.hpp b/morpheus/_lib/include/morpheus/stages/file_source.hpp index ae902625c2..68bf872b3d 100644 --- a/morpheus/_lib/include/morpheus/stages/file_source.hpp +++ b/morpheus/_lib/include/morpheus/stages/file_source.hpp @@ -19,14 +19,15 @@ #include "morpheus/messages/meta.hpp" -#include // for table_with_metadata -#include // for Status -#include // for SourceProperties<>::source_type_t +#include +#include +#include #include -#include // for Object +#include #include #include // for apply, make_subscriber, observable_member, is_on_error<>::not_void, is_on_next_of<>::not_void, trace_activity +#include #include #include #include // for vector diff --git a/morpheus/_lib/include/morpheus/stages/filter_detection.hpp b/morpheus/_lib/include/morpheus/stages/filter_detection.hpp index d13b14cfd5..f86a550f99 100644 --- a/morpheus/_lib/include/morpheus/stages/filter_detection.hpp +++ b/morpheus/_lib/include/morpheus/stages/filter_detection.hpp @@ -21,11 +21,11 @@ #include "morpheus/objects/dev_mem_info.hpp" // for DevMemInfo #include "morpheus/objects/filter_source.hpp" -#include // for Status -#include // for SinkProperties<>::sink_type_t -#include // for SourceProperties<>::source_type_t +#include +#include +#include #include -#include // for Object +#include #include #include diff --git a/morpheus/_lib/include/morpheus/stages/kafka_source.hpp b/morpheus/_lib/include/morpheus/stages/kafka_source.hpp index d259172178..6e1a6f6e3d 100644 --- a/morpheus/_lib/include/morpheus/stages/kafka_source.hpp +++ b/morpheus/_lib/include/morpheus/stages/kafka_source.hpp @@ -19,12 +19,13 @@ #include "morpheus/messages/meta.hpp" +#include #include #include -#include // for Status -#include // for SourceProperties<>::source_type_t +#include +#include #include -#include // for Object +#include #include #include // for apply, make_subscriber, observable_member, is_on_error<>::not_void, is_on_next_of<>::not_void, trace_activity @@ -105,7 +106,7 @@ class KafkaSourceStage : public mrc::pymrc::PythonSource */ - std::unique_ptr build_kafka_conf(const std::map &config_in); + std::unique_ptr build_kafka_conf(const std::map& config_in); /** * @brief Creates Kafka consumer instance. @@ -113,7 +114,7 @@ class KafkaSourceStage : public mrc::pymrc::PythonSource */ - std::unique_ptr create_consumer(RdKafka::RebalanceCb &rebalancer); + std::unique_ptr create_consumer(RdKafka::RebalanceCb& rebalancer); /** * @brief Load messages from a buffer/file to a cuDF table. @@ -121,7 +122,7 @@ class KafkaSourceStage : public mrc::pymrc::PythonSource */ std::shared_ptr process_batch( - std::vector> &&message_batch); + std::vector>&& message_batch); size_t m_max_batch_size{128}; uint32_t m_batch_timeout_ms{100}; @@ -145,7 +146,7 @@ class KafkaSourceStage : public mrc::pymrc::PythonSource // for SinkProperties<>::sink_type_t -#include // for SourceProperties<>::source_type_t #include -#include // for Object #include #include -#include #include #include #include diff --git a/morpheus/_lib/include/morpheus/stages/preprocess_fil.hpp b/morpheus/_lib/include/morpheus/stages/preprocess_fil.hpp index 302340aa40..3128fd2bae 100644 --- a/morpheus/_lib/include/morpheus/stages/preprocess_fil.hpp +++ b/morpheus/_lib/include/morpheus/stages/preprocess_fil.hpp @@ -19,21 +19,22 @@ #include "morpheus/messages/multi.hpp" #include "morpheus/messages/multi_inference.hpp" +#include "morpheus/objects/table_info.hpp" -#include // for Status -#include // for SinkProperties<>::sink_type_t -#include // for SourceProperties<>::source_type_t +#include +#include +#include #include -#include // for Object +#include #include #include // for apply, make_subscriber, observable_member, is_on_error<>::not_void, is_on_next_of<>::not_void, from +#include #include #include #include namespace morpheus { -struct TableInfo; /****** Component public implementations *******************/ /****** PreprocessFILStage**********************************/ diff --git a/morpheus/_lib/include/morpheus/stages/preprocess_nlp.hpp b/morpheus/_lib/include/morpheus/stages/preprocess_nlp.hpp index 689e72985e..722502e1fa 100644 --- a/morpheus/_lib/include/morpheus/stages/preprocess_nlp.hpp +++ b/morpheus/_lib/include/morpheus/stages/preprocess_nlp.hpp @@ -20,15 +20,16 @@ #include "morpheus/messages/multi.hpp" #include "morpheus/messages/multi_inference.hpp" -#include // for Status -#include // for SinkProperties<>::sink_type_t -#include // for SourceProperties<>::source_type_t +#include +#include +#include #include -#include // for Object +#include #include #include // for apply, make_subscriber, observable_member, is_on_error<>::not_void, is_on_next_of<>::not_void, from #include // for uint32_t +#include #include #include #include diff --git a/morpheus/_lib/include/morpheus/stages/serialize.hpp b/morpheus/_lib/include/morpheus/stages/serialize.hpp index 27c63a6c71..87a9883366 100644 --- a/morpheus/_lib/include/morpheus/stages/serialize.hpp +++ b/morpheus/_lib/include/morpheus/stages/serialize.hpp @@ -19,16 +19,16 @@ #include "morpheus/messages/meta.hpp" // for MessageMeta #include "morpheus/messages/multi.hpp" -#include "morpheus/objects/table_info.hpp" // for TableInfo -#include // for Status -#include // for SinkProperties<>::sink_type_t -#include // for SourceProperties<>::source_type_t +#include +#include +#include #include -#include // for Object +#include #include #include // for apply, make_subscriber, observable_member, is_on_error<>::not_void, is_on_next_of<>::not_void, from +#include #include #include #include @@ -65,18 +65,18 @@ class SerializeStage : public mrc::pymrc::PythonNode &include, - const std::vector &exclude, + SerializeStage(const std::vector& include, + const std::vector& exclude, bool fixed_columns = true); private: - void make_regex_objs(const std::vector ®ex_strs, std::vector ®ex_objs); + void make_regex_objs(const std::vector& regex_strs, std::vector& regex_objs); - bool match_column(const std::vector &patterns, const std::string &column) const; + bool match_column(const std::vector& patterns, const std::string& column) const; - bool include_column(const std::string &column) const; + bool include_column(const std::string& column) const; - bool exclude_column(const std::string &column) const; + bool exclude_column(const std::string& column) const; std::shared_ptr get_meta(sink_type_t& msg); diff --git a/morpheus/_lib/include/morpheus/stages/triton_inference.hpp b/morpheus/_lib/include/morpheus/stages/triton_inference.hpp index 74bd1f803d..349d4014ed 100644 --- a/morpheus/_lib/include/morpheus/stages/triton_inference.hpp +++ b/morpheus/_lib/include/morpheus/stages/triton_inference.hpp @@ -21,12 +21,12 @@ #include "morpheus/messages/multi_response_probs.hpp" #include "morpheus/objects/triton_in_out.hpp" +#include #include -#include // for Status -#include // for SinkProperties<>::sink_type_t -#include // for SourceProperties<>::source_type_t +#include +#include #include -#include // for Object +#include #include #include // for apply, make_subscriber, observable_member, is_on_error<>::not_void, is_on_next_of<>::not_void, from @@ -85,7 +85,7 @@ class InferenceClientStage /** * TODO(Documentation) */ - bool is_default_grpc_port(std::string &server_url); + bool is_default_grpc_port(std::string& server_url); /** * TODO(Documentation) diff --git a/morpheus/_lib/include/morpheus/stages/write_to_file.hpp b/morpheus/_lib/include/morpheus/stages/write_to_file.hpp index bcd1e69a47..5a6f0e31f6 100644 --- a/morpheus/_lib/include/morpheus/stages/write_to_file.hpp +++ b/morpheus/_lib/include/morpheus/stages/write_to_file.hpp @@ -20,16 +20,17 @@ #include "morpheus/messages/meta.hpp" #include "morpheus/objects/file_types.hpp" -#include // for Status -#include // for SinkProperties<>::sink_type_t -#include // for SourceProperties<>::source_type_t +#include +#include +#include #include -#include // for Object +#include #include #include #include #include // for function +#include #include #include #include diff --git a/morpheus/_lib/include/morpheus/utilities/cudf_util.hpp b/morpheus/_lib/include/morpheus/utilities/cudf_util.hpp index 5707f17394..41aa9e6836 100644 --- a/morpheus/_lib/include/morpheus/utilities/cudf_util.hpp +++ b/morpheus/_lib/include/morpheus/utilities/cudf_util.hpp @@ -17,15 +17,12 @@ #pragma once -#include "morpheus/objects/data_table.hpp" // for IDataTable #include "morpheus/objects/table_info.hpp" #include // for column_view #include #include -#include // for shared_ptr - namespace morpheus { /****** Component public free function implementations******/ @@ -49,16 +46,16 @@ void load_cudf_helpers(); * * @return pybind11::object */ -pybind11::object proxy_table_from_table_with_metadata(cudf::io::table_with_metadata &&, int); +pybind11::object proxy_table_from_table_with_metadata(cudf::io::table_with_metadata&&, int); TableInfoData proxy_table_info_data_from_table(pybind11::object table); /** * @brief cudf_helper stubs -- currently not used anywhere */ pybind11::object /*PyColumn*/ proxy_column_from_view(cudf::column_view view); -cudf::column_view proxy_view_from_column(pybind11::object *column /*PyColumn**/); -pybind11::object /*PyTable*/ proxy_table_from_table_info(morpheus::TableInfo table_info, pybind11::object *object); -pybind11::object /*PyTable*/ proxy_series_from_table_info(morpheus::TableInfo table_info, pybind11::object *object); +cudf::column_view proxy_view_from_column(pybind11::object* column /*PyColumn**/); +pybind11::object /*PyTable*/ proxy_table_from_table_info(morpheus::TableInfo table_info, pybind11::object* object); +pybind11::object /*PyTable*/ proxy_series_from_table_info(morpheus::TableInfo table_info, pybind11::object* object); /** @} */ // end of group } // namespace morpheus diff --git a/morpheus/_lib/include/morpheus/utilities/cupy_util.hpp b/morpheus/_lib/include/morpheus/utilities/cupy_util.hpp index 3915049d3a..9e6da3c982 100644 --- a/morpheus/_lib/include/morpheus/utilities/cupy_util.hpp +++ b/morpheus/_lib/include/morpheus/utilities/cupy_util.hpp @@ -47,7 +47,7 @@ struct CupyUtil /** * TODO(Documentation) */ - static pybind11::object tensor_to_cupy(const TensorObject &tensor); + static pybind11::object tensor_to_cupy(const TensorObject& tensor); /** * TODO(Documentation) diff --git a/morpheus/_lib/include/morpheus/utilities/matx_util.hpp b/morpheus/_lib/include/morpheus/utilities/matx_util.hpp index 76842a5466..85be5a149a 100644 --- a/morpheus/_lib/include/morpheus/utilities/matx_util.hpp +++ b/morpheus/_lib/include/morpheus/utilities/matx_util.hpp @@ -102,7 +102,7 @@ struct MatxUtil static std::shared_ptr reduce_max(const DevMemInfo& input, const std::vector& seq_ids, size_t seq_id_offset, - const std::vector& output_shape); + const std::vector& output_shape); }; /** @} */ // end of group } // namespace morpheus diff --git a/morpheus/_lib/include/morpheus/utilities/stage_util.hpp b/morpheus/_lib/include/morpheus/utilities/stage_util.hpp index f9a05a862c..a72c3ec8f3 100644 --- a/morpheus/_lib/include/morpheus/utilities/stage_util.hpp +++ b/morpheus/_lib/include/morpheus/utilities/stage_util.hpp @@ -35,7 +35,7 @@ namespace morpheus { * @return std::vector */ template -auto foreach_map(const SeqT &seq, FuncT func) +auto foreach_map(const SeqT& seq, FuncT func) { using value_t = typename SeqT::const_reference; using return_t = decltype(func(std::declval())); diff --git a/morpheus/_lib/include/morpheus/utilities/table_util.hpp b/morpheus/_lib/include/morpheus/utilities/table_util.hpp index ddd477a16a..44531fb9b7 100644 --- a/morpheus/_lib/include/morpheus/utilities/table_util.hpp +++ b/morpheus/_lib/include/morpheus/utilities/table_util.hpp @@ -40,7 +40,7 @@ struct CuDFTableUtil /** * TODO(Documentation) */ - static cudf::io::table_with_metadata load_table(const std::string &filename); + static cudf::io::table_with_metadata load_table(const std::string& filename); }; /** @} */ // end of group } // namespace morpheus diff --git a/morpheus/_lib/src/io/data_loader.cpp b/morpheus/_lib/src/io/data_loader.cpp index 7ebc63eb93..80fe548e92 100644 --- a/morpheus/_lib/src/io/data_loader.cpp +++ b/morpheus/_lib/src/io/data_loader.cpp @@ -19,6 +19,8 @@ #include "morpheus/messages/control.hpp" +#include + #include #include diff --git a/morpheus/_lib/src/io/deserializers.cpp b/morpheus/_lib/src/io/deserializers.cpp index 8d4c737e65..979605e66e 100644 --- a/morpheus/_lib/src/io/deserializers.cpp +++ b/morpheus/_lib/src/io/deserializers.cpp @@ -19,18 +19,25 @@ #include "morpheus/utilities/stage_util.hpp" +#include #include #include #include // for string_scalar #include +#include #include // IWYU pragma: keep #include // for cudf::type_id +#include #include #include +#include #include +#include #include // needed for logging #include +#include +#include namespace morpheus { diff --git a/morpheus/_lib/src/io/loaders/file.cpp b/morpheus/_lib/src/io/loaders/file.cpp index 17e6933d67..0fb9c450e2 100644 --- a/morpheus/_lib/src/io/loaders/file.cpp +++ b/morpheus/_lib/src/io/loaders/file.cpp @@ -21,6 +21,7 @@ #include "morpheus/messages/meta.hpp" #include +#include #include #include #include diff --git a/morpheus/_lib/src/io/loaders/file_list.cpp b/morpheus/_lib/src/io/loaders/file_list.cpp index 7753c1c818..dc8389525a 100644 --- a/morpheus/_lib/src/io/loaders/file_list.cpp +++ b/morpheus/_lib/src/io/loaders/file_list.cpp @@ -18,6 +18,7 @@ #include "morpheus/io/loaders/file_list.hpp" #include +#include #include #include diff --git a/morpheus/_lib/src/io/loaders/grpc.cpp b/morpheus/_lib/src/io/loaders/grpc.cpp index 12f814bb36..bb21a560fe 100644 --- a/morpheus/_lib/src/io/loaders/grpc.cpp +++ b/morpheus/_lib/src/io/loaders/grpc.cpp @@ -17,6 +17,7 @@ #include "morpheus/io/loaders/grpc.hpp" +#include #include #include diff --git a/morpheus/_lib/src/io/loaders/lambda.cpp b/morpheus/_lib/src/io/loaders/lambda.cpp index 5e0b7a63fa..4392b7672a 100644 --- a/morpheus/_lib/src/io/loaders/lambda.cpp +++ b/morpheus/_lib/src/io/loaders/lambda.cpp @@ -17,6 +17,7 @@ #include "morpheus/io/loaders/lambda.hpp" +#include #include #include diff --git a/morpheus/_lib/src/io/loaders/payload.cpp b/morpheus/_lib/src/io/loaders/payload.cpp index c9e0abb388..22b461930e 100644 --- a/morpheus/_lib/src/io/loaders/payload.cpp +++ b/morpheus/_lib/src/io/loaders/payload.cpp @@ -17,6 +17,7 @@ #include "morpheus/io/loaders/payload.hpp" +#include #include #include diff --git a/morpheus/_lib/src/io/loaders/rest.cpp b/morpheus/_lib/src/io/loaders/rest.cpp index 2a0d9fe740..62799c190e 100644 --- a/morpheus/_lib/src/io/loaders/rest.cpp +++ b/morpheus/_lib/src/io/loaders/rest.cpp @@ -17,6 +17,7 @@ #include "morpheus/io/loaders/rest.hpp" +#include #include #include diff --git a/morpheus/_lib/src/io/serializers.cpp b/morpheus/_lib/src/io/serializers.cpp index 5a3a5a3384..f261f8f735 100644 --- a/morpheus/_lib/src/io/serializers.cpp +++ b/morpheus/_lib/src/io/serializers.cpp @@ -33,6 +33,7 @@ #include #include #include // IWYU pragma: keep +#include #include // IWYU pragma: no_include diff --git a/morpheus/_lib/src/messages/control.cpp b/morpheus/_lib/src/messages/control.cpp index fbe131bd06..50cf44a914 100644 --- a/morpheus/_lib/src/messages/control.cpp +++ b/morpheus/_lib/src/messages/control.cpp @@ -185,16 +185,22 @@ const nlohmann::json MessageControl::pop_task(const std::string& task_type) void MessageControl::config(const nlohmann::json& config) { - auto& tasks = config["tasks"]; - for (const auto& task : tasks) + if (config.contains("tasks")) { - add_task(task.at("type"), task.at("properties")); + auto& tasks = config["tasks"]; + for (const auto& task : tasks) + { + add_task(task.at("type"), task.at("properties")); + } } - auto& metadata = config["metadata"]; - for (auto it = metadata.begin(); it != metadata.end(); ++it) + if (config.contains("metadata")) { - set_metadata(it.key(), it.value()); + auto& metadata = config["metadata"]; + for (auto it = metadata.begin(); it != metadata.end(); ++it) + { + set_metadata(it.key(), it.value()); + } } } diff --git a/morpheus/_lib/src/messages/memory/inference_memory_fil.cpp b/morpheus/_lib/src/messages/memory/inference_memory_fil.cpp index b5c6039c91..0b5878ab23 100644 --- a/morpheus/_lib/src/messages/memory/inference_memory_fil.cpp +++ b/morpheus/_lib/src/messages/memory/inference_memory_fil.cpp @@ -40,7 +40,7 @@ InferenceMemoryFIL::InferenceMemoryFIL(size_t count, TensorObject input__0, Tens this->tensors["seq_ids"] = std::move(seq_ids); } -const TensorObject &InferenceMemoryFIL::get_input__0() const +const TensorObject& InferenceMemoryFIL::get_input__0() const { auto found = this->tensors.find("input__0"); if (found == this->tensors.end()) @@ -56,7 +56,7 @@ void InferenceMemoryFIL::set_input__0(TensorObject input__0) this->tensors["input__0"] = std::move(input__0); } -const TensorObject &InferenceMemoryFIL::get_seq_ids() const +const TensorObject& InferenceMemoryFIL::get_seq_ids() const { auto found = this->tensors.find("seq_ids"); if (found == this->tensors.end()) @@ -81,32 +81,32 @@ std::shared_ptr InferenceMemoryFILInterfaceProxy::init(cudf: count, std::move(CupyUtil::cupy_to_tensor(input__0)), std::move(CupyUtil::cupy_to_tensor(seq_ids))); } -std::size_t InferenceMemoryFILInterfaceProxy::count(InferenceMemoryFIL &self) +std::size_t InferenceMemoryFILInterfaceProxy::count(InferenceMemoryFIL& self) { return self.count; } -TensorObject InferenceMemoryFILInterfaceProxy::get_tensor(InferenceMemoryFIL &self, const std::string &name) +TensorObject InferenceMemoryFILInterfaceProxy::get_tensor(InferenceMemoryFIL& self, const std::string& name) { return self.tensors[name]; } -pybind11::object InferenceMemoryFILInterfaceProxy::get_input__0(InferenceMemoryFIL &self) +pybind11::object InferenceMemoryFILInterfaceProxy::get_input__0(InferenceMemoryFIL& self) { return CupyUtil::tensor_to_cupy(self.get_input__0()); } -void InferenceMemoryFILInterfaceProxy::set_input__0(InferenceMemoryFIL &self, pybind11::object cupy_values) +void InferenceMemoryFILInterfaceProxy::set_input__0(InferenceMemoryFIL& self, pybind11::object cupy_values) { self.set_input__0(CupyUtil::cupy_to_tensor(cupy_values)); } -pybind11::object InferenceMemoryFILInterfaceProxy::get_seq_ids(InferenceMemoryFIL &self) +pybind11::object InferenceMemoryFILInterfaceProxy::get_seq_ids(InferenceMemoryFIL& self) { return CupyUtil::tensor_to_cupy(self.get_seq_ids()); } -void InferenceMemoryFILInterfaceProxy::set_seq_ids(InferenceMemoryFIL &self, pybind11::object cupy_values) +void InferenceMemoryFILInterfaceProxy::set_seq_ids(InferenceMemoryFIL& self, pybind11::object cupy_values) { return self.set_seq_ids(CupyUtil::cupy_to_tensor(cupy_values)); } diff --git a/morpheus/_lib/src/messages/memory/inference_memory_nlp.cpp b/morpheus/_lib/src/messages/memory/inference_memory_nlp.cpp index 1d8f6511ff..bb6e81a338 100644 --- a/morpheus/_lib/src/messages/memory/inference_memory_nlp.cpp +++ b/morpheus/_lib/src/messages/memory/inference_memory_nlp.cpp @@ -43,7 +43,7 @@ InferenceMemoryNLP::InferenceMemoryNLP(std::size_t count, this->tensors["seq_ids"] = std::move(seq_ids); } -const TensorObject &InferenceMemoryNLP::get_input_ids() const +const TensorObject& InferenceMemoryNLP::get_input_ids() const { auto found = this->tensors.find("input_ids"); if (found == this->tensors.end()) @@ -59,7 +59,7 @@ void InferenceMemoryNLP::set_input_ids(TensorObject input_ids) this->tensors["input_ids"] = std::move(input_ids); } -const TensorObject &InferenceMemoryNLP::get_input_mask() const +const TensorObject& InferenceMemoryNLP::get_input_mask() const { auto found = this->tensors.find("input_mask"); if (found == this->tensors.end()) @@ -75,7 +75,7 @@ void InferenceMemoryNLP::set_input_mask(TensorObject input_mask) this->tensors["input_mask"] = std::move(input_mask); } -const TensorObject &InferenceMemoryNLP::get_seq_ids() const +const TensorObject& InferenceMemoryNLP::get_seq_ids() const { auto found = this->tensors.find("seq_ids"); if (found == this->tensors.end()) @@ -104,37 +104,37 @@ std::shared_ptr InferenceMemoryNLPInterfaceProxy::init(cudf: std::move(CupyUtil::cupy_to_tensor(seq_ids))); } -std::size_t InferenceMemoryNLPInterfaceProxy::count(InferenceMemoryNLP &self) +std::size_t InferenceMemoryNLPInterfaceProxy::count(InferenceMemoryNLP& self) { return self.count; } -pybind11::object InferenceMemoryNLPInterfaceProxy::get_input_ids(InferenceMemoryNLP &self) +pybind11::object InferenceMemoryNLPInterfaceProxy::get_input_ids(InferenceMemoryNLP& self) { return CupyUtil::tensor_to_cupy(self.get_input_ids()); } -void InferenceMemoryNLPInterfaceProxy::set_input_ids(InferenceMemoryNLP &self, pybind11::object cupy_values) +void InferenceMemoryNLPInterfaceProxy::set_input_ids(InferenceMemoryNLP& self, pybind11::object cupy_values) { self.set_input_ids(CupyUtil::cupy_to_tensor(cupy_values)); } -pybind11::object InferenceMemoryNLPInterfaceProxy::get_input_mask(InferenceMemoryNLP &self) +pybind11::object InferenceMemoryNLPInterfaceProxy::get_input_mask(InferenceMemoryNLP& self) { return CupyUtil::tensor_to_cupy(self.get_input_mask()); } -void InferenceMemoryNLPInterfaceProxy::set_input_mask(InferenceMemoryNLP &self, pybind11::object cupy_values) +void InferenceMemoryNLPInterfaceProxy::set_input_mask(InferenceMemoryNLP& self, pybind11::object cupy_values) { return self.set_input_mask(CupyUtil::cupy_to_tensor(cupy_values)); } -pybind11::object InferenceMemoryNLPInterfaceProxy::get_seq_ids(InferenceMemoryNLP &self) +pybind11::object InferenceMemoryNLPInterfaceProxy::get_seq_ids(InferenceMemoryNLP& self) { return CupyUtil::tensor_to_cupy(self.get_seq_ids()); } -void InferenceMemoryNLPInterfaceProxy::set_seq_ids(InferenceMemoryNLP &self, pybind11::object cupy_values) +void InferenceMemoryNLPInterfaceProxy::set_seq_ids(InferenceMemoryNLP& self, pybind11::object cupy_values) { return self.set_seq_ids(CupyUtil::cupy_to_tensor(cupy_values)); } diff --git a/morpheus/_lib/src/messages/memory/response_memory.cpp b/morpheus/_lib/src/messages/memory/response_memory.cpp index 77094c9e12..53e6d4116e 100644 --- a/morpheus/_lib/src/messages/memory/response_memory.cpp +++ b/morpheus/_lib/src/messages/memory/response_memory.cpp @@ -28,15 +28,15 @@ namespace morpheus { /****** Component public implementations *******************/ /****** ResponseMemory****************************************/ ResponseMemory::ResponseMemory(size_t count) : TensorMemory(count) {} -ResponseMemory::ResponseMemory(size_t count, tensor_map_t &&tensors) : TensorMemory(count, std::move(tensors)) {} +ResponseMemory::ResponseMemory(size_t count, tensor_map_t&& tensors) : TensorMemory(count, std::move(tensors)) {} -bool ResponseMemory::has_output(const std::string &name) const +bool ResponseMemory::has_output(const std::string& name) const { return this->has_tensor(name); } /****** ResponseMemoryInterfaceProxy *************************/ -pybind11::object ResponseMemoryInterfaceProxy::get_output(ResponseMemory &self, const std::string &name) +pybind11::object ResponseMemoryInterfaceProxy::get_output(ResponseMemory& self, const std::string& name) { // Directly return the tensor object if (!self.has_tensor(name)) @@ -47,7 +47,7 @@ pybind11::object ResponseMemoryInterfaceProxy::get_output(ResponseMemory &self, return CupyUtil::tensor_to_cupy(self.tensors[name]); } -TensorObject ResponseMemoryInterfaceProxy::get_output_tensor(ResponseMemory &self, const std::string &name) +TensorObject ResponseMemoryInterfaceProxy::get_output_tensor(ResponseMemory& self, const std::string& name) { // Directly return the tensor object if (!self.has_tensor(name)) diff --git a/morpheus/_lib/src/messages/memory/response_memory_probs.cpp b/morpheus/_lib/src/messages/memory/response_memory_probs.cpp index 4f50f7d6e8..f4b3009845 100644 --- a/morpheus/_lib/src/messages/memory/response_memory_probs.cpp +++ b/morpheus/_lib/src/messages/memory/response_memory_probs.cpp @@ -20,11 +20,13 @@ #include "morpheus/utilities/cupy_util.hpp" #include +#include #include #include #include // this->tensors is a map #include +#include #include // for runtime_error #include @@ -36,13 +38,13 @@ ResponseMemoryProbs::ResponseMemoryProbs(size_t count, TensorObject probs) : Res this->tensors["probs"] = std::move(probs); } -ResponseMemoryProbs::ResponseMemoryProbs(size_t count, tensor_map_t &&tensors) : +ResponseMemoryProbs::ResponseMemoryProbs(size_t count, tensor_map_t&& tensors) : ResponseMemory(count, std::move(tensors)) { CHECK(has_tensor("probs")) << "Tensor: 'probs' not found in memory"; } -const TensorObject &ResponseMemoryProbs::get_probs() const +const TensorObject& ResponseMemoryProbs::get_probs() const { auto found = this->tensors.find("probs"); if (found == this->tensors.end()) @@ -66,17 +68,17 @@ std::shared_ptr ResponseMemoryProbsInterfaceProxy::init(cud return std::make_shared(count, std::move(CupyUtil::cupy_to_tensor(probs))); } -std::size_t ResponseMemoryProbsInterfaceProxy::count(ResponseMemoryProbs &self) +std::size_t ResponseMemoryProbsInterfaceProxy::count(ResponseMemoryProbs& self) { return self.count; } -pybind11::object ResponseMemoryProbsInterfaceProxy::get_probs(ResponseMemoryProbs &self) +pybind11::object ResponseMemoryProbsInterfaceProxy::get_probs(ResponseMemoryProbs& self) { return CupyUtil::tensor_to_cupy(self.get_probs()); } -void ResponseMemoryProbsInterfaceProxy::set_probs(ResponseMemoryProbs &self, pybind11::object cupy_values) +void ResponseMemoryProbsInterfaceProxy::set_probs(ResponseMemoryProbs& self, pybind11::object cupy_values) { self.set_probs(CupyUtil::cupy_to_tensor(cupy_values)); } diff --git a/morpheus/_lib/src/messages/memory/tensor_memory.cpp b/morpheus/_lib/src/messages/memory/tensor_memory.cpp index c277a21e84..a1d3fb44ac 100644 --- a/morpheus/_lib/src/messages/memory/tensor_memory.cpp +++ b/morpheus/_lib/src/messages/memory/tensor_memory.cpp @@ -24,18 +24,18 @@ namespace morpheus { /****** Component public implementations *******************/ /****** TensorMemory****************************************/ TensorMemory::TensorMemory(size_t count) : count(count) {} -TensorMemory::TensorMemory(size_t count, tensor_map_t &&tensors) : count(count), tensors(std::move(tensors)) {} +TensorMemory::TensorMemory(size_t count, tensor_map_t&& tensors) : count(count), tensors(std::move(tensors)) {} -bool TensorMemory::has_tensor(const std::string &name) const +bool TensorMemory::has_tensor(const std::string& name) const { return this->tensors.find(name) != this->tensors.end(); } TensorMemory::tensor_map_t TensorMemory::copy_tensor_ranges( - const std::vector> &ranges, size_t num_selected_rows) const + const std::vector>& ranges, size_t num_selected_rows) const { tensor_map_t tensors; - for (const auto &p : this->tensors) + for (const auto& p : this->tensors) { tensors.insert(std::pair{p.first, p.second.copy_rows(ranges, num_selected_rows)}); } diff --git a/morpheus/_lib/src/messages/meta.cpp b/morpheus/_lib/src/messages/meta.cpp index 0855b78594..ddc410216d 100644 --- a/morpheus/_lib/src/messages/meta.cpp +++ b/morpheus/_lib/src/messages/meta.cpp @@ -25,12 +25,11 @@ #include #include -#include #include +#include +#include #include -#include // for ostringstream -#include // for runtime_error #include namespace morpheus { diff --git a/morpheus/_lib/src/messages/multi.cpp b/morpheus/_lib/src/messages/multi.cpp index 6a1aa2b3fc..eee69672a7 100644 --- a/morpheus/_lib/src/messages/multi.cpp +++ b/morpheus/_lib/src/messages/multi.cpp @@ -27,6 +27,7 @@ #include #include #include +#include #include #include // for MRC_CHECK_CUDA #include @@ -58,14 +59,14 @@ TableInfo MultiMessage::get_meta() return table_info; } -TableInfo MultiMessage::get_meta(const std::string &col_name) +TableInfo MultiMessage::get_meta(const std::string& col_name) { auto table_view = this->get_meta(std::vector{col_name}); return table_view; } -TableInfo MultiMessage::get_meta(const std::vector &column_names) +TableInfo MultiMessage::get_meta(const std::vector& column_names) { TableInfo info = this->meta->get_info(); @@ -83,7 +84,7 @@ void MultiMessage::get_slice_impl(std::shared_ptr new_message, std } void MultiMessage::copy_ranges_impl(std::shared_ptr new_message, - const std::vector> &ranges, + const std::vector>& ranges, size_t num_selected_rows) const { new_message->mess_offset = 0; @@ -91,12 +92,12 @@ void MultiMessage::copy_ranges_impl(std::shared_ptr new_message, new_message->meta = copy_meta_ranges(ranges); } -std::shared_ptr MultiMessage::copy_meta_ranges(const std::vector> &ranges) const +std::shared_ptr MultiMessage::copy_meta_ranges(const std::vector>& ranges) const { // copy ranges into a sequntial list of values // https://github.com/rapidsai/cudf/issues/11223 std::vector cudf_ranges; - for (const auto &p : ranges) + for (const auto& p : ranges) { // Append the message offset to the range here cudf_ranges.push_back(static_cast(p.first + this->mess_offset)); @@ -116,12 +117,12 @@ std::shared_ptr MultiMessage::copy_meta_ranges(const std::vector{col_name}, std::vector{tensor}); } -void MultiMessage::set_meta(const std::vector &column_names, const std::vector &tensors) +void MultiMessage::set_meta(const std::vector& column_names, const std::vector& tensors) { TableInfo table_meta; try @@ -151,7 +152,7 @@ void MultiMessage::set_meta(const std::vector &column_names, const const auto item_size = tensors[i].dtype().item_size(); // Dont use cv.data<>() here since that does not account for the size of each element - auto data_start = const_cast(cv.head()) + cv.offset() * item_size; + auto data_start = const_cast(cv.head()) + cv.offset() * item_size; if (row_stride == 1) { @@ -172,7 +173,7 @@ void MultiMessage::set_meta(const std::vector &column_names, const } std::vector> MultiMessage::apply_offset_to_ranges( - std::size_t offset, const std::vector> &ranges) const + std::size_t offset, const std::vector>& ranges) const { std::vector> offset_ranges(ranges.size()); std::transform( @@ -191,22 +192,22 @@ std::shared_ptr MultiMessageInterfaceProxy::init(std::shared_ptr(std::move(meta), mess_offset, mess_count); } -std::shared_ptr MultiMessageInterfaceProxy::meta(const MultiMessage &self) +std::shared_ptr MultiMessageInterfaceProxy::meta(const MultiMessage& self) { return self.meta; } -std::size_t MultiMessageInterfaceProxy::mess_offset(const MultiMessage &self) +std::size_t MultiMessageInterfaceProxy::mess_offset(const MultiMessage& self) { return self.mess_offset; } -std::size_t MultiMessageInterfaceProxy::mess_count(const MultiMessage &self) +std::size_t MultiMessageInterfaceProxy::mess_count(const MultiMessage& self) { return self.mess_count; } -pybind11::object MultiMessageInterfaceProxy::get_meta(MultiMessage &self) +pybind11::object MultiMessageInterfaceProxy::get_meta(MultiMessage& self) { // Need to release the GIL before calling `get_meta()` pybind11::gil_scoped_release no_gil; @@ -217,7 +218,7 @@ pybind11::object MultiMessageInterfaceProxy::get_meta(MultiMessage &self) return info.copy_to_py_object(); } -pybind11::object MultiMessageInterfaceProxy::get_meta(MultiMessage &self, std::string col_name) +pybind11::object MultiMessageInterfaceProxy::get_meta(MultiMessage& self, std::string col_name) { // Need to release the GIL before calling `get_meta()` pybind11::gil_scoped_release no_gil; @@ -228,7 +229,7 @@ pybind11::object MultiMessageInterfaceProxy::get_meta(MultiMessage &self, std::s return info.copy_to_py_object(); } -pybind11::object MultiMessageInterfaceProxy::get_meta(MultiMessage &self, std::vector columns) +pybind11::object MultiMessageInterfaceProxy::get_meta(MultiMessage& self, std::vector columns) { // Need to release the GIL before calling `get_meta()` pybind11::gil_scoped_release no_gil; @@ -239,7 +240,7 @@ pybind11::object MultiMessageInterfaceProxy::get_meta(MultiMessage &self, std::v return info.copy_to_py_object(); } -pybind11::object MultiMessageInterfaceProxy::get_meta_list(MultiMessage &self, pybind11::object col_name) +pybind11::object MultiMessageInterfaceProxy::get_meta_list(MultiMessage& self, pybind11::object col_name) { std::vector column_names; if (!col_name.is_none()) @@ -268,7 +269,7 @@ pybind11::object MultiMessageInterfaceProxy::get_meta_list(MultiMessage &self, p return py_list; } -void MultiMessageInterfaceProxy::set_meta(MultiMessage &self, pybind11::object columns, pybind11::object value) +void MultiMessageInterfaceProxy::set_meta(MultiMessage& self, pybind11::object columns, pybind11::object value) { // Need to release the GIL before calling `get_meta()` pybind11::gil_scoped_release no_gil; @@ -291,7 +292,7 @@ void MultiMessageInterfaceProxy::set_meta(MultiMessage &self, pybind11::object c mutable_info.return_obj(std::move(df)); } -std::shared_ptr MultiMessageInterfaceProxy::get_slice(MultiMessage &self, +std::shared_ptr MultiMessageInterfaceProxy::get_slice(MultiMessage& self, std::size_t start, std::size_t stop) { @@ -303,12 +304,12 @@ std::shared_ptr MultiMessageInterfaceProxy::get_slice(MultiMessage } std::shared_ptr MultiMessageInterfaceProxy::copy_ranges( - MultiMessage &self, const std::vector> &ranges, pybind11::object num_selected_rows) + MultiMessage& self, const std::vector>& ranges, pybind11::object num_selected_rows) { std::size_t num_rows = 0; if (num_selected_rows.is_none()) { - for (const auto &range : ranges) + for (const auto& range : ranges) { num_rows += range.second - range.first; } diff --git a/morpheus/_lib/src/messages/multi_inference.cpp b/morpheus/_lib/src/messages/multi_inference.cpp index 6541c47a71..ea85e3d643 100644 --- a/morpheus/_lib/src/messages/multi_inference.cpp +++ b/morpheus/_lib/src/messages/multi_inference.cpp @@ -18,7 +18,6 @@ #include "morpheus/messages/multi_inference.hpp" #include "morpheus/messages/memory/inference_memory.hpp" -#include "morpheus/messages/memory/tensor_memory.hpp" // for TensorMemory::tensor_map_t #include "morpheus/messages/meta.hpp" #include "morpheus/messages/multi.hpp" #include "morpheus/utilities/cupy_util.hpp" @@ -27,9 +26,7 @@ #include #include -#include // for int32_t #include -#include // needed for logging #include #include @@ -45,17 +42,17 @@ MultiInferenceMessage::MultiInferenceMessage(std::shared_ptr MultiInferenceMessageInterfaceProxy::init std::move(meta), mess_offset, mess_count, std::move(memory), offset, count); } -std::shared_ptr MultiInferenceMessageInterfaceProxy::memory(MultiInferenceMessage &self) +std::shared_ptr MultiInferenceMessageInterfaceProxy::memory(MultiInferenceMessage& self) { DCHECK(std::dynamic_pointer_cast(self.memory) != nullptr); return std::static_pointer_cast(self.memory); } -std::size_t MultiInferenceMessageInterfaceProxy::offset(MultiInferenceMessage &self) +std::size_t MultiInferenceMessageInterfaceProxy::offset(MultiInferenceMessage& self) { return self.offset; } -std::size_t MultiInferenceMessageInterfaceProxy::count(MultiInferenceMessage &self) +std::size_t MultiInferenceMessageInterfaceProxy::count(MultiInferenceMessage& self) { return self.count; } -pybind11::object MultiInferenceMessageInterfaceProxy::get_input(MultiInferenceMessage &self, const std::string &name) +pybind11::object MultiInferenceMessageInterfaceProxy::get_input(MultiInferenceMessage& self, const std::string& name) { - const auto &py_tensor = CupyUtil::tensor_to_cupy(self.get_input(name)); + const auto& py_tensor = CupyUtil::tensor_to_cupy(self.get_input(name)); return py_tensor; } -std::shared_ptr MultiInferenceMessageInterfaceProxy::get_slice(MultiInferenceMessage &self, +std::shared_ptr MultiInferenceMessageInterfaceProxy::get_slice(MultiInferenceMessage& self, std::size_t start, std::size_t stop) { diff --git a/morpheus/_lib/src/messages/multi_inference_fil.cpp b/morpheus/_lib/src/messages/multi_inference_fil.cpp index 60d7c626ef..683c4c3695 100644 --- a/morpheus/_lib/src/messages/multi_inference_fil.cpp +++ b/morpheus/_lib/src/messages/multi_inference_fil.cpp @@ -22,6 +22,7 @@ #include "morpheus/messages/multi_inference.hpp" #include +#include #include #include @@ -43,7 +44,7 @@ const TensorObject MultiInferenceFILMessage::get_input__0() const return this->get_input("input__0"); } -void MultiInferenceFILMessage::set_input__0(const TensorObject &input__0) +void MultiInferenceFILMessage::set_input__0(const TensorObject& input__0) { this->set_input("input__0", input__0); } @@ -53,7 +54,7 @@ const TensorObject MultiInferenceFILMessage::get_seq_ids() const return this->get_input("seq_ids"); } -void MultiInferenceFILMessage::set_seq_ids(const TensorObject &seq_ids) +void MultiInferenceFILMessage::set_seq_ids(const TensorObject& seq_ids) { this->set_input("seq_ids", seq_ids); } @@ -71,18 +72,18 @@ std::shared_ptr MultiInferenceFILMessageInterfaceProxy } std::shared_ptr MultiInferenceFILMessageInterfaceProxy::memory( - MultiInferenceFILMessage &self) + MultiInferenceFILMessage& self) { DCHECK(std::dynamic_pointer_cast(self.memory) != nullptr); return std::static_pointer_cast(self.memory); } -std::size_t MultiInferenceFILMessageInterfaceProxy::offset(MultiInferenceFILMessage &self) +std::size_t MultiInferenceFILMessageInterfaceProxy::offset(MultiInferenceFILMessage& self) { return self.offset; } -std::size_t MultiInferenceFILMessageInterfaceProxy::count(MultiInferenceFILMessage &self) +std::size_t MultiInferenceFILMessageInterfaceProxy::count(MultiInferenceFILMessage& self) { return self.count; } diff --git a/morpheus/_lib/src/messages/multi_inference_nlp.cpp b/morpheus/_lib/src/messages/multi_inference_nlp.cpp index 8160827710..8365bdd4af 100644 --- a/morpheus/_lib/src/messages/multi_inference_nlp.cpp +++ b/morpheus/_lib/src/messages/multi_inference_nlp.cpp @@ -23,6 +23,7 @@ #include "morpheus/utilities/cupy_util.hpp" #include +#include #include #include @@ -45,7 +46,7 @@ const TensorObject MultiInferenceNLPMessage::get_input_ids() const return this->get_input("input_ids"); } -void MultiInferenceNLPMessage::set_input_ids(const TensorObject &input_ids) +void MultiInferenceNLPMessage::set_input_ids(const TensorObject& input_ids) { this->set_input("input_ids", input_ids); } @@ -55,7 +56,7 @@ const TensorObject MultiInferenceNLPMessage::get_input_mask() const return this->get_input("input_mask"); } -void MultiInferenceNLPMessage::set_input_mask(const TensorObject &input_mask) +void MultiInferenceNLPMessage::set_input_mask(const TensorObject& input_mask) { this->set_input("input_mask", input_mask); } @@ -65,7 +66,7 @@ const TensorObject MultiInferenceNLPMessage::get_seq_ids() const return this->get_input("seq_ids"); } -void MultiInferenceNLPMessage::set_seq_ids(const TensorObject &seq_ids) +void MultiInferenceNLPMessage::set_seq_ids(const TensorObject& seq_ids) { this->set_input("seq_ids", seq_ids); } @@ -84,23 +85,23 @@ std::shared_ptr MultiInferenceNLPMessageInterfaceProxy } std::shared_ptr MultiInferenceNLPMessageInterfaceProxy::memory( - MultiInferenceNLPMessage &self) + MultiInferenceNLPMessage& self) { DCHECK(std::dynamic_pointer_cast(self.memory) != nullptr); return std::static_pointer_cast(self.memory); } -std::size_t MultiInferenceNLPMessageInterfaceProxy::offset(MultiInferenceNLPMessage &self) +std::size_t MultiInferenceNLPMessageInterfaceProxy::offset(MultiInferenceNLPMessage& self) { return self.offset; } -std::size_t MultiInferenceNLPMessageInterfaceProxy::count(MultiInferenceNLPMessage &self) +std::size_t MultiInferenceNLPMessageInterfaceProxy::count(MultiInferenceNLPMessage& self) { return self.count; } -pybind11::object MultiInferenceNLPMessageInterfaceProxy::input_ids(MultiInferenceNLPMessage &self) +pybind11::object MultiInferenceNLPMessageInterfaceProxy::input_ids(MultiInferenceNLPMessage& self) { // Get and convert auto tensor = self.get_input_ids(); @@ -108,7 +109,7 @@ pybind11::object MultiInferenceNLPMessageInterfaceProxy::input_ids(MultiInferenc return CupyUtil::tensor_to_cupy(tensor); } -pybind11::object MultiInferenceNLPMessageInterfaceProxy::input_mask(MultiInferenceNLPMessage &self) +pybind11::object MultiInferenceNLPMessageInterfaceProxy::input_mask(MultiInferenceNLPMessage& self) { // Get and convert auto tensor = self.get_input_mask(); @@ -116,7 +117,7 @@ pybind11::object MultiInferenceNLPMessageInterfaceProxy::input_mask(MultiInferen return CupyUtil::tensor_to_cupy(tensor); } -pybind11::object MultiInferenceNLPMessageInterfaceProxy::seq_ids(MultiInferenceNLPMessage &self) +pybind11::object MultiInferenceNLPMessageInterfaceProxy::seq_ids(MultiInferenceNLPMessage& self) { // Get and convert auto tensor = self.get_seq_ids(); diff --git a/morpheus/_lib/src/messages/multi_response.cpp b/morpheus/_lib/src/messages/multi_response.cpp index fb5fadbb74..9b7008c565 100644 --- a/morpheus/_lib/src/messages/multi_response.cpp +++ b/morpheus/_lib/src/messages/multi_response.cpp @@ -18,7 +18,6 @@ #include "morpheus/messages/multi_response.hpp" #include "morpheus/messages/memory/response_memory.hpp" -#include "morpheus/messages/memory/tensor_memory.hpp" // for TensorMemory::tensor_map_t #include "morpheus/messages/meta.hpp" #include "morpheus/messages/multi.hpp" #include "morpheus/objects/tensor_object.hpp" @@ -30,7 +29,6 @@ #include #include -#include // needed for logging #include #include @@ -45,17 +43,17 @@ MultiResponseMessage::MultiResponseMessage(std::shared_ptr meta, DerivedMultiMessage(meta, mess_offset, mess_count, memory, offset, count) {} -const TensorObject MultiResponseMessage::get_output(const std::string &name) const +const TensorObject MultiResponseMessage::get_output(const std::string& name) const { return get_tensor(name); } -TensorObject MultiResponseMessage::get_output(const std::string &name) +TensorObject MultiResponseMessage::get_output(const std::string& name) { return get_tensor(name); } -void MultiResponseMessage::set_output(const std::string &name, const TensorObject &value) +void MultiResponseMessage::set_output(const std::string& name, const TensorObject& value) { set_tensor(name, value); } @@ -72,24 +70,24 @@ std::shared_ptr MultiResponseMessageInterfaceProxy::init(s std::move(meta), mess_offset, mess_count, std::move(memory), offset, count); } -std::shared_ptr MultiResponseMessageInterfaceProxy::memory(MultiResponseMessage &self) +std::shared_ptr MultiResponseMessageInterfaceProxy::memory(MultiResponseMessage& self) { DCHECK(std::dynamic_pointer_cast(self.memory) != nullptr); return std::static_pointer_cast(self.memory); } -std::size_t MultiResponseMessageInterfaceProxy::offset(MultiResponseMessage &self) +std::size_t MultiResponseMessageInterfaceProxy::offset(MultiResponseMessage& self) { return self.offset; } -std::size_t MultiResponseMessageInterfaceProxy::count(MultiResponseMessage &self) +std::size_t MultiResponseMessageInterfaceProxy::count(MultiResponseMessage& self) { return self.count; } -pybind11::object MultiResponseMessageInterfaceProxy::get_output(MultiResponseMessage &self, const std::string &name) +pybind11::object MultiResponseMessageInterfaceProxy::get_output(MultiResponseMessage& self, const std::string& name) { auto tensor = self.get_output(name); diff --git a/morpheus/_lib/src/messages/multi_response_probs.cpp b/morpheus/_lib/src/messages/multi_response_probs.cpp index e29650022b..6cbfeba8a3 100644 --- a/morpheus/_lib/src/messages/multi_response_probs.cpp +++ b/morpheus/_lib/src/messages/multi_response_probs.cpp @@ -21,6 +21,7 @@ #include "morpheus/utilities/cupy_util.hpp" #include +#include #include #include @@ -43,7 +44,7 @@ const TensorObject MultiResponseProbsMessage::get_probs() const return this->get_output("probs"); } -void MultiResponseProbsMessage::set_probs(const TensorObject &probs) +void MultiResponseProbsMessage::set_probs(const TensorObject& probs) { this->set_output("probs", probs); } @@ -65,24 +66,24 @@ std::shared_ptr MultiResponseProbsMessageInterfacePro } std::shared_ptr MultiResponseProbsMessageInterfaceProxy::memory( - MultiResponseProbsMessage &self) + MultiResponseProbsMessage& self) { DCHECK(std::dynamic_pointer_cast(self.memory) != nullptr); return std::static_pointer_cast(self.memory); } -std::size_t MultiResponseProbsMessageInterfaceProxy::offset(MultiResponseProbsMessage &self) +std::size_t MultiResponseProbsMessageInterfaceProxy::offset(MultiResponseProbsMessage& self) { return self.offset; } -std::size_t MultiResponseProbsMessageInterfaceProxy::count(MultiResponseProbsMessage &self) +std::size_t MultiResponseProbsMessageInterfaceProxy::count(MultiResponseProbsMessage& self) { return self.count; } -pybind11::object MultiResponseProbsMessageInterfaceProxy::probs(MultiResponseProbsMessage &self) +pybind11::object MultiResponseProbsMessageInterfaceProxy::probs(MultiResponseProbsMessage& self) { // Get and convert auto tensor = self.get_probs(); diff --git a/morpheus/_lib/src/messages/multi_tensor.cpp b/morpheus/_lib/src/messages/multi_tensor.cpp index 70a4569116..2ca9b29b93 100644 --- a/morpheus/_lib/src/messages/multi_tensor.cpp +++ b/morpheus/_lib/src/messages/multi_tensor.cpp @@ -17,8 +17,6 @@ #include "morpheus/messages/multi_tensor.hpp" -#include "morpheus/utilities/cupy_util.hpp" - #include // for cudf::size_type> #include @@ -40,17 +38,17 @@ MultiTensorMessage::MultiTensorMessage(std::shared_ptr me count(count) {} -const TensorObject MultiTensorMessage::get_tensor(const std::string &name) const +const TensorObject MultiTensorMessage::get_tensor(const std::string& name) const { return get_tensor_impl(name); } -TensorObject MultiTensorMessage::get_tensor(const std::string &name) +TensorObject MultiTensorMessage::get_tensor(const std::string& name) { return get_tensor_impl(name); } -TensorObject MultiTensorMessage::get_tensor_impl(const std::string &name) const +TensorObject MultiTensorMessage::get_tensor_impl(const std::string& name) const { CHECK(this->memory->has_tensor(name)) << "Cound not find tensor: " << name; @@ -64,7 +62,7 @@ TensorObject MultiTensorMessage::get_tensor_impl(const std::string &name) const {static_cast(this->offset + this->count), -1}); } -void MultiTensorMessage::set_tensor(const std::string &name, const TensorObject &value) +void MultiTensorMessage::set_tensor(const std::string& name, const TensorObject& value) { // Get the input slice first auto slice = this->get_tensor(name); @@ -83,7 +81,7 @@ void MultiTensorMessage::get_slice_impl(std::shared_ptr new_messag sliced_message->offset = start; sliced_message->count = stop - start; - // If we have more inference rows than message rows, we need to use the seq_ids to figure out the slicing. This + // If we have more tensor rows than message rows, we need to use the seq_ids to figure out the slicing. This // will be slow and should be avoided at all costs if (this->count != this->mess_count && this->memory->has_tensor("seq_ids")) { @@ -99,7 +97,7 @@ void MultiTensorMessage::get_slice_impl(std::shared_ptr new_messag } void MultiTensorMessage::copy_ranges_impl(std::shared_ptr new_message, - const std::vector> &ranges, + const std::vector>& ranges, size_t num_selected_rows) const { DCHECK(std::dynamic_pointer_cast(new_message) != nullptr); @@ -112,7 +110,7 @@ void MultiTensorMessage::copy_ranges_impl(std::shared_ptr new_mess } std::shared_ptr MultiTensorMessage::copy_input_ranges( - const std::vector> &ranges, size_t num_selected_rows) const + const std::vector>& ranges, size_t num_selected_rows) const { auto offset_ranges = apply_offset_to_ranges(offset, ranges); auto tensors = memory->copy_tensor_ranges(offset_ranges, num_selected_rows); diff --git a/morpheus/_lib/src/objects/data_table.cpp b/morpheus/_lib/src/objects/data_table.cpp index 278adcb110..82d99e6129 100644 --- a/morpheus/_lib/src/objects/data_table.cpp +++ b/morpheus/_lib/src/objects/data_table.cpp @@ -20,9 +20,12 @@ #include "morpheus/objects/table_info.hpp" #include +#include #include +#include #include +#include namespace morpheus { diff --git a/morpheus/_lib/src/objects/dev_mem_info.cpp b/morpheus/_lib/src/objects/dev_mem_info.cpp index 92651193ed..742d7b0b92 100644 --- a/morpheus/_lib/src/objects/dev_mem_info.cpp +++ b/morpheus/_lib/src/objects/dev_mem_info.cpp @@ -21,8 +21,9 @@ #include // for DCHECK -#include // for uint8_t -#include // for move +#include // for uint8_t +#include +#include // for move namespace morpheus { // Component public implementations diff --git a/morpheus/_lib/src/objects/dtype.cpp b/morpheus/_lib/src/objects/dtype.cpp index 638d69c6e7..e3cd91bb15 100644 --- a/morpheus/_lib/src/objects/dtype.cpp +++ b/morpheus/_lib/src/objects/dtype.cpp @@ -26,6 +26,7 @@ #include // Needed by MORPHEUS_CONCAT_STR #include #include +#include namespace { const std::map> StrToTypeId = { diff --git a/morpheus/_lib/src/objects/fiber_queue.cpp b/morpheus/_lib/src/objects/fiber_queue.cpp index 81e5d6f78d..170c12a6b3 100644 --- a/morpheus/_lib/src/objects/fiber_queue.cpp +++ b/morpheus/_lib/src/objects/fiber_queue.cpp @@ -33,7 +33,7 @@ namespace morpheus { /****** FiberQueue****************************************/ FiberQueue::FiberQueue(size_t max_size) : m_queue(max_size) {} -boost::fibers::channel_op_status FiberQueue::put(pybind11::object &&item, bool block, float timeout) +boost::fibers::channel_op_status FiberQueue::put(pybind11::object&& item, bool block, float timeout) { if (!block) { @@ -51,7 +51,7 @@ boost::fibers::channel_op_status FiberQueue::put(pybind11::object &&item, bool b } } -boost::fibers::channel_op_status FiberQueue::get(pybind11::object &item, bool block, float timeout) +boost::fibers::channel_op_status FiberQueue::get(pybind11::object& item, bool block, float timeout) { if (!block) { @@ -96,7 +96,7 @@ std::shared_ptr FiberQueueInterfaceProxy::init(std::size_t return std::make_shared(max_size); } -void FiberQueueInterfaceProxy::put(morpheus::FiberQueue &self, pybind11::object item, bool block, float timeout) +void FiberQueueInterfaceProxy::put(morpheus::FiberQueue& self, pybind11::object item, bool block, float timeout) { boost::fibers::channel_op_status status; @@ -139,7 +139,7 @@ void FiberQueueInterfaceProxy::put(morpheus::FiberQueue &self, pybind11::object } } -pybind11::object FiberQueueInterfaceProxy::get(morpheus::FiberQueue &self, bool block, float timeout) +pybind11::object FiberQueueInterfaceProxy::get(morpheus::FiberQueue& self, bool block, float timeout) { boost::fibers::channel_op_status status; @@ -186,7 +186,7 @@ pybind11::object FiberQueueInterfaceProxy::get(morpheus::FiberQueue &self, bool } } -void FiberQueueInterfaceProxy::close(morpheus::FiberQueue &self) +void FiberQueueInterfaceProxy::close(morpheus::FiberQueue& self) { self.close(); } diff --git a/morpheus/_lib/src/objects/file_types.cpp b/morpheus/_lib/src/objects/file_types.cpp index 81ed65a917..36e5fe5a6b 100644 --- a/morpheus/_lib/src/objects/file_types.cpp +++ b/morpheus/_lib/src/objects/file_types.cpp @@ -24,13 +24,13 @@ #include namespace morpheus { -FileTypes FileTypesInterfaceProxy::determine_file_type(const std::string &filename) +FileTypes FileTypesInterfaceProxy::determine_file_type(const std::string& filename) { return morpheus::determine_file_type(filename); } } // namespace morpheus -morpheus::FileTypes morpheus::determine_file_type(const std::string &filename) +morpheus::FileTypes morpheus::determine_file_type(const std::string& filename) { auto filename_path = std::filesystem::path(filename); diff --git a/morpheus/_lib/src/objects/mutable_table_ctx_mgr.cpp b/morpheus/_lib/src/objects/mutable_table_ctx_mgr.cpp index 6aabb9b21d..1f85546a80 100644 --- a/morpheus/_lib/src/objects/mutable_table_ctx_mgr.cpp +++ b/morpheus/_lib/src/objects/mutable_table_ctx_mgr.cpp @@ -17,8 +17,13 @@ #include "morpheus/objects/mutable_table_ctx_mgr.hpp" +#include "morpheus/utilities/string_util.hpp" + +#include #include +#include + namespace morpheus { namespace py = pybind11; @@ -46,11 +51,10 @@ void MutableTableCtxMgr::exit(const py::object& type, const py::object& value, c void MutableTableCtxMgr::throw_usage_error(pybind11::args args, const pybind11::kwargs& kwargs) { - std::ostringstream err_msg; - err_msg << "Error attempting to use mutable_dataframe outside of context manager. Intended usage :\n"; - err_msg << "with message_meta.mutable_dataframe() as df:\n"; - err_msg << " df['col'] = 5"; - throw py::attribute_error(err_msg.str()); + throw py::attribute_error( + MORPHEUS_CONCAT_STR("Error attempting to use mutable_dataframe outside of context manager. Intended usage :\n" + "with message_meta.mutable_dataframe() as df:\n" + " df['col'] = 5")); } } // namespace morpheus diff --git a/morpheus/_lib/src/objects/python_data_table.cpp b/morpheus/_lib/src/objects/python_data_table.cpp index 8526188326..9ef2de53a5 100644 --- a/morpheus/_lib/src/objects/python_data_table.cpp +++ b/morpheus/_lib/src/objects/python_data_table.cpp @@ -30,7 +30,7 @@ namespace morpheus { /****** Component public implementations *******************/ /****** PyDataTable****************************************/ -PyDataTable::PyDataTable(pybind11::object &&py_table) : m_py_table(std::move(py_table)) {} +PyDataTable::PyDataTable(pybind11::object&& py_table) : m_py_table(std::move(py_table)) {} PyDataTable::~PyDataTable() { diff --git a/morpheus/_lib/src/objects/table_info.cpp b/morpheus/_lib/src/objects/table_info.cpp index 9b40ce69ae..b81786f0be 100644 --- a/morpheus/_lib/src/objects/table_info.cpp +++ b/morpheus/_lib/src/objects/table_info.cpp @@ -32,6 +32,7 @@ #include // for size_t #include // for back_insert_iterator, back_inserter #include +#include #include #include #include @@ -237,7 +238,7 @@ void MutableTableInfo::insert_columns(const std::vectorget_data().column_names.size(); const auto num_rows = this->get_data().table_view.num_rows(); - // TODO figure out how to do this without the gil + // TODO(mdemoret): figure out how to do this without the gil { namespace py = pybind11; pybind11::gil_scoped_acquire gil; diff --git a/morpheus/_lib/src/objects/tensor.cpp b/morpheus/_lib/src/objects/tensor.cpp index a4df09ec0b..6501143918 100644 --- a/morpheus/_lib/src/objects/tensor.cpp +++ b/morpheus/_lib/src/objects/tensor.cpp @@ -45,9 +45,9 @@ Tensor::Tensor(std::shared_ptr buffer, m_offset(init_offset) {} -void *Tensor::data() const +void* Tensor::data() const { - return static_cast(m_device_buffer->data()) + m_offset; + return static_cast(m_device_buffer->data()) + m_offset; } size_t Tensor::bytes_count() const diff --git a/morpheus/_lib/src/objects/wrapped_tensor.cpp b/morpheus/_lib/src/objects/wrapped_tensor.cpp index 8a279dcd12..1a2d4fae71 100644 --- a/morpheus/_lib/src/objects/wrapped_tensor.cpp +++ b/morpheus/_lib/src/objects/wrapped_tensor.cpp @@ -30,20 +30,20 @@ namespace morpheus { /****** Component public implementations *******************/ /****** TensorObject****************************************/ /****** TensorObjectInterfaceProxy *************************/ -pybind11::dict TensorObjectInterfaceProxy::cuda_array_interface(TensorObject &self) +pybind11::dict TensorObjectInterfaceProxy::cuda_array_interface(TensorObject& self) { pybind11::dict array_interface; pybind11::list shape_list; - for (auto &idx : self.get_shape()) + for (auto& idx : self.get_shape()) { shape_list.append(idx); } pybind11::list stride_list; - for (auto &idx : self.get_stride()) + for (auto& idx : self.get_stride()) { stride_list.append(idx * self.dtype_size()); } diff --git a/morpheus/_lib/src/python_modules/common.cpp b/morpheus/_lib/src/python_modules/common.cpp index 0ab02c8e1b..a5c5a57c2b 100644 --- a/morpheus/_lib/src/python_modules/common.cpp +++ b/morpheus/_lib/src/python_modules/common.cpp @@ -32,6 +32,7 @@ #include #include +#include namespace morpheus { namespace py = pybind11; diff --git a/morpheus/_lib/src/python_modules/messages.cpp b/morpheus/_lib/src/python_modules/messages.cpp index 731883e037..66405c92ec 100644 --- a/morpheus/_lib/src/python_modules/messages.cpp +++ b/morpheus/_lib/src/python_modules/messages.cpp @@ -34,10 +34,15 @@ #include "morpheus/utilities/cudf_util.hpp" #include "morpheus/version.hpp" +#include #include // for Status #include #include +#include +#include +#include #include +#include #include // IWYU pragma: keep #include #include @@ -46,9 +51,11 @@ #include #include #include // for pymrc::import +#include #include #include +#include #include #include #include diff --git a/morpheus/_lib/src/python_modules/stages.cpp b/morpheus/_lib/src/python_modules/stages.cpp index 67b71f3b2a..6e43d1eb52 100644 --- a/morpheus/_lib/src/python_modules/stages.cpp +++ b/morpheus/_lib/src/python_modules/stages.cpp @@ -39,7 +39,6 @@ #include // for multiple_inheritance #include // for arg, init, class_, module_, str_attr_accessor, PYBIND11_MODULE, pybind11 #include // for dict, sequence -#include // for dict->map conversions #include // for pymrc::import #include diff --git a/morpheus/_lib/src/stages/add_classification.cpp b/morpheus/_lib/src/stages/add_classification.cpp index de0394ce4e..720cb2ef56 100644 --- a/morpheus/_lib/src/stages/add_classification.cpp +++ b/morpheus/_lib/src/stages/add_classification.cpp @@ -35,9 +35,8 @@ #include #include // for divides, bind, placeholders #include -#include // needed for logging -#include // for declval -#include // for move +#include // needed for logging +#include // for move // IWYU thinks we need __alloc_traits<>::value_type for vector assignments // IWYU pragma: no_include diff --git a/morpheus/_lib/src/stages/add_scores.cpp b/morpheus/_lib/src/stages/add_scores.cpp index 487faa3b2f..495b11a9a6 100644 --- a/morpheus/_lib/src/stages/add_scores.cpp +++ b/morpheus/_lib/src/stages/add_scores.cpp @@ -17,21 +17,17 @@ #include "morpheus/stages/add_scores.hpp" -#include "morpheus/objects/tensor.hpp" #include "morpheus/objects/tensor_object.hpp" // for TensorIndex, TensorObject #include -#include // for SinkProperties<>::sink_type_t -#include // for SourceProperties<>::source_type_t -#include // for Object #include // for size_t #include +#include #include #include -#include // for logging -#include // for declval -#include // for move +#include // for logging +#include // for move #include // IWYU thinks we need __alloc_traits<>::value_type for vector assignments // IWYU pragma: no_include diff --git a/morpheus/_lib/src/stages/deserialize.cpp b/morpheus/_lib/src/stages/deserialize.cpp index b3d48699c6..03df438fc3 100644 --- a/morpheus/_lib/src/stages/deserialize.cpp +++ b/morpheus/_lib/src/stages/deserialize.cpp @@ -24,8 +24,8 @@ #include // for min #include #include +#include #include -#include // for declval #include namespace morpheus { diff --git a/morpheus/_lib/src/stages/file_source.cpp b/morpheus/_lib/src/stages/file_source.cpp index eadafc7063..d0bdfc3aac 100644 --- a/morpheus/_lib/src/stages/file_source.cpp +++ b/morpheus/_lib/src/stages/file_source.cpp @@ -18,29 +18,18 @@ #include "morpheus/stages/file_source.hpp" #include "morpheus/io/deserializers.hpp" +#include "morpheus/objects/table_info.hpp" -#include // for column -#include -#include -#include // for string_scalar -#include -#include // for strings_column_view -#include // for table #include #include #include -#include // for object_api::operator() #include #include // for str_attr_accessor #include // for pybind11::int_ -#include // for find -#include // for size_t -#include +#include #include -#include #include -#include // for runtime_error #include // IWYU thinks we need __alloc_traits<>::value_type for vector assignments // IWYU pragma: no_include diff --git a/morpheus/_lib/src/stages/filter_detection.cpp b/morpheus/_lib/src/stages/filter_detection.cpp index abcf701fd2..fd23ed1029 100644 --- a/morpheus/_lib/src/stages/filter_detection.cpp +++ b/morpheus/_lib/src/stages/filter_detection.cpp @@ -20,11 +20,14 @@ #include "morpheus/messages/multi_tensor.hpp" #include "morpheus/objects/dev_mem_info.hpp" // for DevMemInfo #include "morpheus/objects/dtype.hpp" // for DataType +#include "morpheus/objects/table_info.hpp" #include "morpheus/objects/tensor_object.hpp" // for TensorIndex, TensorObject #include "morpheus/utilities/matx_util.hpp" #include "morpheus/utilities/tensor_util.hpp" // for TensorUtils::get_element_stride -#include // for cudaMemcpy, cudaMemcpyDeviceToDevice, cudaMemcpyDeviceToHost +#include // for cudaMemcpy, cudaMemcpyDeviceToDevice, cudaMemcpyDeviceToHost +#include +#include #include // for CHECK, CHECK_NE #include // for MRC_CHECK_CUDA #include // for cuda_stream_per_thread @@ -33,11 +36,11 @@ #include #include // for uint8_t #include +#include #include #include // needed for glog #include -#include // for declval (indirectly via templates) -#include // for pair +#include // for pair // IWYU thinks we need ext/new_allocator.h for size_t for some reason // IWYU pragma: no_include diff --git a/morpheus/_lib/src/stages/kafka_source.cpp b/morpheus/_lib/src/stages/kafka_source.cpp index 3101a5917a..d34fff51d4 100644 --- a/morpheus/_lib/src/stages/kafka_source.cpp +++ b/morpheus/_lib/src/stages/kafka_source.cpp @@ -24,12 +24,7 @@ #include // for sleep_for, yield #include -#include // for column #include -#include // for string_scalar -#include -#include // for strings_column_view -#include // for table #include #include #include @@ -40,6 +35,7 @@ #include // for find, min, transform #include +#include #include #include #include @@ -70,7 +66,7 @@ namespace morpheus { // Component-private classes. // ************ KafkaSourceStage__UnsubscribedException**************// -class KafkaSourceStage__UnsubscribedException : public std::exception +class KafkaSourceStageUnsubscribedException : public std::exception {}; class KafkaSourceStageStopAfter : public std::exception @@ -286,7 +282,7 @@ KafkaSourceStage::subscriber_fn_t KafkaSourceStage::build() // If we are unsubscribed, throw an error to break the loops if (!sub.is_subscribed()) { - throw KafkaSourceStage__UnsubscribedException(); + throw KafkaSourceStageUnsubscribedException(); } else if (m_stop_after > 0 && records_emitted >= m_stop_after) { diff --git a/morpheus/_lib/src/stages/preprocess_fil.cpp b/morpheus/_lib/src/stages/preprocess_fil.cpp index 645b76c300..9290ed285f 100644 --- a/morpheus/_lib/src/stages/preprocess_fil.cpp +++ b/morpheus/_lib/src/stages/preprocess_fil.cpp @@ -18,8 +18,7 @@ #include "morpheus/stages/preprocess_fil.hpp" #include "morpheus/messages/memory/inference_memory_fil.hpp" -#include "morpheus/messages/meta.hpp" // for MessageMeta -#include "morpheus/objects/data_table.hpp" +#include "morpheus/messages/meta.hpp" // for MessageMeta #include "morpheus/objects/dev_mem_info.hpp" // for DevMemInfo #include "morpheus/objects/dtype.hpp" #include "morpheus/objects/table_info.hpp" // for TableInfo @@ -30,7 +29,6 @@ #include // for cudaMemcpy, cudaMemcpyDeviceToDevice #include // for column, column::contents #include // for column_view -#include // for table_view #include #include #include // for MRC_CHECK_CUDA @@ -40,7 +38,6 @@ #include // for str_attr_accessor, arg #include #include -#include #include // for cuda_stream_per_thread #include // for device_buffer @@ -48,8 +45,8 @@ #include #include #include +#include #include -#include // for declval #include namespace morpheus { diff --git a/morpheus/_lib/src/stages/preprocess_nlp.cpp b/morpheus/_lib/src/stages/preprocess_nlp.cpp index 8ebdcc8c82..9db112c57f 100644 --- a/morpheus/_lib/src/stages/preprocess_nlp.cpp +++ b/morpheus/_lib/src/stages/preprocess_nlp.cpp @@ -36,9 +36,9 @@ #include #include +#include #include #include -#include // for declval #include namespace morpheus { diff --git a/morpheus/_lib/src/stages/serialize.cpp b/morpheus/_lib/src/stages/serialize.cpp index 3775922c97..384abb6b36 100644 --- a/morpheus/_lib/src/stages/serialize.cpp +++ b/morpheus/_lib/src/stages/serialize.cpp @@ -18,14 +18,13 @@ #include "morpheus/stages/serialize.hpp" #include "morpheus/messages/meta.hpp" - -#include // for gil_scoped_acquire +#include "morpheus/objects/table_info.hpp" #include +#include #include #include -#include // for declval -#include // for move +#include // for move // IWYU thinks basic_stringbuf & map are needed for the regex constructor // IWYU pragma: no_include // IWYU pragma: no_include @@ -37,8 +36,8 @@ constexpr std::regex_constants::syntax_option_type RegexOptions = // Component public implementations // ************ WriteToFileStage **************************** // -SerializeStage::SerializeStage(const std::vector &include, - const std::vector &exclude, +SerializeStage::SerializeStage(const std::vector& include, + const std::vector& exclude, bool fixed_columns) : PythonNode(base_t::op_factory_from_sub_fn(build_operator())), m_fixed_columns{fixed_columns} @@ -47,17 +46,17 @@ SerializeStage::SerializeStage(const std::vector &include, make_regex_objs(exclude, m_exclude); } -void SerializeStage::make_regex_objs(const std::vector ®ex_strs, std::vector ®ex_objs) +void SerializeStage::make_regex_objs(const std::vector& regex_strs, std::vector& regex_objs) { - for (const auto &s : regex_strs) + for (const auto& s : regex_strs) { regex_objs.emplace_back(std::regex{s, RegexOptions}); } } -bool SerializeStage::match_column(const std::vector &patterns, const std::string &column) const +bool SerializeStage::match_column(const std::vector& patterns, const std::string& column) const { - for (const auto &re : patterns) + for (const auto& re : patterns) { if (std::regex_match(column, re)) { @@ -67,7 +66,7 @@ bool SerializeStage::match_column(const std::vector &patterns, const return false; } -bool SerializeStage::include_column(const std::string &column) const +bool SerializeStage::include_column(const std::string& column) const { if (m_include.empty()) { @@ -79,7 +78,7 @@ bool SerializeStage::include_column(const std::string &column) const } } -bool SerializeStage::exclude_column(const std::string &column) const +bool SerializeStage::exclude_column(const std::string& column) const { return match_column(m_exclude, column); } @@ -92,7 +91,7 @@ std::shared_ptr SerializeStage::get_meta(sink_type_t& msg) if (!m_fixed_columns || m_column_names.empty()) { m_column_names.clear(); - for (const auto &c : msg->get_meta().get_column_names()) + for (const auto& c : msg->get_meta().get_column_names()) { if (include_column(c) && !exclude_column(c)) { diff --git a/morpheus/_lib/src/stages/triton_inference.cpp b/morpheus/_lib/src/stages/triton_inference.cpp index 903183ce6d..8b959d623d 100644 --- a/morpheus/_lib/src/stages/triton_inference.cpp +++ b/morpheus/_lib/src/stages/triton_inference.cpp @@ -17,21 +17,22 @@ #include "morpheus/stages/triton_inference.hpp" -#include "morpheus/messages/memory/inference_memory.hpp" // for InferenceMemory #include "morpheus/messages/memory/response_memory_probs.hpp" // for ResponseMemoryProbs #include "morpheus/messages/memory/tensor_memory.hpp" // for TensorMemory::tensor_map_t #include "morpheus/messages/multi_response_probs.hpp" #include "morpheus/objects/dev_mem_info.hpp" // for DevMemInfo #include "morpheus/objects/dtype.hpp" // for DType +#include "morpheus/objects/rmm_tensor.hpp" #include "morpheus/objects/tensor.hpp" #include "morpheus/objects/tensor_object.hpp" // for TensorIndex, TensorObject #include "morpheus/objects/triton_in_out.hpp" #include "morpheus/utilities/matx_util.hpp" -#include "morpheus/utilities/stage_util.hpp" +#include "morpheus/utilities/stage_util.hpp" // for foreach_map #include "morpheus/utilities/string_util.hpp" // for MORPHEUS_CONCAT_STR #include "morpheus/utilities/tensor_util.hpp" // for get_elem_count #include // for cudaMemcpy, cudaMemcpy2D, cudaMemcpyDeviceToHost, cudaMemcpyHostToDevice +#include #include #include #include // for MRC_CHECK_CUDA @@ -44,16 +45,21 @@ #include #include #include +#include #include #include -#include // for runtime_error, out_of_range -#include // for declval +#include // for runtime_error, out_of_range #include // IWYU pragma: no_include #define CHECK_TRITON(method) ::InferenceClientStage__check_triton_errors(method, #method, __FILE__, __LINE__); namespace { + +using namespace morpheus; +using tensor_map_t = TensorMemory::tensor_map_t; +using buffer_map_t = std::map>; + // Component-private free functions. void InferenceClientStage__check_triton_errors(triton::client::Error status, const std::string& methodName, @@ -70,6 +76,177 @@ void InferenceClientStage__check_triton_errors(triton::client::Error status, } } +void build_output_tensors(std::size_t count, + const std::vector& model_outputs, + buffer_map_t& output_buffers, + tensor_map_t& output_tensors) +{ + // Create the output memory blocks + for (auto& model_output : model_outputs) + { + std::vector total_shape{model_output.shape.begin(), model_output.shape.end()}; + + // First dimension will always end up being the number of rows in the dataframe + total_shape[0] = static_cast(count); + auto elem_count = TensorUtils::get_elem_count(total_shape); + + // Create the output memory + auto output_buffer = std::make_shared(elem_count * model_output.datatype.item_size(), + rmm::cuda_stream_per_thread); + + output_buffers[model_output.mapped_name] = output_buffer; + + // Triton results are always in row-major as required by the KServe protocol + // https://github.com/kserve/kserve/blob/master/docs/predict-api/v2/required_api.md#tensor-data + std::vector stride{total_shape[1], 1}; + output_tensors[model_output.mapped_name] = + Tensor::create(std::move(output_buffer), model_output.datatype, total_shape, stride, 0); + } +} + +std::vector get_seq_ids(const InferenceClientStage::sink_type_t& message) +{ + // Take a copy of the sequence Ids allowing us to map rows in the response to rows in the dataframe + // The output tensors we store in `reponse_memory` will all be of the same length as the the + // dataframe. seq_ids has three columns, but we are only interested in the first column. + auto seq_ids = message->get_input("seq_ids"); + const auto item_size = seq_ids.dtype().item_size(); + + std::vector host_seq_ids(message->count); + MRC_CHECK_CUDA(cudaMemcpy2D(host_seq_ids.data(), + item_size, + seq_ids.data(), + seq_ids.stride(0) * item_size, + item_size, + host_seq_ids.size(), + cudaMemcpyDeviceToHost)); + + return host_seq_ids; +} + +std::pair, std::vector> build_input( + const InferenceClientStage::sink_type_t& msg_slice, const TritonInOut& model_input) +{ + DCHECK(msg_slice->memory->has_tensor(model_input.mapped_name)) + << "Model input '" << model_input.mapped_name << "' not found in InferenceMemory"; + + auto const& inp_tensor = msg_slice->get_input(model_input.mapped_name); + + // Convert to the right type. Make shallow if necessary + auto final_tensor = inp_tensor.as_type(model_input.datatype); + + std::vector inp_data = final_tensor.get_host_data(); + + // Test + triton::client::InferInput* inp_ptr; + + triton::client::InferInput::Create( + &inp_ptr, model_input.name, {inp_tensor.shape(0), inp_tensor.shape(1)}, model_input.datatype.triton_str()); + + std::shared_ptr inp_shared; + inp_shared.reset(inp_ptr); + + inp_ptr->AppendRaw(inp_data); + + return std::make_pair(inp_shared, std::move(inp_data)); +} + +std::shared_ptr build_output(const TritonInOut& model_output) +{ + triton::client::InferRequestedOutput* out_ptr; + + triton::client::InferRequestedOutput::Create(&out_ptr, model_output.name); + std::shared_ptr out_shared; + out_shared.reset(out_ptr); + + return out_shared; +} + +void reduce_outputs(const InferenceClientStage::sink_type_t& x, + buffer_map_t& output_buffers, + tensor_map_t& output_tensors) +{ + // When our tensor lengths are longer than our dataframe we will need to use the seq_ids array to + // lookup how the values should map back into the dataframe. + auto host_seq_ids = get_seq_ids(x); + + tensor_map_t reduced_outputs; + + for (const auto& output : output_tensors) + { + DCHECK(std::dynamic_pointer_cast(output.second.get_tensor()) != nullptr); + auto tensor = std::static_pointer_cast(output.second.get_tensor()); + + const auto rank = tensor->rank(); + std::vector shape(rank); + tensor->get_shape(shape); + + std::vector stride(rank); + tensor->get_stride(stride); + + // DevMemInfo wants the shape & stride in size_t + std::vector tensor_shape(shape.size()); + std::copy(shape.cbegin(), shape.cend(), tensor_shape.begin()); + + std::vector tensor_stride(stride.size()); + std::copy(stride.cbegin(), stride.cend(), tensor_stride.begin()); + + std::vector reduced_shape{tensor_shape}; + reduced_shape[0] = x->mess_count; + + auto& buffer = output_buffers[output.first]; + auto reduced_buffer = MatxUtil::reduce_max( + DevMemInfo{buffer, tensor->dtype(), tensor_shape, tensor_stride}, host_seq_ids, 0, reduced_shape); + + output_buffers[output.first] = reduced_buffer; + + reduced_outputs[output.first] = + Tensor::create(std::move(reduced_buffer), + tensor->dtype(), + {static_cast(reduced_shape[0]), static_cast(reduced_shape[1])}, + stride, + 0); + } + + output_tensors = std::move(reduced_outputs); +} + +void apply_logits(buffer_map_t& output_buffers, tensor_map_t& output_tensors) +{ + tensor_map_t logit_outputs; + + for (const auto& output : output_tensors) + { + DCHECK(std::dynamic_pointer_cast(output.second.get_tensor()) != nullptr); + auto input_tensor = std::static_pointer_cast(output.second.get_tensor()); + + const auto rank = input_tensor->rank(); + std::vector shape(rank); + input_tensor->get_shape(shape); + + std::vector stride(rank); + input_tensor->get_stride(stride); + + // DevMemInfo wants the shape & stride in size_t + std::vector input_shape(shape.size()); + std::copy(shape.cbegin(), shape.cend(), input_shape.begin()); + + std::vector input_stride(stride.size()); + std::copy(stride.cbegin(), stride.cend(), input_stride.begin()); + + auto& buffer = output_buffers[output.first]; + + auto output_buffer = MatxUtil::logits(DevMemInfo{buffer, input_tensor->dtype(), input_shape, input_stride}); + + output_buffers[output.first] = output_buffer; + + // For logits the input and output shapes will be the same + logit_outputs[output.first] = Tensor::create(std::move(output_buffer), input_tensor->dtype(), shape, stride, 0); + } + + output_tensors = std::move(logit_outputs); +} + } // namespace namespace morpheus { @@ -103,121 +280,33 @@ InferenceClientStage::subscribe_fn_t InferenceClientStage::build_operator() return input.subscribe(rxcpp::make_observer( [this, &output, &client](sink_type_t x) { - // When our tensor lengths are longer than our dataframe we will need to use the seq_ids - // array to lookup how the values should map back into the dataframe - const bool needs_seq_ids = x->mess_count != x->count; - std::map response_outputs; - - // Create the output memory blocks - for (auto& model_output : m_model_outputs) - { - std::vector total_shape{model_output.shape.begin(), model_output.shape.end()}; - - // First dimension will always end up being the number of rows in the dataframe - total_shape[0] = static_cast(x->mess_count); - auto elem_count = TensorUtils::get_elem_count(total_shape); - - // Create the output memory - auto output_buffer = std::make_shared( - elem_count * model_output.datatype.item_size(), rmm::cuda_stream_per_thread); - - response_outputs[model_output.mapped_name] = Tensor::create( - std::move(output_buffer), model_output.datatype, total_shape, std::vector{}, 0); - } - - // This will be the final output of all mini-batches - auto response_mem_probs = - std::make_shared(x->mess_count, std::move(response_outputs)); - auto response = std::make_shared(x->meta, - x->mess_offset, - x->mess_count, - std::move(response_mem_probs), - 0, - response_mem_probs->count); - - std::unique_ptr> host_seq_ids{nullptr}; - if (needs_seq_ids) - { - // Take a copy of the sequence Ids allowing us to map rows in the response to rows in the dataframe - // The output tensors we store in `reponse_memory` will all be of the same length as the the - // dataframe. seq_ids has three columns, but we are only interested in the first column. - auto seq_ids = x->get_input("seq_ids"); - const auto item_size = seq_ids.dtype().item_size(); - - host_seq_ids = std::make_unique>(x->count); - MRC_CHECK_CUDA(cudaMemcpy2D(host_seq_ids->data(), - item_size, - seq_ids.data(), - seq_ids.stride(0) * item_size, - item_size, - host_seq_ids->size(), - cudaMemcpyDeviceToHost)); - } - - for (size_t i = 0; i < x->count; i += m_max_batch_size) + // Using the `count` which is the number of rows in the inference tensors. We will check later if this + // doesn't match the number of rows in the dataframe (`mess_count`). This happens when the size of the + // input is too large and needs to be broken up in chunks in the pre-process stage. When this is the + // case we will reduce the rows in the response outputs such that we have a single response for each + // row int he dataframe. + tensor_map_t output_tensors; + buffer_map_t output_buffers; + build_output_tensors(x->count, m_model_outputs, output_buffers, output_tensors); + + for (size_t start = 0; start < x->count; start += m_max_batch_size) { triton::client::InferInput* input1; - size_t start = i; - size_t stop = std::min(i + m_max_batch_size, x->count); + size_t stop = std::min(start + m_max_batch_size, x->count); sink_type_t mini_batch_input = x->get_slice(start, stop); - size_t out_start = start; - size_t out_stop = stop; - if (needs_seq_ids) - { - out_start = (*host_seq_ids)[out_start]; - if (out_stop < host_seq_ids->size()) - { - out_stop = (*host_seq_ids)[out_stop]; - } - else - { - out_stop = x->mess_count; - } - } - - source_type_t mini_batch_output = response->get_slice(out_start, out_stop); - // Iterate on the model inputs in case the model takes less than what tensors are available std::vector, std::vector>> - saved_inputs = foreach_map(m_model_inputs, [this, &mini_batch_input](auto const& model_input) { - DCHECK(mini_batch_input->memory->has_tensor(model_input.mapped_name)) - << "Model input '" << model_input.mapped_name << "' not found in InferenceMemory"; - - auto const& inp_tensor = mini_batch_input->get_input(model_input.mapped_name); - - // Convert to the right type. Make shallow if necessary - auto final_tensor = inp_tensor.as_type(model_input.datatype); - - std::vector inp_data = final_tensor.get_host_data(); - - // Test - triton::client::InferInput* inp_ptr; - - triton::client::InferInput::Create(&inp_ptr, - model_input.name, - {inp_tensor.shape(0), inp_tensor.shape(1)}, - model_input.datatype.triton_str()); - std::shared_ptr inp_shared; - inp_shared.reset(inp_ptr); - - inp_ptr->AppendRaw(inp_data); - - return std::make_pair(inp_shared, std::move(inp_data)); + saved_inputs = foreach_map(m_model_inputs, [&mini_batch_input](auto const& model_input) { + return (build_input(mini_batch_input, model_input)); }); std::vector> saved_outputs = - foreach_map(m_model_outputs, [this](auto const& model_output) { + foreach_map(m_model_outputs, [](auto const& model_output) { // Generate the outputs to be requested. - triton::client::InferRequestedOutput* out_ptr; - - triton::client::InferRequestedOutput::Create(&out_ptr, model_output.name); - std::shared_ptr out_shared; - out_shared.reset(out_ptr); - - return out_shared; + return build_output(model_output); }); std::vector inputs = @@ -226,8 +315,6 @@ InferenceClientStage::subscribe_fn_t InferenceClientStage::build_operator() std::vector outputs = foreach_map(saved_outputs, [](auto x) { return x.get(); }); - // this->segment().resources().fiber_pool().enqueue([client, output](){}); - triton::client::InferResult* results; CHECK_TRITON(client->Infer(&results, m_options, inputs, outputs)); @@ -248,58 +335,38 @@ InferenceClientStage::subscribe_fn_t InferenceClientStage::build_operator() size_t output_ptr_size = 0; CHECK_TRITON(results->RawData(model_output.name, &output_ptr, &output_ptr_size)); - auto output_buffer = - std::make_shared(output_ptr_size, rmm::cuda_stream_per_thread); + auto output_tensor = output_tensors[model_output.mapped_name].slice( + {static_cast(start), 0}, {static_cast(stop), -1}); - MRC_CHECK_CUDA( - cudaMemcpy(output_buffer->data(), output_ptr, output_ptr_size, cudaMemcpyHostToDevice)); + DCHECK_EQ(stop - start, output_shape[0]); + DCHECK_EQ(output_tensor.bytes(), output_ptr_size); - if (needs_seq_ids && output_shape[0] != mini_batch_output->count) - { - // Since we are working with slices of both the input and the output, the seq_ids have - // already been applied to the output's start & stop, so we only need to reduce the - // response tensort when the size doesn't match our output - std::vector mapped_output_shape{output_shape}; - mapped_output_shape[0] = mini_batch_output->count; - - // The shape of the triton output is the input to the reduce_max method - std::vector input_shape(output_shape.size()); - std::copy(output_shape.cbegin(), output_shape.cend(), input_shape.begin()); - - // Triton results are always in row-major as required by the KServe protocol - // https://github.com/kserve/kserve/blob/master/docs/predict-api/v2/required_api.md#tensor-data - std::vector stride{static_cast(output_shape[1]), 1}; - output_buffer = MatxUtil::reduce_max( - DevMemInfo{output_buffer, model_output.datatype, input_shape, stride}, - *host_seq_ids, - mini_batch_input->offset, - mapped_output_shape); - output_shape = std::move(mapped_output_shape); - } + MRC_CHECK_CUDA( + cudaMemcpy(output_tensor.data(), output_ptr, output_ptr_size, cudaMemcpyHostToDevice)); + } + } - // If we need to do logits, do that here - if (m_needs_logits) - { - std::vector input_shape(output_shape.size()); - std::copy(output_shape.cbegin(), output_shape.cend(), input_shape.begin()); - - output_buffer = - MatxUtil::logits(DevMemInfo{output_buffer, - model_output.datatype, - input_shape, - {static_cast(output_shape[1]), 1}}); - } + if (x->mess_count != x->count) + { + reduce_outputs(x, output_buffers, output_tensors); + } - mini_batch_output->set_output( - model_output.mapped_name, - Tensor::create(std::move(output_buffer), - model_output.datatype, - std::vector{static_cast(output_shape[0]), - static_cast(output_shape[1])}, - std::vector{}, - 0)); - } + // If we need to do logits, do that here + if (m_needs_logits) + { + apply_logits(output_buffers, output_tensors); } + + // Final output of all mini-batches + auto response_mem_probs = + std::make_shared(x->mess_count, std::move(output_tensors)); + auto response = std::make_shared(x->meta, + x->mess_offset, + x->mess_count, + std::move(response_mem_probs), + 0, + response_mem_probs->count); + output.on_next(std::move(response)); }, [&](std::exception_ptr error_ptr) { output.on_error(error_ptr); }, @@ -404,7 +471,7 @@ void InferenceClientStage::connect_with_server() bytes *= y; } - std::string mapped_name = input.at("name").get(); + auto mapped_name = input.at("name").get(); if (m_inout_mapping.find(mapped_name) != m_inout_mapping.end()) { @@ -437,7 +504,7 @@ void InferenceClientStage::connect_with_server() bytes *= y; } - std::string mapped_name = output.at("name").get(); + auto mapped_name = output.at("name").get(); if (m_inout_mapping.find(mapped_name) != m_inout_mapping.end()) { diff --git a/morpheus/_lib/src/stages/write_to_file.cpp b/morpheus/_lib/src/stages/write_to_file.cpp index 53d81e4cf2..74af01c012 100644 --- a/morpheus/_lib/src/stages/write_to_file.cpp +++ b/morpheus/_lib/src/stages/write_to_file.cpp @@ -26,8 +26,7 @@ #include #include // for invalid_argument, runtime_error #include -#include // for declval (indirectly via templates) -#include // for forward, move, addressof +#include // for forward, move, addressof namespace morpheus { diff --git a/morpheus/_lib/src/utilities/cudf_util.cpp b/morpheus/_lib/src/utilities/cudf_util.cpp index 01f0cb98dc..01f91c2a60 100644 --- a/morpheus/_lib/src/utilities/cudf_util.cpp +++ b/morpheus/_lib/src/utilities/cudf_util.cpp @@ -44,11 +44,11 @@ void morpheus::load_cudf_helpers() } } -pybind11::object morpheus::proxy_table_from_table_with_metadata(cudf::io::table_with_metadata &&table, +pybind11::object morpheus::proxy_table_from_table_with_metadata(cudf::io::table_with_metadata&& table, int index_col_count) { return pybind11::reinterpret_steal( - (PyObject *)make_table_from_table_with_metadata(std::move(table), index_col_count)); + (PyObject*)make_table_from_table_with_metadata(std::move(table), index_col_count)); } morpheus::TableInfoData morpheus::proxy_table_info_data_from_table(pybind11::object table) diff --git a/morpheus/_lib/src/utilities/matx_util.cu b/morpheus/_lib/src/utilities/matx_util.cu index f4fb4419b0..71992c4a56 100644 --- a/morpheus/_lib/src/utilities/matx_util.cu +++ b/morpheus/_lib/src/utilities/matx_util.cu @@ -15,412 +15,454 @@ * limitations under the License. */ -#include "morpheus/utilities/matx_util.hpp" - #include "morpheus/objects/dev_mem_info.hpp" #include "morpheus/objects/dtype.hpp" #include "morpheus/objects/tensor_object.hpp" +#include "morpheus/utilities/matx_util.hpp" -#include - +#include #include #include #include +#include #include namespace morpheus { - // Component-private classes. - // ************ MatxUtil__MatxCast**************// +using tensorShape_1d = std::array; +using tensorShape_2d = std::array; + +// Component-private classes. +// ************ MatxUtil__MatxCast**************// +/** + * TODO(Documentation) + */ +struct MatxUtil__MatxCast +{ + size_t element_count; + rmm::cuda_stream_view stream; + /** * TODO(Documentation) */ - struct MatxUtil__MatxCast { // NOLINT - size_t element_count; - rmm::cuda_stream_view stream; - - /** - * TODO(Documentation) - */ - template() || !cudf::is_numeric()> * = nullptr> - void operator()(void *input_data, void *output_data) { - throw std::invalid_argument("Unsupported conversion"); - } + template () || !cudf::is_numeric()>* = nullptr> + void operator()(void* input_data, void* output_data) + { + throw std::invalid_argument("Unsupported conversion"); + } + + /** + * TODO(Documentation) + */ + template () && cudf::is_numeric()>* = nullptr> + void operator()(void* input_data, void* output_data) + { + tensorShape_1d shape({static_cast(element_count)}); - /** - * TODO(Documentation) - */ - template() && cudf::is_numeric()> * = nullptr> - void operator()(void *input_data, void *output_data) { - matx::tensorShape_t<1> shape({static_cast(element_count)}); + auto input_tensor = matx::make_tensor(static_cast(input_data), shape); + auto output_tensor = matx::make_tensor(static_cast(output_data), shape); - matx::tensor_t input_tensor(static_cast(input_data), shape); - matx::tensor_t output_tensor(static_cast(output_data), shape); + (output_tensor = input_tensor).run(stream.value()); + } +}; - (output_tensor = input_tensor).run(stream.value()); - } - }; +// ************ MatxUtil__MatxCreateSegIds**************// +/** + * TODO(Documentation) + */ +struct MatxUtil__MatxCreateSegIds +{ + size_t element_count; + size_t fea_len; + rmm::cuda_stream_view stream; - // ************ MatxUtil__MatxCreateSegIds**************// /** * TODO(Documentation) */ - struct MatxUtil__MatxCreateSegIds { - size_t element_count; - size_t fea_len; - rmm::cuda_stream_view stream; - - /** - * TODO(Documentation) - */ - template> * = nullptr> - void operator()(void *output_data) { - throw std::invalid_argument("Unsupported conversion"); - } + template >* = nullptr> + void operator()(void* output_data) + { + throw std::invalid_argument("Unsupported conversion"); + } - /** - * TODO(Documentation) - */ - template> * = nullptr> - void operator()(void *output_data) { - matx::tensorShape_t<2> shape({static_cast(element_count), 3}); + /** + * TODO(Documentation) + */ + template >* = nullptr> + void operator()(void* output_data) + { + auto matx_count = static_cast(element_count); + tensorShape_2d shape({matx_count, 3}); - matx::tensor_t output_tensor(static_cast(output_data), shape); + auto output_tensor = matx::make_tensor(static_cast(output_data), shape); - auto col0 = output_tensor.template Slice<1>({0, 0}, {matx::matxEnd, matx::matxDropDim}); - auto col2 = output_tensor.template Slice<1>({0, 2}, {matx::matxEnd, matx::matxDropDim}); - auto range_col = - matx::range_x(matx::tensorShape_t<1>({static_cast(element_count)}), 0, 1); + auto col0 = output_tensor.template Slice<1>({0, 0}, {matx::matxEnd, matx::matxDropDim}); + auto col2 = output_tensor.template Slice<1>({0, 2}, {matx::matxEnd, matx::matxDropDim}); + auto range_col = matx::range<0, tensorShape_1d, OutputT>({matx_count}, 0, 1); - (col0 = range_col).run(stream.value()); - (col2 = fea_len - 1).run(stream.value()); - } - }; // NOLINT + (col0 = range_col).run(stream.value()); + (col2 = fea_len - 1).run(stream.value()); + } +}; + +// ************ MatxUtil__MatxLogits**************// +/** + * TODO(Documentation) + */ +struct MatxUtil__MatxLogits +{ + size_t element_count; + rmm::cuda_stream_view stream; - // ************ MatxUtil__MatxLogits**************// /** * TODO(Documentation) */ - struct MatxUtil__MatxLogits { // NOLINT - size_t element_count; - rmm::cuda_stream_view stream; - - /** - * TODO(Documentation) - */ - template()> * = nullptr> - void operator()(void *input_data, void *output_data) { - throw std::invalid_argument("Unsupported conversion"); - } + template ()>* = nullptr> + void operator()(void* input_data, void* output_data) + { + throw std::invalid_argument("Unsupported conversion"); + } - /** - * TODO(Documentation) - */ - template()> * = nullptr> - void operator()(void *input_data, void *output_data) { - matx::tensorShape_t<1> shape({static_cast(element_count)}); + /** + * TODO(Documentation) + */ + template ()>* = nullptr> + void operator()(void* input_data, void* output_data) + { + tensorShape_1d shape({static_cast(element_count)}); - matx::tensor_t input_tensor(static_cast(input_data), shape); + auto input_tensor = matx::make_tensor(static_cast(input_data), shape); - matx::tensor_t output_tensor(static_cast(output_data), shape); + auto output_tensor = matx::make_tensor(static_cast(output_data), shape); - (output_tensor = (InputT) 1 / ((InputT) 1 + matx::exp((InputT) -1 * input_tensor))).run(stream.value()); - } - }; // NOLINT + (output_tensor = (InputT)1 / ((InputT)1 + matx::exp((InputT)-1 * input_tensor))).run(stream.value()); + } +}; + +// ************ MatxUtil__MatxTranspose**************// +/** + * TODO(Documentation) + */ +struct MatxUtil__MatxTranspose +{ + size_t element_count; + rmm::cuda_stream_view stream; + size_t rows; + size_t cols; - // ************ MatxUtil__MatxTranspose**************// /** * TODO(Documentation) */ - struct MatxUtil__MatxTranspose { // NOLINT - size_t element_count; - rmm::cuda_stream_view stream; - size_t rows; - size_t cols; - - /** - * TODO(Documentation) - */ - template()> * = nullptr> - void operator()(void *input_data, void *output_data) { - throw std::invalid_argument("Unsupported conversion"); - } + template ()>* = nullptr> + void operator()(void* input_data, void* output_data) + { + throw std::invalid_argument("Unsupported conversion"); + } - /** - * TODO(Documentation) - */ - template()> * = nullptr> - void operator()(void *input_data, void *output_data) { - matx::tensorShape_t<2> input_shape({static_cast(rows), static_cast(cols)}); - matx::tensorShape_t<2> output_shape({static_cast(cols), static_cast(rows)}); + /** + * TODO(Documentation) + */ + template ()>* = nullptr> + void operator()(void* input_data, void* output_data) + { + tensorShape_2d input_shape({static_cast(rows), static_cast(cols)}); + tensorShape_2d output_shape({static_cast(cols), static_cast(rows)}); - matx::tensor_t input_tensor(static_cast(input_data), input_shape); - matx::tensor_t output_tensor(static_cast(output_data), output_shape); + auto input_tensor = matx::make_tensor(static_cast(input_data), input_shape); + auto output_tensor = matx::make_tensor(static_cast(output_data), output_shape); - (output_tensor = input_tensor.Permute({1, 0})).run(stream.value()); - } - }; + (output_tensor = input_tensor.Permute({1, 0})).run(stream.value()); + } +}; + +// ************ MatxUtil__MatxThreshold**************// +/** + * TODO(Documentation) + */ +struct MatxUtil__MatxThreshold +{ + size_t rows; + size_t cols; + bool by_row; + rmm::cuda_stream_view stream; - // ************ MatxUtil__MatxThreshold**************// /** * TODO(Documentation) */ - struct MatxUtil__MatxThreshold { // NOLINT - size_t rows; - size_t cols; - bool by_row; - rmm::cuda_stream_view stream; - - /** - * TODO(Documentation) - */ - template()> * = nullptr> - void - operator()(void *input_data, void *output_data, double threshold, const std::vector& stride) { - throw std::invalid_argument("Unsupported conversion"); - } + template ()>* = nullptr> + void operator()(void* input_data, void* output_data, double threshold, const std::vector& stride) + { + throw std::invalid_argument("Unsupported conversion"); + } - /** - * TODO(Documentation) - */ - template()> * = nullptr> - void - operator()(void *input_data, void *output_data, double threshold, const std::vector& stride) { - if (by_row) { - this->threshold_by_row(input_data, output_data, threshold, stride); - } else { - this->threshold(input_data, output_data, threshold, stride); - } + /** + * TODO(Documentation) + */ + template ()>* = nullptr> + void operator()(void* input_data, void* output_data, double threshold, const std::vector& stride) + { + if (by_row) + { + this->threshold_by_row(input_data, output_data, threshold, stride); + } + else + { + this->threshold(input_data, output_data, threshold, stride); } + } - private: - /** - * TODO(Documentation) - */ - template - void threshold_by_row(void *input_data, void *output_data, double threshold, - const std::vector& stride) { - matx::tensorShape_t<2> input_shape({static_cast(rows), static_cast(cols)}); + private: + /** + * TODO(Documentation) + */ + template + void threshold_by_row(void* input_data, void* output_data, double threshold, const std::vector& stride) + { + // Output is always 1 column + tensorShape_1d output_shape({static_cast(rows)}); - // Output is always 1 column - matx::tensorShape_t<1> output_shape({static_cast(rows)}); + matx::DefaultDescriptor<2> desc{{static_cast(rows), static_cast(cols)}, + {static_cast(stride[0]), static_cast(stride[1])}}; - // Specify the stride here since the data comes in column major order. - matx::tensor_t input_tensor(static_cast(input_data), - input_shape, - {static_cast(stride[0]), - static_cast(stride[1])}); + auto input_tensor = + matx::make_tensor>(static_cast(input_data), std::move(desc)); - // Tmp array to hold max value - matx::tensor_t max_tensor(output_shape); + // Tmp array to hold max value + auto max_tensor = matx::make_tensor(output_shape); - // row-wise reduction - matx::rmax(max_tensor, input_tensor, stream.value()); + // row-wise reduction + matx::rmax(max_tensor, input_tensor, stream.value()); - matx::tensor_t output_tensor(static_cast(output_data), output_shape); + auto output_tensor = matx::make_tensor(static_cast(output_data), output_shape); - // Convert max value to bool - (output_tensor = max_tensor > (InputT) threshold).run(stream.value()); - } + // Convert max value to bool + (output_tensor = max_tensor > (InputT)threshold).run(stream.value()); + } - /** - * TODO(Documentation) - */ - template - void - threshold(void *input_data, void *output_data, double threshold, const std::vector& stride) { - matx::tensorShape_t<2> shape({static_cast(rows), static_cast(cols)}); + /** + * TODO(Documentation) + */ + template + void threshold(void* input_data, void* output_data, double threshold, const std::vector& stride) + { + matx::DefaultDescriptor<2> input_desc{ + {static_cast(rows), static_cast(cols)}, + {static_cast(stride[0]), static_cast(stride[1])}}; - matx::index_t matx_stride[2] = {static_cast(stride[0]), - static_cast(stride[1])}; + // Input & Output have the same shape & stride. The make_tensor API requires a move for the descriptor + // so we need to take a copy of it here. + matx::DefaultDescriptor<2> output_desc = input_desc; - matx::tensor_t input_tensor(static_cast(input_data), shape, matx_stride); - matx::tensor_t output_tensor(static_cast(output_data), shape, matx_stride); + auto input_tensor = matx::make_tensor(static_cast(input_data), std::move(input_desc)); + auto output_tensor = matx::make_tensor(static_cast(output_data), std::move(output_desc)); - // Convert max value to bool - (output_tensor = input_tensor > (InputT) threshold).run(stream.value()); - } - }; - - struct MatxUtil__MatxReduceMax { - matx::index_t num_input_rows; - matx::index_t num_cols; - std::vector input_stride; - matx::index_t num_output_rows; - void *input_data; - void *output_data; - rmm::cuda_stream_view stream; - - template()> * = nullptr> - void operator()(std::size_t start, std::size_t stop, int32_t output_idx) { - throw std::invalid_argument("Unsupported conversion"); + // Convert max value to bool + (output_tensor = input_tensor > (InputT)threshold).run(stream.value()); + } +}; + +struct MatxUtil__MatxReduceMax +{ + matx::index_t num_input_rows; + matx::index_t num_output_rows; + matx::index_t num_cols; + std::vector input_stride; + const std::vector& seq_ids; + size_t seq_id_offset; + rmm::cuda_stream_view stream; + + template ()>* = nullptr> + void operator()(void* input_data, void* output_data) + { + throw std::invalid_argument("Unsupported conversion"); + } + + template ()>* = nullptr> + void operator()(void* input_data, void* output_data) + { + auto input_ptr = static_cast(input_data); + matx::DefaultDescriptor<2> input_desc{{num_input_rows, num_cols}, {input_stride[0], input_stride[1]}}; + auto input_tensor = matx::make_tensor>(input_ptr, std::move(input_desc)); + + auto output_ptr = static_cast(output_data); + + matx::index_t output_stride[2] = {input_stride[0], input_stride[1]}; + if (output_stride[0] == 1) + { + output_stride[1] = num_output_rows; } - template()> * = nullptr> - void operator()(std::size_t start, std::size_t stop, int32_t output_idx) { - auto input_count = stop - start; - matx::tensorShape_t<2> input_shape({static_cast(input_count), num_cols}); - matx::tensorShape_t<1> output_shape({num_cols}); + matx::DefaultDescriptor<2> output_desc{{num_output_rows, num_cols}, output_stride}; + auto output_tensor = matx::make_tensor>(output_ptr, std::move(output_desc)); - matx::index_t output_stride[2] = {input_stride[0], input_stride[1]}; - if (output_stride[0] == 1) + matx::index_t start = 0; + auto output_offset = static_cast(seq_ids[seq_id_offset]); + for (matx::index_t i = 1; i < num_input_rows; ++i) + { + auto idx = seq_ids[i + seq_id_offset]; + if (idx != seq_ids[start + seq_id_offset]) { - output_stride[1] = num_output_rows; + DCHECK(seq_ids[start + seq_id_offset] - output_offset < num_output_rows); + reduce_rows(input_tensor, + output_tensor, + start, + i, + static_cast(seq_ids[start + seq_id_offset]) - output_offset); + start = i; } + } - auto input_ptr = static_cast(input_data) + (start * input_stride[0]); - auto output_ptr = static_cast(output_data) + (output_idx * output_stride[0]); + DCHECK(seq_ids[start + seq_id_offset] - output_offset < num_output_rows) + << "\nstart=" << start + << " seq_ids[start+seq_id_offset]-output_offset=" << seq_ids[start + seq_id_offset] - output_offset + << " num_output_rows=" << num_output_rows; + reduce_rows(input_tensor, + output_tensor, + start, + num_input_rows, + static_cast(seq_ids[start + seq_id_offset]) - output_offset); + } - matx::tensor_t input_tensor(input_ptr, input_shape, {input_stride[0], input_stride[1]}); - matx::tensor_t output_tensor(output_ptr, output_shape, {output_stride[1]}); + template + void reduce_rows(matx::tensor_t& input_tensor, + matx::tensor_t& output_tensor, + matx::index_t start, + matx::index_t stop, + matx::index_t output_idx) + { + auto input_slice = input_tensor.Slice({start, 0}, {stop, matx::matxEnd}); + auto tmp_tensor = matx::make_tensor({num_cols}); - // We need to transpose the input such that rmax will reduce the rows - // Matx performs reductions over the innermost dimensions. - // see https://nvidia.github.io/MatX/api/reduce.html - matx::rmax(output_tensor, input_tensor.Permute({1, 0}), stream.value()); - } - }; + matx::rmax(tmp_tensor, input_slice.Permute({1, 0}), stream.value()); - // Component public implementations - // ************ MatxUtil************************* // - std::shared_ptr MatxUtil::cast(const DevMemInfo &input, TypeId output_type) { - auto output_dtype = DType(output_type); + auto output_slice = output_tensor.template Slice<1>({output_idx, 0}, {matx::matxDropDim, matx::matxEnd}); + (output_slice = tmp_tensor).run(stream.value()); + } +}; - // Create the output - auto output = input.make_new_buffer(output_dtype.item_size() * input.count()); +// Component public implementations +// ************ MatxUtil************************* // +std::shared_ptr MatxUtil::cast(const DevMemInfo& input, TypeId output_type) +{ + auto output_dtype = DType(output_type); - cudf::double_type_dispatcher(cudf::data_type{input.dtype().cudf_type_id()}, - cudf::data_type{output_dtype.cudf_type_id()}, - MatxUtil__MatxCast{input.count(), output->stream()}, - input.data(), - output->data()); + // Create the output + auto output = input.make_new_buffer(output_dtype.item_size() * input.count()); - mrc::enqueue_stream_sync_event(output->stream()).get(); + cudf::double_type_dispatcher(cudf::data_type{input.dtype().cudf_type_id()}, + cudf::data_type{output_dtype.cudf_type_id()}, + MatxUtil__MatxCast{input.count(), output->stream()}, + input.data(), + output->data()); - return output; - } + mrc::enqueue_stream_sync_event(output->stream()).get(); - std::shared_ptr - MatxUtil::create_seg_ids(size_t row_count, size_t fea_len, TypeId output_type) { - auto output_dtype = DType(output_type); + return output; +} - // Now create the output - auto output = - std::make_shared(output_dtype.item_size() * row_count * 3, - rmm::cuda_stream_per_thread); +std::shared_ptr MatxUtil::create_seg_ids(size_t row_count, size_t fea_len, TypeId output_type) +{ + auto output_dtype = DType(output_type); - cudf::type_dispatcher(cudf::data_type{output_dtype.cudf_type_id()}, - MatxUtil__MatxCreateSegIds{row_count, fea_len, output->stream()}, - output->data()); + // Now create the output + auto output = + std::make_shared(output_dtype.item_size() * row_count * 3, rmm::cuda_stream_per_thread); - return output; - } + cudf::type_dispatcher(cudf::data_type{output_dtype.cudf_type_id()}, + MatxUtil__MatxCreateSegIds{row_count, fea_len, output->stream()}, + output->data()); - std::shared_ptr MatxUtil::logits(const DevMemInfo &input) { - // Create the output - auto output = input.make_new_buffer(input.bytes()); + return output; +} - cudf::type_dispatcher(cudf::data_type{input.dtype().cudf_type_id()}, - MatxUtil__MatxLogits{input.count(), output->stream()}, - input.data(), - output->data()); +std::shared_ptr MatxUtil::logits(const DevMemInfo& input) +{ + // Create the output + auto output = input.make_new_buffer(input.bytes()); - return output; - } + cudf::type_dispatcher(cudf::data_type{input.dtype().cudf_type_id()}, + MatxUtil__MatxLogits{input.count(), output->stream()}, + input.data(), + output->data()); + + return output; +} - std::shared_ptr MatxUtil::transpose(const DevMemInfo &input) { - // Now create the output - auto output = input.make_new_buffer(input.bytes()); +std::shared_ptr MatxUtil::transpose(const DevMemInfo& input) +{ + // Now create the output + auto output = input.make_new_buffer(input.bytes()); - cudf::type_dispatcher(cudf::data_type{input.dtype().cudf_type_id()}, - MatxUtil__MatxTranspose{input.count(), output->stream(), input.shape(0), input.shape(1)}, - input.data(), - output->data()); + cudf::type_dispatcher(cudf::data_type{input.dtype().cudf_type_id()}, + MatxUtil__MatxTranspose{input.count(), output->stream(), input.shape(0), input.shape(1)}, + input.data(), + output->data()); - return output; + return output; +} + +std::shared_ptr MatxUtil::threshold(const DevMemInfo& input, double thresh_val, bool by_row) +{ + const auto rows = input.shape(0); + const auto cols = input.shape(1); + std::size_t output_size = sizeof(bool) * rows; + if (!by_row) + { + output_size *= cols; } - std::shared_ptr - MatxUtil::threshold(const DevMemInfo &input, - double thresh_val, bool by_row) { - const auto rows = input.shape(0); - const auto cols = input.shape(1); - std::size_t output_size = sizeof(bool) * rows; - if (!by_row) { - output_size *= cols; - } + // Now create the output array of bools + auto output = input.make_new_buffer(output_size); - // Now create the output array of bools - auto output = input.make_new_buffer(output_size); + cudf::type_dispatcher(cudf::data_type{input.dtype().cudf_type_id()}, + MatxUtil__MatxThreshold{rows, cols, by_row, output->stream()}, + input.data(), + output->data(), + thresh_val, + input.stride()); - cudf::type_dispatcher(cudf::data_type{input.dtype().cudf_type_id()}, - MatxUtil__MatxThreshold{rows, cols, by_row, output->stream()}, - input.data(), - output->data(), - thresh_val, - input.stride()); + mrc::enqueue_stream_sync_event(output->stream()).get(); - mrc::enqueue_stream_sync_event(output->stream()).get(); + return output; +} - return output; - } +std::shared_ptr MatxUtil::reduce_max(const DevMemInfo& input, + const std::vector& seq_ids, + size_t seq_id_offset, + const std::vector& output_shape) +{ + const auto& dtype = input.dtype(); + auto cudf_type = cudf::data_type{dtype.cudf_type_id()}; + auto num_input_rows = input.shape(0); + auto num_input_cols = input.shape(1); - std::shared_ptr - MatxUtil::reduce_max(const DevMemInfo &input, - const std::vector &seq_ids, - size_t seq_id_offset, - const std::vector &output_shape) - { - const auto& dtype = input.dtype(); - auto cudf_type = cudf::data_type{dtype.cudf_type_id()}; - auto num_input_rows = input.shape(0); - auto num_input_cols = input.shape(1); - - std::vector matx_stride{static_cast(input.stride(0)), static_cast(input.stride(1))}; - std::size_t output_element_count = output_shape[0] * output_shape[1]; - std::size_t output_buff_size = dtype.item_size() * output_element_count; - - DCHECK(output_element_count <= input.count()) << "Output buffer size should be less than or equal to the input"; - DCHECK(num_input_cols == output_shape[1]) << "Number of input and output columns must match"; - - auto output = input.make_new_buffer(output_buff_size); - - MatxUtil__MatxReduceMax matx_reduce_max{static_cast(num_input_rows), - static_cast(num_input_cols), - matx_stride, - output_shape[0], - input.data(), - output->data(), - output->stream()}; - - std::size_t start = 0; - auto output_offset = seq_ids[seq_id_offset]; - for (std::size_t i=0; i < num_input_rows; ++i) - { - auto idx = seq_ids[i+seq_id_offset]; - if (idx != seq_ids[start+seq_id_offset]) - { - cudf::type_dispatcher(cudf_type, - matx_reduce_max, - start, - i, - seq_ids[start+seq_id_offset]-output_offset); - start = i; - } - } + std::vector matx_stride{static_cast(input.stride(0)), + static_cast(input.stride(1))}; - cudf::type_dispatcher(cudf_type, - matx_reduce_max, - start, - num_input_rows, - seq_ids[start+seq_id_offset]-output_offset); + std::size_t output_element_count = output_shape[0] * output_shape[1]; + std::size_t output_buff_size = dtype.item_size() * output_element_count; - mrc::enqueue_stream_sync_event(output->stream()).get(); - return output; - } + DCHECK(output_element_count <= input.count()) << "Output buffer size should be less than or equal to the input"; + DCHECK(num_input_cols == output_shape[1]) << "Number of input and output columns must match"; + + auto output = input.make_new_buffer(output_buff_size); + + MatxUtil__MatxReduceMax matx_reduce_max{static_cast(num_input_rows), + static_cast(output_shape[0]), + static_cast(num_input_cols), + matx_stride, + seq_ids, + seq_id_offset, + output->stream()}; + + cudf::type_dispatcher(cudf_type, matx_reduce_max, input.data(), output->data()); + + mrc::enqueue_stream_sync_event(output->stream()).get(); + return output; } +} // namespace morpheus diff --git a/morpheus/_lib/src/utilities/string_util.cpp b/morpheus/_lib/src/utilities/string_util.cpp index 8ccda65a62..19108baad9 100644 --- a/morpheus/_lib/src/utilities/string_util.cpp +++ b/morpheus/_lib/src/utilities/string_util.cpp @@ -18,7 +18,7 @@ #include "morpheus/utilities/string_util.hpp" namespace morpheus { -bool StringUtil::str_contains(const std::string &str, const std::string &search_str) +bool StringUtil::str_contains(const std::string& str, const std::string& search_str) { return str.find(search_str) != std::string::npos; } diff --git a/morpheus/_lib/src/utilities/table_util.cpp b/morpheus/_lib/src/utilities/table_util.cpp index f6ad283290..8ba1d616d3 100644 --- a/morpheus/_lib/src/utilities/table_util.cpp +++ b/morpheus/_lib/src/utilities/table_util.cpp @@ -29,7 +29,7 @@ namespace fs = std::filesystem; namespace py = pybind11; -cudf::io::table_with_metadata morpheus::CuDFTableUtil::load_table(const std::string &filename) +cudf::io::table_with_metadata morpheus::CuDFTableUtil::load_table(const std::string& filename) { auto file_path = fs::path(filename); diff --git a/morpheus/_lib/src/utilities/tensor_util.cpp b/morpheus/_lib/src/utilities/tensor_util.cpp index b6b1aefa47..e7307e7bd9 100644 --- a/morpheus/_lib/src/utilities/tensor_util.cpp +++ b/morpheus/_lib/src/utilities/tensor_util.cpp @@ -19,12 +19,9 @@ #include // for DCHECK_EQ #include // for sort_indexes - -// clang-format off + // clang-format off // prevent from moving this into the third-party section #include // for make_ostream_joiner -// clang-format on -#include // for begin, end #include // for operator<<, ostream, stringstream #include // for char_traits, string #include // for decay_t diff --git a/morpheus/_lib/tests/CMakeLists.txt b/morpheus/_lib/tests/CMakeLists.txt index 5b3a57b478..8032d01c45 100644 --- a/morpheus/_lib/tests/CMakeLists.txt +++ b/morpheus/_lib/tests/CMakeLists.txt @@ -15,6 +15,8 @@ list(APPEND CMAKE_MESSAGE_CONTEXT "tests") +find_package(pybind11 REQUIRED) + # Keep all source files sorted add_executable(test_libmorpheus # test_cuda.cu diff --git a/morpheus/_lib/tests/test_cuda.cu b/morpheus/_lib/tests/test_cuda.cu index 60b9371116..33cda73903 100644 --- a/morpheus/_lib/tests/test_cuda.cu +++ b/morpheus/_lib/tests/test_cuda.cu @@ -19,8 +19,12 @@ #include "morpheus/objects/tensor_object.hpp" -#include // for MRC_CHECK_CUDA -#include // for enqueue_stream_sync_event +#include +#include +#include +#include +#include // for MRC_CHECK_CUDA +#include // for enqueue_stream_sync_event #include #include #include @@ -29,13 +33,6 @@ #include #include #include - -#include -#include - -#include -#include - #include #include @@ -48,7 +45,6 @@ using namespace morpheus; using RankType = int; - class TestCuda : public ::testing::Test { protected: @@ -151,7 +147,6 @@ TEST_F(TestCuda, Tensor2D) CHECK_EQ(one_d.data(), two_d.data()); } - TEST_F(TestCuda, Shape) { std::array array_2d = {3, 5}; diff --git a/morpheus/_lib/tests/test_matx_util.cpp b/morpheus/_lib/tests/test_matx_util.cpp index cce7192cc8..9bdb493c68 100644 --- a/morpheus/_lib/tests/test_matx_util.cpp +++ b/morpheus/_lib/tests/test_matx_util.cpp @@ -25,6 +25,7 @@ #include // for cudaMemcpy, cudaMemcpyDeviceToHost, cudaMemcpyHostToDevice #include // for column #include // for column_view +#include #include #include // for data_type, size_type #include @@ -35,7 +36,6 @@ #include // for int64_t, int32_t, uint8_t #include // for std::getenv #include // for shared_ptr, make_shared, unique_ptr -#include #include using namespace morpheus; @@ -57,7 +57,7 @@ TEST_F(TestMatxUtil, ReduceMax1d) MRC_CHECK_CUDA(cudaMemcpy(input_buffer->data(), input.data(), input_buffer->size(), cudaMemcpyHostToDevice)); DevMemInfo dm{input_buffer, dtype, {input.size(), 1}, {1, 0}}; - std::vector output_shape{static_cast(expected_output.size()), 1}; + std::vector output_shape{expected_output.size(), 1}; auto output_buffer = MatxUtil::reduce_max(dm, seq_ids, 0, output_shape); std::vector output(expected_output.size()); @@ -111,7 +111,7 @@ TEST_F(TestMatxUtil, ReduceMax2dRowMajor) MRC_CHECK_CUDA(cudaMemcpy(input_buffer->data(), input.data(), input_buffer->size(), cudaMemcpyHostToDevice)); DevMemInfo dm{input_buffer, dtype, {num_rows, num_cols}, {num_cols, 1}}; - std::vector output_shape{static_cast(expected_rows), static_cast(num_cols)}; + std::vector output_shape{expected_rows, num_cols}; auto output_buffer = MatxUtil::reduce_max(dm, seq_ids, 0, output_shape); EXPECT_EQ(output_buffer->size(), expected_rows * num_cols * dtype.item_size()); @@ -172,7 +172,7 @@ TEST_F(TestMatxUtil, ReduceMax2dColMajor) EXPECT_EQ(expected_rows * num_cols, expected_output.size()); DevMemInfo dm{input_buffer, dtype, {num_rows, num_cols}, {1, num_rows}}; - std::vector output_shape{static_cast(expected_rows), static_cast(num_cols)}; + std::vector output_shape{expected_rows, num_cols}; auto output_buffer = MatxUtil::reduce_max(dm, seq_ids, 0, output_shape); EXPECT_EQ(output_buffer->size(), expected_rows * num_cols * dtype.item_size()); @@ -186,3 +186,129 @@ TEST_F(TestMatxUtil, ReduceMax2dColMajor) EXPECT_DOUBLE_EQ(output[i], expected_output[i]); } } + +TEST_F(TestMatxUtil, Cast) +{ + std::vector float_vec{5.1, 2.2, 8.3, 9.4, 8.5, 2.6, 1.7, 8.1}; + + DType float_type(TypeId::FLOAT32); + + auto float_buffer = + std::make_shared(float_vec.size() * float_type.item_size(), rmm::cuda_stream_per_thread); + + MRC_CHECK_CUDA(cudaMemcpy(float_buffer->data(), float_vec.data(), float_buffer->size(), cudaMemcpyHostToDevice)); + + DevMemInfo dm{float_buffer, float_type, {4, 2}, {1, 4}}; + + DType double_type(TypeId::FLOAT64); + auto double_buffer = MatxUtil::cast(dm, double_type.type_id()); + EXPECT_EQ(float_vec.size() * double_type.item_size(), double_buffer->size()); + + std::vector double_vec(float_vec.size()); + MRC_CHECK_CUDA(cudaMemcpy(double_vec.data(), double_buffer->data(), double_buffer->size(), cudaMemcpyDeviceToHost)); + + EXPECT_EQ(double_vec.size(), float_vec.size()); + for (std::size_t i = 0; i < double_vec.size(); ++i) + { + EXPECT_DOUBLE_EQ(double_vec[i], float_vec[i]); + } +} + +TEST_F(TestMatxUtil, Threshold) +{ + // clang-format off + // disabling clang-format to illustrate row-major layout + + std::vector input + { + 1.0, 0.2, 0.7, 0.9, + 1.0, 0.6, 0.1, 0.9, + 0.2, 0.8, 1.0, 0.9, + 0.1, 0.4, 0.1, 0.3, + 0.8, 1.0, 1.0, 0.8 + }; + + std::vector expected_output + { + true, false, true, true, + true, true, false, true, + false, true, true, true, + false, false, false, false, + true, true, true, true, + }; + // clang-format on + + std::size_t num_cols = 4; + std::size_t num_rows = 5; + EXPECT_EQ(num_cols * num_rows, input.size()); + + DType dtype(TypeId::FLOAT32); + + std::size_t buff_size = input.size() * dtype.item_size(); + auto input_buffer = std::make_shared(buff_size, rmm::cuda_stream_per_thread); + + MRC_CHECK_CUDA(cudaMemcpy(input_buffer->data(), input.data(), input_buffer->size(), cudaMemcpyHostToDevice)); + + DevMemInfo dm{input_buffer, dtype, {num_rows, num_cols}, {num_cols, 1}}; + + auto output = MatxUtil::threshold(dm, 0.5, false); + + // output and output_by_row are holding 1-byte bool values, so the byte size and element size should be the same + EXPECT_EQ(output->size(), expected_output.size()); + + std::vector host_byte_outut(expected_output.size()); + + MRC_CHECK_CUDA(cudaMemcpy(host_byte_outut.data(), output->data(), output->size(), cudaMemcpyDeviceToHost)); + + for (std::size_t i = 0; i < host_byte_outut.size(); ++i) + { + bool output_val = host_byte_outut[i]; + EXPECT_EQ(output_val, expected_output[i]); + } +} + +TEST_F(TestMatxUtil, ThresholdByRow) +{ + // clang-format off + // disabling clang-format to illustrate row-major layout + + std::vector input + { + 1.0, 0.2, 0.7, 0.9, + 1.0, 0.6, 0.1, 0.9, + 0.2, 0.8, 1.0, 0.9, + 0.1, 0.4, 0.1, 0.3, + 0.8, 1.0, 1.0, 0.8 + }; + + std::vector expected_output{true, true, true, false, true}; + // clang-format on + + std::size_t num_cols = 4; + std::size_t num_rows = 5; + EXPECT_EQ(num_cols * num_rows, input.size()); + + DType dtype(TypeId::FLOAT32); + + std::size_t buff_size = input.size() * dtype.item_size(); + auto input_buffer = std::make_shared(buff_size, rmm::cuda_stream_per_thread); + + MRC_CHECK_CUDA(cudaMemcpy(input_buffer->data(), input.data(), input_buffer->size(), cudaMemcpyHostToDevice)); + + DevMemInfo dm{input_buffer, dtype, {num_rows, num_cols}, {num_cols, 1}}; + + auto output = MatxUtil::threshold(dm, 0.5, true); + + // output and output_by_row are holding 1-byte bool values, so the byte size and element size should be the same + EXPECT_EQ(output->size(), expected_output.size()); + + std::vector host_byte_outut(expected_output.size()); + + MRC_CHECK_CUDA(cudaMemcpy(host_byte_outut.data(), output->data(), output->size(), cudaMemcpyDeviceToHost)); + + for (std::size_t i = 0; i < host_byte_outut.size(); ++i) + { + bool output_val = host_byte_outut[i]; + EXPECT_EQ(output_val, expected_output[i]); + } +} diff --git a/morpheus/_lib/tests/test_morpheus.cpp b/morpheus/_lib/tests/test_morpheus.cpp index 30d8349e88..ae52d8e4f9 100644 --- a/morpheus/_lib/tests/test_morpheus.cpp +++ b/morpheus/_lib/tests/test_morpheus.cpp @@ -23,6 +23,11 @@ #include +#include "test_morpheus.hpp" + +#include + +#include #include #include #include diff --git a/morpheus/_lib/tests/test_multi_slices.cpp b/morpheus/_lib/tests/test_multi_slices.cpp index 4d5d4f9140..1fdfbd08d6 100644 --- a/morpheus/_lib/tests/test_multi_slices.cpp +++ b/morpheus/_lib/tests/test_multi_slices.cpp @@ -18,13 +18,7 @@ #include "./test_morpheus.hpp" // IWYU pragma: associated #include "morpheus/io/deserializers.hpp" -#include "morpheus/messages/meta.hpp" -#include "morpheus/messages/multi_inference.hpp" -#include "morpheus/messages/multi_response.hpp" -#include "morpheus/objects/dtype.hpp" // for TypeId -#include "morpheus/objects/tensor.hpp" -#include // for cudaMemcpy, cudaMemcpyHostToDevice #include #include #include @@ -32,17 +26,12 @@ #include #include #include -#include // for MRC_CHECK_CUDA #include -#include // for cuda_stream_per_thread -#include -#include #include #include #include // for unique_ptr #include -#include //for typeid #include using namespace morpheus; diff --git a/morpheus/_lib/tests/test_tensor.cpp b/morpheus/_lib/tests/test_tensor.cpp index 3fbb5cbd8f..8e1ff723d9 100644 --- a/morpheus/_lib/tests/test_tensor.cpp +++ b/morpheus/_lib/tests/test_tensor.cpp @@ -17,12 +17,19 @@ #include "./test_morpheus.hpp" // IWYU pragma: associated +#include "morpheus/objects/dtype.hpp" // for DType +#include "morpheus/objects/rmm_tensor.hpp" #include "morpheus/objects/tensor_object.hpp" // for TensorIndex #include "morpheus/utilities/tensor_util.hpp" // for TensorUtils, TensorUtils::shape_type_t +#include #include // for AssertionResult, SuiteApiResolver, TestInfo, EXPECT_TRUE, Message, TEST_F, Test, TestFactoryImpl, TestPartResult +#include +#include +#include #include // for size_t +#include // shared_ptr #include // for allocator, operator==, basic_string, string #include // for vector // IWYU pragma: no_include "morpheus/utilities/string_util.hpp" @@ -41,7 +48,7 @@ class TestTensor : public ::testing::Test TEST_F(TestTensor, UtilsShapeString) { TensorUtils::shape_type_t shape = {100, 10, 1}; - auto shape_str = TensorUtils::shape_to_string(shape); + auto shape_str = TensorUtils::shape_to_string(shape); EXPECT_TRUE(shape_str == std::string("(100, 10, 1)")); } @@ -78,6 +85,38 @@ TEST_F(TestTensor, GetElementStride) } } +TEST_F(TestTensor, AsType) +{ + std::vector float_vec{5.1, 2.2, 8.3, 9.4, 8.5, 2.6, 1.7, 8.1}; + + DType float_type(TypeId::FLOAT32); + + auto float_buffer = + std::make_shared(float_vec.size() * float_type.item_size(), rmm::cuda_stream_per_thread); + + MRC_CHECK_CUDA(cudaMemcpy(float_buffer->data(), float_vec.data(), float_buffer->size(), cudaMemcpyHostToDevice)); + + std::vector shape{4, 2}; + std::vector stride{1, 4}; + auto float_tensor = std::make_shared(float_buffer, 0, float_type, shape, stride); + + DType double_type(TypeId::FLOAT64); + auto double_tensor = float_tensor->as_type(double_type); + + EXPECT_EQ(float_vec.size(), double_tensor->count()); + EXPECT_EQ(float_vec.size() * double_type.item_size(), double_tensor->bytes()); + + std::vector double_vec(float_vec.size()); + MRC_CHECK_CUDA( + cudaMemcpy(double_vec.data(), double_tensor->data(), double_tensor->bytes(), cudaMemcpyDeviceToHost)); + + EXPECT_EQ(double_vec.size(), float_vec.size()); + for (std::size_t i = 0; i < double_vec.size(); ++i) + { + EXPECT_DOUBLE_EQ(double_vec[i], float_vec[i]); + } +} + /* TEST_F(TestTensor, UtilsValidateShapeAndStride) { diff --git a/morpheus/modules/filter_detections.py b/morpheus/modules/filter_detections.py index a58cd442c8..0ca208dd01 100644 --- a/morpheus/modules/filter_detections.py +++ b/morpheus/modules/filter_detections.py @@ -1,4 +1,4 @@ -# Copyright (c) 2023, NVIDIA CORPORATION. +# Copyright (c) 2022-2023, NVIDIA CORPORATION. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/morpheus/stages/inference/triton_inference_stage.py b/morpheus/stages/inference/triton_inference_stage.py index a15a39d92f..1b47288705 100644 --- a/morpheus/stages/inference/triton_inference_stage.py +++ b/morpheus/stages/inference/triton_inference_stage.py @@ -16,7 +16,6 @@ import dataclasses import logging import queue -import threading import typing import warnings from abc import abstractmethod @@ -41,6 +40,8 @@ from morpheus.stages.inference.inference_stage import InferenceWorker from morpheus.utils.producer_consumer_queue import ProducerConsumerQueue +_T = typing.TypeVar("_T") + logger = logging.getLogger(__name__) @@ -97,7 +98,7 @@ class TritonInOut: ptr: cp.cuda.MemoryPointer = None -class ResourcePool: +class ResourcePool(typing.Generic[_T]): """ This class provides a bounded pool of resources. Users of the pool can borrow a resource where they will get exclusive access to that resource until it is returned. New objects will be created if the pool is @@ -116,57 +117,72 @@ class ResourcePool: """ - def __init__(self, create_fn: typing.Callable[[], typing.Any], max_size: int = 1000): + def __init__(self, create_fn: typing.Callable[[], _T], max_size: int = 1000): self._create_fn = create_fn self._max_size = max_size self._added_count = 0 - self._queue = ProducerConsumerQueue() - - self._adding_condition = threading.Condition(self._queue.mutex) - - self._outstanding = [] + self._queue: ProducerConsumerQueue[_T] = ProducerConsumerQueue(maxsize=self._max_size) - def _borrow(self): + def _add_item(self): try: - return self._queue.get_nowait() - except queue.Empty: - # Now try and create one + # Hold the queue mutex while we create this with self._queue.mutex: - # Only add it if we have room + # Only add it if we have room. Otherwise we allocate memory each time we try to exceed the size if (self._added_count < self._max_size): - self._queue.put(self._create_fn()) + self._queue.put_nowait(self._create_fn()) self._added_count += 1 - return self._queue.get() + except queue.Full: + logger.error( + "Failed to add item to the Triton ResourcePool. The ResourcePool and queue size are out of sync.") + raise - def borrow(self): + @property + def added_count(self): + """ + The number of items that have been generated by the pool. Starts at 0 and increases for ever borrow request when + the current pool is empty. + + Returns + ------- + int + Current number of added items. + """ + return self._added_count + + def borrow_obj(self, timeout: float = None) -> _T: """ Returns an item from the pool. If the pool is empty, a new item will be created and returned. Returns ------- - obj : typing.Any + obj Item from the queue. """ - obj = self._borrow() + try: + return self._queue.get_nowait() + except queue.Empty: + # Now try and create one + self._add_item() - return obj + return self._queue.get(timeout=timeout) - def return_obj(self, obj): + def return_obj(self, obj: _T): """ Returns a borrowed item back to the pool to be used by new calls to `borrow()`. Parameters ---------- - obj : typing.Any + obj An item to be added to the queue. - - self._queue.put(obj) """ + # Use put_nowait here because we should never exceed the size and this should fail instead of blocking + self._queue.put_nowait(obj) + class InputWrapper: """ @@ -600,7 +616,7 @@ def process(self, batch: MultiInferenceMessage, cb: typing.Callable[[ResponseMem Callback to set the values for the inference response. """ - mem: InputWrapper = self._mem_pool.borrow() + mem: InputWrapper = self._mem_pool.borrow_obj() inputs: typing.List[tritonclient.InferInput] = [ mem.build_input(input.name, diff --git a/tests/messages/test_control_message.py b/tests/messages/test_control_message.py index 5b2fd24997..9da0404ced 100644 --- a/tests/messages/test_control_message.py +++ b/tests/messages/test_control_message.py @@ -31,14 +31,44 @@ def test_control_message_init(): @pytest.mark.usefixtures("config_only_cpp") def test_control_message_get(): - raw_control_message = _messages.MessageControl({"test": "test_rcm"}) - control_message = messages.MessageControl({"test": "test_cm"}) - - assert "test" in raw_control_message.config() - assert raw_control_message.config()["test"] == "test_rcm" - - assert "test" in control_message.config() - assert control_message.config()["test"] == "test_cm" + raw_control_message = _messages.MessageControl( + { + "test": "test_rcm", + "tasks": [ + { + "type": "load", + "properties": { + "loader_id": "payload" + } + } + ] + } + ) + control_message = messages.MessageControl( + { + "test": "test_cm", + "tasks": [ + { + "type": "load", + "properties": { + "loader_id": "payload" + } + } + ] + } + ) + + assert "test" not in raw_control_message.config() + assert "tasks" in raw_control_message.config() + tasks = raw_control_message.config()["tasks"] + assert len(tasks) == 1 + tasks[0]["type"] == "load" + + assert "test" not in control_message.config() + assert "tasks" in control_message.config() + tasks = control_message.config()["tasks"] + assert len(tasks) == 1 + tasks[0]["type"] == "load" @pytest.mark.usefixtures("config_only_cpp") @@ -46,14 +76,40 @@ def test_control_message_set(): raw_control_message = _messages.MessageControl() control_message = messages.MessageControl() - raw_control_message.config({"test": "test_rcm"}) - control_message.config({"test": "test_cm"}) - - assert "test" in raw_control_message.config() - assert raw_control_message.config()["test"] == "test_rcm" + raw_control_message.config({ + "test": "test_rcm", + "tasks": [ + { + "type": "load", + "properties": { + "loader_id": "payload" + } + } + ] + }) + control_message.config({ + "test": "test_cm", + "tasks": [ + { + "type": "load", + "properties": { + "loader_id": "payload" + } + } + ] + }) - assert "test" in control_message.config() - assert control_message.config()["test"] == "test_cm" + assert "test" not in raw_control_message.config() + assert "tasks" in raw_control_message.config() + tasks = raw_control_message.config()["tasks"] + assert len(tasks) == 1 + assert tasks[0]["type"] == "load" + + assert "test" not in control_message.config() + assert "tasks" in control_message.config() + tasks = control_message.config()["tasks"] + assert len(tasks) == 1 + assert tasks[0]["type"] == "load" @pytest.mark.usefixtures("config_only_cpp") From f9e3563eea28e06e3acfcbba2172d5f8f5aabb7b Mon Sep 17 00:00:00 2001 From: bsuryadevara Date: Sun, 5 Mar 2023 16:22:47 -0600 Subject: [PATCH 068/157] added kafka source stage for control messages --- .../morpheus/dfp_modules_pipeline.py | 6 +- .../dfp_modules_streaming_pipeline.py | 202 ++++++++++++++++++ .../control_message_file_source_stage.py | 76 +++++++ .../control_message_kafka_source_stage.py | 133 ++++++++++++ morpheus/stages/input/kafka_source_stage.py | 12 +- 5 files changed, 425 insertions(+), 4 deletions(-) create mode 100644 examples/digital_fingerprinting/production/morpheus/dfp_modules_streaming_pipeline.py create mode 100644 morpheus/stages/input/control_message_file_source_stage.py create mode 100644 morpheus/stages/input/control_message_kafka_source_stage.py diff --git a/examples/digital_fingerprinting/production/morpheus/dfp_modules_pipeline.py b/examples/digital_fingerprinting/production/morpheus/dfp_modules_pipeline.py index 22621d94eb..65c87c4d7f 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp_modules_pipeline.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp_modules_pipeline.py @@ -30,12 +30,12 @@ from morpheus.pipeline.pipeline import Pipeline from morpheus.stages.general.monitor_stage import MonitorStage from morpheus.stages.general.multi_port_module_stage import MultiPortModuleStage -from morpheus.stages.input.control_message_source_stage import ControlMessageSourceStage +from morpheus.stages.input.control_message_file_source_stage import ControlMessageFileSourceStage @click.command() @click.option( - "--log_type", + "--source", type=click.Choice(["duo", "azure"], case_sensitive=False), required=True, help=("Indicates what type of logs are going to be used in the workload."), @@ -158,7 +158,7 @@ def run_pipeline(source: str, # Create a pipeline object pipeline = Pipeline(config) - source_stage = pipeline.add_stage(ControlMessageSourceStage(config, filenames=list(kwargs["input_file"]))) + source_stage = pipeline.add_stage(ControlMessageFileSourceStage(config, filenames=list(kwargs["input_file"]))) dfp_deployment_stage = pipeline.add_stage( MultiPortModuleStage(config, diff --git a/examples/digital_fingerprinting/production/morpheus/dfp_modules_streaming_pipeline.py b/examples/digital_fingerprinting/production/morpheus/dfp_modules_streaming_pipeline.py new file mode 100644 index 0000000000..27dd304ba0 --- /dev/null +++ b/examples/digital_fingerprinting/production/morpheus/dfp_modules_streaming_pipeline.py @@ -0,0 +1,202 @@ +# Copyright (c) 2023, NVIDIA CORPORATION. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import logging +import typing +from datetime import datetime + +import click +import dfp.modules.dfp_deployment # noqa: F401 +from dfp.utils.config_generator import ConfigGenerator +from dfp.utils.config_generator import generate_ae_config +from dfp.utils.dfp_arg_parser import DFPArgParser +from dfp.utils.schema_utils import Schema +from dfp.utils.schema_utils import SchemaBuilder + +from morpheus.cli.utils import get_log_levels +from morpheus.cli.utils import parse_log_level +from morpheus.config import Config +from morpheus.pipeline.pipeline import Pipeline +from morpheus.stages.general.monitor_stage import MonitorStage +from morpheus.stages.general.multi_port_module_stage import MultiPortModuleStage +from morpheus.stages.input.control_message_kafka_source_stage import ControlMessageKafkaSourceStage + + +@click.command() +@click.option( + "--source", + type=click.Choice(["duo", "azure"], case_sensitive=False), + required=True, + help=("Indicates what type of logs are going to be used in the workload."), +) +@click.option( + "--train_users", + type=click.Choice(["all", "generic", "individual"], case_sensitive=False), + help=("Indicates whether or not to train per user or a generic model for all users. " + "Selecting none runs the inference pipeline."), +) +@click.option( + "--skip_user", + multiple=True, + type=str, + help="User IDs to skip. Mutually exclusive with only_user", +) +@click.option( + "--only_user", + multiple=True, + type=str, + help="Only users specified by this option will be included. Mutually exclusive with skip_user", +) +@click.option( + "--start_time", + type=click.DateTime( + formats=['%Y-%m-%d', '%Y-%m-%dT%H:%M:%S', '%Y-%m-%d %H:%M:%S', '%Y-%m-%dT%H:%M:%S%z', '%Y-%m-%d %H:%M:%S%z']), + default=None, + help="The start of the time window, if undefined start_date will be `now()-duration`", +) +@click.option( + "--duration", + type=str, + default="60d", + help="The training duration to run starting from start_time", +) +@click.option( + "--use_cpp", + type=click.BOOL, + default=False, + help=("Indicates what type of logs are going to be used in the workload."), +) +@click.option( + "--cache_dir", + type=str, + default="./.cache/dfp", + show_envvar=True, + help="The location to cache data such as S3 downloads and pre-processed data", +) +@click.option("--log_level", + default=logging.getLevelName(Config().log_level), + type=click.Choice(get_log_levels(), case_sensitive=False), + callback=parse_log_level, + help="Specify the logging level to use.") +@click.option("--sample_rate_s", + type=int, + default=0, + show_envvar=True, + help="Minimum time step, in milliseconds, between object logs.") +@click.option('--tracking_uri', + type=str, + default="http://mlflow:5000", + help=("The MLflow tracking URI to connect to the tracking backend.")) +@click.option('--bootstrap_servers', + type=str, + default="localhost:9092", + required=True, + help=("Comma-separated list of bootstrap servers.")) +@click.option('--input_topic', type=str, default="test_cm", required=True, help="Kafka topic to read from") +@click.option('--group_id', type=str, default="morpheus", required=True, help="") +@click.option('--poll_interval', + type=str, + default="10millis", + required=True, + help="Polling interval to check for messages.") +@click.option("--disable_commit", + is_flag=False, + help=("Enabling this option will skip committing messages as they are pulled off the server. " + "This is only useful for debugging, allowing the user to process the same messages multiple times")) +@click.option("--disable_pre_filtering", + is_flag=True, + help=("Enabling this option will skip pre-filtering of json messages. " + "This is only useful when inputs are known to be valid json.")) +def run_pipeline(source: str, + train_users: str, + skip_user: typing.Tuple[str], + only_user: typing.Tuple[str], + start_time: datetime, + duration: str, + cache_dir: str, + log_level: int, + sample_rate_s: int, + tracking_uri, + use_cpp, + **kwargs): + if (skip_user and only_user): + logging.error("Option --skip_user and --only_user are mutually exclusive. Exiting") + + dfp_arg_parser = DFPArgParser(skip_user, + only_user, + start_time, + log_level, + cache_dir, + sample_rate_s, + duration, + source, + tracking_uri, + train_users) + + dfp_arg_parser.init() + + # Default user_id column -- override with ControlMessage + userid_column_name = "username" + # Default timestamp column -- override with ControlMessage + timestamp_column_name = "timestamp" + + config: Config = generate_ae_config(source, userid_column_name, timestamp_column_name, use_cpp=use_cpp) + + # Construct the data frame Schema used to normalize incoming data + schema_builder = SchemaBuilder(config, source) + schema: Schema = schema_builder.build_schema() + + # Create config helper used to generate config parameters for the DFP module + # This will populate to the minimum configuration parameters with intelligent default values + config_generator = ConfigGenerator(config, dfp_arg_parser, schema) + + module_conf = config_generator.get_module_conf() + + output_port_count = module_conf.get("output_port_count") + + # Create a pipeline object + pipeline = Pipeline(config) + + source_stage = pipeline.add_stage( + ControlMessageKafkaSourceStage(config, + bootstrap_servers=kwargs["bootstrap_servers"], + input_topic=kwargs["input_topic"], + group_id=kwargs["group_id"], + poll_interval=kwargs["poll_interval"], + disable_commit=kwargs["disable_commit"], + disable_pre_filtering=kwargs["disable_pre_filtering"])) + + dfp_deployment_stage = pipeline.add_stage( + MultiPortModuleStage(config, + module_conf, + input_port_name="input", + output_port_name_prefix="output", + output_port_count=output_port_count)) + + train_moniter_stage = pipeline.add_stage( + MonitorStage(config, description="DFP Training Pipeline rate", smoothing=0.001)) + + infer_moniter_stage = pipeline.add_stage( + MonitorStage(config, description="DFP Inference Pipeline rate", smoothing=0.001)) + + pipeline.add_edge(source_stage, dfp_deployment_stage) + pipeline.add_edge(dfp_deployment_stage.output_ports[0], train_moniter_stage) + pipeline.add_edge(dfp_deployment_stage.output_ports[1], infer_moniter_stage) + + # Run the pipeline + pipeline.run() + + +if __name__ == "__main__": + run_pipeline(obj={}, auto_envvar_prefix='DFP', show_default=True, prog_name="dfp") diff --git a/morpheus/stages/input/control_message_file_source_stage.py b/morpheus/stages/input/control_message_file_source_stage.py new file mode 100644 index 0000000000..986a38956b --- /dev/null +++ b/morpheus/stages/input/control_message_file_source_stage.py @@ -0,0 +1,76 @@ +# Copyright (c) 2023, NVIDIA CORPORATION. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import json +import logging +import typing + +import fsspec +import fsspec.utils +import mrc + +from morpheus.config import Config +from morpheus.messages.message_control import MessageControl +from morpheus.pipeline.single_output_source import SingleOutputSource +from morpheus.pipeline.stream_pair import StreamPair + +logger = logging.getLogger("morpheus.{}".format(__name__)) + + +class ControlMessageFileSourceStage(SingleOutputSource): + """ + Source stage is used to recieve control messages from different sources. + + Parameters + ---------- + c : `morpheus.config.Config` + Pipeline configuration instance. + filenames : List[str] + List of paths to be read from, can be a list of S3 urls (`s3://path`) amd can include wildcard characters `*` + as defined by `fsspec`: + https://filesystem-spec.readthedocs.io/en/latest/api.html?highlight=open_files#fsspec.open_files + """ + + def __init__(self, c: Config, filenames: typing.List[str]): + super().__init__(c) + self._filenames = filenames + + @property + def name(self) -> str: + return "from-message-control" + + def supports_cpp_node(self): + return True + + def _create_control_message(self) -> MessageControl: + + openfiles: fsspec.core.OpenFiles = fsspec.open_files(self._filenames) + + if (len(openfiles) == 0): + raise RuntimeError(f"No files matched input strings: '{self._filenames}'. " + "Check your input pattern and ensure any credentials are correct") + + # TODO(Devin): Support multiple tasks in a single file + for openfile in openfiles: + with openfile as f: + message_configs = json.load(f) + for message_config in message_configs.get("inputs", []): + message_control = MessageControl(message_config) + yield message_control + + def _build_source(self, builder: mrc.Builder) -> StreamPair: + + out_stream = builder.make_source(self.unique_name, self._create_control_message()) + + return out_stream, fsspec.core.OpenFiles diff --git a/morpheus/stages/input/control_message_kafka_source_stage.py b/morpheus/stages/input/control_message_kafka_source_stage.py new file mode 100644 index 0000000000..c913af9483 --- /dev/null +++ b/morpheus/stages/input/control_message_kafka_source_stage.py @@ -0,0 +1,133 @@ +# Copyright (c) 2023, NVIDIA CORPORATION. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import logging +from io import StringIO + +import mrc + +import cudf + +from morpheus.cli.register_stage import register_stage +from morpheus.config import Config +from morpheus.config import PipelineModes +from morpheus.messages.message_control import MessageControl +from morpheus.pipeline.stream_pair import StreamPair +from morpheus.stages.input.kafka_source_stage import AutoOffsetReset +from morpheus.stages.input.kafka_source_stage import KafkaSourceStage + +logger = logging.getLogger(__name__) + + +@register_stage("from-cm-kafka", modes=[PipelineModes.AE]) +class ControlMessageKafkaSourceStage(KafkaSourceStage): + """ + Load control messages from a Kafka cluster. + + Parameters + ---------- + c : `morpheus.config.Config` + Pipeline configuration instance. + bootstrap_servers : str + Comma-separated list of bootstrap servers. If using Kafka created via `docker-compose`, this can be set to + 'auto' to automatically determine the cluster IPs and ports + input_topic : str + Input kafka topic. + group_id : str + Specifies the name of the consumer group a Kafka consumer belongs to. + client_id : str, default = None + An optional identifier of the consumer. + poll_interval : str + Seconds that elapse between polling Kafka for new messages. Follows the pandas interval format. + disable_commit : bool, default = False + Enabling this option will skip committing messages as they are pulled off the server. This is only useful for + debugging, allowing the user to process the same messages multiple times. + disable_pre_filtering : bool, default = False + Enabling this option will skip pre-filtering of json messages. This is only useful when inputs are known to be + valid json. + auto_offset_reset : `AutoOffsetReset`, case_sensitive = False + Sets the value for the configuration option 'auto.offset.reset'. See the kafka documentation for more + information on the effects of each value." + stop_after: int, default = 0 + Stops ingesting after emitting `stop_after` records (rows in the dataframe). Useful for testing. Disabled if `0` + async_commits: bool, default = True + Enable commits to be performed asynchronously. Ignored if `disable_commit` is `True`. + """ + + def __init__(self, + c: Config, + bootstrap_servers: str, + input_topic: str = "test_cm", + group_id: str = "morpheus", + client_id: str = None, + poll_interval: str = "10millis", + disable_commit: bool = False, + disable_pre_filtering: bool = False, + auto_offset_reset: AutoOffsetReset = AutoOffsetReset.LATEST, + stop_after: int = 0, + async_commits: bool = True): + + super().__init__(c, + bootstrap_servers, + input_topic, + group_id, + client_id, + poll_interval, + disable_commit, + disable_pre_filtering, + auto_offset_reset, + stop_after, + async_commits) + + @property + def name(self) -> str: + return "from-cm-kafka" + + def supports_cpp_node(self): + return False + + def _convert_to_df(self, buffer: StringIO) -> cudf.DataFrame: + + df = super()._convert_to_df(buffer, engine="pandas", lines=True, orient="records") + + return df + + def _source_generator(self): + + source_gen = super()._source_generator() + + for message_meta in source_gen: + + df = message_meta.df + + if "inputs" not in df.columns: + error_msg = "\nDataframe didn't have the required column `inputs`. Check the control message format." + logger.error(error_msg) + + continue + + num_rows = len(df) + + # Iterate over each row in a dataframe. + for i in range(num_rows): + msg_inputs = df.inputs.iloc[i] + # Iterate on inputs list for to generate a control message. + for msg_input in msg_inputs: + yield MessageControl(msg_input) + + def _build_source(self, builder: mrc.Builder) -> StreamPair: + + source = builder.make_source(self.unique_name, self._source_generator) + + return source, MessageControl diff --git a/morpheus/stages/input/kafka_source_stage.py b/morpheus/stages/input/kafka_source_stage.py index 3421d3dbf8..70a5a5f114 100644 --- a/morpheus/stages/input/kafka_source_stage.py +++ b/morpheus/stages/input/kafka_source_stage.py @@ -138,6 +138,16 @@ def stop(self): return super().stop() + def _convert_to_df(self, + buffer: StringIO, + engine: str = "cudf", + lines: bool = True, + orient: str = "records") -> cudf.DataFrame: + + df = cudf.io.read_json(buffer, engine=engine, lines=lines, orient=orient) + + return df + def _process_batch(self, consumer, batch): message_meta = None if len(batch): @@ -152,7 +162,7 @@ def _process_batch(self, consumer, batch): df = None try: buffer.seek(0) - df = cudf.io.read_json(buffer, engine='cudf', lines=True, orient='records') + df = self._convert_to_df(buffer=buffer) except Exception as e: logger.error("Error parsing payload into a dataframe : {}".format(e)) finally: From c61b4e789dd8c21a83f8ae4954c55b5e8db633e7 Mon Sep 17 00:00:00 2001 From: bsuryadevara Date: Sun, 5 Mar 2023 16:24:15 -0600 Subject: [PATCH 069/157] added kafka source stage for control messages --- .../input/control_message_source_stage.py | 76 ------------------- 1 file changed, 76 deletions(-) delete mode 100644 morpheus/stages/input/control_message_source_stage.py diff --git a/morpheus/stages/input/control_message_source_stage.py b/morpheus/stages/input/control_message_source_stage.py deleted file mode 100644 index c8e2c19ef1..0000000000 --- a/morpheus/stages/input/control_message_source_stage.py +++ /dev/null @@ -1,76 +0,0 @@ -# Copyright (c) 2022-2023, NVIDIA CORPORATION. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import json -import logging -import typing - -import fsspec -import fsspec.utils -import mrc - -from morpheus.config import Config -from morpheus.messages.message_control import MessageControl -from morpheus.pipeline.single_output_source import SingleOutputSource -from morpheus.pipeline.stream_pair import StreamPair - -logger = logging.getLogger("morpheus.{}".format(__name__)) - - -class ControlMessageSourceStage(SingleOutputSource): - """ - Source stage is used to recieve control messages from different sources. - - Parameters - ---------- - c : `morpheus.config.Config` - Pipeline configuration instance. - filenames : List[str] - List of paths to be read from, can be a list of S3 urls (`s3://path`) amd can include wildcard characters `*` - as defined by `fsspec`: - https://filesystem-spec.readthedocs.io/en/latest/api.html?highlight=open_files#fsspec.open_files - """ - - def __init__(self, c: Config, filenames: typing.List[str]): - super().__init__(c) - self._filenames = filenames - - @property - def name(self) -> str: - return "from-message-control" - - def supports_cpp_node(self): - return True - - def _create_control_message(self) -> MessageControl: - - openfiles: fsspec.core.OpenFiles = fsspec.open_files(self._filenames) - - if (len(openfiles) == 0): - raise RuntimeError(f"No files matched input strings: '{self._filenames}'. " - "Check your input pattern and ensure any credentials are correct") - - # TODO(Devin): Support multiple tasks in a single file - for openfile in openfiles: - with openfile as f: - message_configs = json.load(f) - for message_config in message_configs.get("inputs", []): - message_control = MessageControl(message_config) - yield message_control - - def _build_source(self, builder: mrc.Builder) -> StreamPair: - - out_stream = builder.make_source(self.unique_name, self._create_control_message()) - - return out_stream, fsspec.core.OpenFiles From de4edeadf1140a776d345dad2c37c9e50d717cd3 Mon Sep 17 00:00:00 2001 From: bsuryadevara Date: Sun, 5 Mar 2023 16:35:07 -0600 Subject: [PATCH 070/157] added kafka source stage for control messages --- morpheus/modules/file_batcher.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/morpheus/modules/file_batcher.py b/morpheus/modules/file_batcher.py index c81ffdb95f..5be548eb37 100644 --- a/morpheus/modules/file_batcher.py +++ b/morpheus/modules/file_batcher.py @@ -19,7 +19,6 @@ import fsspec import fsspec.utils import mrc -import cudf from mrc.core import operators as ops import cudf @@ -52,7 +51,6 @@ def file_batcher(builder: mrc.Builder): TimestampFileObj = namedtuple("TimestampFileObj", ["timestamp", "file_name"]) iso_date_regex_pattern = config.get("iso_date_regex_pattern", None) - task_type = config.get("task_type", None) start_time = config.get("start_time", None) end_time = config.get("end_time", None) sampling_rate_s = config.get("sampling_rate_s", None) @@ -98,8 +96,8 @@ def build_fs_filename_df(files): timestamps.append(ts) full_names.append(file_name) - df = pd.DataFrame() - # df = cudf.DataFrame() + # df = pd.DataFrame() + df = cudf.DataFrame() df["ts"] = timestamps df["key"] = full_names @@ -160,7 +158,10 @@ def add_ts_period(df): raise Exception("Unknown period") def on_data(control_message: MessageControl): - data_type = control_message.get_metadata("data_type") + mm = control_message.payload() + with mm.mutable_dataframe() as dfm: + files = dfm.files.to_arrow().to_pylist() + ts_filenames_df = build_fs_filename_df(files) control_messages = [] if len(ts_filenames_df) > 0: @@ -170,10 +171,9 @@ def on_data(control_message: MessageControl): period_gb = ts_filenames_df.groupby("period") n_groups = len(period_gb.groups) - logger.debug("Batching %d files => %d groups", len(ts_filenames_df), n_groups) + logger.debug("Batching %d files => %d groups", len(ts_filenames_df), n_groups) - control_messages = generate_cms_for_batch_periods(control_message, - period_gb, n_groups) + control_messages = generate_cms_for_batch_periods(control_message, period_gb, n_groups) return control_messages From e90c736c73a5a3f9029a6e5d825912ff53329da9 Mon Sep 17 00:00:00 2001 From: bsuryadevara Date: Sun, 5 Mar 2023 18:01:43 -0600 Subject: [PATCH 071/157] added control message filter module --- .../morpheus/dfp/modules/dfp_preproc.py | 7 +- .../morpheus/dfp/utils/config_generator.py | 13 ++-- morpheus/modules/filter_control_message.py | 69 +++++++++++++++++++ morpheus/utils/module_ids.py | 1 + 4 files changed, 85 insertions(+), 5 deletions(-) create mode 100644 morpheus/modules/filter_control_message.py diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_preproc.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_preproc.py index 741cd3c5b1..9a3ca09db2 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_preproc.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_preproc.py @@ -19,9 +19,11 @@ import morpheus.loaders.file_to_df_loader # noqa: F401 import morpheus.modules.file_batcher # noqa: F401 +import morpheus.modules.filter_control_message # noqa: F401 from morpheus.utils.loader_ids import FILE_TO_DF_LOADER from morpheus.utils.module_ids import DATA_LOADER from morpheus.utils.module_ids import FILE_BATCHER +from morpheus.utils.module_ids import FILTER_CONTROL_MESSAGE from morpheus.utils.module_ids import MODULE_NAMESPACE from morpheus.utils.module_utils import get_config_with_overrides from morpheus.utils.module_utils import get_module_config @@ -51,20 +53,23 @@ def dfp_preproc(builder: mrc.Builder): config["module_name"] = "dfp_preproc" config["namespace"] = MODULE_NAMESPACE + filter_control_message_conf = get_config_with_overrides(config, FILTER_CONTROL_MESSAGE, "filter_control_message") file_batcher_conf = get_config_with_overrides(config, FILE_BATCHER, "file_batcher") file_to_df_dataloader_conf = get_config_with_overrides(config, FILE_TO_DF_LOADER) file_to_df_dataloader_conf["module_id"] = DATA_LOADER # Work around some naming issues. dfp_split_users_conf = get_config_with_overrides(config, DFP_SPLIT_USERS, "dfp_split_users") # Load modules + filter_control_message_module = load_module(filter_control_message_conf, builder=builder) file_batcher_module = load_module(file_batcher_conf, builder=builder) file_to_df_dataloader_module = load_module(file_to_df_dataloader_conf, builder=builder) dfp_split_users_module = load_module(dfp_split_users_conf, builder=builder) # Make an edge between the modules. + builder.make_edge(filter_control_message_module.output_port("output"), file_batcher_module.input_port("input")) builder.make_edge(file_batcher_module.output_port("output"), file_to_df_dataloader_module.input_port("input")) builder.make_edge(file_to_df_dataloader_module.output_port("output"), dfp_split_users_module.input_port("input")) # Register input and output port for a module. - builder.register_module_input("input", file_batcher_module.input_port("input")) + builder.register_module_input("input", filter_control_message_module.input_port("input")) builder.register_module_output("output", dfp_split_users_module.output_port("output")) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py b/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py index 6bbe076b1f..be54ca4987 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py @@ -39,6 +39,7 @@ from morpheus.utils.loader_ids import FSSPEC_LOADER from morpheus.utils.module_ids import FILE_BATCHER from morpheus.utils.module_ids import FILE_TO_DF +from morpheus.utils.module_ids import FILTER_CONTROL_MESSAGE from morpheus.utils.module_ids import FILTER_DETECTIONS from morpheus.utils.module_ids import MLFLOW_MODEL_WRITER from morpheus.utils.module_ids import MODULE_NAMESPACE @@ -77,6 +78,9 @@ def fsspec_dataloader_module_conf(self): def infer_module_conf(self): module_conf = { DFP_PREPROC: { + FILTER_CONTROL_MESSAGE: { + "data_type": "streaming", "enable_task_check": True + }, FILE_BATCHER: { "period": "D", "sampling_rate_s": self._dfp_arg_parser.sample_rate_s, @@ -92,8 +96,7 @@ def infer_module_conf(self): "file_type": "JSON", "schema": { "schema_str": self._source_schema_str, "encoding": self._encoding - }, - "task_type": "inference" + } }, FILE_TO_DF_LOADER: { "loaders": [{ @@ -153,6 +156,9 @@ def infer_module_conf(self): def train_module_conf(self): module_conf = { DFP_PREPROC: { + FILTER_CONTROL_MESSAGE: { + "data_type": "streaming", "enable_task_check": True + }, FILE_BATCHER: { "period": "D", "sampling_rate_s": self._dfp_arg_parser.sample_rate_s, @@ -168,8 +174,7 @@ def train_module_conf(self): "file_type": "JSON", "schema": { "schema_str": self._source_schema_str, "encoding": self._encoding - }, - "task_type": "training" + } }, FILE_TO_DF_LOADER: { "loaders": [{ diff --git a/morpheus/modules/filter_control_message.py b/morpheus/modules/filter_control_message.py new file mode 100644 index 0000000000..9258964d14 --- /dev/null +++ b/morpheus/modules/filter_control_message.py @@ -0,0 +1,69 @@ +# Copyright (c) 2023, NVIDIA CORPORATION. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import logging + +import mrc +from mrc.core import operators as ops + +from morpheus.messages import MessageControl +from morpheus.utils.module_ids import FILTER_CONTROL_MESSAGE +from morpheus.utils.module_ids import MODULE_NAMESPACE +from morpheus.utils.module_utils import get_module_config +from morpheus.utils.module_utils import register_module + +logger = logging.getLogger(__name__) + + +@register_module(FILTER_CONTROL_MESSAGE, MODULE_NAMESPACE) +def filter_control_message(builder: mrc.Builder): + """ + When the requirements are met, this module gently discards the control messages. + + Parameters + ---------- + builder : mrc.Builder + mrc Builder object. + """ + + config = get_module_config(FILTER_CONTROL_MESSAGE, builder) + + filter = config.get("data_type", None) + enable_task_check = config.get("enable_task_check", False) + + def on_data(control_message: MessageControl): + data_type = control_message.get_metadata("data_type") + + if enable_task_check: + tasks = control_message.config().get("tasks") + + # Dispose messages if it has no tasks and it's data_type does not matches with filter. + if (not tasks and filter and data_type != filter): + return None + else: + # Regardless of whether tasks are present, discard messages + # if the data_type don't match the filter. + if filter and data_type != filter: + return None + + return control_message + + def node_fn(obs: mrc.Observable, sub: mrc.Subscriber): + obs.pipe(ops.map(on_data), ops.filter(lambda x: x is not None)).subscribe(sub) + + node = builder.make_node_full(FILTER_CONTROL_MESSAGE, node_fn) + + # Register input and output port for a module. + builder.register_module_input("input", node) + builder.register_module_output("output", node) diff --git a/morpheus/utils/module_ids.py b/morpheus/utils/module_ids.py index 81208e968f..321a86ae3b 100644 --- a/morpheus/utils/module_ids.py +++ b/morpheus/utils/module_ids.py @@ -15,6 +15,7 @@ MODULE_NAMESPACE = "morpheus" FILE_BATCHER = "FileBatcher" +FILTER_CONTROL_MESSAGE = "FilterControlMessage" FILE_TO_DF = "FileToDF" MLFLOW_MODEL_WRITER = "MLFlowModelWriter" SERIALIZE = "Serialize" From 1bf2047f065ab8f3bd87bccccf3da69c09d760d0 Mon Sep 17 00:00:00 2001 From: bsuryadevara Date: Mon, 6 Mar 2023 14:55:16 -0600 Subject: [PATCH 072/157] changes to control message kafka source --- .../benchmarks/test_bench_e2e_dfp_pipeline.py | 4 +- .../morpheus/dfp/utils/config_generator.py | 4 +- morpheus/modules/filter_control_message.py | 9 +- .../control_message_kafka_source_stage.py | 126 +++++++++++++----- morpheus/stages/input/kafka_source_stage.py | 12 +- 5 files changed, 99 insertions(+), 56 deletions(-) diff --git a/examples/digital_fingerprinting/production/morpheus/benchmarks/test_bench_e2e_dfp_pipeline.py b/examples/digital_fingerprinting/production/morpheus/benchmarks/test_bench_e2e_dfp_pipeline.py index c3116560da..8f68b2d4ae 100644 --- a/examples/digital_fingerprinting/production/morpheus/benchmarks/test_bench_e2e_dfp_pipeline.py +++ b/examples/digital_fingerprinting/production/morpheus/benchmarks/test_bench_e2e_dfp_pipeline.py @@ -44,7 +44,7 @@ from morpheus.pipeline.linear_pipeline import LinearPipeline from morpheus.pipeline.pipeline import Pipeline from morpheus.stages.general.multi_port_module_stage import MultiPortModuleStage -from morpheus.stages.input.control_message_source_stage import ControlMessageSourceStage +from morpheus.stages.input.control_message_file_source_stage import ControlMessageFileSourceStage from morpheus.stages.output.write_to_file_stage import WriteToFileStage from morpheus.stages.postprocess.filter_detections_stage import FilterDetectionsStage from morpheus.stages.postprocess.serialize_stage import SerializeStage @@ -72,7 +72,7 @@ def dfp_modules_pipeline(pipe_config: Config, pipeline = Pipeline(pipe_config) - source_stage = pipeline.add_stage(ControlMessageSourceStage(pipe_config, filenames=filenames)) + source_stage = pipeline.add_stage(ControlMessageFileSourceStage(pipe_config, filenames=filenames)) # Here we add a wrapped module that implements the DFP Deployment dfp_deployment_stage = pipeline.add_stage( diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py b/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py index be54ca4987..2d7206196e 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py @@ -79,7 +79,7 @@ def infer_module_conf(self): module_conf = { DFP_PREPROC: { FILTER_CONTROL_MESSAGE: { - "data_type": "streaming", "enable_task_check": True + "data_type": "streaming", "enable_task_check": True, "task_type": "inference" }, FILE_BATCHER: { "period": "D", @@ -157,7 +157,7 @@ def train_module_conf(self): module_conf = { DFP_PREPROC: { FILTER_CONTROL_MESSAGE: { - "data_type": "streaming", "enable_task_check": True + "data_type": "streaming", "enable_task_check": True, "task_type": "training" }, FILE_BATCHER: { "period": "D", diff --git a/morpheus/modules/filter_control_message.py b/morpheus/modules/filter_control_message.py index 9258964d14..0faa8e2818 100644 --- a/morpheus/modules/filter_control_message.py +++ b/morpheus/modules/filter_control_message.py @@ -41,15 +41,16 @@ def filter_control_message(builder: mrc.Builder): filter = config.get("data_type", None) enable_task_check = config.get("enable_task_check", False) + task_type = config.get("task_type", None) def on_data(control_message: MessageControl): data_type = control_message.get_metadata("data_type") if enable_task_check: - tasks = control_message.config().get("tasks") - - # Dispose messages if it has no tasks and it's data_type does not matches with filter. - if (not tasks and filter and data_type != filter): + # Verify if control message has expected task_type. + task_exist = control_message.has_task(task_type) + # Dispose messages if it has no expected task and it's data_type does not matches with filter. + if (not task_exist and filter and data_type != filter): return None else: # Regardless of whether tasks are present, discard messages diff --git a/morpheus/stages/input/control_message_kafka_source_stage.py b/morpheus/stages/input/control_message_kafka_source_stage.py index 185a99668a..43ef9f7ee0 100644 --- a/morpheus/stages/input/control_message_kafka_source_stage.py +++ b/morpheus/stages/input/control_message_kafka_source_stage.py @@ -12,26 +12,29 @@ # See the License for the specific language governing permissions and # limitations under the License. +import json import logging -from io import StringIO +import time +import typing +import confluent_kafka as ck import mrc - -import cudf +import pandas as pd from morpheus.cli.register_stage import register_stage from morpheus.config import Config from morpheus.config import PipelineModes from morpheus.messages.message_control import MessageControl +from morpheus.pipeline.preallocator_mixin import PreallocatorMixin +from morpheus.pipeline.single_output_source import SingleOutputSource from morpheus.pipeline.stream_pair import StreamPair from morpheus.stages.input.kafka_source_stage import AutoOffsetReset -from morpheus.stages.input.kafka_source_stage import KafkaSourceStage logger = logging.getLogger(__name__) @register_stage("from-cm-kafka", modes=[PipelineModes.AE]) -class ControlMessageKafkaSourceStage(KafkaSourceStage): +class ControlMessageKafkaSourceStage(PreallocatorMixin, SingleOutputSource): """ Load control messages from a Kafka cluster. @@ -78,17 +81,38 @@ def __init__(self, stop_after: int = 0, async_commits: bool = True): - super().__init__(c, - bootstrap_servers, - input_topic, - group_id, - client_id, - poll_interval, - disable_commit, - disable_pre_filtering, - auto_offset_reset, - stop_after, - async_commits) + super().__init__(c) + + if isinstance(auto_offset_reset, AutoOffsetReset): + auto_offset_reset = auto_offset_reset.value + + self._consumer_params = { + 'bootstrap.servers': bootstrap_servers, + 'group.id': group_id, + 'session.timeout.ms': "60000", + "auto.offset.reset": auto_offset_reset + } + if client_id is not None: + self._consumer_params['client.id'] = client_id + + self._topic = input_topic + # Setting max batch size to 1. As this source recieves only task defination (control messages) + self._max_batch_size = 1 + self._max_concurrent = c.num_threads + self._disable_commit = disable_commit + self._disable_pre_filtering = disable_pre_filtering + self._stop_after = stop_after + self._async_commits = async_commits + self._client = None + + # Flag to indicate whether or not we should stop + self._stop_requested = False + + self._poll_interval = pd.Timedelta(poll_interval).total_seconds() + self._started = False + + self._records_emitted = 0 + self._num_messages = 0 @property def name(self) -> str: @@ -97,34 +121,62 @@ def name(self) -> str: def supports_cpp_node(self): return False - def _convert_to_df(self, buffer: StringIO) -> cudf.DataFrame: - - df = super()._convert_to_df(buffer, engine="pandas", lines=True, orient="records") - - return df + def _process_msg(self, consumer, msg): - def _source_generator(self): - - source_gen = super()._source_generator() + control_messages = [] - for message_meta in source_gen: + payload = msg.value() + if payload is not None: - df = message_meta.df + try: + decoded_msg = payload.decode("utf-8") + control_messages_conf = json.loads(decoded_msg) + self._num_messages += 1 + for control_message_conf in control_messages_conf.get("inputs", []): + self._records_emitted += 1 + control_messages.append(MessageControl(control_message_conf)) + except Exception as e: + logger.error("\nError converting payload to MessageControl : {}".format(e)) - if "inputs" not in df.columns: - error_msg = "\nDataframe didn't have the required column `inputs`. Check the control message format." - logger.error(error_msg) + if (not self._disable_commit): + consumer.commit(message=msg, asynchronous=self._async_commits) - continue + if self._stop_after > 0 and self._records_emitted >= self._stop_after: + self._stop_requested = True - num_rows = len(df) + return control_messages - # Iterate over each row in a dataframe. - for i in range(num_rows): - msg_inputs = df.inputs.iloc[i] - # Iterate on inputs list to generate a control message. - for msg_input in msg_inputs: - yield MessageControl(msg_input) + def _source_generator(self): + consumer = None + try: + consumer = ck.Consumer(self._consumer_params) + consumer.subscribe([self._topic]) + + while not self._stop_requested: + + msg = consumer.poll(timeout=1.0) + if msg is None: + do_sleep = True + + else: + msg_error = msg.error() + if msg_error is None: + control_messages = self._process_msg(consumer, msg) + for control_message in control_messages: + yield control_message + + elif msg_error == ck.KafkaError._PARTITION_EOF: + do_sleep = True + else: + raise ck.KafkaException(msg_error) + + if do_sleep and not self._stop_requested: + time.sleep(self._poll_interval) + + finally: + # Close the consumer and call on_completed + if (consumer): + consumer.close() def _build_source(self, builder: mrc.Builder) -> StreamPair: diff --git a/morpheus/stages/input/kafka_source_stage.py b/morpheus/stages/input/kafka_source_stage.py index 70a5a5f114..3421d3dbf8 100644 --- a/morpheus/stages/input/kafka_source_stage.py +++ b/morpheus/stages/input/kafka_source_stage.py @@ -138,16 +138,6 @@ def stop(self): return super().stop() - def _convert_to_df(self, - buffer: StringIO, - engine: str = "cudf", - lines: bool = True, - orient: str = "records") -> cudf.DataFrame: - - df = cudf.io.read_json(buffer, engine=engine, lines=lines, orient=orient) - - return df - def _process_batch(self, consumer, batch): message_meta = None if len(batch): @@ -162,7 +152,7 @@ def _process_batch(self, consumer, batch): df = None try: buffer.seek(0) - df = self._convert_to_df(buffer=buffer) + df = cudf.io.read_json(buffer, engine='cudf', lines=True, orient='records') except Exception as e: logger.error("Error parsing payload into a dataframe : {}".format(e)) finally: From b04b58169c1902420cc1373a623565d529242e6a Mon Sep 17 00:00:00 2001 From: Devin Robison Date: Mon, 6 Mar 2023 17:15:51 -0700 Subject: [PATCH 073/157] Merge latest from Bhargav 716, locking down control messages --- .../benchmarks/resource/pipelines_conf.json | 2 +- .../include/morpheus/messages/control.hpp | 28 ++++++++- morpheus/_lib/src/messages/control.cpp | 63 ++++++++++++------- morpheus/_lib/src/python_modules/messages.cpp | 4 ++ .../tests/messages/test_control_message.cpp | 54 +++++++++------- tests/messages/test_control_message.py | 20 ++---- 6 files changed, 108 insertions(+), 63 deletions(-) diff --git a/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/pipelines_conf.json b/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/pipelines_conf.json index 73a6d5e268..772353a69e 100644 --- a/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/pipelines_conf.json +++ b/examples/digital_fingerprinting/production/morpheus/benchmarks/resource/pipelines_conf.json @@ -1,5 +1,5 @@ { - "tracking_uri": "http://localhost:8000", + "tracking_uri": "http://localhost:5000", "test_dfp_modules_azure_payload_inference_e2e": { "message_path": "./resource/control_messages/azure_payload_inference.json", "num_threads": 12, diff --git a/morpheus/_lib/include/morpheus/messages/control.hpp b/morpheus/_lib/include/morpheus/messages/control.hpp index c6129a48bb..f2240aa969 100644 --- a/morpheus/_lib/include/morpheus/messages/control.hpp +++ b/morpheus/_lib/include/morpheus/messages/control.hpp @@ -25,6 +25,14 @@ namespace morpheus { class MessageMeta; #pragma GCC visibility push(default) +enum class ControlMessageType +{ + CUSTOM, + DATA, + INFERENCE, + NONE, + TRAINING +}; class MessageControl { @@ -99,13 +107,29 @@ class MessageControl */ std::shared_ptr payload(); + /** + * @brief Get the type of the task + * @return ControlMessageType + */ + ControlMessageType task_type() const; + + /** + * @brief Set the task type for the control message + * @param task_type + * @return + */ + void task_type(ControlMessageType task_type); + private: static const std::string s_config_schema; // NOLINT + ControlMessageType m_cm_type{ControlMessageType::NONE}; std::shared_ptr m_payload{nullptr}; + std::map m_task_type_map{{"inference", ControlMessageType::INFERENCE}, + {"training", ControlMessageType::TRAINING}}; - std::map m_task_count{}; - nlohmann::json m_task_config{}; + nlohmann::json m_tasks{}; + nlohmann::json m_config{}; }; struct ControlMessageProxy diff --git a/morpheus/_lib/src/messages/control.cpp b/morpheus/_lib/src/messages/control.cpp index 50cf44a914..73ce297ffe 100644 --- a/morpheus/_lib/src/messages/control.cpp +++ b/morpheus/_lib/src/messages/control.cpp @@ -112,72 +112,83 @@ const std::string MessageControl::s_config_schema = R"( )"; MessageControl::MessageControl() : - m_task_config({{"tasks", nlohmann::json::array()}, {"metadata", nlohmann::json::object()}}) + m_config({{"metadata", nlohmann::json::object()}}) {} MessageControl::MessageControl(const nlohmann::json& _config) : - m_task_config({{"tasks", nlohmann::json::array()}, {"metadata", nlohmann::json::object()}}) + m_config({{"metadata", nlohmann::json::object()}}) { config(_config); } MessageControl::MessageControl(const MessageControl& other) { - m_task_config = other.m_task_config; - m_task_count = other.m_task_count; + m_config = other.m_config; + m_tasks = other.m_tasks; } const nlohmann::json& MessageControl::config() const { - return m_task_config; + return m_config; } void MessageControl::add_task(const std::string& task_type, const nlohmann::json& task) { // TODO(Devin) Schema check VLOG(20) << "Adding task of type " << task_type << " to control message" << task.dump(4); - m_task_count[task_type] += 1; - m_task_config["tasks"].push_back({{"type", task_type}, {"properties", task}}); + auto _task_type = m_task_type_map.contains(task_type) ? m_task_type_map[task_type] : ControlMessageType::NONE; + + if (this->task_type() == ControlMessageType::NONE) + { + this->task_type(_task_type); + } + + if (this->task_type() != _task_type) + { + throw std::runtime_error("Cannot add inference and training tasks to the same control message"); + } + + m_tasks[task_type].push_back(task); + // m_task_count[task_type] += 1; + // m_config["tasks"].push_back({{"type", task_type}, {"properties", task}}); } bool MessageControl::has_task(const std::string& task_type) const { - return m_task_count.contains(task_type) and m_task_count.at(task_type) > 0; + return m_tasks.contains(task_type) && m_tasks.at(task_type).size() > 0; } void MessageControl::set_metadata(const std::string& key, const nlohmann::json& value) { - if (m_task_config["metadata"].contains(key)) + if (m_config["metadata"].contains(key)) { LOG(WARNING) << "Overwriting metadata key " << key << " with value " << value; } - m_task_config["metadata"][key] = value; + m_config["metadata"][key] = value; } bool MessageControl::has_metadata(const std::string& key) const { - return m_task_config["metadata"].contains(key); + return m_config["metadata"].contains(key); } const nlohmann::json MessageControl::get_metadata(const std::string& key) const { - return m_task_config["metadata"].at(key); + return m_config["metadata"].at(key); } const nlohmann::json MessageControl::pop_task(const std::string& task_type) { - auto& tasks = m_task_config["tasks"]; - for (auto it = tasks.begin(); it != tasks.end(); ++it) + auto& task_set = m_tasks.at(task_type); + auto iter_task = task_set.begin(); + + if (iter_task != task_set.end()) { - if (it->at("type") == task_type) - { - auto task = *it; - tasks.erase(it); - m_task_count[task_type] -= 1; + auto task = *iter_task; + task_set.erase(iter_task); - return task["properties"]; - } + return task; } throw std::runtime_error("No tasks of type " + task_type + " found"); @@ -218,6 +229,16 @@ void MessageControl::payload(const std::shared_ptr& payload) m_payload = payload; } +ControlMessageType MessageControl::task_type() const +{ + return m_cm_type; +} + +void MessageControl::task_type(ControlMessageType type) +{ + m_cm_type = type; +} + /*** Proxy Implementations ***/ std::shared_ptr ControlMessageProxy::create(py::dict& config) diff --git a/morpheus/_lib/src/python_modules/messages.cpp b/morpheus/_lib/src/python_modules/messages.cpp index 66405c92ec..3602c32c24 100644 --- a/morpheus/_lib/src/python_modules/messages.cpp +++ b/morpheus/_lib/src/python_modules/messages.cpp @@ -130,6 +130,10 @@ PYBIND11_MODULE(messages, _module) mrc::edge::EdgeConnector, std::shared_ptr>::register_converter(); + py::enum_(_module, "ControlMessageType") + .value("INFERENCE", ControlMessageType::INFERENCE) + .value("TRAINING", ControlMessageType::TRAINING); + // TODO(Devin): Circle back on return value policy choices py::class_>(_module, "MessageControl") .def(py::init<>(), py::return_value_policy::reference_internal) diff --git a/morpheus/_lib/tests/messages/test_control_message.cpp b/morpheus/_lib/tests/messages/test_control_message.cpp index 778db10d0b..c2e4f2d569 100644 --- a/morpheus/_lib/tests/messages/test_control_message.cpp +++ b/morpheus/_lib/tests/messages/test_control_message.cpp @@ -40,14 +40,13 @@ TEST_F(TestControlMessage, InitializationTest) auto msg_two = MessageControl(config); - ASSERT_EQ(msg_two.config().contains("tasks"), true); + ASSERT_EQ(msg_two.has_task("load"), true); } TEST_F(TestControlMessage, SetMessageTest) { auto msg = MessageControl(); - ASSERT_EQ(msg.config().contains("tasks"), true); ASSERT_EQ(msg.config().contains("nope"), false); auto config = nlohmann::json(); @@ -60,14 +59,15 @@ TEST_F(TestControlMessage, SetMessageTest) msg.config(config); - ASSERT_EQ(msg.config().contains("tasks"), true); + ASSERT_EQ(msg.has_task("load"), true); } TEST_F(TestControlMessage, TaskTest) { - auto msg = MessageControl(); + auto msg_infer = MessageControl(); + auto msg_train = MessageControl(); - ASSERT_EQ(msg.config().contains("some_value"), false); + ASSERT_EQ(msg_infer.config().contains("some_value"), false); auto config = nlohmann::json(); nlohmann::json task_properties; @@ -75,31 +75,39 @@ TEST_F(TestControlMessage, TaskTest) {"loader_id", "payload"}, {"strategy", "aggregate"}, }; + config["type"] = "inference"; config["tasks"] = {{{"type", "load"}, {"properties", task_properties}}}; - msg.config(config); + msg_infer.config(config); - ASSERT_EQ(msg.config().contains("tasks"), true); - ASSERT_EQ(msg.has_task("load"), true); - ASSERT_EQ(msg.has_task("inference"), false); - ASSERT_EQ(msg.has_task("training"), false); - ASSERT_EQ(msg.has_task("custom"), false); + ASSERT_EQ(msg_infer.has_task("load"), true); + ASSERT_EQ(msg_infer.has_task("inference"), false); + ASSERT_EQ(msg_infer.has_task("training"), false); + ASSERT_EQ(msg_infer.has_task("custom"), false); + + msg_infer.add_task("inference", {}); + ASSERT_EQ(msg_infer.has_task("inference"), true); + + msg_infer.pop_task("inference"); + ASSERT_EQ(msg_infer.has_task("inference"), false); - msg.add_task("inference", {}); - ASSERT_EQ(msg.has_task("inference"), true); + ASSERT_THROW(msg_infer.add_task("training", {}), std::runtime_error); - msg.pop_task("inference"); - ASSERT_EQ(msg.has_task("inference"), false); + /* + config["type"] = "training"; + msg_train.config(config); + msg_train.add_task("training", {}); + ASSERT_EQ(msg_train.has_task("training"), true); + msg_train.pop_task("training"); + ASSERT_EQ(msg_train.has_task("training"), false); - msg.add_task("training", {}); - ASSERT_EQ(msg.has_task("training"), true); - msg.pop_task("training"); - ASSERT_EQ(msg.has_task("training"), false); + ASSERT_THROW(msg_train.add_task("inference", {}), std::runtime_error); - msg.add_task("custom", {}); - ASSERT_EQ(msg.has_task("custom"), true); - msg.pop_task("custom"); - ASSERT_EQ(msg.has_task("custom"), false); + msg_train.add_task("custom", {}); + ASSERT_EQ(msg_train.has_task("custom"), true); + msg_train.pop_task("custom"); + ASSERT_EQ(msg_train.has_task("custom"), false); + */ } TEST_F(TestControlMessage, PayloadTest) diff --git a/tests/messages/test_control_message.py b/tests/messages/test_control_message.py index 9da0404ced..1cd06b9d99 100644 --- a/tests/messages/test_control_message.py +++ b/tests/messages/test_control_message.py @@ -59,16 +59,10 @@ def test_control_message_get(): ) assert "test" not in raw_control_message.config() - assert "tasks" in raw_control_message.config() - tasks = raw_control_message.config()["tasks"] - assert len(tasks) == 1 - tasks[0]["type"] == "load" + assert(raw_control_message.has_task("load")) assert "test" not in control_message.config() - assert "tasks" in control_message.config() - tasks = control_message.config()["tasks"] - assert len(tasks) == 1 - tasks[0]["type"] == "load" + assert(control_message.has_task("load")) @pytest.mark.usefixtures("config_only_cpp") @@ -100,16 +94,10 @@ def test_control_message_set(): }) assert "test" not in raw_control_message.config() - assert "tasks" in raw_control_message.config() - tasks = raw_control_message.config()["tasks"] - assert len(tasks) == 1 - assert tasks[0]["type"] == "load" + assert (raw_control_message.has_task("load")) assert "test" not in control_message.config() - assert "tasks" in control_message.config() - tasks = control_message.config()["tasks"] - assert len(tasks) == 1 - assert tasks[0]["type"] == "load" + assert(control_message.has_task("load")) @pytest.mark.usefixtures("config_only_cpp") From 2f219c0adb4932dff0fe7e692769d630da1f70af Mon Sep 17 00:00:00 2001 From: Devin Robison Date: Mon, 6 Mar 2023 17:35:22 -0700 Subject: [PATCH 074/157] Fix control message typing issue --- morpheus/_lib/src/messages/control.cpp | 27 ++++++++++++------- .../tests/messages/test_control_message.cpp | 3 +-- 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/morpheus/_lib/src/messages/control.cpp b/morpheus/_lib/src/messages/control.cpp index 73ce297ffe..f7306ac89b 100644 --- a/morpheus/_lib/src/messages/control.cpp +++ b/morpheus/_lib/src/messages/control.cpp @@ -111,20 +111,17 @@ const std::string MessageControl::s_config_schema = R"( } )"; -MessageControl::MessageControl() : - m_config({{"metadata", nlohmann::json::object()}}) -{} +MessageControl::MessageControl() : m_config({{"metadata", nlohmann::json::object()}}) {} -MessageControl::MessageControl(const nlohmann::json& _config) : - m_config({{"metadata", nlohmann::json::object()}}) +MessageControl::MessageControl(const nlohmann::json& _config) : m_config({{"metadata", nlohmann::json::object()}}) { config(_config); } MessageControl::MessageControl(const MessageControl& other) { - m_config = other.m_config; - m_tasks = other.m_tasks; + m_config = other.m_config; + m_tasks = other.m_tasks; } const nlohmann::json& MessageControl::config() const @@ -143,14 +140,12 @@ void MessageControl::add_task(const std::string& task_type, const nlohmann::json this->task_type(_task_type); } - if (this->task_type() != _task_type) + if (_task_type != ControlMessageType::NONE and this->task_type() != _task_type) { throw std::runtime_error("Cannot add inference and training tasks to the same control message"); } m_tasks[task_type].push_back(task); - // m_task_count[task_type] += 1; - // m_config["tasks"].push_back({{"type", task_type}, {"properties", task}}); } bool MessageControl::has_task(const std::string& task_type) const @@ -196,6 +191,18 @@ const nlohmann::json MessageControl::pop_task(const std::string& task_type) void MessageControl::config(const nlohmann::json& config) { + if (config.contains("type")) + { + auto task_type = config.at("type"); + auto _task_type = + m_task_type_map.contains(task_type) ? m_task_type_map.at(task_type) : ControlMessageType::NONE; + + if (this->task_type() == ControlMessageType::NONE) + { + this->task_type(_task_type); + } + } + if (config.contains("tasks")) { auto& tasks = config["tasks"]; diff --git a/morpheus/_lib/tests/messages/test_control_message.cpp b/morpheus/_lib/tests/messages/test_control_message.cpp index c2e4f2d569..8a9b6b80b0 100644 --- a/morpheus/_lib/tests/messages/test_control_message.cpp +++ b/morpheus/_lib/tests/messages/test_control_message.cpp @@ -75,6 +75,7 @@ TEST_F(TestControlMessage, TaskTest) {"loader_id", "payload"}, {"strategy", "aggregate"}, }; + config["type"] = "inference"; config["tasks"] = {{{"type", "load"}, {"properties", task_properties}}}; @@ -93,7 +94,6 @@ TEST_F(TestControlMessage, TaskTest) ASSERT_THROW(msg_infer.add_task("training", {}), std::runtime_error); - /* config["type"] = "training"; msg_train.config(config); msg_train.add_task("training", {}); @@ -107,7 +107,6 @@ TEST_F(TestControlMessage, TaskTest) ASSERT_EQ(msg_train.has_task("custom"), true); msg_train.pop_task("custom"); ASSERT_EQ(msg_train.has_task("custom"), false); - */ } TEST_F(TestControlMessage, PayloadTest) From 3f3eb6530c283005c8aab9c2974f78e180df1f61 Mon Sep 17 00:00:00 2001 From: Devin Robison Date: Mon, 6 Mar 2023 17:59:53 -0700 Subject: [PATCH 075/157] Add task_type python bindings for control messages --- morpheus/_lib/include/morpheus/messages/control.hpp | 2 +- morpheus/_lib/src/messages/control.cpp | 2 +- morpheus/_lib/src/python_modules/messages.cpp | 3 +++ 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/morpheus/_lib/include/morpheus/messages/control.hpp b/morpheus/_lib/include/morpheus/messages/control.hpp index f2240aa969..1ec879c559 100644 --- a/morpheus/_lib/include/morpheus/messages/control.hpp +++ b/morpheus/_lib/include/morpheus/messages/control.hpp @@ -111,7 +111,7 @@ class MessageControl * @brief Get the type of the task * @return ControlMessageType */ - ControlMessageType task_type() const; + ControlMessageType task_type(); /** * @brief Set the task type for the control message diff --git a/morpheus/_lib/src/messages/control.cpp b/morpheus/_lib/src/messages/control.cpp index f7306ac89b..1fbe1e254b 100644 --- a/morpheus/_lib/src/messages/control.cpp +++ b/morpheus/_lib/src/messages/control.cpp @@ -236,7 +236,7 @@ void MessageControl::payload(const std::shared_ptr& payload) m_payload = payload; } -ControlMessageType MessageControl::task_type() const +ControlMessageType MessageControl::task_type() { return m_cm_type; } diff --git a/morpheus/_lib/src/python_modules/messages.cpp b/morpheus/_lib/src/python_modules/messages.cpp index 3602c32c24..be926a650f 100644 --- a/morpheus/_lib/src/python_modules/messages.cpp +++ b/morpheus/_lib/src/python_modules/messages.cpp @@ -132,6 +132,7 @@ PYBIND11_MODULE(messages, _module) py::enum_(_module, "ControlMessageType") .value("INFERENCE", ControlMessageType::INFERENCE) + .value("NONE", ControlMessageType::INFERENCE) .value("TRAINING", ControlMessageType::TRAINING); // TODO(Devin): Circle back on return value policy choices @@ -151,6 +152,8 @@ PYBIND11_MODULE(messages, _module) .def("add_task", &ControlMessageProxy::add_task, py::arg("task_type"), py::arg("task")) .def("has_task", &MessageControl::has_task, py::arg("task_type")) .def("pop_task", &ControlMessageProxy::pop_task, py::arg("task_type")) + .def("task_type", pybind11::overload_cast<>(&MessageControl::task_type)) + .def("task_type", pybind11::overload_cast(&MessageControl::task_type), py::arg("task_type")) .def("set_metadata", &ControlMessageProxy::set_metadata, py::arg("key"), py::arg("value")) .def("has_metadata", &MessageControl::has_metadata, py::arg("key")) .def("get_metadata", &ControlMessageProxy::get_metadata, py::arg("key")) From 3ebc9e7af6b7a11e0b64f2eef8100cc1eb0b4603 Mon Sep 17 00:00:00 2001 From: bsuryadevara Date: Tue, 7 Mar 2023 09:57:54 -0600 Subject: [PATCH 076/157] removed deprecated statements --- .../production/morpheus/dfp/modules/dfp_data_prep.py | 2 +- .../production/morpheus/dfp/modules/dfp_inference.py | 2 +- .../production/morpheus/dfp/modules/dfp_postprocessing.py | 2 +- .../production/morpheus/dfp/modules/dfp_rolling_window.py | 2 +- .../production/morpheus/dfp/modules/dfp_split_users.py | 8 +++++--- .../production/morpheus/dfp/modules/dfp_training.py | 2 +- .../production/morpheus/dfp/utils/model_cache.py | 2 +- morpheus/modules/file_batcher.py | 2 +- morpheus/modules/file_to_df.py | 2 +- morpheus/modules/filter_control_message.py | 3 ++- morpheus/modules/mlflow_model_writer.py | 4 ++-- morpheus/modules/write_to_file.py | 2 +- 12 files changed, 18 insertions(+), 15 deletions(-) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_data_prep.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_data_prep.py index 1f95402299..9565d8b2d5 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_data_prep.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_data_prep.py @@ -82,7 +82,7 @@ def process_features(message: MessageControl): def node_fn(obs: mrc.Observable, sub: mrc.Subscriber): obs.pipe(ops.map(process_features)).subscribe(sub) - node = builder.make_node_full(DFP_DATA_PREP, node_fn) + node = builder.make_node(DFP_DATA_PREP, mrc.core.operators.build(node_fn)) builder.register_module_input("input", node) builder.register_module_output("output", node) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference.py index 39668a62e7..d05f5e4f9f 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference.py @@ -129,7 +129,7 @@ def on_data(control_message: MessageControl): def node_fn(obs: mrc.Observable, sub: mrc.Subscriber): obs.pipe(ops.map(on_data), ops.flatten()).subscribe(sub) - node = builder.make_node_full(DFP_INFERENCE, node_fn) + node = builder.make_node(DFP_INFERENCE, mrc.core.operators.build(node_fn)) builder.register_module_input("input", node) builder.register_module_output("output", node) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_postprocessing.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_postprocessing.py index 7a4be7c193..e32ab6106f 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_postprocessing.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_postprocessing.py @@ -76,7 +76,7 @@ def on_data(message: MultiAEMessage): def node_fn(obs: mrc.Observable, sub: mrc.Subscriber): obs.pipe(ops.map(on_data), ops.filter(lambda x: x is not None)).subscribe(sub) - node = builder.make_node_full(DFP_POST_PROCESSING, node_fn) + node = builder.make_node(DFP_POST_PROCESSING, mrc.core.operators.build(node_fn)) builder.register_module_input("input", node) builder.register_module_output("output", node) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_rolling_window.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_rolling_window.py index 1784175bf4..6804456efa 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_rolling_window.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_rolling_window.py @@ -175,7 +175,7 @@ def on_data(control_message: MessageControl): def node_fn(obs: mrc.Observable, sub: mrc.Subscriber): obs.pipe(ops.map(on_data), ops.filter(lambda x: x is not None)).subscribe(sub) - node = builder.make_node_full(DFP_ROLLING_WINDOW, node_fn) + node = builder.make_node(DFP_ROLLING_WINDOW, mrc.core.operators.build(node_fn)) builder.register_module_input("input", node) builder.register_module_output("output", node) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_split_users.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_split_users.py index aacf6f3c80..f87057fe0e 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_split_users.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_split_users.py @@ -23,7 +23,8 @@ import cudf -from morpheus.messages import MessageControl, MessageMeta +from morpheus.messages import MessageControl +from morpheus.messages import MessageMeta from morpheus.utils.module_ids import MODULE_NAMESPACE from morpheus.utils.module_utils import get_module_config from morpheus.utils.module_utils import register_module @@ -88,7 +89,8 @@ def extract_users(control_message: MessageControl): if (include_individual): split_dataframes.update( - {username: user_df for username, user_df in df.groupby("username", sort=False)}) + {username: user_df + for username, user_df in df.groupby("username", sort=False)}) output_messages: typing.List[MessageControl] = [] @@ -134,7 +136,7 @@ def extract_users(control_message: MessageControl): def node_fn(obs: mrc.Observable, sub: mrc.Subscriber): obs.pipe(ops.map(extract_users), ops.flatten()).subscribe(sub) - node = builder.make_node_full(DFP_SPLIT_USERS, node_fn) + node = builder.make_node(DFP_SPLIT_USERS, mrc.core.operators.build(node_fn)) builder.register_module_input("input", node) builder.register_module_output("output", node) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training.py index 499707cb09..ce6f38284f 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training.py @@ -94,7 +94,7 @@ def on_data(control_message: MessageControl): def node_fn(obs: mrc.Observable, sub: mrc.Subscriber): obs.pipe(ops.map(on_data), ops.flatten(), ops.filter(lambda x: x is not None)).subscribe(sub) - node = builder.make_node_full(DFP_TRAINING, node_fn) + node = builder.make_node(DFP_TRAINING, mrc.core.operators.build(node_fn)) builder.register_module_input("input", node) builder.register_module_output("output", node) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/utils/model_cache.py b/examples/digital_fingerprinting/production/morpheus/dfp/utils/model_cache.py index 800f28fb6e..2e49f3e2c9 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/utils/model_cache.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/utils/model_cache.py @@ -216,7 +216,7 @@ def _model_exists(self, reg_model_name: str) -> bool: self._existing_models.clear() # Loop over the registered models with the pagination - while ((results := client.list_registered_models(max_results=1000, page_token=results.token)) + while ((results := client.search_registered_models(max_results=1000, page_token=results.token)) is not None): self._existing_models.update(model.name for model in results) diff --git a/morpheus/modules/file_batcher.py b/morpheus/modules/file_batcher.py index 5be548eb37..39fcdaae9f 100644 --- a/morpheus/modules/file_batcher.py +++ b/morpheus/modules/file_batcher.py @@ -180,7 +180,7 @@ def on_data(control_message: MessageControl): def node_fn(obs: mrc.Observable, sub: mrc.Subscriber): obs.pipe(ops.map(on_data), ops.flatten()).subscribe(sub) - node = builder.make_node_full(FILE_BATCHER, node_fn) + node = builder.make_node(FILE_BATCHER, mrc.core.operators.build(node_fn)) # Register input and output port for a module. builder.register_module_input("input", node) diff --git a/morpheus/modules/file_to_df.py b/morpheus/modules/file_to_df.py index 056e72d409..e7e8ec6c8f 100644 --- a/morpheus/modules/file_to_df.py +++ b/morpheus/modules/file_to_df.py @@ -255,7 +255,7 @@ def node_fn(obs: mrc.Observable, sub: mrc.Subscriber): if (download_method.startswith("dask")): dask_cluster = get_dask_cluster() - node = builder.make_node_full(FILE_TO_DF, node_fn) + node = builder.make_node(FILE_TO_DF, mrc.core.operators.build(node_fn)) # Register input and output port for a module. builder.register_module_input("input", node) diff --git a/morpheus/modules/filter_control_message.py b/morpheus/modules/filter_control_message.py index 0faa8e2818..a95d633d91 100644 --- a/morpheus/modules/filter_control_message.py +++ b/morpheus/modules/filter_control_message.py @@ -49,6 +49,7 @@ def on_data(control_message: MessageControl): if enable_task_check: # Verify if control message has expected task_type. task_exist = control_message.has_task(task_type) + # Dispose messages if it has no expected task and it's data_type does not matches with filter. if (not task_exist and filter and data_type != filter): return None @@ -63,7 +64,7 @@ def on_data(control_message: MessageControl): def node_fn(obs: mrc.Observable, sub: mrc.Subscriber): obs.pipe(ops.map(on_data), ops.filter(lambda x: x is not None)).subscribe(sub) - node = builder.make_node_full(FILTER_CONTROL_MESSAGE, node_fn) + node = builder.make_node(FILTER_CONTROL_MESSAGE, mrc.core.operators.build(node_fn)) # Register input and output port for a module. builder.register_module_input("input", node) diff --git a/morpheus/modules/mlflow_model_writer.py b/morpheus/modules/mlflow_model_writer.py index a9bb481ae6..511a7b3c66 100644 --- a/morpheus/modules/mlflow_model_writer.py +++ b/morpheus/modules/mlflow_model_writer.py @@ -116,7 +116,7 @@ def apply_model_permissions(reg_model_name: str): "access_control_list": [{ "group_name": group, "permission_level": permission } for group, - permission in databricks_permissions.items()] + permission in databricks_permissions.items()] } requests.patch(url=patch_registered_model_permissions_url, @@ -238,7 +238,7 @@ def on_data(message: MultiAEMessage): def node_fn(obs: mrc.Observable, sub: mrc.Subscriber): obs.pipe(ops.map(on_data), ops.filter(lambda x: x is not None)).subscribe(sub) - node = builder.make_node_full(MLFLOW_MODEL_WRITER, node_fn) + node = builder.make_node(MLFLOW_MODEL_WRITER, mrc.core.operators.build(node_fn)) # Register input and output port for a module. builder.register_module_input("input", node) diff --git a/morpheus/modules/write_to_file.py b/morpheus/modules/write_to_file.py index 5f3fb77ab1..e9eb36e5e8 100644 --- a/morpheus/modules/write_to_file.py +++ b/morpheus/modules/write_to_file.py @@ -111,7 +111,7 @@ def write_to_file(x: MessageMeta): # File should be closed by here - node = builder.make_node_full(WRITE_TO_FILE, node_fn) + node = builder.make_node(WRITE_TO_FILE, mrc.core.operators.build(node_fn)) # Register input and output port for a module. builder.register_module_input("input", node) From 44ac55a3e294ff29fb3807f8dc2cf7565f364377 Mon Sep 17 00:00:00 2001 From: bsuryadevara Date: Wed, 8 Mar 2023 18:32:50 -0600 Subject: [PATCH 077/157] dfp demo gui --- .../digital_fingerprinting/demo/bin/start.sh | 12 ++ .../demo/hil_app/__init__.py | 2 + .../demo/hil_app/kafka_helper.py | 25 ++++ .../demo/hil_app/static/styles.css | 116 ++++++++++++++++++ .../demo/hil_app/templates/home.html | 74 +++++++++++ .../demo/hil_app/views.py | 26 ++++ .../demo/hil_app/webapp.py | 3 + 7 files changed, 258 insertions(+) create mode 100644 examples/digital_fingerprinting/demo/bin/start.sh create mode 100644 examples/digital_fingerprinting/demo/hil_app/__init__.py create mode 100644 examples/digital_fingerprinting/demo/hil_app/kafka_helper.py create mode 100644 examples/digital_fingerprinting/demo/hil_app/static/styles.css create mode 100644 examples/digital_fingerprinting/demo/hil_app/templates/home.html create mode 100644 examples/digital_fingerprinting/demo/hil_app/views.py create mode 100644 examples/digital_fingerprinting/demo/hil_app/webapp.py diff --git a/examples/digital_fingerprinting/demo/bin/start.sh b/examples/digital_fingerprinting/demo/bin/start.sh new file mode 100644 index 0000000000..bf74806eaf --- /dev/null +++ b/examples/digital_fingerprinting/demo/bin/start.sh @@ -0,0 +1,12 @@ +#!/bin/sh + +export set FLASK_APP=webapp + +THIS_DIR=$( dirname -- "$( readlink -f -- "$0"; )"; ) + +APP_PATH="$THIS_DIR/../hil_app" + +#$(cd $APP_PATH && python -m flask run) + +# Run this command if default port is already being used. +$(cd $APP_PATH && python -m flask run -p 3000) diff --git a/examples/digital_fingerprinting/demo/hil_app/__init__.py b/examples/digital_fingerprinting/demo/hil_app/__init__.py new file mode 100644 index 0000000000..8acb4fef52 --- /dev/null +++ b/examples/digital_fingerprinting/demo/hil_app/__init__.py @@ -0,0 +1,2 @@ +import flask +app = flask.Flask(__name__) diff --git a/examples/digital_fingerprinting/demo/hil_app/kafka_helper.py b/examples/digital_fingerprinting/demo/hil_app/kafka_helper.py new file mode 100644 index 0000000000..961cfcfe5b --- /dev/null +++ b/examples/digital_fingerprinting/demo/hil_app/kafka_helper.py @@ -0,0 +1,25 @@ +from confluent_kafka import Producer + + +def delivery_report(err, msg): + """ Called once for each message produced to indicate delivery result. + Triggered by poll() or flush(). """ + if err is not None: + print('Message delivery failed: {}'.format(err)) + else: + print('Message delivered to {} [{}]'.format(msg.topic(), msg.partition())) + + +def publish_message(message): + p = Producer({'bootstrap.servers': 'localhost:9092'}) + + p.poll(0) + + # Asynchronously produce a message. The delivery report callback will + # be triggered from the call to poll() above, or flush() below, when the + # message has been successfully delivered or failed permanently. + p.produce('test_cm', message.encode('utf-8')) + + # Wait for any outstanding messages to be delivered and delivery report + # callbacks to be triggered. + p.flush() diff --git a/examples/digital_fingerprinting/demo/hil_app/static/styles.css b/examples/digital_fingerprinting/demo/hil_app/static/styles.css new file mode 100644 index 0000000000..ad3021f71b --- /dev/null +++ b/examples/digital_fingerprinting/demo/hil_app/static/styles.css @@ -0,0 +1,116 @@ +html, body { + min-height: 100%; + } + body, div, form, input, select, textarea, label { + padding: 0; + margin: 0; + outline: none; + font-family: Roboto, Arial, sans-serif; + font-size: 14px; + color: #666; + line-height: 22px; + } + h1 { + position: absolute; + margin: 0; + font-size: 50px; + color: #bdc0b5; + z-index: 2; + line-height: 83px; + } + legend { + padding: 10px; + font-family: Roboto, Arial, sans-serif; + font-size: 18px; + color: rgb(15, 15, 15); + background-color: #a6da2e; + } + textarea { + width: calc(100% - 12px); + padding: 5px; + } + .testbox { + display: flex; + justify-content: center; + align-items: center; + height: inherit; + padding: 20px; + } + form { + width: 100%; + padding: 20px; + border-radius: 6px; + background: #fff; + box-shadow: 0 0 8px #a6da2e; + } + .banner { + position:relative; + height: 200px; + max-width: 100%; + background: url("https://developer-blogs.nvidia.com/wp-content/uploads/2022/10/router-box-featured.jpg"); + background-size:auto; + display: flex; + justify-content: center; + align-items: center; + text-align: center; + } + .banner::after { + content: ""; + background-color: rgba(0, 0, 0, 0.4); + position: absolute; + width: 100%; + height: 100%; + } + input, select, textarea { + margin-bottom: 10px; + border: 1px solid #ccc; + border-radius: 3px; + } + input { + width: calc(100% - 10px); + padding: 5px; + } + .item input:hover, .item select:hover, .item textarea:hover { + border: 1px solid transparent; + box-shadow: 0 0 3px 0 #a6da2e; + color: #0a9b3a; + width: 1000px; + height: 300px; + text-align: left; + align-items: center; + } + .item { + position: relative; + margin: 10px 0; + } + .item span { + color: red; + } + + .btn-block { + margin-top: 10px; + text-align: center; + } + button { + width: 150px; + padding: 10px; + border: none; + border-radius: 5px; + background: #3a413c; + font-size: 16px; + color: #fff; + cursor: pointer; + } + #controlMessage { + display: none; + font-size: 1.0em; + color: red; + } + + #controlMessage.visible { + display: block; + } + + input.invalid { + border-color: red; + } diff --git a/examples/digital_fingerprinting/demo/hil_app/templates/home.html b/examples/digital_fingerprinting/demo/hil_app/templates/home.html new file mode 100644 index 0000000000..92f7b67547 --- /dev/null +++ b/examples/digital_fingerprinting/demo/hil_app/templates/home.html @@ -0,0 +1,74 @@ + + + + + DFP Integrated Training Demo + + +
+
+ +
+
+ Control Message Publisher +
+
+ +
+
+
+ +
+ +
+
+ + + diff --git a/examples/digital_fingerprinting/demo/hil_app/views.py b/examples/digital_fingerprinting/demo/hil_app/views.py new file mode 100644 index 0000000000..0c13696db8 --- /dev/null +++ b/examples/digital_fingerprinting/demo/hil_app/views.py @@ -0,0 +1,26 @@ +import json +import logging + +from flask import jsonify +from flask import render_template +from flask import request +from hil_app.kafka_helper import publish_message + +from . import app + +logger = logging.getLogger(__name__) + + +@app.route('/', methods=["GET", "POST"]) +def submit_messages(): + if request.method == "POST": + control_message = request.form.get("control_message") + logger.error(control_message) + publish_message(control_message) + data = { + "Data": "Successfully published task to kafka topic.", + } + return jsonify(data) + + if request.method == "GET": + return render_template("home.html") diff --git a/examples/digital_fingerprinting/demo/hil_app/webapp.py b/examples/digital_fingerprinting/demo/hil_app/webapp.py new file mode 100644 index 0000000000..09bd06088f --- /dev/null +++ b/examples/digital_fingerprinting/demo/hil_app/webapp.py @@ -0,0 +1,3 @@ +# Entry point for the application. +from . import app +from . import views From c0377f873d5cbc439ec5fdf8f1e742d9284f9288 Mon Sep 17 00:00:00 2001 From: Devin Robison Date: Thu, 9 Mar 2023 13:30:36 -0700 Subject: [PATCH 078/157] Checkpoint. Mostly through config rework --- .../morpheus/dfp/modules/dfp_data_prep.py | 29 +++-- .../morpheus/dfp/modules/dfp_deployment.py | 30 +++-- .../morpheus/dfp/modules/dfp_inference.py | 7 +- .../dfp/modules/dfp_inference_pipe.py | 65 ++++++----- .../dfp/modules/dfp_inference_pipeline.py | 103 ------------------ .../dfp/modules/dfp_model_train_deploy.py | 4 +- .../dfp/modules/dfp_postprocessing.py | 7 +- .../morpheus/dfp/modules/dfp_preproc.py | 76 +++++++++---- .../morpheus/dfp/modules/dfp_preprocessing.py | 6 +- .../dfp/modules/dfp_rolling_window.py | 84 ++++++++------ .../morpheus/dfp/modules/dfp_split_users.py | 30 +++-- .../morpheus/dfp/modules/dfp_training.py | 24 ++-- .../morpheus/dfp/modules/dfp_training_pipe.py | 94 ++++++++++++---- .../dfp/modules/dfp_training_pipeline.py | 88 --------------- .../morpheus/dfp/utils/cached_user_window.py | 3 + .../morpheus/dfp/utils/config_generator.py | 92 +++++++++++++++- .../production/morpheus/test_input.json | 24 ---- .../_lib/include/morpheus/io/data_loader.hpp | 12 +- .../morpheus/modules/data_loader_module.hpp | 5 + morpheus/_lib/src/io/data_loader.cpp | 3 +- morpheus/_lib/src/messages/control.cpp | 2 +- morpheus/_lib/src/python_modules/modules.cpp | 1 - morpheus/modules/file_batcher.py | 38 +++++-- morpheus/modules/file_to_df.py | 15 ++- morpheus/modules/filter_control_message.py | 46 +++++--- morpheus/modules/filter_detections.py | 6 +- morpheus/modules/mlflow_model_writer.py | 33 ++++-- morpheus/modules/serialize.py | 7 +- morpheus/modules/write_to_file.py | 8 +- morpheus/utils/module_ids.py | 2 +- morpheus/utils/module_utils.py | 34 ++++++ 31 files changed, 559 insertions(+), 419 deletions(-) delete mode 100644 examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference_pipeline.py delete mode 100644 examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training_pipeline.py delete mode 100644 examples/digital_fingerprinting/production/morpheus/test_input.json diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_data_prep.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_data_prep.py index 1f95402299..17a1fa16d9 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_data_prep.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_data_prep.py @@ -19,13 +19,10 @@ import mrc from mrc.core import operators as ops -import cudf - from morpheus.messages import MessageControl from morpheus.messages import MessageMeta from morpheus.utils.column_info import process_dataframe -from morpheus.utils.module_ids import MODULE_NAMESPACE -from morpheus.utils.module_utils import get_module_config +from morpheus.utils.module_ids import MORPHEUS_MODULE_NAMESPACE from morpheus.utils.module_utils import register_module from ..utils.module_ids import DFP_DATA_PREP @@ -33,7 +30,7 @@ logger = logging.getLogger("morpheus.{}".format(__name__)) -@register_module(DFP_DATA_PREP, MODULE_NAMESPACE) +@register_module(DFP_DATA_PREP, MORPHEUS_MODULE_NAMESPACE) def dfp_data_prep(builder: mrc.Builder): """ This module function prepares data for either inference or model training. @@ -42,17 +39,27 @@ def dfp_data_prep(builder: mrc.Builder): ---------- builder : mrc.Builder Pipeline budler instance. + + Notes + ---------- + Configurable parameters: + - schema: Schema of the data + - timestamp_column_name: Name of the timestamp column """ - config = get_module_config(DFP_DATA_PREP, builder) - schema_config = config.get("schema", None) - schema_str = schema_config.get("schema_str", None) - encoding = schema_config.get("encoding", None) - timestamp_column_name = config.get("timestamp_column_name", None) + config = builder.get_current_module_config() + + timestamp_column_name = config.get("timestamp_column_name", "timestamp") + + if ("schema" not in config): + raise ValueError("Data prep module requires a defined schema") + + schema_config = config["schema"] + schema_str = schema_config["schema_str"] + encoding = schema_config["encoding"] schema = pickle.loads(bytes(schema_str, encoding)) - # def process_features(message: MultiDFPMessage): def process_features(message: MessageControl): if (message is None): diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_deployment.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_deployment.py index 81040d2d96..7301133a12 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_deployment.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_deployment.py @@ -22,9 +22,8 @@ import morpheus.loaders.fsspec_loader # noqa: F401 from morpheus.utils.loader_ids import FSSPEC_LOADER from morpheus.utils.module_ids import DATA_LOADER -from morpheus.utils.module_ids import MODULE_NAMESPACE +from morpheus.utils.module_ids import MORPHEUS_MODULE_NAMESPACE from morpheus.utils.module_utils import get_config_with_overrides -from morpheus.utils.module_utils import get_module_config from morpheus.utils.module_utils import load_module from morpheus.utils.module_utils import register_module @@ -35,26 +34,33 @@ logger = logging.getLogger("morpheus.{}".format(__name__)) -@register_module(DFP_DEPLOYMENT, MODULE_NAMESPACE) +@register_module(DFP_DEPLOYMENT, MORPHEUS_MODULE_NAMESPACE) def dfp_deployment(builder: mrc.Builder): - module_config = get_module_config(DFP_DEPLOYMENT, builder) + # module_config = get_module_config(DFP_DEPLOYMENT, builder) + module_config = builder.get_current_module_config() - fsspec_dataloader_conf = get_config_with_overrides(module_config, FSSPEC_LOADER, "fsspec_dataloader") - fsspec_dataloader_conf["module_id"] = DATA_LOADER # Work around some naming issues. + # fsspec_dataloader_conf = get_config_with_overrides(module_config, FSSPEC_LOADER, "fsspec_dataloader") + fsspec_dataloader_conf = module_config[FSSPEC_LOADER] - dfp_training_pipe_conf = get_config_with_overrides(module_config, DFP_TRAINING_PIPE, "dfp_training_pipe") - dfp_inference_pipe_conf = get_config_with_overrides(module_config, DFP_INFERENCE_PIPE, "dfp_inference_pipe") + # dfp_training_pipe_conf = get_config_with_overrides(module_config, DFP_TRAINING_PIPE, "dfp_training_pipe") + dfp_training_pipe_conf = module_config[DFP_TRAINING_PIPE] + # dfp_inference_pipe_conf = get_config_with_overrides(module_config, DFP_INFERENCE_PIPE, "dfp_inference_pipe") + dfp_inference_pipe_conf = module_config[DFP_INFERENCE_PIPE] if "output_port_count" not in module_config: raise KeyError("Missing required configuration 'output_port_count'") output_port_count = module_config.get("output_port_count") - fsspec_dataloader_module = load_module(fsspec_dataloader_conf, builder=builder) - + # fsspec_dataloader_module = load_module(fsspec_dataloader_conf, builder=builder) + fsspec_dataloader_module = builder.load_module(DATA_LOADER, "morpheus", "fsspec_dataloader", + fsspec_dataloader_conf) # Load module from registry. - dfp_training_pipe_module = load_module(dfp_training_pipe_conf, builder=builder) - dfp_inference_pipe_module = load_module(dfp_inference_pipe_conf, builder=builder) + dfp_training_pipe_module = builder.load_module(DFP_TRAINING_PIPE, "morpheus", "dfp_training_pipe", + dfp_training_pipe_conf) + # dfp_inference_pipe_module = load_module(dfp_inference_pipe_conf, builder=builder) + dfp_inference_pipe_module = builder.load_module(DFP_INFERENCE_PIPE, "morpheus", "dfp_inference_pipe", + dfp_inference_pipe_conf) # Create broadcast node to fork the pipeline. boradcast = Broadcast(builder, "broadcast") diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference.py index 39668a62e7..2ba1cb2850 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference.py @@ -26,8 +26,7 @@ from morpheus.messages import MessageControl from morpheus.messages.multi_ae_message import MultiAEMessage -from morpheus.utils.module_ids import MODULE_NAMESPACE -from morpheus.utils.module_utils import get_module_config +from morpheus.utils.module_ids import MORPHEUS_MODULE_NAMESPACE from morpheus.utils.module_utils import register_module from ..messages.multi_dfp_message import DFPMessageMeta @@ -37,7 +36,7 @@ logger = logging.getLogger("morpheus.{}".format(__name__)) -@register_module(DFP_INFERENCE, MODULE_NAMESPACE) +@register_module(DFP_INFERENCE, MORPHEUS_MODULE_NAMESPACE) def dfp_inference(builder: mrc.Builder): """ Inference module function. @@ -48,7 +47,7 @@ def dfp_inference(builder: mrc.Builder): Pipeline budler instance. """ - config = get_module_config(DFP_INFERENCE, builder) + config = builder.get_current_module_config() fallback_user = config.get("fallback_username", None) model_name_formatter = config.get("model_name_formatter", None) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference_pipe.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference_pipe.py index b0fe7022ff..3d3796b800 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference_pipe.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference_pipe.py @@ -25,12 +25,10 @@ import morpheus.modules.serialize # noqa: F401 import morpheus.modules.write_to_file # noqa: F401 from morpheus.utils.module_ids import FILTER_DETECTIONS -from morpheus.utils.module_ids import MODULE_NAMESPACE +from morpheus.utils.module_ids import MORPHEUS_MODULE_NAMESPACE from morpheus.utils.module_ids import SERIALIZE from morpheus.utils.module_ids import WRITE_TO_FILE -from morpheus.utils.module_utils import get_config_with_overrides -from morpheus.utils.module_utils import get_module_config -from morpheus.utils.module_utils import load_module +from morpheus.utils.module_utils import merge_dictionaries from morpheus.utils.module_utils import register_module from ..utils.module_ids import DFP_DATA_PREP @@ -43,7 +41,7 @@ logger = logging.getLogger("morpheus.{}".format(__name__)) -@register_module(DFP_INFERENCE_PIPE, MODULE_NAMESPACE) +@register_module(DFP_INFERENCE_PIPE, MORPHEUS_MODULE_NAMESPACE) def dfp_inference_pipe(builder: mrc.Builder): """ This module function allows for the consolidation of multiple dfp pipeline modules relevent to inference @@ -55,29 +53,46 @@ def dfp_inference_pipe(builder: mrc.Builder): Pipeline budler instance. """ - config = get_module_config(DFP_INFERENCE_PIPE, builder) - config["module_id"] = DFP_INFERENCE_PIPE - config["namespace"] = MODULE_NAMESPACE - config["module_name"] = "dfp_inf" + config = builder.get_current_module_config() - preproc_conf = get_config_with_overrides(config, DFP_PREPROC, "dfp_preproc") - dfp_rolling_window_conf = get_config_with_overrides(config, DFP_ROLLING_WINDOW, "dfp_rolling_window") - dfp_data_prep_conf = get_config_with_overrides(config, DFP_DATA_PREP, "dfp_data_prep") - dfp_inference_conf = get_config_with_overrides(config, DFP_INFERENCE, "dfp_inference") - filter_detections_conf = get_config_with_overrides(config, FILTER_DETECTIONS, "filter_detections") - dfp_post_proc_conf = get_config_with_overrides(config, DFP_POST_PROCESSING, "dfp_postprocessing") - serialize_conf = get_config_with_overrides(config, SERIALIZE, "serialize") - write_to_file_conf = get_config_with_overrides(config, WRITE_TO_FILE, "write_to_file") + cache_dir = config.get("cache_dir") + ts_column_name = config.get("timestamp_column_name") + + preproc_options = { + "batching_options": config.get("batching_options", {}), + "cache_dir": cache_dir, + "pre_filter_options": { + "enable_task_filtering": True, + "filter_task_type": "inference" + }, + "timestamp_column_name": ts_column_name, + "user_splitting_options": config.get("user_splitting_options", {}), + } + + preproc_defaults = {} + preproc_conf = merge_dictionaries(preproc_options, preproc_defaults) + + dfp_rolling_window_conf = config[DFP_ROLLING_WINDOW] + dfp_data_prep_conf = config[DFP_DATA_PREP] + dfp_inference_conf = config[DFP_INFERENCE] + filter_detections_conf = config[FILTER_DETECTIONS] + dfp_post_proc_conf = config[DFP_POST_PROCESSING] + serialize_conf = config[SERIALIZE] + write_to_file_conf = config[WRITE_TO_FILE] # Load modules - preproc_module = load_module(preproc_conf, builder=builder) - dfp_rolling_window_module = load_module(dfp_rolling_window_conf, builder=builder) - dfp_data_prep_module = load_module(dfp_data_prep_conf, builder=builder) - dfp_inference_module = load_module(dfp_inference_conf, builder=builder) - filter_detections_module = load_module(filter_detections_conf, builder=builder) - dfp_post_proc_module = load_module(dfp_post_proc_conf, builder=builder) - serialize_module = load_module(serialize_conf, builder=builder) - write_to_file_module = load_module(write_to_file_conf, builder=builder) + preproc_module = builder.load_module(DFP_PREPROC, "morpheus", "dfp_preproc", preproc_conf) + dfp_rolling_window_module = builder.load_module(DFP_ROLLING_WINDOW, "morpheus", "dfp_rolling_window", + dfp_rolling_window_conf) + dfp_data_prep_module = builder.load_module(DFP_DATA_PREP, "morpheus", "dfp_data_prep", dfp_data_prep_conf) + dfp_inference_module = builder.load_module(DFP_INFERENCE, "morpheus", "dfp_inference", dfp_inference_conf) + filter_detections_module = builder.load_module(FILTER_DETECTIONS, "morpheus", "filter_detections", + filter_detections_conf) + dfp_post_proc_module = builder.load_module(DFP_POST_PROCESSING, "morpheus", "dfp_post_processing", + dfp_post_proc_conf) + serialize_module = builder.load_module(SERIALIZE, "morpheus", "serialize", serialize_conf) + write_to_file_module = builder.load_module(WRITE_TO_FILE, "morpheus", "write_to_file", + write_to_file_conf) # Make an edge between the modules. builder.make_edge(preproc_module.output_port("output"), dfp_rolling_window_module.input_port("input")) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference_pipeline.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference_pipeline.py deleted file mode 100644 index ec63c60c04..0000000000 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference_pipeline.py +++ /dev/null @@ -1,103 +0,0 @@ -# Copyright (c) 2022-2023, NVIDIA CORPORATION. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import logging - -import dfp.modules.dfp_data_prep # noqa: F401 -import dfp.modules.dfp_inference # noqa: F401 -import dfp.modules.dfp_postprocessing # noqa: F401 -import dfp.modules.dfp_rolling_window # noqa: F401 -import dfp.modules.dfp_split_users # noqa: F401 -import mrc - -import morpheus.modules.file_batcher # noqa: F401 -import morpheus.modules.file_to_df # noqa: F401 -import morpheus.modules.filter_detections # noqa: F401 -import morpheus.modules.serialize # noqa: F401 -import morpheus.modules.write_to_file # noqa: F401 -from morpheus.utils.module_ids import FILE_BATCHER -from morpheus.utils.module_ids import FILE_TO_DF -from morpheus.utils.module_ids import FILTER_DETECTIONS -from morpheus.utils.module_ids import MODULE_NAMESPACE -from morpheus.utils.module_ids import SERIALIZE -from morpheus.utils.module_ids import WRITE_TO_FILE -from morpheus.utils.module_utils import get_config_with_overrides -from morpheus.utils.module_utils import get_module_config -from morpheus.utils.module_utils import load_module -from morpheus.utils.module_utils import register_module - -from ..utils.module_ids import DFP_DATA_PREP -from ..utils.module_ids import DFP_INFERENCE -from ..utils.module_ids import DFP_INFERENCE_PIPELINE -from ..utils.module_ids import DFP_POST_PROCESSING -from ..utils.module_ids import DFP_ROLLING_WINDOW -from ..utils.module_ids import DFP_SPLIT_USERS - -logger = logging.getLogger("morpheus.{}".format(__name__)) - - -@register_module(DFP_INFERENCE_PIPELINE, MODULE_NAMESPACE) -def dfp_inference_pipeline(builder: mrc.Builder): - """ - This module function allows for the consolidation of multiple dfp pipeline modules relevent to inference - process into a single module. - - Parameters - ---------- - builder : mrc.Builder - Pipeline budler instance. - """ - - config = get_module_config(DFP_INFERENCE_PIPELINE, builder) - config["module_id"] = DFP_INFERENCE_PIPELINE - config["namespace"] = MODULE_NAMESPACE - config["module_name"] = "dfp_inference_pipeline" - - file_batcher_conf = get_config_with_overrides(config, FILE_BATCHER, "file_batcher") - file_to_df_conf = get_config_with_overrides(config, FILE_TO_DF, "file_to_df_dataloader") - dfp_split_users_conf = get_config_with_overrides(config, DFP_SPLIT_USERS, "dfp_split_users") - dfp_rolling_window_conf = get_config_with_overrides(config, DFP_ROLLING_WINDOW, "dfp_rolling_window_infer") - dfp_data_prep_conf = get_config_with_overrides(config, DFP_DATA_PREP, "dfp_data_prep") - dfp_inference_conf = get_config_with_overrides(config, DFP_INFERENCE, "dfp_inference") - filter_detections_conf = get_config_with_overrides(config, FILTER_DETECTIONS, "filter_detections") - dfp_post_proc_conf = get_config_with_overrides(config, DFP_POST_PROCESSING, "dfp_post_processing") - serialize_conf = get_config_with_overrides(config, SERIALIZE, "serialize") - write_to_file_conf = get_config_with_overrides(config, WRITE_TO_FILE, "write_to_file") - - # Load modules - file_batcher_module = load_module(file_batcher_conf, builder=builder) - file_to_dataframe_module = load_module(file_to_df_conf, builder=builder) - dfp_split_users_modules = load_module(dfp_split_users_conf, builder=builder) - dfp_rolling_window_module = load_module(dfp_rolling_window_conf, builder=builder) - dfp_data_prep_module = load_module(dfp_data_prep_conf, builder=builder) - dfp_inference_module = load_module(dfp_inference_conf, builder=builder) - filter_detections_module = load_module(filter_detections_conf, builder=builder) - dfp_post_proc_module = load_module(dfp_post_proc_conf, builder=builder) - serialize_module = load_module(serialize_conf, builder=builder) - write_to_file_module = load_module(write_to_file_conf, builder=builder) - - # Make an edge between the modules. - builder.make_edge(file_batcher_module.output_port("output"), file_to_dataframe_module.input_port("input")) - builder.make_edge(file_to_dataframe_module.output_port("output"), dfp_split_users_modules.input_port("input")) - builder.make_edge(dfp_split_users_modules.output_port("output"), dfp_rolling_window_module.input_port("input")) - builder.make_edge(dfp_rolling_window_module.output_port("output"), dfp_data_prep_module.input_port("input")) - builder.make_edge(dfp_data_prep_module.output_port("output"), dfp_inference_module.input_port("input")) - builder.make_edge(dfp_inference_module.output_port("output"), filter_detections_module.input_port("input")) - builder.make_edge(filter_detections_module.output_port("output"), dfp_post_proc_module.input_port("input")) - builder.make_edge(dfp_post_proc_module.output_port("output"), serialize_module.input_port("input")) - builder.make_edge(serialize_module.output_port("output"), write_to_file_module.input_port("input")) - - # Register input and output port for a module. - builder.register_module_input("input", file_batcher_module.input_port("input")) - builder.register_module_output("output", write_to_file_module.output_port("output")) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_model_train_deploy.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_model_train_deploy.py index 6e7d3006fb..3118be0c74 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_model_train_deploy.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_model_train_deploy.py @@ -19,7 +19,7 @@ import morpheus.modules.mlflow_model_writer # noqa: F401 from morpheus.utils.module_ids import MLFLOW_MODEL_WRITER -from morpheus.utils.module_ids import MODULE_NAMESPACE +from morpheus.utils.module_ids import MORPHEUS_MODULE_NAMESPACE from morpheus.utils.module_utils import get_module_config from morpheus.utils.module_utils import load_module from morpheus.utils.module_utils import register_module @@ -30,7 +30,7 @@ logger = logging.getLogger("morpheus.{}".format(__name__)) -@register_module(DFP_MODEL_TRAIN_DEPLOY, MODULE_NAMESPACE) +@register_module(DFP_MODEL_TRAIN_DEPLOY, MORPHEUS_MODULE_NAMESPACE) def dfp_model_train_deploy(builder: mrc.Builder): """ This module function allows for the consolidation of multiple dfp training and mlflow model deployment modules into diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_postprocessing.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_postprocessing.py index 7a4be7c193..14490c5092 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_postprocessing.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_postprocessing.py @@ -20,8 +20,7 @@ from mrc.core import operators as ops from morpheus.messages.multi_ae_message import MultiAEMessage -from morpheus.utils.module_ids import MODULE_NAMESPACE -from morpheus.utils.module_utils import get_module_config +from morpheus.utils.module_ids import MORPHEUS_MODULE_NAMESPACE from morpheus.utils.module_utils import register_module from ..utils.module_ids import DFP_POST_PROCESSING @@ -29,7 +28,7 @@ logger = logging.getLogger("morpheus.{}".format(__name__)) -@register_module(DFP_POST_PROCESSING, MODULE_NAMESPACE) +@register_module(DFP_POST_PROCESSING, MORPHEUS_MODULE_NAMESPACE) def dfp_postprocessing(builder: mrc.Builder): """ Postprocessing module function. @@ -40,7 +39,7 @@ def dfp_postprocessing(builder: mrc.Builder): Pipeline budler instance. """ - config = get_module_config(DFP_POST_PROCESSING, builder) + config = builder.get_current_module_config() timestamp_column_name = config.get("timestamp_column_name", None) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_preproc.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_preproc.py index 9a3ca09db2..1ae1b89c26 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_preproc.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_preproc.py @@ -20,14 +20,13 @@ import morpheus.loaders.file_to_df_loader # noqa: F401 import morpheus.modules.file_batcher # noqa: F401 import morpheus.modules.filter_control_message # noqa: F401 + from morpheus.utils.loader_ids import FILE_TO_DF_LOADER from morpheus.utils.module_ids import DATA_LOADER from morpheus.utils.module_ids import FILE_BATCHER from morpheus.utils.module_ids import FILTER_CONTROL_MESSAGE -from morpheus.utils.module_ids import MODULE_NAMESPACE -from morpheus.utils.module_utils import get_config_with_overrides -from morpheus.utils.module_utils import get_module_config -from morpheus.utils.module_utils import load_module +from morpheus.utils.module_ids import MORPHEUS_MODULE_NAMESPACE +from morpheus.utils.module_utils import merge_dictionaries from morpheus.utils.module_utils import register_module from ..utils.module_ids import DFP_PREPROC @@ -36,7 +35,7 @@ logger = logging.getLogger("morpheus.{}".format(__name__)) -@register_module(DFP_PREPROC, MODULE_NAMESPACE) +@register_module(DFP_PREPROC, MORPHEUS_MODULE_NAMESPACE) def dfp_preproc(builder: mrc.Builder): """ This module function allows for the consolidation of multiple dfp pipeline modules relevent to inference/training @@ -48,22 +47,57 @@ def dfp_preproc(builder: mrc.Builder): Pipeline budler instance. """ - config = get_module_config(DFP_PREPROC, builder) - config["module_id"] = DFP_PREPROC - config["module_name"] = "dfp_preproc" - config["namespace"] = MODULE_NAMESPACE - - filter_control_message_conf = get_config_with_overrides(config, FILTER_CONTROL_MESSAGE, "filter_control_message") - file_batcher_conf = get_config_with_overrides(config, FILE_BATCHER, "file_batcher") - file_to_df_dataloader_conf = get_config_with_overrides(config, FILE_TO_DF_LOADER) - file_to_df_dataloader_conf["module_id"] = DATA_LOADER # Work around some naming issues. - dfp_split_users_conf = get_config_with_overrides(config, DFP_SPLIT_USERS, "dfp_split_users") - - # Load modules - filter_control_message_module = load_module(filter_control_message_conf, builder=builder) - file_batcher_module = load_module(file_batcher_conf, builder=builder) - file_to_df_dataloader_module = load_module(file_to_df_dataloader_conf, builder=builder) - dfp_split_users_module = load_module(dfp_split_users_conf, builder=builder) + config = builder.get_current_module_config() + + cache_dir = config.get("cache_dir", None) + ts_column_name = config.get("timestamp_column_name", None) + + pre_filter_options = config.get("pre_filter_options", {}) + + batching_opts = config.get("batching_options", {}) + batching_opts["cache_dir"] = cache_dir + batching_opts["timestamp_column_name"] = ts_column_name + + splitting_opts = config.get("user_splitting_options", {}) + splitting_opts["cache_dir"] = cache_dir + splitting_opts["timestamp_column_name"] = ts_column_name + + supported_loaders = config.get("supported_loaders", {}) + + pre_filter_default = {} + pre_filter_conf = merge_dictionaries(pre_filter_options, pre_filter_default) + + # Double check on how 'batcher_config' is used in the file_batcher module. + batching_opts_default = { + "file_type": "JSON", + "filter_null": True, + "parser_kwargs": { + "lines": False, "orient": "records" + }, + } + file_batcher_conf = merge_dictionaries(batching_opts, batching_opts_default) + + file_to_df_defaults = { + "loaders": [{ + "id": FILE_TO_DF_LOADER + }], + } + file_to_df_conf = merge_dictionaries(supported_loaders, file_to_df_defaults) + + dfp_split_users_default = { + "fallback_username": config.get("fallback_username", "generic") + } + dfp_split_users_conf = merge_dictionaries(splitting_opts, dfp_split_users_default) + + filter_control_message_module = builder.load_module(FILTER_CONTROL_MESSAGE, "morpheus", "filter_control_message", + pre_filter_conf) + import json + # print(f"Creating file batcher module with config: {json.dumps(file_batcher_conf, indent=2)}", flush=True) + file_batcher_module = builder.load_module(FILE_BATCHER, "morpheus", "file_batcher", file_batcher_conf) + file_to_df_dataloader_module = builder.load_module(DATA_LOADER, "morpheus", "dfp_file_to_df_dataloader", + file_to_df_conf) + dfp_split_users_module = builder.load_module(DFP_SPLIT_USERS, "morpheus", "dfp_split_users", + dfp_split_users_conf) # Make an edge between the modules. builder.make_edge(filter_control_message_module.output_port("output"), file_batcher_module.input_port("input")) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_preprocessing.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_preprocessing.py index 9e6e940dc5..5c2005c015 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_preprocessing.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_preprocessing.py @@ -23,7 +23,7 @@ import morpheus.modules.file_to_df # noqa: F401 from morpheus.utils.module_ids import FILE_BATCHER from morpheus.utils.module_ids import FILE_TO_DF -from morpheus.utils.module_ids import MODULE_NAMESPACE +from morpheus.utils.module_ids import MORPHEUS_MODULE_NAMESPACE from morpheus.utils.module_utils import get_module_config from morpheus.utils.module_utils import get_config_with_overrides from morpheus.utils.module_utils import load_module @@ -37,7 +37,7 @@ logger = logging.getLogger("morpheus.{}".format(__name__)) -@register_module(DFP_PREPROCESSING, MODULE_NAMESPACE) +@register_module(DFP_PREPROCESSING, MORPHEUS_MODULE_NAMESPACE) def dfp_preprocessing(builder: mrc.Builder): """ This module function allows for the consolidation of multiple dfp pipeline preprocessing modules @@ -51,7 +51,7 @@ def dfp_preprocessing(builder: mrc.Builder): config = get_module_config(DFP_PREPROCESSING, builder) config["module_id"] = DFP_PREPROCESSING - config["namespace"] = MODULE_NAMESPACE + config["namespace"] = MORPHEUS_MODULE_NAMESPACE config["module_name"] = "dfp_preprocessing" file_batcher_conf = get_config_with_overrides(config, FILE_BATCHER, "file_batcher") diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_rolling_window.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_rolling_window.py index 1784175bf4..518e6a35d2 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_rolling_window.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_rolling_window.py @@ -27,8 +27,7 @@ from morpheus.messages import MessageControl from morpheus.messages import MessageMeta -from morpheus.utils.module_ids import MODULE_NAMESPACE -from morpheus.utils.module_utils import get_module_config +from morpheus.utils.module_ids import MORPHEUS_MODULE_NAMESPACE from morpheus.utils.module_utils import register_module from ..utils.module_ids import DFP_ROLLING_WINDOW @@ -36,7 +35,7 @@ logger = logging.getLogger("morpheus.{}".format(__name__)) -@register_module(DFP_ROLLING_WINDOW, MODULE_NAMESPACE) +@register_module(DFP_ROLLING_WINDOW, MORPHEUS_MODULE_NAMESPACE) def dfp_rolling_window(builder: mrc.Builder): """ This module function establishes a rolling window to maintain history. @@ -44,17 +43,31 @@ def dfp_rolling_window(builder: mrc.Builder): Parameters ---------- builder : mrc.Builder - Pipeline budler instance. + Pipeline builder instance. + + Notes + ---------- + Configurable parameters: + - aggregation_span: The span of time to aggregate over + - cache_dir: Directory to cache the rolling window data + - cache_to_disk: Whether to cache the rolling window data to disk + - timestamp_column_name: Name of the timestamp column + - trigger_on_min_history: Minimum number of rows to trigger the rolling window + - trigger_on_min_increment: Minimum number of rows to trigger the rolling window """ - config = get_module_config(DFP_ROLLING_WINDOW, builder) - timestamp_column_name = config.get("timestamp_column_name", None) - min_history = config.get("min_history", None) - max_history = config.get("max_history", None) - min_increment = config.get("min_increment", None) - cache_dir = config.get("cache_dir", None) + config = builder.get_current_module_config() - cache_dir = os.path.join(cache_dir, "rolling-user-data") + timestamp_column_name = config.get("timestamp_column_name", "timestamp") + + min_history = config.get("trigger_on_min_history", 1) + min_increment = config.get("trigger_on_min_increment", 0) + aggregation_span = config.get("aggregation_span", "360d") + + cache_to_disk = config.get("cache_to_disk", False) + cache_dir = config.get("cache_dir", None) + if (cache_dir is not None): + cache_dir = os.path.join(cache_dir, "rolling-user-data") user_cache_map: typing.Dict[str, CachedUserWindow] = {} @@ -75,13 +88,14 @@ def get_user_cache(user_id: str): yield user_cache - def build_window(message: MessageMeta, user_id: str) -> MessageMeta: + def try_build_window(message: MessageMeta, user_id: str) -> typing.Union[MessageMeta, None]: with get_user_cache(user_id) as user_cache: # incoming_df = message.get_df() with message.mutable_dataframe() as dfm: incoming_df = dfm.to_pandas() + # TODO(Devin): note cuDF does not support tz aware datetimes (?) incoming_df[timestamp_column_name] = pd.to_datetime(incoming_df[timestamp_column_name], utc=True) if (not user_cache.append_dataframe(incoming_df=incoming_df)): @@ -90,10 +104,11 @@ def build_window(message: MessageMeta, user_id: str) -> MessageMeta: "Consider deleting the rolling window cache and restarting.")) return None - user_cache.save() - logger.debug("Saved rolling window cache for %s == %d items", user_id, user_cache.total_count) + if (cache_to_disk and cache_dir is not None): + logger.debug("Saved rolling window cache for %s == %d items", user_id, user_cache.total_count) + user_cache.save() - # Exit early if we dont have enough data + # Exit early if we don't have enough data if (user_cache.count < min_history): logger.debug("Not enough data to train") return None @@ -103,20 +118,20 @@ def build_window(message: MessageMeta, user_id: str) -> MessageMeta: logger.debug("Elapsed time since last train is too short") return None - # Save the last train statistics - train_df = user_cache.get_train_df(max_history=max_history) + # Obtain a dataframe spanning the aggregation window + df_window = user_cache.get_spanning_df(max_history=aggregation_span) # Hash the incoming data rows to find a match incoming_hash = pd.util.hash_pandas_object(incoming_df.iloc[[0, -1]], index=False) # Find the index of the first and last row - match = train_df[train_df["_row_hash"] == incoming_hash.iloc[0]] + match = df_window[df_window["_row_hash"] == incoming_hash.iloc[0]] if (len(match) == 0): raise RuntimeError("Invalid rolling window") first_row_idx = match.index[0].item() - last_row_idx = train_df[train_df["_row_hash"] == incoming_hash.iloc[-1]].index[-1].item() + last_row_idx = df_window[df_window["_row_hash"] == incoming_hash.iloc[-1]].index[-1].item() found_count = (last_row_idx - first_row_idx) + 1 @@ -125,23 +140,25 @@ def build_window(message: MessageMeta, user_id: str) -> MessageMeta: "Rolling history can only be used with non-overlapping batches")) # TODO(Devin): Optimize - return MessageMeta(cudf.from_pandas(train_df)) + return MessageMeta(cudf.from_pandas(df_window)) def on_data(control_message: MessageControl): payload = control_message.payload() user_id = control_message.get_metadata("user_id") - data_type = "streaming" + # TODO(Devin): Require data type to be set if (control_message.has_metadata("data_type")): data_type = control_message.get_metadata("data_type") + else: + data_type = "streaming" - # If we're an explicit training or inference task, then we dont need to do any rolling window logic + # If we're an explicit training or inference task, then we don't need to do any rolling window logic if (data_type == "payload"): return control_message elif (data_type == "streaming"): with log_time(logger.debug) as log_info: - result = build_window(payload, user_id) # Return a MessageMeta + result = try_build_window(payload, user_id) # Return a MessageMeta if (result is not None): log_info.set_log( @@ -156,19 +173,22 @@ def on_data(control_message: MessageControl): result.df[timestamp_column_name].max(), ) else: - # Dont print anything + # Result is None indicates that we don't have enough data to build payload for the event + # CM is discarded here log_info.disable() return None + # TODO (bhargav) Check if we need to pass control_message config to data_prep module. # If no config is passed there won't be any tasks to perform in the DataPrep stage. - rw_control_message = MessageControl(control_message.config()) - rw_control_message.payload(result) - # TODO(Devin): Configure based on module config - # TODO(Devin): Stop using dfp rolling window for inference, it makes zero sense - rw_control_message.set_metadata("user_id", user_id) - rw_control_message.set_metadata("data_type", "payload") - - return rw_control_message + # TODO(Devin): requires a bit more thought, should be safe to re-use the control message here, but + # I'm not 100 percent sure + + control_message.payload(result) + # Don't need this? control_message.set_metadata("user_id", user_id) + # Update data type to payload and forward + control_message.set_metadata("data_type", "payload") + + return control_message else: raise RuntimeError("Unknown data type") diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_split_users.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_split_users.py index aacf6f3c80..edc65de0d5 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_split_users.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_split_users.py @@ -24,8 +24,7 @@ import cudf from morpheus.messages import MessageControl, MessageMeta -from morpheus.utils.module_ids import MODULE_NAMESPACE -from morpheus.utils.module_utils import get_module_config +from morpheus.utils.module_ids import MORPHEUS_MODULE_NAMESPACE from morpheus.utils.module_utils import register_module from ..utils.module_ids import DFP_SPLIT_USERS @@ -33,7 +32,7 @@ logger = logging.getLogger("morpheus.{}".format(__name__)) -@register_module(DFP_SPLIT_USERS, MODULE_NAMESPACE) +@register_module(DFP_SPLIT_USERS, MORPHEUS_MODULE_NAMESPACE) def dfp_split_users(builder: mrc.Builder): """ This module function split the data based on user Id's. @@ -42,18 +41,35 @@ def dfp_split_users(builder: mrc.Builder): ---------- builder : mrc.Builder Pipeline budler instance. + + Notes + ---------- + Configurable parameters: + - fallback_username: Name of the user Id to use if the user Id is not found + - include_generic: Whether to include the generic user Id + - include_individual: Whether to include the individual user Id's + - only_users: List of user Id's to include + - skip_users: List of user Id's to skip + - timestamp_column_name: Name of the timestamp column + - userid_column_name: Name of the user Id column """ - config = get_module_config(DFP_SPLIT_USERS, builder) + config = builder.get_current_module_config() skip_users = config.get("skip_users", []) only_users = config.get("only_users", []) - timestamp_column_name = config.get("timestamp_column_name", None) - userid_column_name = config.get("userid_column_name", None) - fallback_username = config.get("fallback_username", None) + + timestamp_column_name = config.get("timestamp_column_name", "timestamp") + userid_column_name = config.get("userid_column_name", "username") include_generic = config.get("include_generic", False) include_individual = config.get("include_individual", False) + if (include_generic): + # TODO(Devin): Should this be an error? + # if not "fallback_username" in config: + # raise ValueError("fallback_username must be specified if include_generic is True") + fallback_username = config.get("fallback_username", "generic") + # Map of user ids to total number of messages. Keep indexes monotonic and increasing per user user_index_map: typing.Dict[str, int] = {} diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training.py index 499707cb09..e7549448df 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training.py @@ -22,8 +22,7 @@ from morpheus.messages.message_control import MessageControl from morpheus.messages.multi_ae_message import MultiAEMessage -from morpheus.utils.module_ids import MODULE_NAMESPACE -from morpheus.utils.module_utils import get_module_config +from morpheus.utils.module_ids import MORPHEUS_MODULE_NAMESPACE from morpheus.utils.module_utils import register_module from ..messages.multi_dfp_message import DFPMessageMeta @@ -33,7 +32,7 @@ logger = logging.getLogger("morpheus.{}".format(__name__)) -@register_module(DFP_TRAINING, MODULE_NAMESPACE) +@register_module(DFP_TRAINING, MORPHEUS_MODULE_NAMESPACE) def dfp_training(builder: mrc.Builder): """ Model training is done using this module function. @@ -42,14 +41,25 @@ def dfp_training(builder: mrc.Builder): ---------- builder : mrc.Builder Pipeline budler instance. + + Notes + ---------- + Configurable parameters: + - feature_columns: List of feature columns to train on + - epochs: Number of epochs to train for + - model_kwargs: Keyword arguments to pass to the model (see dfencoder.AutoEncoder) + - validation_size: Size of the validation set """ - config = get_module_config(DFP_TRAINING, builder) + config = builder.get_current_module_config() + + if ("feature_columns" not in config): + raise ValueError("Training module requires feature_columns to be configured") - feature_columns = config.get("feature_columns", None) + epochs = config.get("epochs", 1) + feature_columns = config["feature_columns"] + model_kwargs = config.get("model_kwargs", {}) validation_size = config.get("validation_size", 0.0) - epochs = config.get("epochs", None) - model_kwargs = config.get("model_kwargs", None) if (validation_size > 0.0 and validation_size < 1.0): validation_size = validation_size diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training_pipe.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training_pipe.py index d265394392..f7c1a545e7 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training_pipe.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training_pipe.py @@ -23,10 +23,8 @@ import morpheus._lib.modules # noqa: F401 import morpheus.modules.mlflow_model_writer # noqa: F401 from morpheus.utils.module_ids import MLFLOW_MODEL_WRITER -from morpheus.utils.module_ids import MODULE_NAMESPACE -from morpheus.utils.module_utils import get_config_with_overrides -from morpheus.utils.module_utils import get_module_config -from morpheus.utils.module_utils import load_module +from morpheus.utils.module_ids import MORPHEUS_MODULE_NAMESPACE +from morpheus.utils.module_utils import merge_dictionaries from morpheus.utils.module_utils import register_module from ..utils.module_ids import DFP_DATA_PREP @@ -38,7 +36,7 @@ logger = logging.getLogger("morpheus.{}".format(__name__)) -@register_module(DFP_TRAINING_PIPE, MODULE_NAMESPACE) +@register_module(DFP_TRAINING_PIPE, MORPHEUS_MODULE_NAMESPACE) def dfp_training_pipe(builder: mrc.Builder): """ This module function allows for the consolidation of multiple dfp pipeline modules relevent to training @@ -50,23 +48,81 @@ def dfp_training_pipe(builder: mrc.Builder): Pipeline budler instance. """ - config = get_module_config(DFP_TRAINING_PIPE, builder) - config["module_id"] = DFP_TRAINING_PIPE - config["namespace"] = MODULE_NAMESPACE - config["module_name"] = "dfp_tra" + config = builder.get_current_module_config() - preproc_conf = get_config_with_overrides(config, DFP_PREPROC, "dfp_preproc") - dfp_rolling_window_conf = get_config_with_overrides(config, DFP_ROLLING_WINDOW, "dfp_rolling_window") - dfp_data_prep_conf = get_config_with_overrides(config, DFP_DATA_PREP, "dfp_data_prep") - dfp_training_conf = get_config_with_overrides(config, DFP_TRAINING, "dfp_training") - mlflow_model_writer_conf = get_config_with_overrides(config, MLFLOW_MODEL_WRITER, "mlflow_model_writer") + cache_dir = config.get("cache_dir") + ts_column_name = config.get("timestamp_column_name") + + preproc_options = { + "batching_options": config.get("batching_options", {}), + "cache_dir": cache_dir, + "pre_filter_options": { + "enable_task_filtering": True, + "filter_task_type": "training" + }, + "timestamp_column_name": ts_column_name, + "user_splitting_options": config.get("user_splitting_options", {}), + } + + stream_aggregation_options = config.get("stream_aggregation_options", { + "timestamp_column_name": ts_column_name, + }) + + data_prep_options = config.get("preprocessing_options", { + "timestamp_column_name": ts_column_name, + }) + + dfencoder_options = config.get("dfencoder_options", {}) + + mlflow_writer_options = config.get("mlflow_writer_options", { + "timestamp_column_name": ts_column_name, + }) + + preproc_defaults = {} + preproc_conf = merge_dictionaries(preproc_options, preproc_defaults) + + # dfp_rolling_window_conf = get_config_with_overrides(config, DFP_ROLLING_WINDOW, "dfp_rolling_window") + stream_aggregation_defaults = { + "trigger_on_min_history": 300, + "trigger_on_min_increment": 300, + } + dfp_rolling_window_conf = merge_dictionaries(stream_aggregation_options, stream_aggregation_defaults) + + data_prep_defaults = {} + dfp_data_prep_conf = merge_dictionaries(data_prep_options, data_prep_defaults) + + # TODO(Devin): Not sure, but it seems like this is the right place to be opinionated about these values + # mostly because dfencoder itself has default values so we don't need them at the dfp_training level + dfp_training_defaults = { + "model_kwargs": { + "encoder_layers": [512, 500], # layers of the encoding part + "decoder_layers": [512], # layers of the decoding part + "activation": 'relu', # activation function + "swap_p": 0.2, # noise parameter + "lr": 0.001, # learning rate + "lr_decay": 0.99, # learning decay + "batch_size": 512, + "verbose": False, + "optimizer": 'sgd', # SGD optimizer is selected(Stochastic gradient descent) + "scaler": 'standard', # feature scaling method + "min_cats": 1, # cut off for minority categories + "progress_bar": False, + "device": "cuda" + }, + } + dfp_training_conf = merge_dictionaries(dfencoder_options, dfp_training_defaults) + + mlflow_model_writer_defaults = {} + mlflow_model_writer_conf = merge_dictionaries(mlflow_writer_options, mlflow_model_writer_defaults) # Load modules - preproc_module = load_module(preproc_conf, builder=builder) - dfp_rolling_window_module = load_module(dfp_rolling_window_conf, builder=builder) - dfp_data_prep_module = load_module(dfp_data_prep_conf, builder=builder) - dfp_training_module = load_module(dfp_training_conf, builder=builder) - mlflow_model_writer_module = load_module(mlflow_model_writer_conf, builder=builder) + preproc_module = builder.load_module(DFP_PREPROC, "morpheus", "dfp_preproc", preproc_conf) + dfp_rolling_window_module = builder.load_module(DFP_ROLLING_WINDOW, "morpheus", "dfp_rolling_window", + dfp_rolling_window_conf) + dfp_data_prep_module = builder.load_module(DFP_DATA_PREP, "morpheus", "dfp_data_prep", dfp_data_prep_conf) + dfp_training_module = builder.load_module(DFP_TRAINING, "morpheus", "dfp_training", dfp_training_conf) + mlflow_model_writer_module = builder.load_module(MLFLOW_MODEL_WRITER, "morpheus", "mlflow_model_writer", + mlflow_model_writer_conf) # Make an edge between the modules. builder.make_edge(preproc_module.output_port("output"), dfp_rolling_window_module.input_port("input")) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training_pipeline.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training_pipeline.py deleted file mode 100644 index f29b217ea7..0000000000 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training_pipeline.py +++ /dev/null @@ -1,88 +0,0 @@ -# Copyright (c) 2022-2023, NVIDIA CORPORATION. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import logging - -import dfp.modules.dfp_data_prep # noqa: F401 -import dfp.modules.dfp_rolling_window # noqa: F401 -import dfp.modules.dfp_split_users # noqa: F401 -import dfp.modules.dfp_training # noqa: F401 -import mrc - -import morpheus.modules.file_batcher # noqa: F401 -import morpheus.modules.file_to_df # noqa: F401 -import morpheus.modules.mlflow_model_writer # noqa: F401 -from morpheus.utils.module_ids import FILE_BATCHER -from morpheus.utils.module_ids import FILE_TO_DF -from morpheus.utils.module_ids import MLFLOW_MODEL_WRITER -from morpheus.utils.module_ids import MODULE_NAMESPACE -from morpheus.utils.module_utils import get_config_with_overrides -from morpheus.utils.module_utils import get_module_config -from morpheus.utils.module_utils import load_module -from morpheus.utils.module_utils import register_module - -from ..utils.module_ids import DFP_DATA_PREP -from ..utils.module_ids import DFP_ROLLING_WINDOW -from ..utils.module_ids import DFP_SPLIT_USERS -from ..utils.module_ids import DFP_TRAINING -from ..utils.module_ids import DFP_TRAINING_PIPELINE - -logger = logging.getLogger("morpheus.{}".format(__name__)) - - -@register_module(DFP_TRAINING_PIPELINE, MODULE_NAMESPACE) -def dfp_training_pipeline(builder: mrc.Builder): - """ - This module function allows for the consolidation of multiple dfp pipeline modules relevent to training - process into a single module. - - Parameters - ---------- - builder : mrc.Builder - Pipeline budler instance. - """ - - config = get_module_config(DFP_TRAINING_PIPELINE, builder) - config["module_id"] = DFP_TRAINING_PIPELINE - config["namespace"] = MODULE_NAMESPACE - config["module_name"] = "dfp_training_pipeline" - - file_batcher_conf = get_config_with_overrides(config, FILE_BATCHER, "file_batcher") - file_to_df_conf = get_config_with_overrides(config, FILE_TO_DF, "file_to_df") - dfp_split_users_conf = get_config_with_overrides(config, DFP_SPLIT_USERS, "dfp_split_users") - dfp_rolling_window_conf = get_config_with_overrides(config, DFP_ROLLING_WINDOW, "dfp_rolling_window") - dfp_data_prep_conf = get_config_with_overrides(config, DFP_DATA_PREP, "dfp_data_prep") - dfp_training_conf = get_config_with_overrides(config, DFP_TRAINING, "dfp_training") - mlflow_model_writer_conf = get_config_with_overrides(config, MLFLOW_MODEL_WRITER, "mlflow_model_writer") - - # Load modules - file_batcher_module = load_module(file_batcher_conf, builder=builder) - file_to_dataframe_module = load_module(file_to_df_conf, builder=builder) - dfp_split_users_modules = load_module(dfp_split_users_conf, builder=builder) - dfp_rolling_window_module = load_module(dfp_rolling_window_conf, builder=builder) - dfp_data_prep_module = load_module(dfp_data_prep_conf, builder=builder) - dfp_training_module = load_module(dfp_training_conf, builder=builder) - mlflow_model_writer_module = load_module(mlflow_model_writer_conf, builder=builder) - - # Make an edge between the modules. - builder.make_edge(file_batcher_module.output_port("output"), file_to_dataframe_module.input_port("input")) - builder.make_edge(file_to_dataframe_module.output_port("output"), dfp_split_users_modules.input_port("input")) - builder.make_edge(dfp_split_users_modules.output_port("output"), dfp_rolling_window_module.input_port("input")) - builder.make_edge(dfp_rolling_window_module.output_port("output"), dfp_data_prep_module.input_port("input")) - builder.make_edge(dfp_data_prep_module.output_port("output"), dfp_training_module.input_port("input")) - builder.make_edge(dfp_training_module.output_port("output"), mlflow_model_writer_module.input_port("input")) - - # Register input and output port for a module. - builder.register_module_input("input", file_batcher_module.input_port("input")) - builder.register_module_output("output", mlflow_model_writer_module.output_port("output")) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/utils/cached_user_window.py b/examples/digital_fingerprinting/production/morpheus/dfp/utils/cached_user_window.py index 7e27b1bc6c..87efcc31e2 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/utils/cached_user_window.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/utils/cached_user_window.py @@ -77,6 +77,9 @@ def append_dataframe(self, incoming_df: pd.DataFrame) -> bool: return True + def get_spanning_df(self, max_history) -> pd.DataFrame: + return self.get_train_df(max_history) + def get_train_df(self, max_history) -> pd.DataFrame: new_df = self.trim_dataframe(self._df, diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py b/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py index 2d7206196e..273543238d 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py @@ -42,7 +42,7 @@ from morpheus.utils.module_ids import FILTER_CONTROL_MESSAGE from morpheus.utils.module_ids import FILTER_DETECTIONS from morpheus.utils.module_ids import MLFLOW_MODEL_WRITER -from morpheus.utils.module_ids import MODULE_NAMESPACE +from morpheus.utils.module_ids import MORPHEUS_MODULE_NAMESPACE from morpheus.utils.module_ids import SERIALIZE from morpheus.utils.module_ids import WRITE_TO_FILE @@ -62,10 +62,10 @@ def get_module_conf(self): module_conf["module_id"] = DFP_DEPLOYMENT module_conf["module_name"] = "dfp_deployment" - module_conf["namespace"] = MODULE_NAMESPACE + module_conf["namespace"] = MORPHEUS_MODULE_NAMESPACE module_conf[FSSPEC_LOADER] = self.fsspec_dataloader_module_conf() - module_conf[DFP_TRAINING_PIPE] = self.train_module_conf() + module_conf[DFP_TRAINING_PIPE] = self.train_module_conf_test() module_conf[DFP_INFERENCE_PIPE] = self.infer_module_conf() module_conf["output_port_count"] = 2 @@ -77,6 +77,38 @@ def fsspec_dataloader_module_conf(self): def infer_module_conf(self): module_conf = { + "timestamp_column_name": self._config.ae.timestamp_column_name, + "cache_dir": self._dfp_arg_parser.cache_dir, + "batching_options": { + "sampling_rate_s": self._dfp_arg_parser.sample_rate_s, + "start_time": self._dfp_arg_parser.time_fields.start_time, + "end_time": self._dfp_arg_parser.time_fields.end_time, + "iso_date_regex_pattern": iso_date_regex_pattern, + "parser_kwargs": { + "lines": False, "orient": "records" + }, + "cache_dir": self._dfp_arg_parser.cache_dir, + "schema": { + "schema_str": self._source_schema_str, "encoding": self._encoding + } + }, + "user_splitting_options": { + "fallback_username": self._config.ae.fallback_username, + "include_generic": self._dfp_arg_parser.include_generic, + "include_individual": self._dfp_arg_parser.include_individual, + "only_users": self._dfp_arg_parser.only_users, + "skip_users": self._dfp_arg_parser.skip_users, + "userid_column_name": self._config.ae.userid_column_name + }, + "stream_aggregation_options": { + "aggregation_span": self._dfp_arg_parser.duration, + "cache_to_disk": False + }, + "preprocessing_options": { + "schema": { + "schema_str": self._preprocess_schema_str, "encoding": self._encoding + } + }, DFP_PREPROC: { FILTER_CONTROL_MESSAGE: { "data_type": "streaming", "enable_task_check": True, "task_type": "inference" @@ -153,6 +185,60 @@ def infer_module_conf(self): return module_conf + def train_module_conf_test(self): + module_conf = { + "timestamp_column_name": self._config.ae.timestamp_column_name, + "cache_dir": self._dfp_arg_parser.cache_dir, + "batching_options": { + "sampling_rate_s": self._dfp_arg_parser.sample_rate_s, + "start_time": self._dfp_arg_parser.time_fields.start_time, + "end_time": self._dfp_arg_parser.time_fields.end_time, + "iso_date_regex_pattern": iso_date_regex_pattern, + "parser_kwargs": { + "lines": False, "orient": "records" + }, + "cache_dir": self._dfp_arg_parser.cache_dir, + "schema": { + "schema_str": self._source_schema_str, "encoding": self._encoding + } + }, + "user_splitting_options": { + "fallback_username": self._config.ae.fallback_username, + "include_generic": self._dfp_arg_parser.include_generic, + "include_individual": self._dfp_arg_parser.include_individual, + "only_users": self._dfp_arg_parser.only_users, + "skip_users": self._dfp_arg_parser.skip_users, + "userid_column_name": self._config.ae.userid_column_name + }, + "stream_aggregation_options": { + "aggregation_span": self._dfp_arg_parser.duration, + "cache_to_disk": False + }, + "preprocessing_options": { + "schema": { + "schema_str": self._preprocess_schema_str, "encoding": self._encoding + } + }, + "dfencoder_options": { + "feature_columns": self._config.ae.feature_columns, + "epochs": 30, + "validation_size": 0.10 + }, + "mlflow_writer_options": { + "model_name_formatter": self._dfp_arg_parser.model_name_formatter, + "experiment_name_formatter": self._dfp_arg_parser.experiment_name_formatter, + "timestamp_column_name": self._config.ae.timestamp_column_name, + "conda_env": { + 'channels': ['defaults', 'conda-forge'], + 'dependencies': ['python={}'.format('3.8'), 'pip'], + 'pip': ['mlflow', 'dfencoder'], + 'name': 'mlflow-env' + } + } + } + + return module_conf + def train_module_conf(self): module_conf = { DFP_PREPROC: { diff --git a/examples/digital_fingerprinting/production/morpheus/test_input.json b/examples/digital_fingerprinting/production/morpheus/test_input.json deleted file mode 100644 index 49c5feeb76..0000000000 --- a/examples/digital_fingerprinting/production/morpheus/test_input.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "inputs": [ - { - "tasks": [ - { - "type": "load", - "properties": { - "loader_id": "fsspec", - "files": [ - "../../../../examples/data/dfp/duo-inference-data/*.json" - ] - } - }, - { - "type": "inference", - "properties": {} - } - ], - "metadata": { - "data_type": "payload" - } - } - ] -} \ No newline at end of file diff --git a/morpheus/_lib/include/morpheus/io/data_loader.hpp b/morpheus/_lib/include/morpheus/io/data_loader.hpp index d61bde3c0c..7453494b73 100644 --- a/morpheus/_lib/include/morpheus/io/data_loader.hpp +++ b/morpheus/_lib/include/morpheus/io/data_loader.hpp @@ -20,6 +20,8 @@ #include "morpheus/messages/control.hpp" #include "morpheus/messages/meta.hpp" +#include + #include #include @@ -29,7 +31,10 @@ namespace morpheus { class Loader { public: - ~Loader() = default; + ~Loader() + { + VLOG(1) << "Called Loader::~Loader()"; + }; Loader() = default; Loader(nlohmann::json config); @@ -48,7 +53,10 @@ class DataLoader { public: DataLoader(); - ~DataLoader() = default; + ~DataLoader() + { + VLOG(1) << "Called DataLoader::~DataLoader()"; + } /** * @brief Load data described by a control message diff --git a/morpheus/_lib/include/morpheus/modules/data_loader_module.hpp b/morpheus/_lib/include/morpheus/modules/data_loader_module.hpp index 610b8b4bc8..77ee54e110 100644 --- a/morpheus/_lib/include/morpheus/modules/data_loader_module.hpp +++ b/morpheus/_lib/include/morpheus/modules/data_loader_module.hpp @@ -30,6 +30,11 @@ class DataLoaderModule : public mrc::modules::SegmentModule, public mrc::modules using type_t = DataLoaderModule; public: + ~DataLoaderModule() override + { + VLOG(1) << "Called DataLoaderModule::~DataLoaderModule()"; + } + DataLoaderModule(std::string module_name); DataLoaderModule(std::string module_name, nlohmann::json _config); diff --git a/morpheus/_lib/src/io/data_loader.cpp b/morpheus/_lib/src/io/data_loader.cpp index 80fe548e92..35accbb93c 100644 --- a/morpheus/_lib/src/io/data_loader.cpp +++ b/morpheus/_lib/src/io/data_loader.cpp @@ -65,6 +65,7 @@ std::shared_ptr DataLoader::load(std::shared_ptr } else { + LOG(ERROR) << "Attempt to load using an unknown or unregistered data loader: " << loader_id << std::endl; throw std::runtime_error("Attempt to load using an unknown or unregistered data loader: " + loader_id.get()); } @@ -82,7 +83,7 @@ void DataLoader::add_loader(const std::string& loader_id, std::shared_ptr MRCModuleVersion{mrc_VERSION_MAJOR, mrc_VERSION_MINOR, mrc_VERSION_PATCH}; using namespace mrc::modules; mrc::modules::ModelRegistryUtil::create_registered_module( diff --git a/morpheus/modules/file_batcher.py b/morpheus/modules/file_batcher.py index 5be548eb37..ec27baa49e 100644 --- a/morpheus/modules/file_batcher.py +++ b/morpheus/modules/file_batcher.py @@ -27,14 +27,17 @@ from morpheus.utils.file_utils import date_extractor from morpheus.utils.loader_ids import FILE_TO_DF_LOADER from morpheus.utils.module_ids import FILE_BATCHER -from morpheus.utils.module_ids import MODULE_NAMESPACE -from morpheus.utils.module_utils import get_module_config +from morpheus.utils.module_ids import MORPHEUS_MODULE_NAMESPACE from morpheus.utils.module_utils import register_module logger = logging.getLogger(__name__) +default_iso_date_regex_pattern = ( + r"(?P\d{4})-(?P\d{1,2})-(?P\d{1,2})" + r"T(?P\d{1,2})(:|_)(?P\d{1,2})(:|_)(?P\d{1,2})(?P\.\d{1,6})?Z") -@register_module(FILE_BATCHER, MODULE_NAMESPACE) + +@register_module(FILE_BATCHER, MORPHEUS_MODULE_NAMESPACE) def file_batcher(builder: mrc.Builder): """ This module loads the input files, removes files that are older than the chosen window of time, @@ -44,18 +47,35 @@ def file_batcher(builder: mrc.Builder): ---------- builder : mrc.Builder mrc Builder object. + + Notes + ---------- + Configurable parameters: + - batch_end_time: datetime + - batch_iso_date_regex_pattern: str + - batch_parser_kwargs: dict + - batch_period: str + - batch_sampling_rate_s: int + - batch_start_time: datetime + - cache_dir: str + - file_type: str + - filter_nulls: bool + - schema: dict + - timestamp_column_name: str """ - config = get_module_config(FILE_BATCHER, builder) + config = builder.get_current_module_config() + # config = get_module_config(FILE_BATCHER, builder) TimestampFileObj = namedtuple("TimestampFileObj", ["timestamp", "file_name"]) - iso_date_regex_pattern = config.get("iso_date_regex_pattern", None) - start_time = config.get("start_time", None) - end_time = config.get("end_time", None) - sampling_rate_s = config.get("sampling_rate_s", None) - period = config.get("period", None) + period = config.get("batch_period", 'D') + sampling_rate_s = config.get("batch_sampling_rate_s", 0) + + start_time = config.get("batch_start_time") + end_time = config.get("batch_end_time") + iso_date_regex_pattern = config.get("batch_iso_date_regex_pattern", default_iso_date_regex_pattern) iso_date_regex = re.compile(iso_date_regex_pattern) def build_fs_filename_df(files): diff --git a/morpheus/modules/file_to_df.py b/morpheus/modules/file_to_df.py index 056e72d409..186e69b9a1 100644 --- a/morpheus/modules/file_to_df.py +++ b/morpheus/modules/file_to_df.py @@ -35,14 +35,13 @@ from morpheus.io.deserializers import read_file_to_df from morpheus.utils.column_info import process_dataframe from morpheus.utils.module_ids import FILE_TO_DF -from morpheus.utils.module_ids import MODULE_NAMESPACE -from morpheus.utils.module_utils import get_module_config +from morpheus.utils.module_ids import MORPHEUS_MODULE_NAMESPACE from morpheus.utils.module_utils import register_module logger = logging.getLogger(__name__) -@register_module(FILE_TO_DF, MODULE_NAMESPACE) +@register_module(FILE_TO_DF, MORPHEUS_MODULE_NAMESPACE) def file_to_df(builder: mrc.Builder): """ This module reads data from the batched files into a dataframe after receiving input from the "FileBatcher" module. @@ -54,7 +53,7 @@ def file_to_df(builder: mrc.Builder): mrc Builder object. """ - config = get_module_config(FILE_TO_DF, builder) + config = builder.get_current_module_config() timestamp_column_name = config.get("timestamp_column_name", None) schema_config = config.get("schema", None) @@ -66,7 +65,7 @@ def file_to_df(builder: mrc.Builder): cache_dir = config.get("cache_dir", None) download_method: typing.Literal["single_thread", "multiprocess", "dask", - "dask_thread"] = os.environ.get("MORPHEUS_FILE_DOWNLOAD_TYPE", "multiprocess") + "dask_thread"] = os.environ.get("MORPHEUS_FILE_DOWNLOAD_TYPE", "multiprocess") cache_dir = os.path.join(cache_dir, "file_cache") # Load input schema @@ -229,14 +228,14 @@ def get_or_create_dataframe_from_s3_batch( return (output_df, False) - def convert_to_dataframe(s3_object_batch: typing.Tuple[fsspec.core.OpenFiles, int]): - if (not s3_object_batch): + def convert_to_dataframe(file_object_batch: typing.Tuple[fsspec.core.OpenFiles, int]): + if (not file_object_batch): return None start_time = time.time() try: - output_df, cache_hit = get_or_create_dataframe_from_s3_batch(s3_object_batch) + output_df, cache_hit = get_or_create_dataframe_from_s3_batch(file_object_batch) duration = (time.time() - start_time) * 1000.0 diff --git a/morpheus/modules/filter_control_message.py b/morpheus/modules/filter_control_message.py index 0faa8e2818..375ae9f8a5 100644 --- a/morpheus/modules/filter_control_message.py +++ b/morpheus/modules/filter_control_message.py @@ -19,14 +19,13 @@ from morpheus.messages import MessageControl from morpheus.utils.module_ids import FILTER_CONTROL_MESSAGE -from morpheus.utils.module_ids import MODULE_NAMESPACE -from morpheus.utils.module_utils import get_module_config +from morpheus.utils.module_ids import MORPHEUS_MODULE_NAMESPACE from morpheus.utils.module_utils import register_module logger = logging.getLogger(__name__) -@register_module(FILTER_CONTROL_MESSAGE, MODULE_NAMESPACE) +@register_module(FILTER_CONTROL_MESSAGE, MORPHEUS_MODULE_NAMESPACE) def filter_control_message(builder: mrc.Builder): """ When the requirements are met, this module gently discards the control messages. @@ -35,27 +34,48 @@ def filter_control_message(builder: mrc.Builder): ---------- builder : mrc.Builder mrc Builder object. + + Notes + ---------- + Configurable parameters: + - enable_task_filtering: bool + - enable_data_type_filtering: bool + - filter_task_type: str + - filter_data_type: str """ - config = get_module_config(FILTER_CONTROL_MESSAGE, builder) + config = builder.get_current_module_config() + + enable_task_filtering = config.get("enable_task_filtering", False) + enable_data_type_filtering = config.get("enable_data_type_filtering", False) - filter = config.get("data_type", None) - enable_task_check = config.get("enable_task_check", False) - task_type = config.get("task_type", None) + filter_task_type = None + if (enable_task_filtering): + if ("filter_task_type" not in config): + raise ValueError("Task filtering is enabled but no task type is specified") + filter_task_type = config["filter_task_type"] + + filter_data_type = None + if (enable_data_type_filtering): + if ("filter_data_type" not in config): + raise ValueError("Data type filtering is enabled but no data type is specified") + filter_data_type = config["filter_data_type"] def on_data(control_message: MessageControl): - data_type = control_message.get_metadata("data_type") + cm_data_type = control_message.get_metadata("data_type") - if enable_task_check: + if enable_task_filtering: # Verify if control message has expected task_type. - task_exist = control_message.has_task(task_type) + # TODO(Devin): Convert this to use enum values + task_exists = control_message.has_task(filter_task_type) + # Dispose messages if it has no expected task and it's data_type does not matches with filter. - if (not task_exist and filter and data_type != filter): + if (not task_exists and filter and cm_data_type != filter): return None - else: + elif (enable_data_type_filtering): # Regardless of whether tasks are present, discard messages # if the data_type don't match the filter. - if filter and data_type != filter: + if (filter_data_type and filter_data_type != filter): return None return control_message diff --git a/morpheus/modules/filter_detections.py b/morpheus/modules/filter_detections.py index 0ca208dd01..393842a02e 100644 --- a/morpheus/modules/filter_detections.py +++ b/morpheus/modules/filter_detections.py @@ -26,14 +26,14 @@ from morpheus.messages import MultiMessage from morpheus.messages.multi_response_message import MultiResponseMessage from morpheus.utils.module_ids import FILTER_DETECTIONS -from morpheus.utils.module_ids import MODULE_NAMESPACE +from morpheus.utils.module_ids import MORPHEUS_MODULE_NAMESPACE from morpheus.utils.module_utils import get_module_config from morpheus.utils.module_utils import register_module logger = logging.getLogger(__name__) -@register_module(FILTER_DETECTIONS, MODULE_NAMESPACE) +@register_module(FILTER_DETECTIONS, MORPHEUS_MODULE_NAMESPACE) def filter_detections(builder: mrc.Builder): """ Filter message by a classification threshold. @@ -67,7 +67,7 @@ def filter_detections(builder: mrc.Builder): mrc Builder object. """ - config = get_module_config(FILTER_DETECTIONS, builder) + config = builder.get_current_module_config() field_name = config.get("field_name", "probs") threshold = config.get("threshold", 0.5) diff --git a/morpheus/modules/mlflow_model_writer.py b/morpheus/modules/mlflow_model_writer.py index a9bb481ae6..2e86ad99a1 100644 --- a/morpheus/modules/mlflow_model_writer.py +++ b/morpheus/modules/mlflow_model_writer.py @@ -36,14 +36,13 @@ from morpheus.messages.multi_ae_message import MultiAEMessage from morpheus.utils.module_ids import MLFLOW_MODEL_WRITER -from morpheus.utils.module_ids import MODULE_NAMESPACE -from morpheus.utils.module_utils import get_module_config +from morpheus.utils.module_ids import MORPHEUS_MODULE_NAMESPACE from morpheus.utils.module_utils import register_module logger = logging.getLogger(__name__) -@register_module(MLFLOW_MODEL_WRITER, MODULE_NAMESPACE) +@register_module(MLFLOW_MODEL_WRITER, MORPHEUS_MODULE_NAMESPACE) def mlflow_model_writer(builder: mrc.Builder): """ This module uploads trained models to the mlflow server. @@ -52,14 +51,34 @@ def mlflow_model_writer(builder: mrc.Builder): ---------- builder : mrc.Builder mrc Builder object. + + Notes + ---------- + Configurable parameters: + - model_name_formatter: Formatter for the model name + - experiment_name_formatter: Formatter for the experiment name + - conda_env: Conda environment for the model + - timestamp_column_name: Name of the timestamp column + - databricks_permissions: Permissions for the model """ - config = get_module_config(MLFLOW_MODEL_WRITER, builder) + config = builder.get_current_module_config() + + timestamp_column_name = config.get("timestamp_column_name", "timestamp") + + if ("model_name_formatter" not in config): + raise ValueError("Model name formatter is required") - model_name_formatter = config.get("model_name_formatter", None) - experiment_name_formatter = config.get("experiment_name_formatter", None) + if ("experiment_name_formatter" not in config): + raise ValueError("Experiment name formatter is required") + + if ("conda_env" not in config): + raise ValueError("Conda environment is required") + + model_name_formatter = config["model_name_formatter"] + experiment_name_formatter = config["experiment_name_formatter"] conda_env = config.get("conda_env", None) - timestamp_column_name = config.get("timestamp_column_name", None) + databricks_permissions = config.get("databricks_permissions", None) def user_id_to_model(user_id: str): diff --git a/morpheus/modules/serialize.py b/morpheus/modules/serialize.py index e19dcea165..38fbc03831 100644 --- a/morpheus/modules/serialize.py +++ b/morpheus/modules/serialize.py @@ -24,7 +24,7 @@ from morpheus.messages import MultiMessage from morpheus.messages.message_meta import MessageMeta -from morpheus.utils.module_ids import MODULE_NAMESPACE +from morpheus.utils.module_ids import MORPHEUS_MODULE_NAMESPACE from morpheus.utils.module_ids import SERIALIZE from morpheus.utils.module_utils import get_module_config from morpheus.utils.module_utils import register_module @@ -32,7 +32,7 @@ logger = logging.getLogger(__name__) -@register_module(SERIALIZE, MODULE_NAMESPACE) +@register_module(SERIALIZE, MORPHEUS_MODULE_NAMESPACE) def serialize(builder: mrc.Builder): """ Includes & excludes columns from messages. @@ -45,7 +45,8 @@ def serialize(builder: mrc.Builder): mrc Builder object. """ - config = get_module_config(SERIALIZE, builder) + config = builder.get_current_module_config() + include_columns = config.get("include", None) exclude_columns = config.get("exclude", [r'^ID$', r'^_ts_']) fixed_columns = config.get("fixed_columns", True) diff --git a/morpheus/modules/write_to_file.py b/morpheus/modules/write_to_file.py index 5f3fb77ab1..5c115ed69a 100644 --- a/morpheus/modules/write_to_file.py +++ b/morpheus/modules/write_to_file.py @@ -26,7 +26,7 @@ from morpheus._lib.common import determine_file_type from morpheus.io import serializers from morpheus.messages.message_meta import MessageMeta -from morpheus.utils.module_ids import MODULE_NAMESPACE +from morpheus.utils.module_ids import MORPHEUS_MODULE_NAMESPACE from morpheus.utils.module_ids import WRITE_TO_FILE from morpheus.utils.module_utils import get_module_config from morpheus.utils.module_utils import register_module @@ -36,7 +36,7 @@ is_first = True -@register_module(WRITE_TO_FILE, MODULE_NAMESPACE) +@register_module(WRITE_TO_FILE, MORPHEUS_MODULE_NAMESPACE) def write_to_file(builder: mrc.Builder): """ Write all messages to a file. @@ -49,7 +49,7 @@ def write_to_file(builder: mrc.Builder): mrc Builder object. """ - config = get_module_config(WRITE_TO_FILE, builder) + config = builder.get_current_module_config() output_file = config.get("filename", None) overwrite = config.get("overwrite", False) @@ -95,9 +95,7 @@ def node_fn(obs: mrc.Observable, sub: mrc.Subscriber): # Open up the file handle with open(output_file, "a") as out_file: - def write_to_file(x: MessageMeta): - lines = convert_to_strings(x.df) out_file.writelines(lines) diff --git a/morpheus/utils/module_ids.py b/morpheus/utils/module_ids.py index 321a86ae3b..d4320c9b0e 100644 --- a/morpheus/utils/module_ids.py +++ b/morpheus/utils/module_ids.py @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -MODULE_NAMESPACE = "morpheus" +MORPHEUS_MODULE_NAMESPACE = "morpheus" FILE_BATCHER = "FileBatcher" FILTER_CONTROL_MESSAGE = "FilterControlMessage" diff --git a/morpheus/utils/module_utils.py b/morpheus/utils/module_utils.py index 74571b1d8f..33d2833f43 100644 --- a/morpheus/utils/module_utils.py +++ b/morpheus/utils/module_utils.py @@ -134,6 +134,40 @@ def verify_module_meta_fields(config: typing.Dict): raise KeyError("Required attribute 'module_name' is missing in the module configuration.") +def merge_dictionaries(primary_dict, secondary_dict): + """Recursively merge two dictionaries, using primary_dict as a tie-breaker. + + Lists are treated as a special case, and all unique elements from both dictionaries are included in the final list. + + Args: + primary_dict (dict): The primary dictionary. + secondary_dict (dict): The secondary dictionary. + + Returns: + dict: The merged dictionary. + """ + result_dict = primary_dict.copy() + + for key, value in secondary_dict.items(): + if key in result_dict: + if isinstance(value, list) and isinstance(result_dict[key], list): + # Combine the two lists and remove duplicates while preserving order + # This isn't perfect, its possible we could still end up with duplicates in some scenarios + combined_list = result_dict[key] + value + unique_list = [] + for item in combined_list: + if item not in unique_list: + unique_list.append(item) + result_dict[key] = unique_list + elif isinstance(value, dict) and isinstance(result_dict[key], dict): + # Recursively merge the two dictionaries + result_dict[key] = merge_dictionaries(result_dict[key], value) + else: + result_dict[key] = value + + return result_dict + + def get_config_with_overrides(config, module_id, module_name=None, module_namespace="morpheus"): sub_config = config.get(module_id, None) From c0f793aa9ae9caad11e05bccb870f2074598da40 Mon Sep 17 00:00:00 2001 From: Devin Robison Date: Thu, 9 Mar 2023 16:21:53 -0700 Subject: [PATCH 079/157] Configuration updates finished -- still need more cleanup --- .../morpheus/dfp/modules/dfp_deployment.py | 31 +- .../morpheus/dfp/modules/dfp_inference.py | 18 +- .../dfp/modules/dfp_inference_pipe.py | 68 +++- .../dfp/modules/dfp_postprocessing.py | 7 +- .../morpheus/dfp/modules/dfp_preproc.py | 4 +- .../dfp/modules/dfp_rolling_window.py | 3 +- .../morpheus/dfp/modules/dfp_training_pipe.py | 2 +- .../morpheus/dfp/utils/cached_user_window.py | 4 + .../morpheus/dfp/utils/config_generator.py | 363 ++---------------- .../morpheus/dfp_modules_pipeline.py | 4 +- morpheus/modules/filter_detections.py | 35 +- .../stages/general/multi_port_module_stage.py | 2 +- 12 files changed, 168 insertions(+), 373 deletions(-) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_deployment.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_deployment.py index 7301133a12..36128578f8 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_deployment.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_deployment.py @@ -23,8 +23,7 @@ from morpheus.utils.loader_ids import FSSPEC_LOADER from morpheus.utils.module_ids import DATA_LOADER from morpheus.utils.module_ids import MORPHEUS_MODULE_NAMESPACE -from morpheus.utils.module_utils import get_config_with_overrides -from morpheus.utils.module_utils import load_module +from morpheus.utils.module_utils import merge_dictionaries from morpheus.utils.module_utils import register_module from ..utils.module_ids import DFP_DEPLOYMENT @@ -36,29 +35,31 @@ @register_module(DFP_DEPLOYMENT, MORPHEUS_MODULE_NAMESPACE) def dfp_deployment(builder: mrc.Builder): - # module_config = get_module_config(DFP_DEPLOYMENT, builder) + """ + This module function allows for the consolidation of multiple dfp pipeline modules relevant to inference/training + :param builder: + :return: + """ module_config = builder.get_current_module_config() - # fsspec_dataloader_conf = get_config_with_overrides(module_config, FSSPEC_LOADER, "fsspec_dataloader") - fsspec_dataloader_conf = module_config[FSSPEC_LOADER] + output_port_count = 2 - # dfp_training_pipe_conf = get_config_with_overrides(module_config, DFP_TRAINING_PIPE, "dfp_training_pipe") - dfp_training_pipe_conf = module_config[DFP_TRAINING_PIPE] - # dfp_inference_pipe_conf = get_config_with_overrides(module_config, DFP_INFERENCE_PIPE, "dfp_inference_pipe") - dfp_inference_pipe_conf = module_config[DFP_INFERENCE_PIPE] + supported_loaders = {} + fsspec_loader_defaults = { + "loaders": [{ + "id": FSSPEC_LOADER + }], + } - if "output_port_count" not in module_config: - raise KeyError("Missing required configuration 'output_port_count'") + fsspec_dataloader_conf = merge_dictionaries(supported_loaders, fsspec_loader_defaults) - output_port_count = module_config.get("output_port_count") + dfp_training_pipe_conf = module_config["training_options"] + dfp_inference_pipe_conf = module_config["inference_options"] - # fsspec_dataloader_module = load_module(fsspec_dataloader_conf, builder=builder) fsspec_dataloader_module = builder.load_module(DATA_LOADER, "morpheus", "fsspec_dataloader", fsspec_dataloader_conf) - # Load module from registry. dfp_training_pipe_module = builder.load_module(DFP_TRAINING_PIPE, "morpheus", "dfp_training_pipe", dfp_training_pipe_conf) - # dfp_inference_pipe_module = load_module(dfp_inference_pipe_conf, builder=builder) dfp_inference_pipe_module = builder.load_module(DFP_INFERENCE_PIPE, "morpheus", "dfp_inference_pipe", dfp_inference_pipe_conf) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference.py index 2ba1cb2850..2883e9c667 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference.py @@ -45,13 +45,27 @@ def dfp_inference(builder: mrc.Builder): ---------- builder : mrc.Builder Pipeline budler instance. + + Notes + ---------- + Configurable parameters: + - model_name_formatter: Formatter for model names + - fallback_username: Fallback user to use if no model is found for a user + - timestamp_column_name: Name of the timestamp column """ config = builder.get_current_module_config() - fallback_user = config.get("fallback_username", None) + if ("model_name_formatter" not in config): + raise ValueError("Inference module requires model_name_formatter to be configured") + + if ("fallback_username" not in config): + raise ValueError("Inference module requires fallback_username to be configured") + model_name_formatter = config.get("model_name_formatter", None) - timestamp_column_name = config.get("timestamp_column_name", None) + fallback_user = config.get("fallback_username", "generic_user") + + timestamp_column_name = config.get("timestamp_column_name", "timestamp") client = MlflowClient() model_manager = ModelManager(model_name_formatter=model_name_formatter) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference_pipe.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference_pipe.py index 3d3796b800..6dc0da144c 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference_pipe.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference_pipe.py @@ -50,7 +50,13 @@ def dfp_inference_pipe(builder: mrc.Builder): Parameters ---------- builder : mrc.Builder - Pipeline budler instance. + Pipeline builder instance. + + Notes + ---------- + Configurable parameters: + - batching_options: Options for batching the data + - cache_dir: Directory to cache the rolling window data """ config = builder.get_current_module_config() @@ -69,16 +75,62 @@ def dfp_inference_pipe(builder: mrc.Builder): "user_splitting_options": config.get("user_splitting_options", {}), } + stream_aggregation_options = config.get("stream_aggregation_options", { + "cache_dir": cache_dir, + "timestamp_column_name": ts_column_name, + }) + + data_prep_options = config.get("preprocessing_options", { + "timestamp_column_name": ts_column_name, + }) + + inference_model_options = config.get("inference_options", {}) + + detection_criteria = config.get("detection_criteria", {}) + + post_processing_options = { + "timestamp_column_name": ts_column_name, + } + + serialize_options = config.get("serialize_options", {}) + + write_to_file_options = config.get("write_to_file_options", {}) + preproc_defaults = {} preproc_conf = merge_dictionaries(preproc_options, preproc_defaults) - dfp_rolling_window_conf = config[DFP_ROLLING_WINDOW] - dfp_data_prep_conf = config[DFP_DATA_PREP] - dfp_inference_conf = config[DFP_INFERENCE] - filter_detections_conf = config[FILTER_DETECTIONS] - dfp_post_proc_conf = config[DFP_POST_PROCESSING] - serialize_conf = config[SERIALIZE] - write_to_file_conf = config[WRITE_TO_FILE] + stream_aggregation_defaults = { + "trigger_on_min_history": 300, + "trigger_on_min_increment": 300, + } + dfp_rolling_window_conf = merge_dictionaries(stream_aggregation_options, stream_aggregation_defaults) + + data_prep_defaults = {} + dfp_data_prep_conf = merge_dictionaries(data_prep_options, data_prep_defaults) + + inference_model_defaults = {} + dfp_inference_conf = merge_dictionaries(inference_model_options, inference_model_defaults) + + detection_criteria_defaults = { + "field_name": "mean_abs_z", + "threshold": 2.0, + "filter_source": "DATAFRAME" + } + filter_detections_conf = merge_dictionaries(detection_criteria, detection_criteria_defaults) + + post_processing_defaults = {} + dfp_post_proc_conf = merge_dictionaries(post_processing_options, post_processing_defaults) + + serialize_defaults = { + "exclude": ['batch_count', 'origin_hash', '_row_hash', '_batch_id'], + "use_cpp": True + } + serialize_conf = merge_dictionaries(serialize_options, serialize_defaults) + + write_to_file_defaults = { + "filename": "dfp_inference_output.csv", + } + write_to_file_conf = merge_dictionaries(write_to_file_options, write_to_file_defaults) # Load modules preproc_module = builder.load_module(DFP_PREPROC, "morpheus", "dfp_preproc", preproc_conf) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_postprocessing.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_postprocessing.py index 14490c5092..bd4b1304cb 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_postprocessing.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_postprocessing.py @@ -37,11 +37,16 @@ def dfp_postprocessing(builder: mrc.Builder): ---------- builder : mrc.Builder Pipeline budler instance. + + Notes + ---------- + Configurable parameters: + timestamp_column_name: str """ config = builder.get_current_module_config() - timestamp_column_name = config.get("timestamp_column_name", None) + timestamp_column_name = config.get("timestamp_column_name", "timestamp") def process_events(message: MultiAEMessage): # Assume that a filter stage preceedes this stage diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_preproc.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_preproc.py index 1ae1b89c26..e958cab838 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_preproc.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_preproc.py @@ -44,7 +44,7 @@ def dfp_preproc(builder: mrc.Builder): Parameters ---------- builder : mrc.Builder - Pipeline budler instance. + Pipeline builder instance. """ config = builder.get_current_module_config() @@ -91,8 +91,6 @@ def dfp_preproc(builder: mrc.Builder): filter_control_message_module = builder.load_module(FILTER_CONTROL_MESSAGE, "morpheus", "filter_control_message", pre_filter_conf) - import json - # print(f"Creating file batcher module with config: {json.dumps(file_batcher_conf, indent=2)}", flush=True) file_batcher_module = builder.load_module(FILE_BATCHER, "morpheus", "file_batcher", file_batcher_conf) file_to_df_dataloader_module = builder.load_module(DATA_LOADER, "morpheus", "dfp_file_to_df_dataloader", file_to_df_conf) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_rolling_window.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_rolling_window.py index 518e6a35d2..5f88512ba4 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_rolling_window.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_rolling_window.py @@ -73,9 +73,8 @@ def dfp_rolling_window(builder: mrc.Builder): @contextmanager def get_user_cache(user_id: str): - # Determine cache location - cache_location = os.path.join(cache_dir, f"{user_id}.pkl") + cache_location = os.path.join(cache_dir, f"{user_id}.pkl") if cache_to_disk else None user_cache = user_cache_map.get(user_id, None) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training_pipe.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training_pipe.py index f7c1a545e7..47841caa5e 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training_pipe.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training_pipe.py @@ -65,6 +65,7 @@ def dfp_training_pipe(builder: mrc.Builder): } stream_aggregation_options = config.get("stream_aggregation_options", { + "cache_dir": cache_dir, "timestamp_column_name": ts_column_name, }) @@ -81,7 +82,6 @@ def dfp_training_pipe(builder: mrc.Builder): preproc_defaults = {} preproc_conf = merge_dictionaries(preproc_options, preproc_defaults) - # dfp_rolling_window_conf = get_config_with_overrides(config, DFP_ROLLING_WINDOW, "dfp_rolling_window") stream_aggregation_defaults = { "trigger_on_min_history": 300, "trigger_on_min_increment": 300, diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/utils/cached_user_window.py b/examples/digital_fingerprinting/production/morpheus/dfp/utils/cached_user_window.py index 87efcc31e2..a20e8a9418 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/utils/cached_user_window.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/utils/cached_user_window.py @@ -101,6 +101,8 @@ def get_train_df(self, max_history) -> pd.DataFrame: return new_df def save(self): + if (not self.cache_location): + raise RuntimeError("No cache location set") # Make sure the directories exist os.makedirs(os.path.dirname(self.cache_location), exist_ok=True) @@ -139,6 +141,8 @@ def trim_dataframe(df: pd.DataFrame, @staticmethod def load(cache_location: str) -> "CachedUserWindow": + if (cache_location is None): + raise RuntimeError("No cache location set") with open(cache_location, "rb") as f: return pickle.load(f) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py b/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py index 273543238d..86c6b011ea 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py @@ -16,16 +16,7 @@ from dfp.utils.dfp_arg_parser import DFPArgParser from dfp.utils.dfp_arg_parser import pyobj2str -from dfp.utils.module_ids import DFP_DATA_PREP from dfp.utils.module_ids import DFP_DEPLOYMENT -from dfp.utils.module_ids import DFP_INFERENCE -from dfp.utils.module_ids import DFP_INFERENCE_PIPE -from dfp.utils.module_ids import DFP_POST_PROCESSING -from dfp.utils.module_ids import DFP_PREPROC -from dfp.utils.module_ids import DFP_ROLLING_WINDOW -from dfp.utils.module_ids import DFP_SPLIT_USERS -from dfp.utils.module_ids import DFP_TRAINING -from dfp.utils.module_ids import DFP_TRAINING_PIPE from dfp.utils.regex_utils import iso_date_regex_pattern from dfp.utils.schema_utils import Schema @@ -35,16 +26,7 @@ from morpheus.config import ConfigAutoEncoder from morpheus.config import CppConfig from morpheus.messages.multi_message import MultiMessage -from morpheus.utils.loader_ids import FILE_TO_DF_LOADER -from morpheus.utils.loader_ids import FSSPEC_LOADER -from morpheus.utils.module_ids import FILE_BATCHER -from morpheus.utils.module_ids import FILE_TO_DF -from morpheus.utils.module_ids import FILTER_CONTROL_MESSAGE -from morpheus.utils.module_ids import FILTER_DETECTIONS -from morpheus.utils.module_ids import MLFLOW_MODEL_WRITER from morpheus.utils.module_ids import MORPHEUS_MODULE_NAMESPACE -from morpheus.utils.module_ids import SERIALIZE -from morpheus.utils.module_ids import WRITE_TO_FILE class ConfigGenerator: @@ -64,17 +46,11 @@ def get_module_conf(self): module_conf["module_name"] = "dfp_deployment" module_conf["namespace"] = MORPHEUS_MODULE_NAMESPACE - module_conf[FSSPEC_LOADER] = self.fsspec_dataloader_module_conf() - module_conf[DFP_TRAINING_PIPE] = self.train_module_conf_test() - module_conf[DFP_INFERENCE_PIPE] = self.infer_module_conf() - module_conf["output_port_count"] = 2 + module_conf["training_options"] = self.train_module_conf() + module_conf["inference_options"] = self.infer_module_conf() return module_conf - def fsspec_dataloader_module_conf(self): - module_conf = {"loaders": [{"id": FSSPEC_LOADER}]} - return module_conf - def infer_module_conf(self): module_conf = { "timestamp_column_name": self._config.ae.timestamp_column_name, @@ -109,83 +85,53 @@ def infer_module_conf(self): "schema_str": self._preprocess_schema_str, "encoding": self._encoding } }, - DFP_PREPROC: { - FILTER_CONTROL_MESSAGE: { - "data_type": "streaming", "enable_task_check": True, "task_type": "inference" - }, - FILE_BATCHER: { - "period": "D", - "sampling_rate_s": self._dfp_arg_parser.sample_rate_s, - "start_time": self._dfp_arg_parser.time_fields.start_time, - "end_time": self._dfp_arg_parser.time_fields.end_time, - "iso_date_regex_pattern": iso_date_regex_pattern, - "timestamp_column_name": self._config.ae.timestamp_column_name, - "parser_kwargs": { - "lines": False, "orient": "records" - }, - "cache_dir": self._dfp_arg_parser.cache_dir, - "filter_null": True, - "file_type": "JSON", - "schema": { - "schema_str": self._source_schema_str, "encoding": self._encoding - } - }, - FILE_TO_DF_LOADER: { - "loaders": [{ - "id": FILE_TO_DF_LOADER - }], "module_name": "dfp_file_to_df_dataloader_inf" - }, - DFP_SPLIT_USERS: { - "include_generic": self._dfp_arg_parser.include_generic, - "include_individual": self._dfp_arg_parser.include_individual, - "skip_users": self._dfp_arg_parser.skip_users, - "only_users": self._dfp_arg_parser.only_users, - "timestamp_column_name": self._config.ae.timestamp_column_name, - "userid_column_name": self._config.ae.userid_column_name, - "fallback_username": self._config.ae.fallback_username - } - }, - DFP_ROLLING_WINDOW: { - "min_history": 1, - "min_increment": 0, - "max_history": "1d", - "cache_dir": self._dfp_arg_parser.cache_dir, - "timestamp_column_name": self._config.ae.timestamp_column_name, - }, - DFP_DATA_PREP: { - "timestamp_column_name": self._config.ae.timestamp_column_name, - "schema": { - "schema_str": self._preprocess_schema_str, "encoding": self._encoding - }, - }, - DFP_INFERENCE: { + # DFP_DATA_PREP: { + # "timestamp_column_name": self._config.ae.timestamp_column_name, + # "schema": { + # "schema_str": self._preprocess_schema_str, "encoding": self._encoding + # }, + # }, + "inference_options": { "model_name_formatter": self._dfp_arg_parser.model_name_formatter, "fallback_username": self._config.ae.fallback_username, - "timestamp_column_name": self._config.ae.timestamp_column_name + "timestamp_column_name": self._config.ae.timestamp_column_name, }, - FILTER_DETECTIONS: { - "field_name": "mean_abs_z", - "threshold": 2.0, - "filter_source": "DATAFRAME", + # DFP_INFERENCE: { + # "model_name_formatter": self._dfp_arg_parser.model_name_formatter, + # "fallback_username": self._config.ae.fallback_username, + # "timestamp_column_name": self._config.ae.timestamp_column_name + # }, + "detection_criteria": { "schema": { "input_message_type": self._input_message_type, "encoding": self._encoding } }, - DFP_POST_PROCESSING: { - "timestamp_column_name": self._config.ae.timestamp_column_name - }, - SERIALIZE: { - "exclude": ['batch_count', 'origin_hash', '_row_hash', '_batch_id'], - "use_cpp": CppConfig.get_should_use_cpp() - }, - WRITE_TO_FILE: { + # FILTER_DETECTIONS: { + # "field_name": "mean_abs_z", + # "threshold": 2.0, + # "filter_source": "DATAFRAME", + # "schema": { + # "input_message_type": self._input_message_type, "encoding": self._encoding + # } + # }, + # DFP_POST_PROCESSING: { + # "timestamp_column_name": self._config.ae.timestamp_column_name + # }, + # SERIALIZE: { + # "exclude": ['batch_count', 'origin_hash', '_row_hash', '_batch_id'], + # "use_cpp": CppConfig.get_should_use_cpp() + # }, + "write_to_file_options": { "filename": "dfp_detections_{}.csv".format(self._dfp_arg_parser.source), "overwrite": True - } + }, + # WRITE_TO_FILE: { + # "filename": "dfp_detections_{}.csv".format(self._dfp_arg_parser.source), "overwrite": True + # } } return module_conf - def train_module_conf_test(self): + def train_module_conf(self): module_conf = { "timestamp_column_name": self._config.ae.timestamp_column_name, "cache_dir": self._dfp_arg_parser.cache_dir, @@ -239,241 +185,6 @@ def train_module_conf_test(self): return module_conf - def train_module_conf(self): - module_conf = { - DFP_PREPROC: { - FILTER_CONTROL_MESSAGE: { - "data_type": "streaming", "enable_task_check": True, "task_type": "training" - }, - FILE_BATCHER: { - "period": "D", - "sampling_rate_s": self._dfp_arg_parser.sample_rate_s, - "start_time": self._dfp_arg_parser.time_fields.start_time, - "end_time": self._dfp_arg_parser.time_fields.end_time, - "iso_date_regex_pattern": iso_date_regex_pattern, - "timestamp_column_name": self._config.ae.timestamp_column_name, - "parser_kwargs": { - "lines": False, "orient": "records" - }, - "cache_dir": self._dfp_arg_parser.cache_dir, - "filter_null": True, - "file_type": "JSON", - "schema": { - "schema_str": self._source_schema_str, "encoding": self._encoding - } - }, - FILE_TO_DF_LOADER: { - "loaders": [{ - "id": FILE_TO_DF_LOADER - }], "module_name": "dfp_file_to_df_dataloader_tra" - }, - DFP_SPLIT_USERS: { - "include_generic": self._dfp_arg_parser.include_generic, - "include_individual": self._dfp_arg_parser.include_individual, - "skip_users": self._dfp_arg_parser.skip_users, - "only_users": self._dfp_arg_parser.only_users, - "timestamp_column_name": self._config.ae.timestamp_column_name, - "userid_column_name": self._config.ae.userid_column_name, - "fallback_username": self._config.ae.fallback_username - } - }, - DFP_ROLLING_WINDOW: { - "min_history": 300, - "min_increment": 300, - "max_history": self._dfp_arg_parser.duration, - "cache_dir": self._dfp_arg_parser.cache_dir, - "timestamp_column_name": self._config.ae.timestamp_column_name - }, - DFP_DATA_PREP: { - "timestamp_column_name": self._config.ae.timestamp_column_name, - "schema": { - "schema_str": self._preprocess_schema_str, "encoding": self._encoding - } - }, - DFP_TRAINING: { - "model_kwargs": { - "encoder_layers": [512, 500], # layers of the encoding part - "decoder_layers": [512], # layers of the decoding part - "activation": 'relu', # activation function - "swap_p": 0.2, # noise parameter - "lr": 0.001, # learning rate - "lr_decay": 0.99, # learning decay - "batch_size": 512, - "verbose": False, - "optimizer": 'sgd', # SGD optimizer is selected(Stochastic gradient descent) - "scaler": 'standard', # feature scaling method - "min_cats": 1, # cut off for minority categories - "progress_bar": False, - "device": "cuda" - }, - "feature_columns": self._config.ae.feature_columns, - "epochs": 30, - "validation_size": 0.10 - }, - MLFLOW_MODEL_WRITER: { - "model_name_formatter": self._dfp_arg_parser.model_name_formatter, - "experiment_name_formatter": self._dfp_arg_parser.experiment_name_formatter, - "timestamp_column_name": self._config.ae.timestamp_column_name, - "conda_env": { - 'channels': ['defaults', 'conda-forge'], - 'dependencies': ['python={}'.format('3.8'), 'pip'], - 'pip': ['mlflow', 'dfencoder'], - 'name': 'mlflow-env' - }, - "databricks_permissions": None - } - } - - return module_conf - - def inf_pipe_module_conf(self): - module_conf = { - FILE_BATCHER: { - "period": "D", - "sampling_rate_s": self._dfp_arg_parser.sample_rate_s, - "start_time": self._dfp_arg_parser.time_fields.start_time, - "end_time": self._dfp_arg_parser.time_fields.end_time, - "iso_date_regex_pattern": iso_date_regex_pattern - }, - FILE_TO_DF: { - "timestamp_column_name": self._config.ae.timestamp_column_name, - "parser_kwargs": { - "lines": False, "orient": "records" - }, - "cache_dir": self._dfp_arg_parser.cache_dir, - "filter_null": True, - "file_type": "JSON", - "schema": { - "schema_str": self._source_schema_str, "encoding": self._encoding - } - }, - DFP_SPLIT_USERS: { - "include_generic": self._dfp_arg_parser.include_generic, - "include_individual": self._dfp_arg_parser.include_individual, - "skip_users": self._dfp_arg_parser.skip_users, - "only_users": self._dfp_arg_parser.only_users, - "timestamp_column_name": self._config.ae.timestamp_column_name, - "userid_column_name": self._config.ae.userid_column_name, - "fallback_username": self._config.ae.fallback_username - }, - DFP_ROLLING_WINDOW: { - "min_history": 1, - "min_increment": 0, - "max_history": "1d", - "cache_dir": self._dfp_arg_parser.cache_dir, - "timestamp_column_name": self._config.ae.timestamp_column_name - }, - DFP_DATA_PREP: { - "timestamp_column_name": self._config.ae.timestamp_column_name, - "schema": { - "schema_str": self._preprocess_schema_str, "encoding": self._encoding - } - }, - DFP_INFERENCE: { - "model_name_formatter": self._dfp_arg_parser.model_name_formatter, - "fallback_username": self._config.ae.fallback_username, - "timestamp_column_name": self._config.ae.timestamp_column_name - }, - FILTER_DETECTIONS: { - "field_name": "mean_abs_z", - "threshold": 2.0, - "filter_source": "DATAFRAME", - "schema": { - "input_message_type": self._input_message_type, "encoding": self._encoding - } - }, - DFP_POST_PROCESSING: { - "timestamp_column_name": self._config.ae.timestamp_column_name - }, - SERIALIZE: { - "exclude": ['batch_count', 'origin_hash', '_row_hash', '_batch_id'] - }, - WRITE_TO_FILE: { - "filename": "dfp_detections_{}.csv".format(self._dfp_arg_parser.source), "overwrite": True - } - } - - return module_conf - - def tra_pipe_module_conf(self): - module_conf = { - FILE_BATCHER: { - "period": "D", - "sampling_rate_s": self._dfp_arg_parser.sample_rate_s, - "start_time": self._dfp_arg_parser.time_fields.start_time, - "end_time": self._dfp_arg_parser.time_fields.end_time, - "iso_date_regex_pattern": iso_date_regex_pattern - }, - FILE_TO_DF: { - "timestamp_column_name": self._config.ae.timestamp_column_name, - "parser_kwargs": { - "lines": False, "orient": "records" - }, - "cache_dir": self._dfp_arg_parser.cache_dir, - "filter_null": True, - "file_type": "JSON", - "schema": { - "schema_str": self._source_schema_str, "encoding": self._encoding - } - }, - DFP_SPLIT_USERS: { - "include_generic": self._dfp_arg_parser.include_generic, - "include_individual": self._dfp_arg_parser.include_individual, - "skip_users": self._dfp_arg_parser.skip_users, - "only_users": self._dfp_arg_parser.only_users, - "timestamp_column_name": self._config.ae.timestamp_column_name, - "userid_column_name": self._config.ae.userid_column_name, - "fallback_username": self._config.ae.fallback_username - }, - DFP_ROLLING_WINDOW: { - "min_history": 300, - "min_increment": 300, - "max_history": self._dfp_arg_parser.duration, - "cache_dir": self._dfp_arg_parser.cache_dir, - "timestamp_column_name": self._config.ae.timestamp_column_name - }, - DFP_DATA_PREP: { - "timestamp_column_name": self._config.ae.timestamp_column_name, - "schema": { - "schema_str": self._preprocess_schema_str, "encoding": self._encoding - } - }, - DFP_TRAINING: { - "model_kwargs": { - "encoder_layers": [512, 500], # layers of the encoding part - "decoder_layers": [512], # layers of the decoding part - "activation": 'relu', # activation function - "swap_p": 0.2, # noise parameter - "lr": 0.001, # learning rate - "lr_decay": 0.99, # learning decay - "batch_size": 512, - "verbose": False, - "optimizer": 'sgd', # SGD optimizer is selected(Stochastic gradient descent) - "scaler": 'standard', # feature scaling method - "min_cats": 1, # cut off for minority categories - "progress_bar": False, - "device": "cuda" - }, - "feature_columns": self._config.ae.feature_columns, - "epochs": 30, - "validation_size": 0.10 - }, - MLFLOW_MODEL_WRITER: { - "model_name_formatter": self._dfp_arg_parser.model_name_formatter, - "experiment_name_formatter": self._dfp_arg_parser.experiment_name_formatter, - "timestamp_column_name": self._config.ae.timestamp_column_name, - "conda_env": { - 'channels': ['defaults', 'conda-forge'], - 'dependencies': ['python={}'.format('3.8'), 'pip'], - 'pip': ['mlflow', 'dfencoder'], - 'name': 'mlflow-env' - }, - "databricks_permissions": None - } - } - - return module_conf - def generate_ae_config(source: str, userid_column_name: str, diff --git a/examples/digital_fingerprinting/production/morpheus/dfp_modules_pipeline.py b/examples/digital_fingerprinting/production/morpheus/dfp_modules_pipeline.py index 65c87c4d7f..7ede65cae1 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp_modules_pipeline.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp_modules_pipeline.py @@ -153,8 +153,6 @@ def run_pipeline(source: str, module_conf = config_generator.get_module_conf() - output_port_count = module_conf.get("output_port_count") - # Create a pipeline object pipeline = Pipeline(config) @@ -165,7 +163,7 @@ def run_pipeline(source: str, module_conf, input_port_name="input", output_port_name_prefix="output", - output_port_count=output_port_count)) + output_port_count=2)) train_moniter_stage = pipeline.add_stage( MonitorStage(config, description="DFP Training Pipeline rate", smoothing=0.001)) diff --git a/morpheus/modules/filter_detections.py b/morpheus/modules/filter_detections.py index 393842a02e..0cb952ccc3 100644 --- a/morpheus/modules/filter_detections.py +++ b/morpheus/modules/filter_detections.py @@ -27,7 +27,6 @@ from morpheus.messages.multi_response_message import MultiResponseMessage from morpheus.utils.module_ids import FILTER_DETECTIONS from morpheus.utils.module_ids import MORPHEUS_MODULE_NAMESPACE -from morpheus.utils.module_utils import get_module_config from morpheus.utils.module_utils import register_module logger = logging.getLogger(__name__) @@ -65,6 +64,17 @@ def filter_detections(builder: mrc.Builder): ---------- builder : mrc.Builder mrc Builder object. + + Notes + ---------- + Configurable parameters: + - `field_name` (str): Name of the field to filter on. Defaults to `probs`. + - `threshold` (float): Threshold value to filter on. Defaults to `0.5`. + - `filter_source` (str): Source of the filter field. Defaults to `AUTO`. + - `copy` (bool): Whether to copy the rows or slice them. Defaults to `True`. + - `schema` (dict): Schema configuration. + - `input_message_type` (str): Pickled message type. + - `encoding` (str): Encoding used to pickle the message type. """ config = builder.get_current_module_config() @@ -74,27 +84,30 @@ def filter_detections(builder: mrc.Builder): filter_source = config.get("filter_source", "AUTO") copy = config.get("copy", True) - schema_config = config.get("schema", None) - input_message_type = schema_config.get("input_message_type", None) - encoding = schema_config.get("encoding", None) + if ("schema" not in config): + raise ValueError("Schema configuration not found.") + + schema_config = config["schema"] + input_message_type = schema_config["input_message_type"] + encoding = schema_config["encoding"] message_type = pickle.loads(bytes(input_message_type, encoding)) - def find_detections(x: MultiMessage, filter_source) -> typing.Union[cp.ndarray, np.ndarray]: + def find_detections(x: MultiMessage, _filter_source) -> typing.Union[cp.ndarray, np.ndarray]: # Determind the filter source - if filter_source == FilterSource.TENSOR: - filter_source = x.get_output(field_name) + if _filter_source == FilterSource.TENSOR: + _filter_source = x.get_output(field_name) else: - filter_source = x.get_meta(field_name).values + _filter_source = x.get_meta(field_name).values - if (isinstance(filter_source, np.ndarray)): + if (isinstance(_filter_source, np.ndarray)): array_mod = np else: array_mod = cp # Get per row detections - detections = (filter_source > threshold) + detections = (_filter_source > threshold) if (len(detections.shape) > 1): detections = detections.any(axis=1) @@ -104,7 +117,7 @@ def find_detections(x: MultiMessage, filter_source) -> typing.Union[cp.ndarray, return array_mod.where(detections[1:] != detections[:-1])[0].reshape((-1, 2)) - def filter_copy(x: MultiMessage) -> MultiMessage: + def filter_copy(x: MultiMessage) -> typing.Union[MultiMessage, None]: """ This function uses a threshold value to filter the messages. diff --git a/morpheus/stages/general/multi_port_module_stage.py b/morpheus/stages/general/multi_port_module_stage.py index 3ea0237b33..9abbc95615 100644 --- a/morpheus/stages/general/multi_port_module_stage.py +++ b/morpheus/stages/general/multi_port_module_stage.py @@ -105,7 +105,7 @@ def _build(self, builder: mrc.Builder, in_stream_pairs: typing.List[StreamPair]) in_stream_node = in_stream_pairs[0][0] - # Load module from theregistry. + # Load module from the registry. module = load_module(self._module_conf, builder=builder) mod_in_stream = module.input_port(self._input_port_name) From f22f1de6dba4cf3a705846d59c3dc72623f18f53 Mon Sep 17 00:00:00 2001 From: Devin Robison Date: Fri, 10 Mar 2023 15:07:11 -0700 Subject: [PATCH 080/157] More cleanup, config fixes --- .../morpheus/dfp/modules/dfp_deployment.py | 13 +- .../morpheus/dfp/modules/dfp_inference.py | 2 - .../dfp/modules/dfp_inference_pipe.py | 10 +- .../morpheus/dfp/modules/dfp_preproc.py | 14 +- .../dfp/modules/dfp_rolling_window.py | 7 - .../morpheus/dfp/modules/dfp_split_users.py | 126 ++++++++++-------- .../morpheus/dfp/modules/dfp_training.py | 3 +- .../morpheus/dfp/modules/dfp_training_pipe.py | 12 ++ .../morpheus/dfp/utils/config_generator.py | 30 +---- .../include/morpheus/messages/control.hpp | 1 + morpheus/loaders/file_to_df_loader.py | 40 +++--- morpheus/modules/file_batcher.py | 41 ++++-- morpheus/modules/file_to_df.py | 32 ++++- morpheus/modules/filter_detections.py | 4 +- 14 files changed, 195 insertions(+), 140 deletions(-) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_deployment.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_deployment.py index 36128578f8..d374c9689f 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_deployment.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_deployment.py @@ -36,9 +36,16 @@ @register_module(DFP_DEPLOYMENT, MORPHEUS_MODULE_NAMESPACE) def dfp_deployment(builder: mrc.Builder): """ - This module function allows for the consolidation of multiple dfp pipeline modules relevant to inference/training - :param builder: - :return: + Parameters + ---------- + builder : mrc.Builder + Pipeline builder instance. + + Notes + ---------- + Configurable parameters: + - training_options: dict + - inference_options: dict """ module_config = builder.get_current_module_config() diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference.py index 2883e9c667..4d88ba4591 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference.py @@ -22,8 +22,6 @@ from mlflow.tracking.client import MlflowClient from mrc.core import operators as ops -import cudf - from morpheus.messages import MessageControl from morpheus.messages.multi_ae_message import MultiAEMessage from morpheus.utils.module_ids import MORPHEUS_MODULE_NAMESPACE diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference_pipe.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference_pipe.py index 6dc0da144c..cd63244caf 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference_pipe.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference_pipe.py @@ -44,7 +44,7 @@ @register_module(DFP_INFERENCE_PIPE, MORPHEUS_MODULE_NAMESPACE) def dfp_inference_pipe(builder: mrc.Builder): """ - This module function allows for the consolidation of multiple dfp pipeline modules relevent to inference + This module function allows for the consolidation of multiple dfp pipeline modules relevant to inference process into a single module. Parameters @@ -57,6 +57,14 @@ def dfp_inference_pipe(builder: mrc.Builder): Configurable parameters: - batching_options: Options for batching the data - cache_dir: Directory to cache the rolling window data + - detection_criteria: Criteria for filtering detections + - inference_options: Options for inference + - output_port_count: Number of output ports + - preprocessing_options: Options for preprocessing the data + - stream_aggregation_options: Options for aggregating the data by stream + - timestamp_column_name: Name of the timestamp column + - user_splitting_options: Options for splitting the data by user + - write_to_file_options: Options for writing the detections to file """ config = builder.get_current_module_config() diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_preproc.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_preproc.py index e958cab838..24e6bbf04a 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_preproc.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_preproc.py @@ -38,13 +38,23 @@ @register_module(DFP_PREPROC, MORPHEUS_MODULE_NAMESPACE) def dfp_preproc(builder: mrc.Builder): """ - This module function allows for the consolidation of multiple dfp pipeline modules relevent to inference/training + This module function allows for the consolidation of multiple dfp pipeline modules relevant to inference/training process into a single module. Parameters ---------- builder : mrc.Builder Pipeline builder instance. + + Notes + ---------- + Configurable parameters: + - cache_dir: str + - timestamp_column_name: str + - pre_filter_options: dict + - batching_options: dict + - user_splitting_options: dict + - supported_loaders: dict """ config = builder.get_current_module_config() @@ -85,7 +95,7 @@ def dfp_preproc(builder: mrc.Builder): file_to_df_conf = merge_dictionaries(supported_loaders, file_to_df_defaults) dfp_split_users_default = { - "fallback_username": config.get("fallback_username", "generic") + "fallback_username": config.get("fallback_username", "generic_user") } dfp_split_users_conf = merge_dictionaries(splitting_opts, dfp_split_users_default) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_rolling_window.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_rolling_window.py index 5f88512ba4..76a4e12f11 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_rolling_window.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_rolling_window.py @@ -146,7 +146,6 @@ def on_data(control_message: MessageControl): payload = control_message.payload() user_id = control_message.get_metadata("user_id") - # TODO(Devin): Require data type to be set if (control_message.has_metadata("data_type")): data_type = control_message.get_metadata("data_type") else: @@ -178,13 +177,7 @@ def on_data(control_message: MessageControl): return None # TODO (bhargav) Check if we need to pass control_message config to data_prep module. - # If no config is passed there won't be any tasks to perform in the DataPrep stage. - # TODO(Devin): requires a bit more thought, should be safe to re-use the control message here, but - # I'm not 100 percent sure - control_message.payload(result) - # Don't need this? control_message.set_metadata("user_id", user_id) - # Update data type to payload and forward control_message.set_metadata("data_type", "payload") return control_message diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_split_users.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_split_users.py index edc65de0d5..0d9ca959ea 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_split_users.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_split_users.py @@ -65,87 +65,99 @@ def dfp_split_users(builder: mrc.Builder): include_individual = config.get("include_individual", False) if (include_generic): - # TODO(Devin): Should this be an error? # if not "fallback_username" in config: # raise ValueError("fallback_username must be specified if include_generic is True") - fallback_username = config.get("fallback_username", "generic") + fallback_username = config.get("fallback_username", "generic_user") # Map of user ids to total number of messages. Keep indexes monotonic and increasing per user user_index_map: typing.Dict[str, int] = {} - def extract_users(control_message: MessageControl): - logger.debug("Extracting users from message") - if (control_message is None): - logger.debug("No message to extract users from") - return [] + def generate_control_messages(control_message: MessageControl, split_dataframes: typing.Dict[str, cudf.DataFrame]): + output_messages: typing.List[MessageControl] = [] - mm = control_message.payload() - with mm.mutable_dataframe() as dfm: - # df = control_message.payload().df - with log_time(logger.debug) as log_info: + for user_id in sorted(split_dataframes.keys()): + if (user_id in skip_users): + continue - if (isinstance(dfm, cudf.DataFrame)): - # Convert to pandas because cudf is slow at this - df = dfm.to_pandas() - df[timestamp_column_name] = pd.to_datetime(df[timestamp_column_name], utc=True) + user_df = split_dataframes[user_id] + + current_user_count = user_index_map.get(user_id, 0) + # logger.debug("Current user count: %s", current_user_count) + + # Reset the index so that users see monotonically increasing indexes + user_df.index = range(current_user_count, current_user_count + len(user_df)) + user_index_map[user_id] = current_user_count + len(user_df) - split_dataframes: typing.Dict[str, cudf.DataFrame] = {} + user_control_message = control_message.copy() + user_control_message.set_metadata("user_id", user_id) - # If we are skipping users, do that here - if (len(skip_users) > 0): - df = df[~df[userid_column_name].isin(skip_users)] + user_cudf = cudf.from_pandas(user_df) + user_control_message.payload(MessageMeta(df=user_cudf)) - if (len(only_users) > 0): - df = df[df[userid_column_name].isin(only_users)] + # output_messages.append(DFPMessageMeta(df=user_df, user_id=user_id)) + output_messages.append(user_control_message) - # Split up the dataframes - if (include_generic): - split_dataframes[fallback_username] = df + # rows_per_user = [len(msg.payload().df.to_pandas()) for msg in output_messages] - if (include_individual): - split_dataframes.update( - {username: user_df for username, user_df in df.groupby("username", sort=False)}) + # if (len(output_messages) > 0): + # log_info.set_log( + # ("Batch split users complete. Input: %s rows from %s to %s. " + # "Output: %s users, rows/user min: %s, max: %s, avg: %.2f. Duration: {duration:.2f} ms"), + # len(df), + # df[timestamp_column_name].min(), + # df[timestamp_column_name].max(), + # len(rows_per_user), + # np.min(rows_per_user), + # np.max(rows_per_user), + # np.mean(rows_per_user), + # ) - output_messages: typing.List[MessageControl] = [] + return output_messages - for user_id in sorted(split_dataframes.keys()): - if (user_id in skip_users): - continue + def generate_split_dataframes(df: pd.DataFrame): + split_dataframes: typing.Dict[str, cudf.DataFrame] = {} - user_df = split_dataframes[user_id] + # If we are skipping users, do that here + if (len(skip_users) > 0): + df = df[~df[userid_column_name].isin(skip_users)] - current_user_count = user_index_map.get(user_id, 0) - logger.debug("Current user count: %s", current_user_count) + if (len(only_users) > 0): + df = df[df[userid_column_name].isin(only_users)] - # Reset the index so that users see monotonically increasing indexes - user_df.index = range(current_user_count, current_user_count + len(user_df)) - user_index_map[user_id] = current_user_count + len(user_df) + # Split up the dataframes + if (include_generic): + split_dataframes[fallback_username] = df - user_control_message = control_message.copy() - user_control_message.set_metadata("user_id", user_id) + if (include_individual): + split_dataframes.update( + {username: user_df for username, user_df in df.groupby(userid_column_name, sort=False)}) - user_cudf = cudf.from_pandas(user_df) - user_control_message.payload(MessageMeta(df=user_cudf)) + return split_dataframes - # output_messages.append(DFPMessageMeta(df=user_df, user_id=user_id)) - output_messages.append(user_control_message) + def extract_users(control_message: MessageControl): + # logger.debug("Extracting users from message") + if (control_message is None): + logger.debug("No message to extract users from") + return [] + + control_messages = None # for readability + mm = control_message.payload() + with mm.mutable_dataframe() as dfm: + # df = control_message.payload().df + with log_time(logger.debug) as log_info: + + if (isinstance(dfm, cudf.DataFrame)): + # Convert to pandas because cudf is slow at this + df = dfm.to_pandas() + df[timestamp_column_name] = pd.to_datetime(df[timestamp_column_name], utc=True) + else: + df = dfm - rows_per_user = [len(msg.payload().df.to_pandas()) for msg in output_messages] + split_dataframes = generate_split_dataframes(df) - if (len(output_messages) > 0): - log_info.set_log( - ("Batch split users complete. Input: %s rows from %s to %s. " - "Output: %s users, rows/user min: %s, max: %s, avg: %.2f. Duration: {duration:.2f} ms"), - len(df), - df[timestamp_column_name].min(), - df[timestamp_column_name].max(), - len(rows_per_user), - np.min(rows_per_user), - np.max(rows_per_user), - np.mean(rows_per_user), - ) + control_messages = generate_control_messages(control_message, split_dataframes) - return output_messages + return control_messages def node_fn(obs: mrc.Observable, sub: mrc.Subscriber): obs.pipe(ops.map(extract_users), ops.flatten()).subscribe(sub) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training.py index e7549448df..e770035ba6 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training.py @@ -40,11 +40,12 @@ def dfp_training(builder: mrc.Builder): Parameters ---------- builder : mrc.Builder - Pipeline budler instance. + Pipeline builder instance. Notes ---------- Configurable parameters: + - feature_columns: List of feature columns to train on - epochs: Number of epochs to train for - model_kwargs: Keyword arguments to pass to the model (see dfencoder.AutoEncoder) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training_pipe.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training_pipe.py index 47841caa5e..bc91013a94 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training_pipe.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training_pipe.py @@ -46,6 +46,18 @@ def dfp_training_pipe(builder: mrc.Builder): ---------- builder : mrc.Builder Pipeline budler instance. + + Notes + ---------- + Configurable parameters: + - timestamp_column_name : str + - cache_dir : str + - batching_options : dict + - user_splitting_options : dict + - stream_aggregation_options : dict + - preprocessing_options : dict + - dfencoder_options : dict + - mlflow_writer_options : dict """ config = builder.get_current_module_config() diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py b/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py index 86c6b011ea..661755b8f3 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py @@ -53,6 +53,7 @@ def get_module_conf(self): def infer_module_conf(self): module_conf = { + "output_port_count": 2, "timestamp_column_name": self._config.ae.timestamp_column_name, "cache_dir": self._dfp_arg_parser.cache_dir, "batching_options": { @@ -85,48 +86,19 @@ def infer_module_conf(self): "schema_str": self._preprocess_schema_str, "encoding": self._encoding } }, - # DFP_DATA_PREP: { - # "timestamp_column_name": self._config.ae.timestamp_column_name, - # "schema": { - # "schema_str": self._preprocess_schema_str, "encoding": self._encoding - # }, - # }, "inference_options": { "model_name_formatter": self._dfp_arg_parser.model_name_formatter, "fallback_username": self._config.ae.fallback_username, "timestamp_column_name": self._config.ae.timestamp_column_name, }, - # DFP_INFERENCE: { - # "model_name_formatter": self._dfp_arg_parser.model_name_formatter, - # "fallback_username": self._config.ae.fallback_username, - # "timestamp_column_name": self._config.ae.timestamp_column_name - # }, "detection_criteria": { "schema": { "input_message_type": self._input_message_type, "encoding": self._encoding } }, - # FILTER_DETECTIONS: { - # "field_name": "mean_abs_z", - # "threshold": 2.0, - # "filter_source": "DATAFRAME", - # "schema": { - # "input_message_type": self._input_message_type, "encoding": self._encoding - # } - # }, - # DFP_POST_PROCESSING: { - # "timestamp_column_name": self._config.ae.timestamp_column_name - # }, - # SERIALIZE: { - # "exclude": ['batch_count', 'origin_hash', '_row_hash', '_batch_id'], - # "use_cpp": CppConfig.get_should_use_cpp() - # }, "write_to_file_options": { "filename": "dfp_detections_{}.csv".format(self._dfp_arg_parser.source), "overwrite": True }, - # WRITE_TO_FILE: { - # "filename": "dfp_detections_{}.csv".format(self._dfp_arg_parser.source), "overwrite": True - # } } return module_conf diff --git a/morpheus/_lib/include/morpheus/messages/control.hpp b/morpheus/_lib/include/morpheus/messages/control.hpp index 1ec879c559..a5f596a699 100644 --- a/morpheus/_lib/include/morpheus/messages/control.hpp +++ b/morpheus/_lib/include/morpheus/messages/control.hpp @@ -88,6 +88,7 @@ class MessageControl */ bool has_metadata(const std::string& key) const; + // TODO(Devin): Allow for a default value /** * @brief Get the metadata value for a given key * @param key diff --git a/morpheus/loaders/file_to_df_loader.py b/morpheus/loaders/file_to_df_loader.py index 52f026ee2e..7b9494b949 100644 --- a/morpheus/loaders/file_to_df_loader.py +++ b/morpheus/loaders/file_to_df_loader.py @@ -49,21 +49,29 @@ def file_to_df_loader(control_message: MessageControl, task: dict): raise RuntimeError("Only 'aggregate' strategy is supported for file_to_df loader.") files = task.get("files", None) - batcher_config = task["batcher_config"] - - # TODO(Devin): Should be configured at loader creation time - timestamp_column_name = batcher_config.get("timestamp_column_name", None) - schema_batcher_config = batcher_config.get("schema", None) - schema_str = schema_batcher_config.get("schema_str", None) - encoding = schema_batcher_config.get("encoding", None) - file_type = batcher_config.get("file_type", None) - filter_null = batcher_config.get("filter_null", None) - parser_kwargs = batcher_config.get("parser_kwargs", None) - cache_dir = batcher_config.get("cache_dir", None) + config = task["batcher_config"] + + timestamp_column_name = config.get("timestamp_column_name", "timestamp") + + if ("schema" not in config) or (config["schema"] is None): + raise ValueError("Input schema is required.") + + schema_config = config["schema"] + schema_str = schema_config["schema_str"] + encoding = schema_config["encoding"] + + file_type = config.get("file_type", "JSON") + filter_null = config.get("filter_null", False) + parser_kwargs = config.get("parser_kwargs", None) + cache_dir = config.get("cache_dir", None) download_method: typing.Literal["single_thread", "multiprocess", "dask", "dask_thread"] = os.environ.get("MORPHEUS_FILE_DOWNLOAD_TYPE", "multiprocess") + if (cache_dir is None): + cache_dir = "./.cache" + logger.warning("Cache directory not set. Defaulting to ./.cache") + cache_dir = os.path.join(cache_dir, "file_cache") # Load input schema @@ -117,7 +125,7 @@ def get_or_create_dataframe_from_s3_batch(file_name_batch: typing.List[str]) -> fs: fsspec.AbstractFileSystem = file_list.fs # Create a list of dictionaries that only contains the information we are interested in hashing. `ukey` just - # hashes all of the output of `info()` which is perfect + # hashes all the output of `info()` which is perfect hash_data = [{"ukey": fs.ukey(file_object.path)} for file_object in file_list] # Convert to base 64 encoding to remove - values @@ -202,10 +210,10 @@ def convert_to_dataframe(filenames: typing.List[str]): duration = (time.time() - start_time) * 1000.0 - logger.debug("S3 objects to DF complete. Rows: %s, Cache: %s, Duration: %s ms", - len(output_df), - "hit" if cache_hit else "miss", - duration) + # logger.debug("S3 objects to DF complete. Rows: %s, Cache: %s, Duration: %s ms", + # len(output_df), + # "hit" if cache_hit else "miss", + # duration) return output_df except Exception: logger.exception("Error while converting S3 buckets to DF.") diff --git a/morpheus/modules/file_batcher.py b/morpheus/modules/file_batcher.py index ec27baa49e..771e29577b 100644 --- a/morpheus/modules/file_batcher.py +++ b/morpheus/modules/file_batcher.py @@ -29,6 +29,7 @@ from morpheus.utils.module_ids import FILE_BATCHER from morpheus.utils.module_ids import MORPHEUS_MODULE_NAMESPACE from morpheus.utils.module_utils import register_module +from morpheus.utils.module_utils import merge_dictionaries logger = logging.getLogger(__name__) @@ -65,28 +66,33 @@ def file_batcher(builder: mrc.Builder): """ config = builder.get_current_module_config() - # config = get_module_config(FILE_BATCHER, builder) TimestampFileObj = namedtuple("TimestampFileObj", ["timestamp", "file_name"]) - period = config.get("batch_period", 'D') - sampling_rate_s = config.get("batch_sampling_rate_s", 0) - - start_time = config.get("batch_start_time") - end_time = config.get("batch_end_time") - iso_date_regex_pattern = config.get("batch_iso_date_regex_pattern", default_iso_date_regex_pattern) iso_date_regex = re.compile(iso_date_regex_pattern) - def build_fs_filename_df(files): + default_batching_opts = { + "period": config.get("batch_period", 'D'), + "sampling_rate_s": config.get("batch_sampling_rate_s", 0), + "start_time": config.get("batch_start_time"), + "end_time": config.get("batch_end_time"), + } + + def build_fs_filename_df(files, params): file_objects: fsspec.core.OpenFiles = fsspec.open_files(files) + start_time = params["start_time"] + end_time = params["end_time"] + sampling_rate_s = params["sampling_rate_s"] + ts_and_files = [] for file_object in file_objects: ts = date_extractor(file_object, iso_date_regex) # Exclude any files outside the time window - if ((start_time is not None and ts < start_time) or (end_time is not None and ts > end_time)): + if ((start_time is not None and ts < start_time) or ( + end_time is not None and ts > end_time)): continue ts_and_files.append(TimestampFileObj(ts, file_object.full_name)) @@ -160,7 +166,8 @@ def generate_cms_for_batch_periods(control_message: MessageControl, period_gb, n return control_messages - def add_ts_period(df): + def add_ts_period(df, period): + # TODO(Devin): Rough approximation of pandas '.dt.to_period()' method, which is not yet supported by cudf if (period == "s"): df["period"] = df["ts"].dt.strftime("%Y-%m-%d %H:%M:%S").astype("datetime64[s]").astype('int') @@ -177,22 +184,28 @@ def add_ts_period(df): else: raise Exception("Unknown period") + def build_processing_params(control_message): + batching_opts = {} + if (control_message.has_metadata("batching_options")): + batching_opts = control_message.get_metadata("batching_options") + + return merge_dictionaries(batching_opts, default_batching_opts) + def on_data(control_message: MessageControl): mm = control_message.payload() + params = build_processing_params(control_message) with mm.mutable_dataframe() as dfm: files = dfm.files.to_arrow().to_pylist() - ts_filenames_df = build_fs_filename_df(files) + ts_filenames_df = build_fs_filename_df(files, params) control_messages = [] if len(ts_filenames_df) > 0: # Now split by the batching settings - add_ts_period(ts_filenames_df) + add_ts_period(ts_filenames_df, params["period"]) period_gb = ts_filenames_df.groupby("period") n_groups = len(period_gb.groups) - logger.debug("Batching %d files => %d groups", len(ts_filenames_df), n_groups) - control_messages = generate_cms_for_batch_periods(control_message, period_gb, n_groups) return control_messages diff --git a/morpheus/modules/file_to_df.py b/morpheus/modules/file_to_df.py index 186e69b9a1..761d0bedea 100644 --- a/morpheus/modules/file_to_df.py +++ b/morpheus/modules/file_to_df.py @@ -51,21 +51,41 @@ def file_to_df(builder: mrc.Builder): ---------- builder : mrc.Builder mrc Builder object. + + Notes + ---------- + Configurable parameters: + - cache_dir: Directory to cache the rolling window data + - file_type: Type of the input file + - filter_null: Whether to filter out null values + - parser_kwargs: Keyword arguments to pass to the parser + - schema: Schema of the input data + - timestamp_column_name: Name of the timestamp column """ config = builder.get_current_module_config() - timestamp_column_name = config.get("timestamp_column_name", None) - schema_config = config.get("schema", None) - schema_str = schema_config.get("schema_str", None) - encoding = schema_config.get("encoding", None) - file_type = config.get("file_type", None) - filter_null = config.get("filter_null", None) + timestamp_column_name = config.get("timestamp_column_name", "timestamp") + + if ("schema" not in config) or (config["schema"] is None): + raise ValueError("Input schema is required.") + + schema_config = config["schema"] + schema_str = schema_config["schema_str"] + encoding = schema_config["encoding"] + + file_type = config.get("file_type", "JSON") + filter_null = config.get("filter_null", False) parser_kwargs = config.get("parser_kwargs", None) cache_dir = config.get("cache_dir", None) download_method: typing.Literal["single_thread", "multiprocess", "dask", "dask_thread"] = os.environ.get("MORPHEUS_FILE_DOWNLOAD_TYPE", "multiprocess") + + if (cache_dir is None): + cache_dir = "./.cache" + logger.warning("Cache directory not set. Defaulting to ./.cache") + cache_dir = os.path.join(cache_dir, "file_cache") # Load input schema diff --git a/morpheus/modules/filter_detections.py b/morpheus/modules/filter_detections.py index 0cb952ccc3..fbf745fa33 100644 --- a/morpheus/modules/filter_detections.py +++ b/morpheus/modules/filter_detections.py @@ -171,8 +171,8 @@ def filter_slice(x: MultiMessage) -> typing.List[MultiMessage]: else: filter_source = FilterSource.DATAFRAME - logger.debug(f"filter_source was set to Auto, infering a filter source of {filter_source} based on an input " - "message type of {message_type}") + # logger.debug(f"filter_source was set to Auto, infering a filter source of {filter_source} based on an input " + # "message type of {message_type}") elif filter_source == "DATAFRAME": filter_source = FilterSource.DATAFRAME else: From 1badf56fc4e32631503c66bd492d1834eaae6fab Mon Sep 17 00:00:00 2001 From: Devin Robison Date: Fri, 10 Mar 2023 15:45:04 -0700 Subject: [PATCH 081/157] Fix some incorrect pipeline default values --- .../dfp/modules/dfp_inference_pipe.py | 7 +- .../dfp/modules/dfp_rolling_window.py | 81 +++++++++++-------- .../morpheus/dfp/modules/dfp_split_users.py | 1 - .../morpheus/dfp/modules/dfp_training_pipe.py | 8 +- .../morpheus/dfp/utils/cached_user_window.py | 13 +++ 5 files changed, 69 insertions(+), 41 deletions(-) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference_pipe.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference_pipe.py index cd63244caf..5ac7444822 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference_pipe.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference_pipe.py @@ -83,12 +83,14 @@ def dfp_inference_pipe(builder: mrc.Builder): "user_splitting_options": config.get("user_splitting_options", {}), } - stream_aggregation_options = config.get("stream_aggregation_options", { + stream_aggregation_options = config.get("stream_aggregation_options", {}) + stream_aggregation_options = merge_dictionaries(stream_aggregation_options, { "cache_dir": cache_dir, "timestamp_column_name": ts_column_name, }) - data_prep_options = config.get("preprocessing_options", { + data_prep_options = config.get("preprocessing_options", {}) + data_prep_options = merge_dictionaries(data_prep_options, { "timestamp_column_name": ts_column_name, }) @@ -108,6 +110,7 @@ def dfp_inference_pipe(builder: mrc.Builder): preproc_conf = merge_dictionaries(preproc_options, preproc_defaults) stream_aggregation_defaults = { + "cache_mode": "batch", "trigger_on_min_history": 300, "trigger_on_min_increment": 300, } diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_rolling_window.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_rolling_window.py index 76a4e12f11..5c3d82f867 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_rolling_window.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_rolling_window.py @@ -51,6 +51,9 @@ def dfp_rolling_window(builder: mrc.Builder): - aggregation_span: The span of time to aggregate over - cache_dir: Directory to cache the rolling window data - cache_to_disk: Whether to cache the rolling window data to disk + - cache_mode: The cache mode to use 'batch' or 'aggregate' + aggregate: Cache the entire rolling window + batch: Cache until batch criteria is met and then flush - timestamp_column_name: Name of the timestamp column - trigger_on_min_history: Minimum number of rows to trigger the rolling window - trigger_on_min_increment: Minimum number of rows to trigger the rolling window @@ -60,14 +63,18 @@ def dfp_rolling_window(builder: mrc.Builder): timestamp_column_name = config.get("timestamp_column_name", "timestamp") + cache_mode = config.get("cache_mode", "batch") min_history = config.get("trigger_on_min_history", 1) min_increment = config.get("trigger_on_min_increment", 0) - aggregation_span = config.get("aggregation_span", "360d") + aggregation_span = config.get("aggregation_span", "60d") cache_to_disk = config.get("cache_to_disk", False) - cache_dir = config.get("cache_dir", None) - if (cache_dir is not None): - cache_dir = os.path.join(cache_dir, "rolling-user-data") + cache_dir = config.get("cache_dir") + if (cache_dir is None): + cache_dir = "./.cache" + logger.warning("No cache directory specified, using default: %s", cache_dir) + + cache_dir = os.path.join(cache_dir, "rolling-user-data") user_cache_map: typing.Dict[str, CachedUserWindow] = {} @@ -98,7 +105,7 @@ def try_build_window(message: MessageMeta, user_id: str) -> typing.Union[Message incoming_df[timestamp_column_name] = pd.to_datetime(incoming_df[timestamp_column_name], utc=True) if (not user_cache.append_dataframe(incoming_df=incoming_df)): - # Then our incoming dataframe wasnt even covered by the window. Generate warning + # Then our incoming dataframe wasn't even covered by the window. Generate warning logger.warning(("Incoming data preceeded existing history. " "Consider deleting the rolling window cache and restarting.")) return None @@ -112,33 +119,36 @@ def try_build_window(message: MessageMeta, user_id: str) -> typing.Union[Message logger.debug("Not enough data to train") return None - # We have enough data, but has enough time since the last training taken place? - if (user_cache.total_count - user_cache.last_train_count < min_increment): - logger.debug("Elapsed time since last train is too short") - return None + if (cache_mode == "batch"): + df_window = user_cache.get_spanning_df(max_history=None) + user_cache.flush() + else: + # We have enough data, but has enough time since the last training taken place? + if (user_cache.total_count - user_cache.last_train_count < min_increment): + logger.debug("Elapsed time since last train is too short") + return None - # Obtain a dataframe spanning the aggregation window - df_window = user_cache.get_spanning_df(max_history=aggregation_span) + # Obtain a dataframe spanning the aggregation window + df_window = user_cache.get_spanning_df(max_history=aggregation_span) - # Hash the incoming data rows to find a match - incoming_hash = pd.util.hash_pandas_object(incoming_df.iloc[[0, -1]], index=False) + # Hash the incoming data rows to find a match + incoming_hash = pd.util.hash_pandas_object(incoming_df.iloc[[0, -1]], index=False) - # Find the index of the first and last row - match = df_window[df_window["_row_hash"] == incoming_hash.iloc[0]] + # Find the index of the first and last row + match = df_window[df_window["_row_hash"] == incoming_hash.iloc[0]] - if (len(match) == 0): - raise RuntimeError("Invalid rolling window") + if (len(match) == 0): + raise RuntimeError("Invalid rolling window") - first_row_idx = match.index[0].item() - last_row_idx = df_window[df_window["_row_hash"] == incoming_hash.iloc[-1]].index[-1].item() + first_row_idx = match.index[0].item() + last_row_idx = df_window[df_window["_row_hash"] == incoming_hash.iloc[-1]].index[-1].item() - found_count = (last_row_idx - first_row_idx) + 1 + found_count = (last_row_idx - first_row_idx) + 1 - if (found_count != len(incoming_df)): - raise RuntimeError(("Overlapping rolling history detected. " - "Rolling history can only be used with non-overlapping batches")) + if (found_count != len(incoming_df)): + raise RuntimeError(("Overlapping rolling history detected. " + "Rolling history can only be used with non-overlapping batches")) - # TODO(Devin): Optimize return MessageMeta(cudf.from_pandas(df_window)) def on_data(control_message: MessageControl): @@ -159,17 +169,18 @@ def on_data(control_message: MessageControl): result = try_build_window(payload, user_id) # Return a MessageMeta if (result is not None): - log_info.set_log( - ("Rolling window complete for %s in {duration:0.2f} ms. " - "Input: %s rows from %s to %s. Output: %s rows from %s to %s"), - user_id, - len(payload.df), - payload.df[timestamp_column_name].min(), - payload.df[timestamp_column_name].max(), - result.count, - result.df[timestamp_column_name].min(), - result.df[timestamp_column_name].max(), - ) + pass + # log_info.set_log( + # ("Rolling window complete for %s in {duration:0.2f} ms. " + # "Input: %s rows from %s to %s. Output: %s rows from %s to %s"), + # user_id, + # len(payload.df), + # payload.df[timestamp_column_name].min(), + # payload.df[timestamp_column_name].max(), + # result.count, + # result.df[timestamp_column_name].min(), + # result.df[timestamp_column_name].max(), + # ) else: # Result is None indicates that we don't have enough data to build payload for the event # CM is discarded here diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_split_users.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_split_users.py index 0d9ca959ea..0276d3ca36 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_split_users.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_split_users.py @@ -143,7 +143,6 @@ def extract_users(control_message: MessageControl): control_messages = None # for readability mm = control_message.payload() with mm.mutable_dataframe() as dfm: - # df = control_message.payload().df with log_time(logger.debug) as log_info: if (isinstance(dfm, cudf.DataFrame)): diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training_pipe.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training_pipe.py index bc91013a94..46b940d01b 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training_pipe.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training_pipe.py @@ -76,15 +76,16 @@ def dfp_training_pipe(builder: mrc.Builder): "user_splitting_options": config.get("user_splitting_options", {}), } - stream_aggregation_options = config.get("stream_aggregation_options", { + stream_aggregation_options = config.get("stream_aggregation_options", {}) + stream_aggregation_options = merge_dictionaries(stream_aggregation_options, { "cache_dir": cache_dir, "timestamp_column_name": ts_column_name, }) - data_prep_options = config.get("preprocessing_options", { + data_prep_options = config.get("preprocessing_options", {}) + data_prep_options = merge_dictionaries(data_prep_options, { "timestamp_column_name": ts_column_name, }) - dfencoder_options = config.get("dfencoder_options", {}) mlflow_writer_options = config.get("mlflow_writer_options", { @@ -95,6 +96,7 @@ def dfp_training_pipe(builder: mrc.Builder): preproc_conf = merge_dictionaries(preproc_options, preproc_defaults) stream_aggregation_defaults = { + "cache_mode": "cache", "trigger_on_min_history": 300, "trigger_on_min_increment": 300, } diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/utils/cached_user_window.py b/examples/digital_fingerprinting/production/morpheus/dfp/utils/cached_user_window.py index a20e8a9418..575ef39e32 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/utils/cached_user_window.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/utils/cached_user_window.py @@ -77,6 +77,19 @@ def append_dataframe(self, incoming_df: pd.DataFrame) -> bool: return True + def flush(self): + self.batch_count = 0 + self.count = 0 + self._df = pd.DataFrame() + self._trained_rows = pd.Series() + self.last_train_batch = 0 + self.last_train_count = 0 + self.last_train_epoch = None + self.max_epoch = datetime(1970, 1, 1, tzinfo=timezone(timedelta(hours=0))) + self.min_epoch = datetime(1970, 1, 1, tzinfo=timezone(timedelta(hours=0))) + self.pending_batch_count = 0 + self.total_count = 0 + def get_spanning_df(self, max_history) -> pd.DataFrame: return self.get_train_df(max_history) From 7696fa82f67c0d5ee0247e58345d7a03d2a73881 Mon Sep 17 00:00:00 2001 From: bsuryadevara Date: Sun, 12 Mar 2023 11:52:38 -0500 Subject: [PATCH 082/157] added UI --- .../hil_app/javascript/control_message.js | 0 .../demo/hil_app/static/styles.css | 235 ++++++++++------ .../demo/hil_app/templates/home.html | 254 +++++++++++++----- 3 files changed, 337 insertions(+), 152 deletions(-) create mode 100644 examples/digital_fingerprinting/demo/hil_app/javascript/control_message.js diff --git a/examples/digital_fingerprinting/demo/hil_app/javascript/control_message.js b/examples/digital_fingerprinting/demo/hil_app/javascript/control_message.js new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/digital_fingerprinting/demo/hil_app/static/styles.css b/examples/digital_fingerprinting/demo/hil_app/static/styles.css index ad3021f71b..efbd01c0be 100644 --- a/examples/digital_fingerprinting/demo/hil_app/static/styles.css +++ b/examples/digital_fingerprinting/demo/hil_app/static/styles.css @@ -1,49 +1,94 @@ -html, body { - min-height: 100%; - } - body, div, form, input, select, textarea, label { - padding: 0; - margin: 0; - outline: none; - font-family: Roboto, Arial, sans-serif; - font-size: 14px; - color: #666; - line-height: 22px; - } - h1 { - position: absolute; - margin: 0; - font-size: 50px; - color: #bdc0b5; - z-index: 2; - line-height: 83px; - } - legend { - padding: 10px; - font-family: Roboto, Arial, sans-serif; - font-size: 18px; - color: rgb(15, 15, 15); - background-color: #a6da2e; - } - textarea { - width: calc(100% - 12px); - padding: 5px; - } - .testbox { +.banner { + position:relative; + height: 200px; + max-width: 100%; + background: url("https://developer-blogs.nvidia.com/wp-content/uploads/2022/10/router-box-featured.jpg"); + background-size:auto; display: flex; justify-content: center; align-items: center; - height: inherit; - padding: 20px; + text-align: center; } - form { + .banner::after { + content: ""; + background-color: #8bb521(0, 0, 0, 0.4); + position: absolute; width: 100%; - padding: 20px; - border-radius: 6px; - background: #fff; - box-shadow: 0 0 8px #a6da2e; + height: 100%; } - .banner { + + h1 { + position: absolute; + margin: 0; + font-size: 50px; + color: white; + z-index: 2; + line-height: 83px; + } +/* */ + + +/* Style for the form element */ +form { + margin: 0 auto; + max-width: 1000px; +} + +/* Style for the input element */ +.input { + margin-bottom: 20px; +} + +/* Style for the label element */ +label { + display: inline-block; + margin-bottom: 5px; +} + +/* Style for the select element */ +select { + margin-bottom: 10px; + padding: 5px; + border-radius: 5px; +} + +/* Style for the button elements */ +button { + margin-top: 10px; + margin-right: 10px; + padding: 5px 10px; + border-radius: 5px; + background-color: #8bb521; + color: #fff; + border: none; +} + +button:hover { + cursor: pointer; + background-color: #8bb521; +} + +/* Style for the input elements */ +input { + margin-bottom: 10px; + padding: 5px; + border-radius: 5px; +} + +/* Style for the tasks and properties container elements */ +.tasks-container, +.properties-container { + margin-bottom: 15px; +} + +/* Style for the task and property elements */ +.task, +.property { + margin-bottom: 10px; + padding: 10px; + border: 1px solid #8bb521; + border-radius: 5px; +}.banner { position:relative; height: 200px; max-width: 100%; @@ -56,61 +101,81 @@ html, body { } .banner::after { content: ""; - background-color: rgba(0, 0, 0, 0.4); + background-color: #8bb521(0, 0, 0, 0.4); position: absolute; width: 100%; height: 100%; } - input, select, textarea { + + h1 { + position: absolute; + margin: 0; + font-size: 50px; + color: white; + z-index: 2; + line-height: 83px; + } +/* */ + + +/* Style for the form element */ +form { + margin: 0 auto; + max-width: 1000px; +} + +/* Style for the input element */ +.input { + margin-bottom: 20px; +} + +/* Style for the label element */ +label { + display: inline-block; + margin-bottom: 5px; +} + +/* Style for the select element */ +select { margin-bottom: 10px; - border: 1px solid #ccc; - border-radius: 3px; - } - input { - width: calc(100% - 10px); padding: 5px; - } - .item input:hover, .item select:hover, .item textarea:hover { - border: 1px solid transparent; - box-shadow: 0 0 3px 0 #a6da2e; - color: #0a9b3a; - width: 1000px; - height: 300px; - text-align: left; - align-items: center; - } - .item { - position: relative; - margin: 10px 0; - } - .item span { - color: red; - } + border-radius: 5px; +} - .btn-block { +/* Style for the button elements */ +button { margin-top: 10px; - text-align: center; - } - button { - width: 150px; - padding: 10px; - border: none; + margin-right: 10px; + padding: 5px 10px; border-radius: 5px; - background: #3a413c; - font-size: 16px; + background-color: #8bb521; color: #fff; + border: none; +} + +button:hover { cursor: pointer; - } - #controlMessage { - display: none; - font-size: 1.0em; - color: red; - } + background-color: #8bb521; +} - #controlMessage.visible { - display: block; - } +/* Style for the input elements */ +input { + margin-bottom: 10px; + padding: 5px; + border-radius: 5px; +} - input.invalid { - border-color: red; - } +/* Style for the tasks and properties container elements */ +.tasks-container, +.properties-container { + margin-bottom: 15px; +} + +/* Style for the task and property elements */ +.task, +.property { + margin-bottom: 10px; + padding: 10px; + border: 1px solid #8bb521; + border-radius: 5px; +} \ No newline at end of file diff --git a/examples/digital_fingerprinting/demo/hil_app/templates/home.html b/examples/digital_fingerprinting/demo/hil_app/templates/home.html index 92f7b67547..9316672185 100644 --- a/examples/digital_fingerprinting/demo/hil_app/templates/home.html +++ b/examples/digital_fingerprinting/demo/hil_app/templates/home.html @@ -1,74 +1,194 @@ - - - - - DFP Integrated Training Demo - - -
-
- -
-
- Control Message Publisher -
-
- -
-
-
- -
- -
-
- + + - - + + + // Add new input button functionality + $("#add-input-btn").click(function() { + var inputHtml = ` +
+ + + + +
+ +
+ + +
`; + $("#inputs-container").append(inputHtml); + }); + + // Add new task button functionality using event delegation + $("#inputs-container").on("click", ".add-task-btn", function() { + var taskHtml = ` +
+ + + +
+ +
+ +
`; + $(this).parent().find(".tasks-container").append(taskHtml); + }); + + // Add new property button functionality + $("#inputs-container").on("click", ".add-property-btn", function() { + var propertyHtml = ` +
+ + + + + + + +
`; + $(this).siblings(".properties-container").append(propertyHtml); + }); + + $("#inputs-container").on("click", ".add-metadata-btn", function() { + var metadataHtml = ` + `; + $(this).siblings(".metadata-container").append(metadataHtml); + }); + + // Remove input button functionality using event delegation + $("#inputs-container").on("click", ".remove-input-btn", function() { + $(this).parent().remove(); + }); + + // Remove task button functionality using event delegation + $("#inputs-container").on("click", ".remove-task-btn", function() { + $(this).parent().remove(); + }); + + // Remove property button functionality using event delegation + $("#inputs-container").on("click", ".remove-property-btn", function() { + $(this).parent().remove(); + }); + + // Remove metadata button functionality using event delegation + $("#inputs-container").on("click", ".remove-metadata-btn", function() { + $(this).parent().remove(); + }); + + }); + + + + +
+
+ +
+ +
+ +
+ +
+ + \ No newline at end of file From 6890400ffa61c2968a6f433b4e4b61f6ef174380 Mon Sep 17 00:00:00 2001 From: bsuryadevara Date: Sun, 12 Mar 2023 12:45:30 -0500 Subject: [PATCH 083/157] trivial changes to demo gui --- .../demo/hil_app/templates/home.html | 24 ++++++++++++------- .../demo/hil_app/views.py | 16 ++++++++----- 2 files changed, 26 insertions(+), 14 deletions(-) diff --git a/examples/digital_fingerprinting/demo/hil_app/templates/home.html b/examples/digital_fingerprinting/demo/hil_app/templates/home.html index 9316672185..e41a48b999 100644 --- a/examples/digital_fingerprinting/demo/hil_app/templates/home.html +++ b/examples/digital_fingerprinting/demo/hil_app/templates/home.html @@ -35,7 +35,11 @@ var key = metadataItem.find('input[name="metadata-key"]').val(); var dataType = metadataItem.find('select[name="metadata-data-type"]').val(); var value = metadataItem.find('input[name="metadata-value"]').val(); - metadataJson[key] = { "type": dataType, "value": value }; + + if (dataType === "text-array") + value = value.split(","); + + metadataJson[key] = value; }); var tasksContainer = input.find('.tasks-container'); @@ -54,7 +58,11 @@ var key = property.find('input[name="property-key"]').val(); var dataType = property.find('select[name="property-data-type"]').val(); var value = property.find('input[name="property-value"]').val(); - propertiesJson[key] = { "type": dataType, "value": value }; + + if (dataType === "text-array") + value = value.split(","); + + propertiesJson[key] = value; }); tasksJson.push({ "type": taskType, "properties": propertiesJson }); @@ -66,7 +74,7 @@ }); var jsonString = JSON.stringify(jsonOutput, null, 2); - $('#json-output').val(jsonString); + $('#control-messages-json').val(jsonString); } @@ -88,7 +96,7 @@ - + `; $("#inputs-container").append(inputHtml); }); @@ -128,7 +136,7 @@ - + `; $(this).siblings(".properties-container").append(propertyHtml); }); @@ -148,7 +156,7 @@ - + `; $(this).siblings(".metadata-container").append(metadataHtml); }); @@ -184,11 +192,11 @@

DFP Integrated Training Demo

- +
- + \ No newline at end of file diff --git a/examples/digital_fingerprinting/demo/hil_app/views.py b/examples/digital_fingerprinting/demo/hil_app/views.py index 0c13696db8..def6986298 100644 --- a/examples/digital_fingerprinting/demo/hil_app/views.py +++ b/examples/digital_fingerprinting/demo/hil_app/views.py @@ -8,19 +8,23 @@ from . import app -logger = logging.getLogger(__name__) +logging.basicConfig() +logger = logging.getLogger("logger") @app.route('/', methods=["GET", "POST"]) def submit_messages(): + if request.method == "POST": - control_message = request.form.get("control_message") - logger.error(control_message) - publish_message(control_message) + control_messages_json = request.form.get("control-messages-json") + publish_message(control_messages_json) data = { - "Data": "Successfully published task to kafka topic.", + "status": "Successfully published task to kafka topic.", + "status_code": 200, + "control_messages": json.loads(control_messages_json) } - return jsonify(data) + data = json.dumps(data, indent=4) + return data if request.method == "GET": return render_template("home.html") From cf703fb4ff5251ec1dcf6c5a8a75c41095073237 Mon Sep 17 00:00:00 2001 From: bsuryadevara Date: Mon, 13 Mar 2023 00:08:33 -0500 Subject: [PATCH 084/157] static ui for training messages --- .../digital_fingerprinting/demo/README.md | 36 ++++ .../digital_fingerprinting/demo/bin/start.sh | 2 +- .../demo/{hil_app => cm_app}/__init__.py | 0 .../kafka_helper.py => cm_app/helper.py} | 16 ++ .../{hil_app => cm_app}/static/styles.css | 6 +- .../demo/cm_app/static/submit_messages.js | 172 +++++++++++++++ .../demo/cm_app/static/training.js | 54 +++++ .../demo/cm_app/static/training_style.css | 34 +++ .../cm_app/templates/submit_messages.html | 24 +++ .../demo/cm_app/templates/training.html | 38 ++++ .../demo/cm_app/views.py | 25 +++ .../demo/{hil_app => cm_app}/webapp.py | 0 .../hil_app/javascript/control_message.js | 0 .../demo/hil_app/templates/home.html | 202 ------------------ .../demo/hil_app/views.py | 30 --- 15 files changed, 405 insertions(+), 234 deletions(-) create mode 100644 examples/digital_fingerprinting/demo/README.md rename examples/digital_fingerprinting/demo/{hil_app => cm_app}/__init__.py (100%) rename examples/digital_fingerprinting/demo/{hil_app/kafka_helper.py => cm_app/helper.py} (63%) rename examples/digital_fingerprinting/demo/{hil_app => cm_app}/static/styles.css (98%) create mode 100644 examples/digital_fingerprinting/demo/cm_app/static/submit_messages.js create mode 100644 examples/digital_fingerprinting/demo/cm_app/static/training.js create mode 100644 examples/digital_fingerprinting/demo/cm_app/static/training_style.css create mode 100644 examples/digital_fingerprinting/demo/cm_app/templates/submit_messages.html create mode 100644 examples/digital_fingerprinting/demo/cm_app/templates/training.html create mode 100644 examples/digital_fingerprinting/demo/cm_app/views.py rename examples/digital_fingerprinting/demo/{hil_app => cm_app}/webapp.py (100%) delete mode 100644 examples/digital_fingerprinting/demo/hil_app/javascript/control_message.js delete mode 100644 examples/digital_fingerprinting/demo/hil_app/templates/home.html delete mode 100644 examples/digital_fingerprinting/demo/hil_app/views.py diff --git a/examples/digital_fingerprinting/demo/README.md b/examples/digital_fingerprinting/demo/README.md new file mode 100644 index 0000000000..183eb94502 --- /dev/null +++ b/examples/digital_fingerprinting/demo/README.md @@ -0,0 +1,36 @@ + +#### Kafka Setup + +``` +git clone https://github.com/conduktor/kafka-stack-docker-compose.git +``` + +``` +cd kafka-stack-docker-compose + +docker-compose -f zk-single-kafka-single.yml up + +docker exec -it kafka1 bash + +kafka-topics --create --topic test_cm --bootstrap-server localhost:9092 + +kafka-console-consumer --topic test_cm --from-beginning --bootstrap-server localhost:9092 +``` + +### Flask Server Setup + +``` +pip install flask +``` + +``` +cd ~/examples/digital_fingerprinting/demo/bin + +bash start.sh +``` + +Access URL +``` +http://localhost:3000/training +``` + diff --git a/examples/digital_fingerprinting/demo/bin/start.sh b/examples/digital_fingerprinting/demo/bin/start.sh index bf74806eaf..fe0be6c615 100644 --- a/examples/digital_fingerprinting/demo/bin/start.sh +++ b/examples/digital_fingerprinting/demo/bin/start.sh @@ -4,7 +4,7 @@ export set FLASK_APP=webapp THIS_DIR=$( dirname -- "$( readlink -f -- "$0"; )"; ) -APP_PATH="$THIS_DIR/../hil_app" +APP_PATH="$THIS_DIR/../cm_app" #$(cd $APP_PATH && python -m flask run) diff --git a/examples/digital_fingerprinting/demo/hil_app/__init__.py b/examples/digital_fingerprinting/demo/cm_app/__init__.py similarity index 100% rename from examples/digital_fingerprinting/demo/hil_app/__init__.py rename to examples/digital_fingerprinting/demo/cm_app/__init__.py diff --git a/examples/digital_fingerprinting/demo/hil_app/kafka_helper.py b/examples/digital_fingerprinting/demo/cm_app/helper.py similarity index 63% rename from examples/digital_fingerprinting/demo/hil_app/kafka_helper.py rename to examples/digital_fingerprinting/demo/cm_app/helper.py index 961cfcfe5b..82a36ca6b5 100644 --- a/examples/digital_fingerprinting/demo/hil_app/kafka_helper.py +++ b/examples/digital_fingerprinting/demo/cm_app/helper.py @@ -1,5 +1,9 @@ from confluent_kafka import Producer +import json +import logging +logging.basicConfig() +logger = logging.getLogger("logger") def delivery_report(err, msg): """ Called once for each message produced to indicate delivery result. @@ -23,3 +27,15 @@ def publish_message(message): # Wait for any outstanding messages to be delivered and delivery report # callbacks to be triggered. p.flush() + +def process_cm(request): + control_messages_json = request.form.get("control-messages-json") + publish_message(control_messages_json) + logging.error(control_messages_json) + data = { + "status": "Successfully published task to kafka topic.", + "status_code": 200, + "control_messages": json.loads(control_messages_json) + } + data = json.dumps(data, indent=4) + return data \ No newline at end of file diff --git a/examples/digital_fingerprinting/demo/hil_app/static/styles.css b/examples/digital_fingerprinting/demo/cm_app/static/styles.css similarity index 98% rename from examples/digital_fingerprinting/demo/hil_app/static/styles.css rename to examples/digital_fingerprinting/demo/cm_app/static/styles.css index efbd01c0be..cb5daf33e9 100644 --- a/examples/digital_fingerprinting/demo/hil_app/static/styles.css +++ b/examples/digital_fingerprinting/demo/cm_app/static/styles.css @@ -52,6 +52,10 @@ select { border-radius: 5px; } +#banner { + width: 100%; + } + /* Style for the button elements */ button { margin-top: 10px; @@ -155,7 +159,7 @@ button { button:hover { cursor: pointer; - background-color: #8bb521; + background-color: #380e0ec1; } /* Style for the input elements */ diff --git a/examples/digital_fingerprinting/demo/cm_app/static/submit_messages.js b/examples/digital_fingerprinting/demo/cm_app/static/submit_messages.js new file mode 100644 index 0000000000..a96d8191af --- /dev/null +++ b/examples/digital_fingerprinting/demo/cm_app/static/submit_messages.js @@ -0,0 +1,172 @@ +$(document).ready(function() { + + $("#submit").click(function() { + convertToJson(); + }); + + // Function to convert inputs-container and child data to JSON + function convertToJson() { + var inputsContainer = $('#inputs-container'); + var inputs = inputsContainer.find('.input'); + var jsonOutput = {}; + jsonOutput.inputs = []; + + inputs.each(function(index) { + var input = $(this); + var dataType = input.find('select[name="data-type"]').val(); + var metadataContainer = input.find('.metadata-container'); + var metadata = metadataContainer.find('.metadata'); + var metadataJson = {}; + + metadata.each(function(index) { + var metadataItem = $(this); + var key = metadataItem.find('input[name="metadata-key"]').val(); + var dataType = metadataItem.find('select[name="metadata-data-type"]').val(); + var value = metadataItem.find('input[name="metadata-value"]').val(); + + if (dataType === "text-array") + value = value.split(","); + + metadataJson[key] = value; + }); + + var tasksContainer = input.find('.tasks-container'); + var tasks = tasksContainer.find('.task'); + var tasksJson = []; + + tasks.each(function(index) { + var task = $(this); + var taskType = task.find('select[name="task-type"]').val(); + var propertiesContainer = task.find('.properties-container'); + var properties = propertiesContainer.find('.property'); + var propertiesJson = {}; + + properties.each(function(index) { + var property = $(this); + var key = property.find('input[name="property-key"]').val(); + var dataType = property.find('select[name="property-data-type"]').val(); + var value = property.find('input[name="property-value"]').val(); + + if (dataType === "text-array") + value = value.split(","); + + propertiesJson[key] = value; + }); + + tasksJson.push({ "type": taskType, "properties": propertiesJson }); + }); + + metadataJson['data_type'] = dataType + var inputJson = { "metadata": metadataJson, "tasks": tasksJson }; + jsonOutput.inputs.push(inputJson); + }); + + var jsonString = JSON.stringify(jsonOutput, null, 2); + $('#control-messages-json').val(jsonString); + } + + + + // Add new input button functionality + $("#add-input-btn").click(function() { + var inputHtml = ` +
+ + + + +
+ +
+ + +
`; + $("#inputs-container").append(inputHtml); + }); + + // Add new task button functionality using event delegation + $("#inputs-container").on("click", ".add-task-btn", function() { + var taskHtml = ` +
+ + + +
+ +
+ +
`; + $(this).parent().find(".tasks-container").append(taskHtml); + }); + + // Add new property button functionality + $("#inputs-container").on("click", ".add-property-btn", function() { + var propertyHtml = ` +
+ + + + + + + +
`; + $(this).siblings(".properties-container").append(propertyHtml); + }); + + $("#inputs-container").on("click", ".add-metadata-btn", function() { + var metadataHtml = ` + `; + $(this).siblings(".metadata-container").append(metadataHtml); + }); + + // Remove input button functionality using event delegation + $("#inputs-container").on("click", ".remove-input-btn", function() { + $(this).parent().remove(); + }); + + // Remove task button functionality using event delegation + $("#inputs-container").on("click", ".remove-task-btn", function() { + $(this).parent().remove(); + }); + + // Remove property button functionality using event delegation + $("#inputs-container").on("click", ".remove-property-btn", function() { + $(this).parent().remove(); + }); + + // Remove metadata button functionality using event delegation + $("#inputs-container").on("click", ".remove-metadata-btn", function() { + $(this).parent().remove(); + }); + + }); \ No newline at end of file diff --git a/examples/digital_fingerprinting/demo/cm_app/static/training.js b/examples/digital_fingerprinting/demo/cm_app/static/training.js new file mode 100644 index 0000000000..0c4602a692 --- /dev/null +++ b/examples/digital_fingerprinting/demo/cm_app/static/training.js @@ -0,0 +1,54 @@ +$(document).ready(function() { + + $("#submit").click(function() { + submitForm(); + }); + + + // Function to convert inputs-container and child data to JSON + function submitForm() { + // get all the input fields in the inputs-container div + const inputs = $('#inputs-container :input'); + + // create an empty object to hold the field values + const formData = {}; + + // loop through the inputs and add their values to the formData object + inputs.each(function() { + formData[this.name] = $(this).val(); + }); + + let loadTask = {"type": "load", + "properties": { + "loader_id": "fsspec", + "files": formData["files"].split(","), + }}; + let trainingTask = { + "type": "training", + "properties": {} + }; + + let tasks = [loadTask, trainingTask]; + let samplingRate = parseInt(formData["sampling_rate_s"]); + + let batching_options ={} + batching_options["period"] = formData["period"]; + batching_options["sampling_rate_s"] = samplingRate; + batching_options["start_time"] = formData["start_time"]; + batching_options["end_time"] = formData["end_time"]; + + let metadata = { + "data_type": "payload", + "batching_options": batching_options + }; + + let controlMessage = {"inputs": [{"tasks": tasks, "metadata": metadata}]}; + + // Submit form as JSON + var jsonString = JSON.stringify(controlMessage, null, 2); + console.error(jsonString); + $('#control-messages-json').val(jsonString); + + } + + }); \ No newline at end of file diff --git a/examples/digital_fingerprinting/demo/cm_app/static/training_style.css b/examples/digital_fingerprinting/demo/cm_app/static/training_style.css new file mode 100644 index 0000000000..6645ac1457 --- /dev/null +++ b/examples/digital_fingerprinting/demo/cm_app/static/training_style.css @@ -0,0 +1,34 @@ +form { + display: flex; + flex-direction: column; + align-items: center; + padding-top: 20px; + } + label { + display: inline-flex; + align-items: center; + margin-bottom: 10px; + width: 200px; + text-align: right; + } + input[type=text], + input[type=number] { + height: 25px; + padding: 5px; + border-radius: 5px; + border: 1px solid #ccc; + width: 200px; + } + button[type=button] { + margin-top: 10px; + height: 30px; + padding: 0 20px; + border-radius: 5px; + border: none; + background-color: #8bb521; + color: #fff; + cursor: pointer; + } + button[type=button]:hover { + background-color: #380e0ec1; +} \ No newline at end of file diff --git a/examples/digital_fingerprinting/demo/cm_app/templates/submit_messages.html b/examples/digital_fingerprinting/demo/cm_app/templates/submit_messages.html new file mode 100644 index 0000000000..64f449554b --- /dev/null +++ b/examples/digital_fingerprinting/demo/cm_app/templates/submit_messages.html @@ -0,0 +1,24 @@ + + + + Dynamic Form + + + + + + +
+
+ +
+ +
+ +
+ +
+ + \ No newline at end of file diff --git a/examples/digital_fingerprinting/demo/cm_app/templates/training.html b/examples/digital_fingerprinting/demo/cm_app/templates/training.html new file mode 100644 index 0000000000..ba2e5eee2f --- /dev/null +++ b/examples/digital_fingerprinting/demo/cm_app/templates/training.html @@ -0,0 +1,38 @@ + + + + Dynamic Form + + + + + + + +
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ +
+ +
+ + \ No newline at end of file diff --git a/examples/digital_fingerprinting/demo/cm_app/views.py b/examples/digital_fingerprinting/demo/cm_app/views.py new file mode 100644 index 0000000000..2dc00161d2 --- /dev/null +++ b/examples/digital_fingerprinting/demo/cm_app/views.py @@ -0,0 +1,25 @@ +from flask import render_template +from flask import request +from cm_app.helper import publish_message +from cm_app.helper import process_cm + +from . import app + + +@app.route('/', methods=["GET", "POST"]) +def submit_messages(): + + if request.method == "POST": + return process_cm(request) + + if request.method == "GET": + return render_template("submit_messages.html") + +@app.route('/training', methods=["GET", "POST"]) +def training(): + + if request.method == "POST": + return process_cm(request) + + if request.method == "GET": + return render_template("training.html") diff --git a/examples/digital_fingerprinting/demo/hil_app/webapp.py b/examples/digital_fingerprinting/demo/cm_app/webapp.py similarity index 100% rename from examples/digital_fingerprinting/demo/hil_app/webapp.py rename to examples/digital_fingerprinting/demo/cm_app/webapp.py diff --git a/examples/digital_fingerprinting/demo/hil_app/javascript/control_message.js b/examples/digital_fingerprinting/demo/hil_app/javascript/control_message.js deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/examples/digital_fingerprinting/demo/hil_app/templates/home.html b/examples/digital_fingerprinting/demo/hil_app/templates/home.html deleted file mode 100644 index e41a48b999..0000000000 --- a/examples/digital_fingerprinting/demo/hil_app/templates/home.html +++ /dev/null @@ -1,202 +0,0 @@ - - - - Dynamic Form - - - - - - - -
-
- -
- -
- -
- -
- - \ No newline at end of file diff --git a/examples/digital_fingerprinting/demo/hil_app/views.py b/examples/digital_fingerprinting/demo/hil_app/views.py deleted file mode 100644 index def6986298..0000000000 --- a/examples/digital_fingerprinting/demo/hil_app/views.py +++ /dev/null @@ -1,30 +0,0 @@ -import json -import logging - -from flask import jsonify -from flask import render_template -from flask import request -from hil_app.kafka_helper import publish_message - -from . import app - -logging.basicConfig() -logger = logging.getLogger("logger") - - -@app.route('/', methods=["GET", "POST"]) -def submit_messages(): - - if request.method == "POST": - control_messages_json = request.form.get("control-messages-json") - publish_message(control_messages_json) - data = { - "status": "Successfully published task to kafka topic.", - "status_code": 200, - "control_messages": json.loads(control_messages_json) - } - data = json.dumps(data, indent=4) - return data - - if request.method == "GET": - return render_template("home.html") From 4287f4788696aea26b7f94fefb1a19a64ac796ad Mon Sep 17 00:00:00 2001 From: Bhargav Suryadevara Date: Mon, 13 Mar 2023 00:21:39 -0500 Subject: [PATCH 085/157] Update README.md --- examples/digital_fingerprinting/demo/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/digital_fingerprinting/demo/README.md b/examples/digital_fingerprinting/demo/README.md index 183eb94502..e4342f89a1 100644 --- a/examples/digital_fingerprinting/demo/README.md +++ b/examples/digital_fingerprinting/demo/README.md @@ -32,5 +32,6 @@ bash start.sh Access URL ``` http://localhost:3000/training +http://localhost:3000 ``` From 48502ce4c84c6d1ef4f9c2db31836dbd0efc6c86 Mon Sep 17 00:00:00 2001 From: Bhargav Suryadevara Date: Mon, 13 Mar 2023 00:22:50 -0500 Subject: [PATCH 086/157] Update README.md --- examples/digital_fingerprinting/demo/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/digital_fingerprinting/demo/README.md b/examples/digital_fingerprinting/demo/README.md index e4342f89a1..de62b59692 100644 --- a/examples/digital_fingerprinting/demo/README.md +++ b/examples/digital_fingerprinting/demo/README.md @@ -17,7 +17,7 @@ kafka-topics --create --topic test_cm --bootstrap-server localhost:9092 kafka-console-consumer --topic test_cm --from-beginning --bootstrap-server localhost:9092 ``` -### Flask Server Setup +#### Flask Server Setup ``` pip install flask @@ -29,7 +29,7 @@ cd ~/examples/digital_fingerprinting/demo/bin bash start.sh ``` -Access URL +#### Endpoint URL's ``` http://localhost:3000/training http://localhost:3000 From 43fd6916ff1f866f5b053bb7190d16a55cd62a0e Mon Sep 17 00:00:00 2001 From: bsuryadevara Date: Mon, 13 Mar 2023 08:45:12 -0500 Subject: [PATCH 087/157] updated file names --- .../demo/cm_app/static/{styles.css => submit_messages.css} | 0 .../demo/cm_app/static/{training_style.css => training.css} | 0 .../demo/cm_app/templates/submit_messages.html | 4 ++-- .../demo/cm_app/templates/training.html | 6 +++--- 4 files changed, 5 insertions(+), 5 deletions(-) rename examples/digital_fingerprinting/demo/cm_app/static/{styles.css => submit_messages.css} (100%) rename examples/digital_fingerprinting/demo/cm_app/static/{training_style.css => training.css} (100%) diff --git a/examples/digital_fingerprinting/demo/cm_app/static/styles.css b/examples/digital_fingerprinting/demo/cm_app/static/submit_messages.css similarity index 100% rename from examples/digital_fingerprinting/demo/cm_app/static/styles.css rename to examples/digital_fingerprinting/demo/cm_app/static/submit_messages.css diff --git a/examples/digital_fingerprinting/demo/cm_app/static/training_style.css b/examples/digital_fingerprinting/demo/cm_app/static/training.css similarity index 100% rename from examples/digital_fingerprinting/demo/cm_app/static/training_style.css rename to examples/digital_fingerprinting/demo/cm_app/static/training.css diff --git a/examples/digital_fingerprinting/demo/cm_app/templates/submit_messages.html b/examples/digital_fingerprinting/demo/cm_app/templates/submit_messages.html index 64f449554b..bbf715da35 100644 --- a/examples/digital_fingerprinting/demo/cm_app/templates/submit_messages.html +++ b/examples/digital_fingerprinting/demo/cm_app/templates/submit_messages.html @@ -1,8 +1,8 @@ - Dynamic Form - + DFP Integrated Training and Inference + diff --git a/examples/digital_fingerprinting/demo/cm_app/templates/training.html b/examples/digital_fingerprinting/demo/cm_app/templates/training.html index ba2e5eee2f..f89241d075 100644 --- a/examples/digital_fingerprinting/demo/cm_app/templates/training.html +++ b/examples/digital_fingerprinting/demo/cm_app/templates/training.html @@ -1,9 +1,9 @@ - Dynamic Form - - + DFP Integrated Training + + From 821874e01158a95d256a95616d3cf159e3deb46b Mon Sep 17 00:00:00 2001 From: bsuryadevara Date: Mon, 13 Mar 2023 12:19:24 -0500 Subject: [PATCH 088/157] updated readme.md --- .../digital_fingerprinting/demo/README.md | 22 ++++++------ .../demo/cm_app/static/submit_messages.js | 35 +++++++++--------- .../demo/cm_app/templates/training.html | 10 +++--- .../production/docker-compose.yml | 36 +++++++++++++++++++ 4 files changed, 71 insertions(+), 32 deletions(-) diff --git a/examples/digital_fingerprinting/demo/README.md b/examples/digital_fingerprinting/demo/README.md index de62b59692..9224319e38 100644 --- a/examples/digital_fingerprinting/demo/README.md +++ b/examples/digital_fingerprinting/demo/README.md @@ -1,20 +1,22 @@ #### Kafka Setup -``` -git clone https://github.com/conduktor/kafka-stack-docker-compose.git -``` +Start Kafka service. ``` -cd kafka-stack-docker-compose - -docker-compose -f zk-single-kafka-single.yml up +cd ~/examples/digital_fingerprinting/production -docker exec -it kafka1 bash +docker-compose up kafka zookeeper +``` -kafka-topics --create --topic test_cm --bootstrap-server localhost:9092 +##### Create Kafka Topic +``` +docker exec -it kafka kafka-topics --create --topic test_cm --bootstrap-server localhost:9092 +``` -kafka-console-consumer --topic test_cm --from-beginning --bootstrap-server localhost:9092 +Verify created topic is receiving messages. +``` +docker exec -it kafka kafka-console-consumer --topic test_cm --from-beginning --bootstrap-server localhost:9092 ``` #### Flask Server Setup @@ -31,7 +33,7 @@ bash start.sh #### Endpoint URL's ``` -http://localhost:3000/training http://localhost:3000 +http://localhost:3000/training ``` diff --git a/examples/digital_fingerprinting/demo/cm_app/static/submit_messages.js b/examples/digital_fingerprinting/demo/cm_app/static/submit_messages.js index a96d8191af..bc6a9426be 100644 --- a/examples/digital_fingerprinting/demo/cm_app/static/submit_messages.js +++ b/examples/digital_fingerprinting/demo/cm_app/static/submit_messages.js @@ -13,7 +13,7 @@ $(document).ready(function() { inputs.each(function(index) { var input = $(this); - var dataType = input.find('select[name="data-type"]').val(); + var dataType = input.find('select[name="type"]').val(); var metadataContainer = input.find('.metadata-container'); var metadata = metadataContainer.find('.metadata'); var metadataJson = {}; @@ -21,11 +21,13 @@ $(document).ready(function() { metadata.each(function(index) { var metadataItem = $(this); var key = metadataItem.find('input[name="metadata-key"]').val(); - var dataType = metadataItem.find('select[name="metadata-data-type"]').val(); + var dataType = metadataItem.find('select[name="metadata-type"]').val(); var value = metadataItem.find('input[name="metadata-value"]').val(); if (dataType === "text-array") value = value.split(","); + if (dataType === "Number") + value = parseInt(value) metadataJson[key] = value; }); @@ -44,11 +46,14 @@ $(document).ready(function() { properties.each(function(index) { var property = $(this); var key = property.find('input[name="property-key"]').val(); - var dataType = property.find('select[name="property-data-type"]').val(); + var dataType = property.find('select[name="property-type"]').val(); var value = property.find('input[name="property-value"]').val(); if (dataType === "text-array") value = value.split(","); + + if (dataType === "Number") + value = parseInt(value) propertiesJson[key] = value; }); @@ -71,8 +76,8 @@ $(document).ready(function() { $("#add-input-btn").click(function() { var inputHtml = `
- - @@ -112,18 +117,16 @@ $(document).ready(function() { $("#inputs-container").on("click", ".add-property-btn", function() { var propertyHtml = `
- - - - + - - +
`; $(this).siblings(".properties-container").append(propertyHtml); @@ -132,18 +135,16 @@ $(document).ready(function() { $("#inputs-container").on("click", ".add-metadata-btn", function() { var metadataHtml = ` `; $(this).siblings(".metadata-container").append(metadataHtml); diff --git a/examples/digital_fingerprinting/demo/cm_app/templates/training.html b/examples/digital_fingerprinting/demo/cm_app/templates/training.html index f89241d075..afc77981bf 100644 --- a/examples/digital_fingerprinting/demo/cm_app/templates/training.html +++ b/examples/digital_fingerprinting/demo/cm_app/templates/training.html @@ -14,19 +14,19 @@

DFP Integrated Training Demo

- +
- +
- +
- +
- +
diff --git a/examples/digital_fingerprinting/production/docker-compose.yml b/examples/digital_fingerprinting/production/docker-compose.yml index b20f5e7770..a123a4ef78 100644 --- a/examples/digital_fingerprinting/production/docker-compose.yml +++ b/examples/digital_fingerprinting/production/docker-compose.yml @@ -32,6 +32,42 @@ services: volumes: - db_data:/opt/mlflow/dbdata - mlflow_data:/opt/mlflow/artifacts + + zookeeper: + image: confluentinc/cp-zookeeper:7.3.2 + hostname: zookeeper + container_name: zookeeper + ports: + - "2181:2181" + environment: + ZOOKEEPER_CLIENT_PORT: 2181 + ZOOKEEPER_SERVER_ID: 1 + ZOOKEEPER_SERVERS: zookeeper:2888:3888 + + kafka: + image: confluentinc/cp-kafka:7.3.2 + hostname: kafka + container_name: kafka + ports: + - "9092:9092" + - "29092:29092" + - "9999:9999" + environment: + KAFKA_ADVERTISED_LISTENERS: INTERNAL://kafka:19092,EXTERNAL://${DOCKER_HOST_IP:-127.0.0.1}:9092,DOCKER://host.docker.internal:29092 + KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: INTERNAL:PLAINTEXT,EXTERNAL:PLAINTEXT,DOCKER:PLAINTEXT + KAFKA_INTER_BROKER_LISTENER_NAME: INTERNAL + KAFKA_ZOOKEEPER_CONNECT: "zookeeper:2181" + KAFKA_BROKER_ID: 1 + KAFKA_LOG4J_LOGGERS: "kafka.controller=INFO,kafka.producer.async.DefaultEventHandler=INFO,state.change.logger=INFO" + KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 + KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1 + KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1 + KAFKA_JMX_PORT: 9999 + KAFKA_JMX_HOSTNAME: ${DOCKER_HOST_IP:-127.0.0.1} + KAFKA_AUTHORIZER_CLASS_NAME: kafka.security.authorizer.AclAuthorizer + KAFKA_ALLOW_EVERYONE_IF_NO_ACL_FOUND: "true" + depends_on: + - zookeeper jupyter: restart: always From 325bc31ffb2b30157b7c9200449f235e1892363a Mon Sep 17 00:00:00 2001 From: Devin Robison Date: Tue, 14 Mar 2023 10:49:26 -0600 Subject: [PATCH 089/157] Naming tweaks --- .../production/morpheus/dfp_modules_pipeline.py | 4 ++-- .../production/morpheus/dfp_modules_streaming_pipeline.py | 6 +++--- morpheus/modules/file_batcher.py | 7 ++++--- .../stages/input/control_message_kafka_source_stage.py | 2 +- 4 files changed, 10 insertions(+), 9 deletions(-) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp_modules_pipeline.py b/examples/digital_fingerprinting/production/morpheus/dfp_modules_pipeline.py index 7ede65cae1..09f282ddf2 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp_modules_pipeline.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp_modules_pipeline.py @@ -151,7 +151,7 @@ def run_pipeline(source: str, # This will populate to the minimum configuration parameters with intelligent default values config_generator = ConfigGenerator(config, dfp_arg_parser, schema) - module_conf = config_generator.get_module_conf() + dfp_deployment_module_config = config_generator.get_module_conf() # Create a pipeline object pipeline = Pipeline(config) @@ -160,7 +160,7 @@ def run_pipeline(source: str, dfp_deployment_stage = pipeline.add_stage( MultiPortModuleStage(config, - module_conf, + dfp_deployment_module_config, input_port_name="input", output_port_name_prefix="output", output_port_count=2)) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp_modules_streaming_pipeline.py b/examples/digital_fingerprinting/production/morpheus/dfp_modules_streaming_pipeline.py index 27dd304ba0..9cab68d8aa 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp_modules_streaming_pipeline.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp_modules_streaming_pipeline.py @@ -161,9 +161,9 @@ def run_pipeline(source: str, # This will populate to the minimum configuration parameters with intelligent default values config_generator = ConfigGenerator(config, dfp_arg_parser, schema) - module_conf = config_generator.get_module_conf() + dfp_deployment_module_config = config_generator.get_module_conf() - output_port_count = module_conf.get("output_port_count") + output_port_count = dfp_deployment_module_config.get("output_port_count") # Create a pipeline object pipeline = Pipeline(config) @@ -179,7 +179,7 @@ def run_pipeline(source: str, dfp_deployment_stage = pipeline.add_stage( MultiPortModuleStage(config, - module_conf, + dfp_deployment_module_config, input_port_name="input", output_port_name_prefix="output", output_port_count=output_port_count)) diff --git a/morpheus/modules/file_batcher.py b/morpheus/modules/file_batcher.py index 8445473bcc..bde751c46d 100644 --- a/morpheus/modules/file_batcher.py +++ b/morpheus/modules/file_batcher.py @@ -16,6 +16,7 @@ import re from collections import namedtuple +import datetime import fsspec import fsspec.utils import mrc @@ -82,9 +83,9 @@ def file_batcher(builder: mrc.Builder): def build_fs_filename_df(files, params): file_objects: fsspec.core.OpenFiles = fsspec.open_files(files) - start_time = params["start_time"] - end_time = params["end_time"] - sampling_rate_s = params["sampling_rate_s"] + start_time = datetime.datetime.strptime(params["start_time"], '%Y-%m-%d').replace(tzinfo=datetime.timezone.utc) + end_time = datetime.datetime.strptime(params["end_time"], '%Y-%m-%d').replace(tzinfo=datetime.timezone.utc) + sampling_rate_s = int(params["sampling_rate_s"]) ts_and_files = [] for file_object in file_objects: diff --git a/morpheus/stages/input/control_message_kafka_source_stage.py b/morpheus/stages/input/control_message_kafka_source_stage.py index 43ef9f7ee0..a6fd3b77f8 100644 --- a/morpheus/stages/input/control_message_kafka_source_stage.py +++ b/morpheus/stages/input/control_message_kafka_source_stage.py @@ -127,11 +127,11 @@ def _process_msg(self, consumer, msg): payload = msg.value() if payload is not None: - try: decoded_msg = payload.decode("utf-8") control_messages_conf = json.loads(decoded_msg) self._num_messages += 1 + # TODO(Devin) - one CM at a time(?), don't need to submit 'inputs' for control_message_conf in control_messages_conf.get("inputs", []): self._records_emitted += 1 control_messages.append(MessageControl(control_message_conf)) From 308a2532e345b874bf86156002fe433af0868386 Mon Sep 17 00:00:00 2001 From: Devin Robison Date: Tue, 14 Mar 2023 11:21:58 -0600 Subject: [PATCH 090/157] file batcher bug fix --- .../benchmarks/test_bench_e2e_dfp_pipeline.py | 1 + .../morpheus/dfp/modules/dfp_training.py | 5 +---- .../morpheus/dfp/modules/dfp_training_pipe.py | 2 +- morpheus/modules/file_batcher.py | 19 +++++++++++++------ 4 files changed, 16 insertions(+), 11 deletions(-) diff --git a/examples/digital_fingerprinting/production/morpheus/benchmarks/test_bench_e2e_dfp_pipeline.py b/examples/digital_fingerprinting/production/morpheus/benchmarks/test_bench_e2e_dfp_pipeline.py index 8f68b2d4ae..12d8d934aa 100644 --- a/examples/digital_fingerprinting/production/morpheus/benchmarks/test_bench_e2e_dfp_pipeline.py +++ b/examples/digital_fingerprinting/production/morpheus/benchmarks/test_bench_e2e_dfp_pipeline.py @@ -55,6 +55,7 @@ logger = logging.getLogger(__name__) PIPELINES_CONF = load_json("resource/pipelines_conf.json") +PIPELINES_CONF["output_port_count"] = 2 set_mlflow_tracking_uri(PIPELINES_CONF.get("tracking_uri")) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training.py index e0a4cc187c..9d0343f84f 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training.py @@ -45,7 +45,6 @@ def dfp_training(builder: mrc.Builder): Notes ---------- Configurable parameters: - - feature_columns: List of feature columns to train on - epochs: Number of epochs to train for - model_kwargs: Keyword arguments to pass to the model (see dfencoder.AutoEncoder) @@ -62,9 +61,7 @@ def dfp_training(builder: mrc.Builder): model_kwargs = config.get("model_kwargs", {}) validation_size = config.get("validation_size", 0.0) - if (validation_size > 0.0 and validation_size < 1.0): - validation_size = validation_size - else: + if (validation_size < 0.0 or validation_size > 1.0): raise ValueError("validation_size={0} should be a positive float in the " "(0, 1) range".format(validation_size)) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training_pipe.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training_pipe.py index 46b940d01b..58e92ace8a 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training_pipe.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training_pipe.py @@ -96,7 +96,7 @@ def dfp_training_pipe(builder: mrc.Builder): preproc_conf = merge_dictionaries(preproc_options, preproc_defaults) stream_aggregation_defaults = { - "cache_mode": "cache", + "cache_mode": "aggregate", "trigger_on_min_history": 300, "trigger_on_min_increment": 300, } diff --git a/morpheus/modules/file_batcher.py b/morpheus/modules/file_batcher.py index bde751c46d..7cbbecbc9a 100644 --- a/morpheus/modules/file_batcher.py +++ b/morpheus/modules/file_batcher.py @@ -74,17 +74,24 @@ def file_batcher(builder: mrc.Builder): iso_date_regex = re.compile(iso_date_regex_pattern) default_batching_opts = { - "period": config.get("batch_period", 'D'), - "sampling_rate_s": config.get("batch_sampling_rate_s", 0), - "start_time": config.get("batch_start_time"), - "end_time": config.get("batch_end_time"), + "period": config.get("period", 'D'), + "sampling_rate_s": config.get("sampling_rate_s", 0), + "start_time": config.get("start_time"), + "end_time": config.get("end_time"), } def build_fs_filename_df(files, params): file_objects: fsspec.core.OpenFiles = fsspec.open_files(files) - start_time = datetime.datetime.strptime(params["start_time"], '%Y-%m-%d').replace(tzinfo=datetime.timezone.utc) - end_time = datetime.datetime.strptime(params["end_time"], '%Y-%m-%d').replace(tzinfo=datetime.timezone.utc) + start_time = params["start_time"] + if (start_time is not None): + start_time = datetime.datetime.strptime(start_time, '%Y-%m-%d').replace( + tzinfo=datetime.timezone.utc) + + end_time = params["end_time"] + if (end_time is not None): + end_time = datetime.datetime.strptime(end_time, '%Y-%m-%d').replace(tzinfo=datetime.timezone.utc) + sampling_rate_s = int(params["sampling_rate_s"]) ts_and_files = [] From f1ff7cb225dcda80ae50071f01d97c45398882e8 Mon Sep 17 00:00:00 2001 From: Devin Robison Date: Tue, 14 Mar 2023 12:38:21 -0600 Subject: [PATCH 091/157] Docs update --- morpheus/modules/file_batcher.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/morpheus/modules/file_batcher.py b/morpheus/modules/file_batcher.py index 7cbbecbc9a..c3169ae9d1 100644 --- a/morpheus/modules/file_batcher.py +++ b/morpheus/modules/file_batcher.py @@ -53,12 +53,13 @@ def file_batcher(builder: mrc.Builder): Notes ---------- Configurable parameters: - - batch_end_time: datetime - - batch_iso_date_regex_pattern: str - - batch_parser_kwargs: dict - - batch_period: str - - batch_sampling_rate_s: int - - batch_start_time: datetime + - batching_options: dict + - end_time: datetime + - iso_date_regex_pattern: str + - parser_kwargs: dict + - period: str + - sampling_rate_s: int + - start_time: datetime - cache_dir: str - file_type: str - filter_nulls: bool From 9b74754b5352861bc4861b5abeedc6a712f8c751 Mon Sep 17 00:00:00 2001 From: Devin Robison Date: Tue, 14 Mar 2023 13:50:10 -0600 Subject: [PATCH 092/157] Comments updates --- .../morpheus/benchmarks/modules_conf.json | 2 +- .../benchmarks/test_bench_e2e_dfp_pipeline.py | 4 +- .../morpheus/dfp/modules/dfp_deployment.py | 4 +- .../dfp/modules/dfp_inference_pipe.py | 2 +- .../morpheus/dfp/utils/config_generator.py | 3 +- .../morpheus/dfp_modules_pipeline.py | 2 +- .../dfp_modules_streaming_pipeline.py | 4 +- morpheus/modules/file_batcher.py | 120 ++++++++++-------- .../stages/general/multi_port_module_stage.py | 16 +-- morpheus/utils/module_utils.py | 58 +++++++++ 10 files changed, 143 insertions(+), 72 deletions(-) diff --git a/examples/digital_fingerprinting/production/morpheus/benchmarks/modules_conf.json b/examples/digital_fingerprinting/production/morpheus/benchmarks/modules_conf.json index d8b1342a0c..6610c331d5 100644 --- a/examples/digital_fingerprinting/production/morpheus/benchmarks/modules_conf.json +++ b/examples/digital_fingerprinting/production/morpheus/benchmarks/modules_conf.json @@ -203,5 +203,5 @@ "overwrite": true } }, - "output_port_count": 2 + "num_output_ports": 2 } \ No newline at end of file diff --git a/examples/digital_fingerprinting/production/morpheus/benchmarks/test_bench_e2e_dfp_pipeline.py b/examples/digital_fingerprinting/production/morpheus/benchmarks/test_bench_e2e_dfp_pipeline.py index 12d8d934aa..ac9b87cc29 100644 --- a/examples/digital_fingerprinting/production/morpheus/benchmarks/test_bench_e2e_dfp_pipeline.py +++ b/examples/digital_fingerprinting/production/morpheus/benchmarks/test_bench_e2e_dfp_pipeline.py @@ -55,7 +55,7 @@ logger = logging.getLogger(__name__) PIPELINES_CONF = load_json("resource/pipelines_conf.json") -PIPELINES_CONF["output_port_count"] = 2 +PIPELINES_CONF["num_output_ports"] = 2 set_mlflow_tracking_uri(PIPELINES_CONF.get("tracking_uri")) @@ -81,7 +81,7 @@ def dfp_modules_pipeline(pipe_config: Config, modules_conf, input_port_name="input", output_port_name_prefix="output", - output_port_count=modules_conf["output_port_count"])) + num_output_ports=modules_conf["num_output_ports"])) pipeline.add_edge(source_stage, dfp_deployment_stage) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_deployment.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_deployment.py index d374c9689f..501ed1a927 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_deployment.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_deployment.py @@ -49,7 +49,7 @@ def dfp_deployment(builder: mrc.Builder): """ module_config = builder.get_current_module_config() - output_port_count = 2 + num_output_ports = 2 supported_loaders = {} fsspec_loader_defaults = { @@ -84,6 +84,6 @@ def dfp_deployment(builder: mrc.Builder): builder.register_module_input("input", fsspec_dataloader_module.input_port("input")) # Register output ports for a module. - for i in range(output_port_count): + for i in range(num_output_ports): # Output ports are registered in increment order. builder.register_module_output(f"output-{i}", out_streams[i]) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference_pipe.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference_pipe.py index 5ac7444822..c8b2f2bbb7 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference_pipe.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference_pipe.py @@ -59,7 +59,7 @@ def dfp_inference_pipe(builder: mrc.Builder): - cache_dir: Directory to cache the rolling window data - detection_criteria: Criteria for filtering detections - inference_options: Options for inference - - output_port_count: Number of output ports + - num_output_ports: Number of output ports - preprocessing_options: Options for preprocessing the data - stream_aggregation_options: Options for aggregating the data by stream - timestamp_column_name: Name of the timestamp column diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py b/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py index 661755b8f3..5382808ae6 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py @@ -53,7 +53,7 @@ def get_module_conf(self): def infer_module_conf(self): module_conf = { - "output_port_count": 2, + "num_output_ports": 2, "timestamp_column_name": self._config.ae.timestamp_column_name, "cache_dir": self._dfp_arg_parser.cache_dir, "batching_options": { @@ -64,7 +64,6 @@ def infer_module_conf(self): "parser_kwargs": { "lines": False, "orient": "records" }, - "cache_dir": self._dfp_arg_parser.cache_dir, "schema": { "schema_str": self._source_schema_str, "encoding": self._encoding } diff --git a/examples/digital_fingerprinting/production/morpheus/dfp_modules_pipeline.py b/examples/digital_fingerprinting/production/morpheus/dfp_modules_pipeline.py index 09f282ddf2..fdc18a4de5 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp_modules_pipeline.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp_modules_pipeline.py @@ -163,7 +163,7 @@ def run_pipeline(source: str, dfp_deployment_module_config, input_port_name="input", output_port_name_prefix="output", - output_port_count=2)) + num_output_ports=2)) train_moniter_stage = pipeline.add_stage( MonitorStage(config, description="DFP Training Pipeline rate", smoothing=0.001)) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp_modules_streaming_pipeline.py b/examples/digital_fingerprinting/production/morpheus/dfp_modules_streaming_pipeline.py index 9cab68d8aa..6fe752de86 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp_modules_streaming_pipeline.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp_modules_streaming_pipeline.py @@ -163,7 +163,7 @@ def run_pipeline(source: str, dfp_deployment_module_config = config_generator.get_module_conf() - output_port_count = dfp_deployment_module_config.get("output_port_count") + num_output_ports = dfp_deployment_module_config.get("num_output_ports") # Create a pipeline object pipeline = Pipeline(config) @@ -182,7 +182,7 @@ def run_pipeline(source: str, dfp_deployment_module_config, input_port_name="input", output_port_name_prefix="output", - output_port_count=output_port_count)) + num_output_ports=num_output_ports)) train_moniter_stage = pipeline.add_stage( MonitorStage(config, description="DFP Training Pipeline rate", smoothing=0.001)) diff --git a/morpheus/modules/file_batcher.py b/morpheus/modules/file_batcher.py index c3169ae9d1..44c7fcff6f 100644 --- a/morpheus/modules/file_batcher.py +++ b/morpheus/modules/file_batcher.py @@ -31,6 +31,7 @@ from morpheus.utils.module_ids import MORPHEUS_MODULE_NAMESPACE from morpheus.utils.module_utils import register_module from morpheus.utils.module_utils import merge_dictionaries +from morpheus.utils.module_utils import to_period_cudf_approximation logger = logging.getLogger(__name__) @@ -48,23 +49,23 @@ def file_batcher(builder: mrc.Builder): Parameters ---------- builder : mrc.Builder - mrc Builder object. + An mrc Builder object. Notes - ---------- + ----- Configurable parameters: - - batching_options: dict - - end_time: datetime - - iso_date_regex_pattern: str - - parser_kwargs: dict - - period: str - - sampling_rate_s: int - - start_time: datetime - - cache_dir: str - - file_type: str - - filter_nulls: bool - - schema: dict - - timestamp_column_name: str + - batching_options (dict): + - end_time (datetime|str): End time of the time window. + - iso_date_regex_pattern (str): Regex pattern for ISO date matching. + - parser_kwargs (dict): Additional arguments for the parser. + - period (str): Time period for grouping files. + - sampling_rate_s (int): Sampling rate in seconds. + - start_time (datetime|str): Start time of the time window. + - cache_dir (str): Cache directory. + - file_type (str): File type. + - filter_nulls (bool): Whether to filter null values. + - schema (dict): Data schema. + - timestamp_column_name (str): Name of the timestamp column. """ config = builder.get_current_module_config() @@ -81,19 +82,44 @@ def file_batcher(builder: mrc.Builder): "end_time": config.get("end_time"), } + def validate_control_message(control_message: MessageControl): + if control_message.has_metadata("batching_options") and not isinstance( + control_message.get_metadata("batching_options"), dict): + raise ValueError("Invalid or missing 'batching_options' metadata in control message") + + data_type = control_message.get_metadata("data_type") + if data_type not in {"payload", "streaming"}: + raise ValueError(f"Invalid 'data_type' metadata in control message: {data_type}") + def build_fs_filename_df(files, params): file_objects: fsspec.core.OpenFiles = fsspec.open_files(files) - start_time = params["start_time"] - if (start_time is not None): - start_time = datetime.datetime.strptime(start_time, '%Y-%m-%d').replace( - tzinfo=datetime.timezone.utc) + try: + start_time = params["start_time"] + end_time = params["end_time"] + sampling_rate_s = params["sampling_rate_s"] + + if not isinstance(start_time, (str, type(None))) or ( + start_time is not None and not re.match(r"\d{4}-\d{2}-\d{2}", start_time)): + raise ValueError(f"Invalid 'start_time' value: {start_time}") + + if not isinstance(end_time, (str, type(None))) or ( + end_time is not None and not re.match(r"\d{4}-\d{2}-\d{2}", end_time)): + raise ValueError(f"Invalid 'end_time' value: {end_time}") - end_time = params["end_time"] - if (end_time is not None): - end_time = datetime.datetime.strptime(end_time, '%Y-%m-%d').replace(tzinfo=datetime.timezone.utc) + if not isinstance(sampling_rate_s, int) or sampling_rate_s < 0: + raise ValueError(f"Invalid 'sampling_rate_s' value: {sampling_rate_s}") - sampling_rate_s = int(params["sampling_rate_s"]) + if (start_time is not None): + start_time = datetime.datetime.strptime(start_time, '%Y-%m-%d').replace( + tzinfo=datetime.timezone.utc) + + if (end_time is not None): + end_time = datetime.datetime.strptime(end_time, '%Y-%m-%d').replace(tzinfo=datetime.timezone.utc) + + except Exception as e: + logger.error(f"Error parsing parameters: {e}") + raise ts_and_files = [] for file_object in file_objects: @@ -175,24 +201,6 @@ def generate_cms_for_batch_periods(control_message: MessageControl, period_gb, n return control_messages - def add_ts_period(df, period): - - # TODO(Devin): Rough approximation of pandas '.dt.to_period()' method, which is not yet supported by cudf - if (period == "s"): - df["period"] = df["ts"].dt.strftime("%Y-%m-%d %H:%M:%S").astype("datetime64[s]").astype('int') - elif (period == "m"): - df["period"] = df["ts"].dt.strftime("%Y-%m-%d %H:%M").astype("datetime64[s]").astype('int') - elif (period == "H"): - df["period"] = df["ts"].dt.strftime("%Y-%m-%d %H").astype("datetime64[s]").astype('int') - elif (period == "D"): - df["period"] = df["ts"].dt.strftime("%Y-%m-%d").astype("datetime64[s]").astype('int') - elif (period == "M"): - df["period"] = df["ts"].dt.strftime("%Y-%m").astype("datetime64[s]").astype('int') - elif (period == "Y"): - df["period"] = df["ts"].dt.strftime("%Y").astype("datetime64[s]").astype('int') - else: - raise Exception("Unknown period") - def build_processing_params(control_message): batching_opts = {} if (control_message.has_metadata("batching_options")): @@ -201,23 +209,29 @@ def build_processing_params(control_message): return merge_dictionaries(batching_opts, default_batching_opts) def on_data(control_message: MessageControl): - mm = control_message.payload() - params = build_processing_params(control_message) - with mm.mutable_dataframe() as dfm: - files = dfm.files.to_arrow().to_pylist() - ts_filenames_df = build_fs_filename_df(files, params) + try: + validate_control_message(control_message) - control_messages = [] - if len(ts_filenames_df) > 0: - # Now split by the batching settings + mm = control_message.payload() + params = build_processing_params(control_message) + with mm.mutable_dataframe() as dfm: + files = dfm.files.to_arrow().to_pylist() + ts_filenames_df = build_fs_filename_df(files, params) - add_ts_period(ts_filenames_df, params["period"]) - period_gb = ts_filenames_df.groupby("period") - n_groups = len(period_gb.groups) + control_messages = [] + if len(ts_filenames_df) > 0: + # Now split by the batching settings - control_messages = generate_cms_for_batch_periods(control_message, period_gb, n_groups) + ts_filenames_df = to_period_cudf_approximation(ts_filenames_df, params["period"]) + period_gb = ts_filenames_df.groupby("period") + n_groups = len(period_gb.groups) - return control_messages + control_messages = generate_cms_for_batch_periods(control_message, period_gb, n_groups) + + return control_messages + except Exception as e: + logger.error(f"Error building file list, discarding control message: {e}") + return [] def node_fn(obs: mrc.Observable, sub: mrc.Subscriber): obs.pipe(ops.map(on_data), ops.flatten()).subscribe(sub) diff --git a/morpheus/stages/general/multi_port_module_stage.py b/morpheus/stages/general/multi_port_module_stage.py index 9abbc95615..b53940209c 100644 --- a/morpheus/stages/general/multi_port_module_stage.py +++ b/morpheus/stages/general/multi_port_module_stage.py @@ -39,7 +39,7 @@ class MultiPortModuleStage(Stage): Name of the input port for the registered module. output_port_name_prefix : str Prefix name of the output ports for the registered module. - output_port_count : str + num_output_ports : str Number of output ports for the registered module. input_type : default `typing.Any` The stage acceptable input type. @@ -53,7 +53,7 @@ def __init__(self, module_conf: typing.Dict[str, any], input_port_name: str, output_port_name_prefix: str, - output_port_count: int, + num_output_ports: int, input_type=typing.Any, output_type=typing.Any): @@ -65,11 +65,11 @@ def __init__(self, self._input_port_name = input_port_name self._output_port_name_prefix = output_port_name_prefix - if output_port_count < 1: - raise ValueError(f"The `output_port_count` must be >= 1, but received {output_port_count}.") + if num_output_ports < 1: + raise ValueError(f"The `output_port_count` must be >= 1, but received {num_output_ports}.") - self._create_ports(1, output_port_count) - self._output_port_count = output_port_count + self._create_ports(1, num_output_ports) + self._output_port_count = num_output_ports @property def name(self) -> str: @@ -83,7 +83,7 @@ def input_types(self) -> typing.Tuple: Returns input type for the current stage. """ - return (typing.Any, ) + return (typing.Any,) def accepted_types(self) -> typing.Tuple: """ @@ -95,7 +95,7 @@ def accepted_types(self) -> typing.Tuple: Accepted input types. """ - return (typing.Any, ) + return (typing.Any,) def _build(self, builder: mrc.Builder, in_stream_pairs: typing.List[StreamPair]) -> typing.List[StreamPair]: diff --git a/morpheus/utils/module_utils.py b/morpheus/utils/module_utils.py index 33d2833f43..d0832ad31a 100644 --- a/morpheus/utils/module_utils.py +++ b/morpheus/utils/module_utils.py @@ -14,9 +14,13 @@ import functools import logging +import re import typing import mrc +import cudf +import pandas as pd +import numpy as np logger = logging.getLogger(__name__) @@ -168,6 +172,60 @@ def merge_dictionaries(primary_dict, secondary_dict): return result_dict +period_to_strptime = { + "s": "%Y-%m-%d %H:%M:%S", + "T": "%Y-%m-%d %H:%M", + "min": "%Y-%m-%d %H:%M", + "H": "%Y-%m-%d %H", + "D": "%Y-%m-%d", + "W": "%Y-%U", + "M": "%Y-%m", + "Y": "%Y", + "Q": "%Y-%q", + "A": "%Y" +} + + +def to_period_cudf_approximation(df, period): + """ + This function converts a cudf dataframe to a period approximation. + + Parameters + ---------- + df : cudf.DataFrame + Input cudf dataframe. + period : int + Period. + + Returns + ------- + cudf.DataFrame + Period approximation of the input cudf dataframe. + """ + + match = re.match(r"(\d*)(\w)", period) + if not match: + raise ValueError(f"Invalid period format: {period}") + + count_str, period = match.groups() + count = int(count_str) if count_str else 1 + + if period not in period_to_strptime: + raise ValueError(f"Unknown period: {period}") + + strptime_format = period_to_strptime[period] + + if "cudf" in str(type(df)): + df["period"] = df["ts"].dt.strftime(strptime_format).astype("datetime64[s]").astype("int") + if count > 1: + df["period"] = df["period"] // (count * np.timedelta64(1, period).astype("int")) + else: + df["period"] = pd.to_datetime(df["ts"].dt.strftime(strptime_format)) + df["period"] = df["period"].dt.to_period(f"{count}{period}") + + return df + + def get_config_with_overrides(config, module_id, module_name=None, module_namespace="morpheus"): sub_config = config.get(module_id, None) From 1e0df31f1e165aea863ca14376191ba6f97781b5 Mon Sep 17 00:00:00 2001 From: Devin Robison Date: Tue, 14 Mar 2023 15:12:42 -0600 Subject: [PATCH 093/157] Config generator fix --- .../production/morpheus/dfp/utils/config_generator.py | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py b/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py index 5382808ae6..b85c2b9de8 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py @@ -45,6 +45,7 @@ def get_module_conf(self): module_conf["module_id"] = DFP_DEPLOYMENT module_conf["module_name"] = "dfp_deployment" module_conf["namespace"] = MORPHEUS_MODULE_NAMESPACE + module_conf["num_output_ports"] = 2 module_conf["training_options"] = self.train_module_conf() module_conf["inference_options"] = self.infer_module_conf() From 5b70ded3ca89b2a1760dff95e18aa7762d80813f Mon Sep 17 00:00:00 2001 From: Devin Robison Date: Tue, 14 Mar 2023 16:22:06 -0600 Subject: [PATCH 094/157] Comment cleanup, improved error handling --- .../morpheus/dfp/modules/dfp_deployment.py | 13 +-- .../dfp/modules/dfp_inference_pipe.py | 28 +++---- .../morpheus/dfp/modules/dfp_preproc.py | 16 ++-- .../dfp/modules/dfp_rolling_window.py | 83 ++++++++----------- .../morpheus/dfp/modules/dfp_split_users.py | 67 ++++++--------- .../morpheus/dfp/modules/dfp_training_pipe.py | 21 +++-- .../_lib/src/modules/data_loader_module.cpp | 26 +----- morpheus/loaders/file_to_df_loader.py | 76 ++++++++--------- morpheus/modules/filter_control_message.py | 12 +-- 9 files changed, 145 insertions(+), 197 deletions(-) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_deployment.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_deployment.py index 501ed1a927..37017e261a 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_deployment.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_deployment.py @@ -44,9 +44,10 @@ def dfp_deployment(builder: mrc.Builder): Notes ---------- Configurable parameters: - - training_options: dict - - inference_options: dict + - training_options (dict): Options for the training pipeline module, including settings and configurations specific to the training process. + - inference_options (dict): Options for the inference pipeline module, including settings and configurations specific to the inference process. """ + module_config = builder.get_current_module_config() num_output_ports = 2 @@ -71,12 +72,12 @@ def dfp_deployment(builder: mrc.Builder): dfp_inference_pipe_conf) # Create broadcast node to fork the pipeline. - boradcast = Broadcast(builder, "broadcast") + broadcast = Broadcast(builder, "broadcast") # Make an edge between modules - builder.make_edge(fsspec_dataloader_module.output_port("output"), boradcast) - builder.make_edge(boradcast, dfp_training_pipe_module.input_port("input")) - builder.make_edge(boradcast, dfp_inference_pipe_module.input_port("input")) + builder.make_edge(fsspec_dataloader_module.output_port("output"), broadcast) + builder.make_edge(broadcast, dfp_training_pipe_module.input_port("input")) + builder.make_edge(broadcast, dfp_inference_pipe_module.input_port("input")) out_streams = [dfp_training_pipe_module.output_port("output"), dfp_inference_pipe_module.output_port("output")] diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference_pipe.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference_pipe.py index c8b2f2bbb7..ae7bc54e0f 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference_pipe.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference_pipe.py @@ -55,16 +55,16 @@ def dfp_inference_pipe(builder: mrc.Builder): Notes ---------- Configurable parameters: - - batching_options: Options for batching the data - - cache_dir: Directory to cache the rolling window data - - detection_criteria: Criteria for filtering detections - - inference_options: Options for inference - - num_output_ports: Number of output ports - - preprocessing_options: Options for preprocessing the data - - stream_aggregation_options: Options for aggregating the data by stream - - timestamp_column_name: Name of the timestamp column - - user_splitting_options: Options for splitting the data by user - - write_to_file_options: Options for writing the detections to file + - batching_options (dict): Options for batching the data, including start and end times, sampling rate, and other settings. + - cache_dir (str): Directory to cache the rolling window data. + - detection_criteria (dict): Criteria for filtering detections, such as threshold and field_name. + - inference_options (dict): Options for the inference module, including model settings and other configurations. + - num_output_ports (int): Number of output ports for the module. + - preprocessing_options (dict): Options for preprocessing the data, including schema and timestamp column name. + - stream_aggregation_options (dict): Options for aggregating the data by stream, including aggregation span and cache settings. + - timestamp_column_name (str): Name of the timestamp column in the input data. + - user_splitting_options (dict): Options for splitting the data by user, including filtering and user ID column name. + - write_to_file_options (dict): Options for writing the detections to a file, such as filename and overwrite settings. """ config = builder.get_current_module_config() @@ -106,7 +106,7 @@ def dfp_inference_pipe(builder: mrc.Builder): write_to_file_options = config.get("write_to_file_options", {}) - preproc_defaults = {} + preproc_defaults = {} # placeholder for future defaults preproc_conf = merge_dictionaries(preproc_options, preproc_defaults) stream_aggregation_defaults = { @@ -116,10 +116,10 @@ def dfp_inference_pipe(builder: mrc.Builder): } dfp_rolling_window_conf = merge_dictionaries(stream_aggregation_options, stream_aggregation_defaults) - data_prep_defaults = {} + data_prep_defaults = {} # placeholder for future defaults dfp_data_prep_conf = merge_dictionaries(data_prep_options, data_prep_defaults) - inference_model_defaults = {} + inference_model_defaults = {} # placeholder for future defaults dfp_inference_conf = merge_dictionaries(inference_model_options, inference_model_defaults) detection_criteria_defaults = { @@ -129,7 +129,7 @@ def dfp_inference_pipe(builder: mrc.Builder): } filter_detections_conf = merge_dictionaries(detection_criteria, detection_criteria_defaults) - post_processing_defaults = {} + post_processing_defaults = {} # placeholder for future defaults dfp_post_proc_conf = merge_dictionaries(post_processing_options, post_processing_defaults) serialize_defaults = { diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_preproc.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_preproc.py index 24e6bbf04a..25913c57ae 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_preproc.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_preproc.py @@ -48,13 +48,13 @@ def dfp_preproc(builder: mrc.Builder): Notes ---------- - Configurable parameters: - - cache_dir: str - - timestamp_column_name: str - - pre_filter_options: dict - - batching_options: dict - - user_splitting_options: dict - - supported_loaders: dict + Configurable parameters: + - cache_dir : str (Directory used for caching intermediate results) + - timestamp_column_name : str (Name of the column containing timestamps) + - pre_filter_options : dict (Options for pre-filtering control messages) + - batching_options : dict (Options for batching files) + - user_splitting_options : dict (Options for splitting data by user) + - supported_loaders : dict (Supported data loaders for different file types) """ config = builder.get_current_module_config() @@ -70,7 +70,7 @@ def dfp_preproc(builder: mrc.Builder): splitting_opts = config.get("user_splitting_options", {}) splitting_opts["cache_dir"] = cache_dir - splitting_opts["timestamp_column_name"] = ts_column_name + splitting_opts["timestamp_col_name"] = ts_column_name supported_loaders = config.get("supported_loaders", {}) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_rolling_window.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_rolling_window.py index a05675ccd4..eef1eba759 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_rolling_window.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_rolling_window.py @@ -46,17 +46,17 @@ def dfp_rolling_window(builder: mrc.Builder): Pipeline builder instance. Notes - ---------- + ----- Configurable parameters: - - aggregation_span: The span of time to aggregate over - - cache_dir: Directory to cache the rolling window data - - cache_to_disk: Whether to cache the rolling window data to disk - - cache_mode: The cache mode to use 'batch' or 'aggregate' - aggregate: Cache the entire rolling window - batch: Cache until batch criteria is met and then flush - - timestamp_column_name: Name of the timestamp column - - trigger_on_min_history: Minimum number of rows to trigger the rolling window - - trigger_on_min_increment: Minimum number of rows to trigger the rolling window + - aggregation_span: The time span to aggregate over (e.g., '60d' for 60 days) + - cache_dir: The directory to cache the rolling window data + - cache_to_disk: Whether to cache the rolling window data to disk (default: False) + - cache_mode: The cache mode to use, either 'batch' or 'aggregate' + 'aggregate': Cache the entire rolling window + 'batch': Cache until batch criteria is met and then flush + - timestamp_column_name: The name of the timestamp column (default: 'timestamp') + - trigger_on_min_history: The minimum number of rows required to trigger the rolling window (default: 1) + - trigger_on_min_increment: The minimum number of rows required to trigger the rolling window (default: 0) """ config = builder.get_current_module_config() @@ -152,48 +152,33 @@ def try_build_window(message: MessageMeta, user_id: str) -> typing.Union[Message return MessageMeta(cudf.from_pandas(df_window)) def on_data(control_message: MessageControl): + try: + payload = control_message.payload() + user_id = control_message.get_metadata("user_id") - payload = control_message.payload() - user_id = control_message.get_metadata("user_id") - - if (control_message.has_metadata("data_type")): - data_type = control_message.get_metadata("data_type") - else: - data_type = "streaming" - - # If we're an explicit training or inference task, then we don't need to do any rolling window logic - if (data_type == "payload"): - return control_message - elif (data_type == "streaming"): - with log_time(logger.debug) as log_info: - result = try_build_window(payload, user_id) # Return a MessageMeta - - if (result is not None): - pass - # log_info.set_log( - # ("Rolling window complete for %s in {duration:0.2f} ms. " - # "Input: %s rows from %s to %s. Output: %s rows from %s to %s"), - # user_id, - # len(payload.df), - # payload.df[timestamp_column_name].min(), - # payload.df[timestamp_column_name].max(), - # result.count, - # result.df[timestamp_column_name].min(), - # result.df[timestamp_column_name].max(), - # ) - else: - # Result is None indicates that we don't have enough data to build payload for the event - # CM is discarded here - log_info.disable() - return None + if (control_message.has_metadata("data_type")): + data_type = control_message.get_metadata("data_type") + else: + data_type = "streaming" + + # If we're an explicit training or inference task, then we don't need to do any rolling window logic + if (data_type == "payload"): + return control_message + elif (data_type == "streaming"): + with log_time(logger.debug) as log_info: + result = try_build_window(payload, user_id) # Return a MessageMeta + + if (result is None): + return result + + control_message.payload(result) + control_message.set_metadata("data_type", "payload") - # TODO (bhargav) Check if we need to pass control_message config to data_prep module. - control_message.payload(result) - control_message.set_metadata("data_type", "payload") + return control_message - return control_message - else: - raise RuntimeError("Unknown data type") + except Exception as e: + logger.error(f"Error processing control message in rolling window: {e}\nDiscarding control message.") + return None def node_fn(obs: mrc.Observable, sub: mrc.Subscriber): obs.pipe(ops.map(on_data), ops.filter(lambda x: x is not None)).subscribe(sub) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_split_users.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_split_users.py index c7a8552134..40707485f5 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_split_users.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_split_users.py @@ -40,18 +40,18 @@ def dfp_split_users(builder: mrc.Builder): Parameters ---------- builder : mrc.Builder - Pipeline budler instance. + Pipeline builder instance. Notes ---------- Configurable parameters: - - fallback_username: Name of the user Id to use if the user Id is not found - - include_generic: Whether to include the generic user Id - - include_individual: Whether to include the individual user Id's - - only_users: List of user Id's to include - - skip_users: List of user Id's to skip - - timestamp_column_name: Name of the timestamp column - - userid_column_name: Name of the user Id column + - fallback_username: The user ID to use if the user ID is not found (default: 'generic_user') + - include_generic: Whether to include a generic user ID in the output (default: False) + - include_individual: Whether to include individual user IDs in the output (default: False) + - only_users: List of user IDs to include in the output; other user IDs will be excluded (default: []) + - skip_users: List of user IDs to exclude from the output (default: []) + - timestamp_column_name: Name of the column containing timestamps (default: 'timestamp') + - userid_column_name: Name of the column containing user IDs (default: 'username') """ config = builder.get_current_module_config() @@ -82,7 +82,6 @@ def generate_control_messages(control_message: MessageControl, split_dataframes: user_df = split_dataframes[user_id] current_user_count = user_index_map.get(user_id, 0) - # logger.debug("Current user count: %s", current_user_count) # Reset the index so that users see monotonically increasing indexes user_df.index = range(current_user_count, current_user_count + len(user_df)) @@ -94,24 +93,8 @@ def generate_control_messages(control_message: MessageControl, split_dataframes: user_cudf = cudf.from_pandas(user_df) user_control_message.payload(MessageMeta(df=user_cudf)) - # output_messages.append(DFPMessageMeta(df=user_df, user_id=user_id)) output_messages.append(user_control_message) - # rows_per_user = [len(msg.payload().df.to_pandas()) for msg in output_messages] - - # if (len(output_messages) > 0): - # log_info.set_log( - # ("Batch split users complete. Input: %s rows from %s to %s. " - # "Output: %s users, rows/user min: %s, max: %s, avg: %.2f. Duration: {duration:.2f} ms"), - # len(df), - # df[timestamp_column_name].min(), - # df[timestamp_column_name].max(), - # len(rows_per_user), - # np.min(rows_per_user), - # np.max(rows_per_user), - # np.mean(rows_per_user), - # ) - return output_messages def generate_split_dataframes(df: pd.DataFrame): @@ -140,26 +123,30 @@ def extract_users(control_message: MessageControl): logger.debug("No message to extract users from") return [] - control_messages = None # for readability - mm = control_message.payload() - with mm.mutable_dataframe() as dfm: - with log_time(logger.debug) as log_info: + try: + control_messages = None # for readability + mm = control_message.payload() + with mm.mutable_dataframe() as dfm: + with log_time(logger.debug) as log_info: - if (isinstance(dfm, cudf.DataFrame)): - # Convert to pandas because cudf is slow at this - df = dfm.to_pandas() - df[timestamp_column_name] = pd.to_datetime(df[timestamp_column_name], utc=True) - else: - df = dfm + if (isinstance(dfm, cudf.DataFrame)): + # Convert to pandas because cudf is slow at this + df = dfm.to_pandas() + df[timestamp_column_name] = pd.to_datetime(df[timestamp_column_name], utc=True) + else: + df = dfm - split_dataframes = generate_split_dataframes(df) + split_dataframes = generate_split_dataframes(df) - control_messages = generate_control_messages(control_message, split_dataframes) + control_messages = generate_control_messages(control_message, split_dataframes) - return control_messages + return control_messages + except Exception as e: + logger.exception("Error extracting users from message, discarding control message") + return [] - def node_fn(obs: mrc.Observable, sub: mrc.Subscriber): - obs.pipe(ops.map(extract_users), ops.flatten()).subscribe(sub) + def node_fn(observable: mrc.Observable, subscriber: mrc.Subscriber): + observable.pipe(ops.map(extract_users), ops.flatten()).subscribe(subscriber) node = builder.make_node(DFP_SPLIT_USERS, mrc.core.operators.build(node_fn)) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training_pipe.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training_pipe.py index 58e92ace8a..e9b1cdde8b 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training_pipe.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training_pipe.py @@ -45,19 +45,20 @@ def dfp_training_pipe(builder: mrc.Builder): Parameters ---------- builder : mrc.Builder - Pipeline budler instance. + Pipeline builder instance. Notes ---------- Configurable parameters: - - timestamp_column_name : str - - cache_dir : str - - batching_options : dict - - user_splitting_options : dict - - stream_aggregation_options : dict - - preprocessing_options : dict - - dfencoder_options : dict - - mlflow_writer_options : dict + - timestamp_column_name (str): Name of the timestamp column used in the data. + - cache_dir (str): Directory to cache the rolling window data. + - batching_options (dict): Options for batching the data. + - user_splitting_options (dict): Options for splitting the data by user. + - stream_aggregation_options (dict): Options for aggregating the data by stream. + - preprocessing_options (dict): Options for preprocessing the data. + - dfencoder_options (dict): Options for configuring the data frame encoder, used for training the model. + - mlflow_writer_options (dict): Options for the MLflow model writer, which is responsible for saving the trained + model. """ config = builder.get_current_module_config() @@ -105,8 +106,6 @@ def dfp_training_pipe(builder: mrc.Builder): data_prep_defaults = {} dfp_data_prep_conf = merge_dictionaries(data_prep_options, data_prep_defaults) - # TODO(Devin): Not sure, but it seems like this is the right place to be opinionated about these values - # mostly because dfencoder itself has default values so we don't need them at the dfp_training level dfp_training_defaults = { "model_kwargs": { "encoder_layers": [512, 500], # layers of the encoding part diff --git a/morpheus/_lib/src/modules/data_loader_module.cpp b/morpheus/_lib/src/modules/data_loader_module.cpp index bd587a3037..60eab984a3 100644 --- a/morpheus/_lib/src/modules/data_loader_module.cpp +++ b/morpheus/_lib/src/modules/data_loader_module.cpp @@ -33,31 +33,7 @@ using nlohmann::json; namespace morpheus { -const std::string DataLoaderModule::s_config_schema = R"( -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "DataLoaderModule", - "type": "object", - "required": ["loaders"], - "properties": { - "loaders": { - "type": "array", - "items": { - "type": "object", - "required": ["id"], - "properties": { - "id": { - "type": "string" - }, - "properties": { - "type": "object" - } - } - } - } - } -} -)"; +const std::string DataLoaderModule::s_config_schema = R"()"; DataLoaderModule::DataLoaderModule(std::string module_name) : SegmentModule(module_name) {} diff --git a/morpheus/loaders/file_to_df_loader.py b/morpheus/loaders/file_to_df_loader.py index 7b9494b949..c62a9bc427 100644 --- a/morpheus/loaders/file_to_df_loader.py +++ b/morpheus/loaders/file_to_df_loader.py @@ -43,6 +43,44 @@ dask_cluster = None +def get_dask_cluster(download_method: str): + global dask_cluster + + if dask_cluster is None: + try: + import dask + from dask.distributed import LocalCluster + except ModuleNotFoundError: + raise Exception("Install 'dask' and 'distributed' to allow file downloads using dask mode.") + + logger.debug("Dask cluster doesn't exist. Creating dask cluster...") + + # Up the heartbeat interval which can get violated with long download times + dask.config.set({"distributed.client.heartbeat": "30s"}) + + dask_cluster = LocalCluster(start=True, processes=not download_method == "dask_thread") + + logger.debug("Creating dask cluster... Done. Dashboard: %s", dask_cluster.dashboard_link) + + return dask_cluster + + +def get_dask_client(dask_cluster): + from dask.distributed import Client + dask_client = Client(get_dask_cluster(dask_cluster)) + logger.debug("Creating dask client %s ... Done.", dask_client) + + return dask_client + + +def close_dask_cluster(): + if (dask_cluster is not None): + logger.debug("Stopping dask cluster...") + dask_cluster.close() + + logger.debug("Stopping dask cluster... Done.") + + @register_loader(FILE_TO_DF_LOADER) def file_to_df_loader(control_message: MessageControl, task: dict): if task.get("strategy", "aggregate") != "aggregate": @@ -231,41 +269,3 @@ def convert_to_dataframe(filenames: typing.List[str]): control_message.payload(MessageMeta(dfm)) return control_message - - -def get_dask_cluster(download_method: str): - global dask_cluster - - if dask_cluster is None: - try: - import dask - from dask.distributed import LocalCluster - except ModuleNotFoundError: - raise Exception("Install 'dask' and 'distributed' to allow file downloads using dask mode.") - - logger.debug("Dask cluster doesn't exist. Creating dask cluster...") - - # Up the heartbeat interval which can get violated with long download times - dask.config.set({"distributed.client.heartbeat": "30s"}) - - dask_cluster = LocalCluster(start=True, processes=not download_method == "dask_thread") - - logger.debug("Creating dask cluster... Done. Dashboard: %s", dask_cluster.dashboard_link) - - return dask_cluster - - -def get_dask_client(dask_cluster): - from dask.distributed import Client - dask_client = Client(get_dask_cluster(dask_cluster)) - logger.debug("Creating dask client %s ... Done.", dask_client) - - return dask_client - - -def close_dask_cluster(): - if (dask_cluster is not None): - logger.debug("Stopping dask cluster...") - dask_cluster.close() - - logger.debug("Stopping dask cluster... Done.") diff --git a/morpheus/modules/filter_control_message.py b/morpheus/modules/filter_control_message.py index be36f724e0..603dff2c31 100644 --- a/morpheus/modules/filter_control_message.py +++ b/morpheus/modules/filter_control_message.py @@ -38,10 +38,10 @@ def filter_control_message(builder: mrc.Builder): Notes ---------- Configurable parameters: - - enable_task_filtering: bool - - enable_data_type_filtering: bool - - filter_task_type: str - - filter_data_type: str + - enable_task_filtering : bool (Enables filtering based on task type) + - enable_data_type_filtering : bool (Enables filtering based on data type) + - filter_task_type : str (The task type to be used as a filter) + - filter_data_type : str (The data type to be used as a filter) """ config = builder.get_current_module_config() @@ -69,12 +69,12 @@ def on_data(control_message: MessageControl): # TODO(Devin): Convert this to use enum values task_exists = control_message.has_task(filter_task_type) - # Dispose messages if it has no expected task and it's data_type does not matches with filter. + # Dispose messages if it has no expected task and it's data_type does not matches with filter_task_type. if (not task_exists and filter and cm_data_type != filter): return None elif (enable_data_type_filtering): # Regardless of whether tasks are present, discard messages - # if the data_type don't match the filter. + # if the data_type don't match the filter_task_type. if (filter_data_type and filter_data_type != filter): return None From 37510dba773e1e40c2355e2d70fd6c374dbfa9c5 Mon Sep 17 00:00:00 2001 From: Devin Robison Date: Wed, 15 Mar 2023 16:50:22 -0600 Subject: [PATCH 095/157] First pass markdown files for new modules --- docs/source/modules/core/file_batcher.md | 44 +++++++++++ docs/source/modules/core/file_to_df.md | 30 +++++++ .../modules/core/filter_control_message.md | 21 +++++ docs/source/modules/core/filter_detections.md | 30 +++++++ .../modules/core/mlflow_model_writer.md | 26 +++++++ docs/source/modules/core/serializer.md | 23 ++++++ docs/source/modules/core/write_to_file.md | 23 ++++++ .../digital_fingerprinting/dfp_data_prep.md | 22 ++++++ .../digital_fingerprinting/dfp_deployment.md | 19 +++++ .../digital_fingerprinting/dfp_inference.md | 19 +++++ .../dfp_inference_pipe.md | 33 ++++++++ .../dfp_postprocessing.md | 15 ++++ .../digital_fingerprinting/dfp_preproc.md | 37 +++++++++ .../dfp_rolling_window.md | 27 +++++++ .../digital_fingerprinting/dfp_split_users.md | 27 +++++++ .../digital_fingerprinting/dfp_training.md | 25 ++++++ .../dfp_training_pipe.md | 65 ++++++++++++++++ .../morpheus/dfp/modules/dfp_inference.py | 6 +- .../morpheus/dfp/modules/dfp_preprocessing.py | 78 ------------------- .../dfp/modules/dfp_rolling_window.py | 2 +- morpheus/modules/file_batcher.py | 2 +- morpheus/modules/serialize.py | 9 +++ morpheus/modules/write_to_file.py | 9 +++ 23 files changed, 510 insertions(+), 82 deletions(-) create mode 100644 docs/source/modules/core/file_batcher.md create mode 100644 docs/source/modules/core/file_to_df.md create mode 100644 docs/source/modules/core/filter_control_message.md create mode 100644 docs/source/modules/core/filter_detections.md create mode 100644 docs/source/modules/core/mlflow_model_writer.md create mode 100644 docs/source/modules/core/serializer.md create mode 100644 docs/source/modules/core/write_to_file.md create mode 100644 docs/source/modules/examples/digital_fingerprinting/dfp_data_prep.md create mode 100644 docs/source/modules/examples/digital_fingerprinting/dfp_deployment.md create mode 100644 docs/source/modules/examples/digital_fingerprinting/dfp_inference.md create mode 100644 docs/source/modules/examples/digital_fingerprinting/dfp_inference_pipe.md create mode 100644 docs/source/modules/examples/digital_fingerprinting/dfp_postprocessing.md create mode 100644 docs/source/modules/examples/digital_fingerprinting/dfp_preproc.md create mode 100644 docs/source/modules/examples/digital_fingerprinting/dfp_rolling_window.md create mode 100644 docs/source/modules/examples/digital_fingerprinting/dfp_split_users.md create mode 100644 docs/source/modules/examples/digital_fingerprinting/dfp_training.md create mode 100644 docs/source/modules/examples/digital_fingerprinting/dfp_training_pipe.md delete mode 100644 examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_preprocessing.py diff --git a/docs/source/modules/core/file_batcher.md b/docs/source/modules/core/file_batcher.md new file mode 100644 index 0000000000..516da65c6a --- /dev/null +++ b/docs/source/modules/core/file_batcher.md @@ -0,0 +1,44 @@ +## File Batcher Module + +This module loads the input files, removes files that are older than the chosen window of time, and then groups the remaining files by period that fall inside the window. + +### Configurable Parameters + +- **batching_options**: A dictionary containing the following options: + - **end_time**: End time of the time window (datetime or string format) + - **iso_date_regex_pattern**: Regex pattern for ISO date matching + - **parser_kwargs**: Additional arguments for the parser (dictionary) + - **period**: Time period for grouping files (e.g., '1d' for 1 day) + - **sampling_rate_s**: Sampling rate in seconds (integer) + - **start_time**: Start time of the time window (datetime or string format) +- **cache_dir**: Cache directory (string) +- **file_type**: File type (string) +- **filter_nulls**: Whether to filter null values (boolean) + - `True`: Filter null values + - `False`: Don't filter null values +- **schema**: Data schema (dictionary) +- **timestamp_column_name**: Name of the timestamp column (string) + +### Example JSON Configuration + +```json +{ + "batching_options": { + "end_time": "2023-03-14T23:59:59", + "iso_date_regex_pattern": "\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}", + "parser_kwargs": {}, + "period": "1d", + "sampling_rate_s": 60, + "start_time": "2023-03-01T00:00:00" + }, + "cache_dir": "./file_batcher_cache", + "file_type": "csv", + "filter_nulls": true, + "schema": { + "column1": "string", + "column2": "int", + "timestamp": "datetime" + }, + "timestamp_column_name": "timestamp" +} +``` \ No newline at end of file diff --git a/docs/source/modules/core/file_to_df.md b/docs/source/modules/core/file_to_df.md new file mode 100644 index 0000000000..5d95a66bf3 --- /dev/null +++ b/docs/source/modules/core/file_to_df.md @@ -0,0 +1,30 @@ +## File to DataFrame Module + +This module reads data from the batched files into a dataframe after receiving input from the "FileBatcher" module. In addition to loading data from the disk, it has the ability to load the file content from S3 buckets. + +### Configurable Parameters + +- **cache_dir** (str): Directory to cache the rolling window data. +- **file_type** (str): Type of the input file. +- **filter_null** (bool): Whether to filter out null values. +- **parser_kwargs** (dict): Keyword arguments to pass to the parser. +- **schema** (dict): Schema of the input data. +- **timestamp_column_name** (str): Name of the timestamp column. + +### Example JSON Configuration + +```json +{ + "cache_dir": "/path/to/cache", + "file_type": "csv", + "filter_null": true, + "parser_kwargs": { + "delimiter": "," + }, + "schema": { + "column1": "float", + "column2": "float" + }, + "timestamp_column_name": "timestamp" +} +``` \ No newline at end of file diff --git a/docs/source/modules/core/filter_control_message.md b/docs/source/modules/core/filter_control_message.md new file mode 100644 index 0000000000..8ff2d9d4fe --- /dev/null +++ b/docs/source/modules/core/filter_control_message.md @@ -0,0 +1,21 @@ +## Filter Control Message Module + +When the requirements are met, this module gently discards the control messages. + +### Configurable Parameters + +- **enable_task_filtering** (bool): Enables filtering based on task type. +- **enable_data_type_filtering** (bool): Enables filtering based on data type. +- **filter_task_type** (str): The task type to be used as a filter. +- **filter_data_type** (str): The data type to be used as a filter. + +### Example JSON Configuration + +```json +{ + "enable_task_filtering": true, + "enable_data_type_filtering": true, + "filter_task_type": "specific_task", + "filter_data_type": "desired_data_type" +} +``` \ No newline at end of file diff --git a/docs/source/modules/core/filter_detections.md b/docs/source/modules/core/filter_detections.md new file mode 100644 index 0000000000..74e9796820 --- /dev/null +++ b/docs/source/modules/core/filter_detections.md @@ -0,0 +1,30 @@ +## Filter Detections Module + +Filter message by a classification threshold. + +The Filter Detections module is used to filter rows from a dataframe based on values in a tensor using a specified criteria. Rows in the `meta` dataframe are excluded if their associated value in the `probs` array is less than or equal to `threshold`. + +### Configurable Parameters + +- **field_name** (str): Name of the field to filter on. Defaults to `probs`. +- **threshold** (float): Threshold value to filter on. Defaults to `0.5`. +- **filter_source** (str): Source of the filter field. Defaults to `AUTO`. +- **copy** (bool): Whether to copy the rows or slice them. Defaults to `True`. +- **schema** (dict): Schema configuration. + - **input_message_type** (str): Pickled message type. + - **encoding** (str): Encoding used to pickle the message type. + +### Example JSON Configuration + +```json +{ + "field_name": "probs", + "threshold": 0.5, + "filter_source": "AUTO", + "copy": true, + "schema": { + "input_message_type": "pickle_message_type", + "encoding": "utf-8" + } +} +``` \ No newline at end of file diff --git a/docs/source/modules/core/mlflow_model_writer.md b/docs/source/modules/core/mlflow_model_writer.md new file mode 100644 index 0000000000..85b62949b8 --- /dev/null +++ b/docs/source/modules/core/mlflow_model_writer.md @@ -0,0 +1,26 @@ +## MLflow Model Writer Module + +This module uploads trained models to the MLflow server. + +### Configurable Parameters + +- **model_name_formatter** (str): Formatter for the model name. +- **experiment_name_formatter** (str): Formatter for the experiment name. +- **conda_env** (str): Conda environment for the model. +- **timestamp_column_name** (str): Name of the timestamp column. +- **databricks_permissions** (dict): Permissions for the model. + +### Example JSON Configuration + +```json +{ + "model_name_formatter": "model_name_{timestamp}", + "experiment_name_formatter": "experiment_name_{timestamp}", + "conda_env": "path/to/conda_env.yml", + "timestamp_column_name": "timestamp", + "databricks_permissions": { + "read": ["read_user1", "read_user2"], + "write": ["write_user1", "write_user2"] + } +} +``` \ No newline at end of file diff --git a/docs/source/modules/core/serializer.md b/docs/source/modules/core/serializer.md new file mode 100644 index 0000000000..37a143f9f0 --- /dev/null +++ b/docs/source/modules/core/serializer.md @@ -0,0 +1,23 @@ +## Serialize Module + +This module filters columns from a `MultiMessage` object, emitting a `MessageMeta`. + +### Configurable Parameters + +- **include** (str): Regex to include columns. +- **exclude** (List[str]): List of regex patterns to exclude columns. +- **fixed_columns** (bool): If true, the columns are fixed and not determined at runtime. +- **columns** (List[str]): List of columns to include. +- **use_cpp** (bool): If true, use C++ to serialize. + +### Example JSON Configuration + +```json +{ + "include": "^column", + "exclude": ["column_to_exclude"], + "fixed_columns": true, + "columns": ["column1", "column2", "column3"], + "use_cpp": true +} +``` \ No newline at end of file diff --git a/docs/source/modules/core/write_to_file.md b/docs/source/modules/core/write_to_file.md new file mode 100644 index 0000000000..ae45fa0c34 --- /dev/null +++ b/docs/source/modules/core/write_to_file.md @@ -0,0 +1,23 @@ +## WriteToFile Module + +This module writes messages to a file. + +### Configurable Parameters + +- **filename** (str): Path to the output file. +- **overwrite** (bool): If true, overwrite the file if it exists. +- **flush** (bool): If true, flush the file after each write. +- **file_type** (FileTypes): Type of file to write. +- **include_index_col** (bool): If true, include the index column. + +### Example JSON Configuration + +```json +{ + "filename": "output.csv", + "overwrite": true, + "flush": false, + "file_type": "CSV", + "include_index_col": false +} +``` \ No newline at end of file diff --git a/docs/source/modules/examples/digital_fingerprinting/dfp_data_prep.md b/docs/source/modules/examples/digital_fingerprinting/dfp_data_prep.md new file mode 100644 index 0000000000..b231c0126d --- /dev/null +++ b/docs/source/modules/examples/digital_fingerprinting/dfp_data_prep.md @@ -0,0 +1,22 @@ +## DFP Data Prep Module + +This module function prepares data for either inference or model training. + +### Configurable Parameters + +- **schema**: Schema of the data (dictionary) +- **timestamp_column_name**: Name of the timestamp column (string, default: 'timestamp') + +### Example JSON Configuration + +```json +{ + "schema": { + "column1": "int", + "column2": "float", + "column3": "string", + "timestamp": "datetime" + }, + "timestamp_column_name": "timestamp" +} +``` \ No newline at end of file diff --git a/docs/source/modules/examples/digital_fingerprinting/dfp_deployment.md b/docs/source/modules/examples/digital_fingerprinting/dfp_deployment.md new file mode 100644 index 0000000000..89f50084fe --- /dev/null +++ b/docs/source/modules/examples/digital_fingerprinting/dfp_deployment.md @@ -0,0 +1,19 @@ +## Pipeline Module + +This module function sets up a pipeline builder instance. + +### Configurable Parameters + +- **training_options**: Options for the training pipeline module, including settings and configurations specific to the training process (dictionary). +- **inference_options**: Options for the inference pipeline module, including settings and configurations specific to the inference process (dictionary). + +### Example JSON Configuration + +```json +{ + "training_options": { + }, + "inference_options": { + } +} +``` \ No newline at end of file diff --git a/docs/source/modules/examples/digital_fingerprinting/dfp_inference.md b/docs/source/modules/examples/digital_fingerprinting/dfp_inference.md new file mode 100644 index 0000000000..1843cc3432 --- /dev/null +++ b/docs/source/modules/examples/digital_fingerprinting/dfp_inference.md @@ -0,0 +1,19 @@ +## DFP Inference Module + +This module function performs the inference process. + +### Configurable Parameters + +- **model_name_formatter**: Formatter for model names (string). +- **fallback_username**: Fallback user to use if no model is found for a user (string). +- **timestamp_column_name**: Name of the timestamp column (string). + +### Example JSON Configuration + +```json +{ + "model_name_formatter": "user_{username}_model", + "fallback_username": "generic_user", + "timestamp_column_name": "timestamp" +} +``` \ No newline at end of file diff --git a/docs/source/modules/examples/digital_fingerprinting/dfp_inference_pipe.md b/docs/source/modules/examples/digital_fingerprinting/dfp_inference_pipe.md new file mode 100644 index 0000000000..8992042415 --- /dev/null +++ b/docs/source/modules/examples/digital_fingerprinting/dfp_inference_pipe.md @@ -0,0 +1,33 @@ +## DFP Inference Pipe Module + +This module function allows for the consolidation of multiple dfp pipeline modules relevant to the inference process into a single module. + +### Configurable Parameters + +- **batching_options** (dict): Options for batching the data, including start and end times, sampling rate, and other settings. +- **cache_dir** (string): Directory to cache the rolling window data. +- **detection_criteria** (dict): Criteria for filtering detections, such as threshold and field_name. +- **inference_options** (dict): Options for the inference module, including model settings and other configurations. +- **num_output_ports** (int): Number of output ports for the module. +- **preprocessing_options** (dict): Options for preprocessing the data, including schema and timestamp column name. +- **stream_aggregation_options** (dict): Options for aggregating the data by stream, including aggregation span and cache settings. +- **timestamp_column_name** (string): Name of the timestamp column in the input data. +- **user_splitting_options** (dict): Options for splitting the data by user, including filtering and user ID column name. +- **write_to_file_options** (dict): Options for writing the detections to a file, such as filename and overwrite settings. + +### Example JSON Configuration + +```json +{ + "batching_options": {...}, + "cache_dir": "/path/to/cache", + "detection_criteria": {...}, + "inference_options": {...}, + "num_output_ports": 2, + "preprocessing_options": {...}, + "stream_aggregation_options": {...}, + "timestamp_column_name": "timestamp", + "user_splitting_options": {...}, + "write_to_file_options": {...} +} +``` \ No newline at end of file diff --git a/docs/source/modules/examples/digital_fingerprinting/dfp_postprocessing.md b/docs/source/modules/examples/digital_fingerprinting/dfp_postprocessing.md new file mode 100644 index 0000000000..0d606c770f --- /dev/null +++ b/docs/source/modules/examples/digital_fingerprinting/dfp_postprocessing.md @@ -0,0 +1,15 @@ +## DFP Postprocessing Module + +This module function performs postprocessing tasks after the inference process. + +### Configurable Parameters + +- **timestamp_column_name** (string): Name of the timestamp column in the input data. + +### Example JSON Configuration + +```json +{ + "timestamp_column_name": "timestamp" +} +``` \ No newline at end of file diff --git a/docs/source/modules/examples/digital_fingerprinting/dfp_preproc.md b/docs/source/modules/examples/digital_fingerprinting/dfp_preproc.md new file mode 100644 index 0000000000..f50fe6e0e4 --- /dev/null +++ b/docs/source/modules/examples/digital_fingerprinting/dfp_preproc.md @@ -0,0 +1,37 @@ +## DFP Preprocessing Module + +This module function consolidates multiple DFP pipeline modules relevant to the inference/training process into a single module. + +### Configurable Parameters + +- **cache_dir** (string): Directory used for caching intermediate results. +- **timestamp_column_name** (string): Name of the column containing timestamps. +- **pre_filter_options** (dict): Options for pre-filtering control messages. +- **batching_options** (dict): Options for batching files. +- **user_splitting_options** (dict): Options for splitting data by user. +- **supported_loaders** (dict): Supported data loaders for different file types. + +### Example JSON Configuration + +```json +{ + "cache_dir": "/path/to/cache", + "timestamp_column_name": "timestamp", + "pre_filter_options": { + "option1": "value1", + "option2": "value2" + }, + "batching_options": { + "option1": "value1", + "option2": "value2" + }, + "user_splitting_options": { + "option1": "value1", + "option2": "value2" + }, + "supported_loaders": { + "file_type_1": "loader_1", + "file_type_2": "loader_2" + } +} +``` \ No newline at end of file diff --git a/docs/source/modules/examples/digital_fingerprinting/dfp_rolling_window.md b/docs/source/modules/examples/digital_fingerprinting/dfp_rolling_window.md new file mode 100644 index 0000000000..a92ac92065 --- /dev/null +++ b/docs/source/modules/examples/digital_fingerprinting/dfp_rolling_window.md @@ -0,0 +1,27 @@ +## DFP Split Users Module + +This module function splits the data based on user IDs. + +### Configurable Parameters + +- **fallback_username**: The user ID to use if the user ID is not found (string, default: 'generic_user') +- **include_generic**: Whether to include a generic user ID in the output (boolean, default: `False`) +- **include_individual**: Whether to include individual user IDs in the output (boolean, default: `False`) +- **only_users**: List of user IDs to include in the output; other user IDs will be excluded (list, default: `[]`). *Note: You can specify either `only_users` or `skip_users`, but not both.* +- **skip_users**: List of user IDs to exclude from the output (list, default: `[]`). *Note: You can specify either `only_users` or `skip_users`, but not both.* +- **timestamp_column_name**: Name of the column containing timestamps (string, default: 'timestamp') +- **userid_column_name**: Name of the column containing user IDs (string, default: 'username') + +### Example JSON Configuration + +```json +{ + "fallback_username": "generic_user", + "include_generic": false, + "include_individual": true, + "only_users": ["user1", "user2", "user3"], + "skip_users": [], + "timestamp_column_name": "timestamp", + "userid_column_name": "username" +} +``` \ No newline at end of file diff --git a/docs/source/modules/examples/digital_fingerprinting/dfp_split_users.md b/docs/source/modules/examples/digital_fingerprinting/dfp_split_users.md new file mode 100644 index 0000000000..0eefb03ebc --- /dev/null +++ b/docs/source/modules/examples/digital_fingerprinting/dfp_split_users.md @@ -0,0 +1,27 @@ +## DFP Split Users Module + +This module function splits the data based on user IDs. + +### Configurable Parameters + +- **fallback_username**: The user ID to use if the user ID is not found (string, default: 'generic_user') +- **include_generic**: Whether to include a generic user ID in the output (boolean, default: `False`) +- **include_individual**: Whether to include individual user IDs in the output (boolean, default: `False`) +- **only_users**: List of user IDs to include in the output; other user IDs will be excluded (list, default: `[]`) +- **skip_users**: List of user IDs to exclude from the output (list, default: `[]`) +- **timestamp_column_name**: Name of the column containing timestamps (string, default: 'timestamp') +- **userid_column_name**: Name of the column containing user IDs (string, default: 'username') + +### Example JSON Configuration + +```json +{ + "fallback_username": "generic_user", + "include_generic": false, + "include_individual": true, + "only_users": ["user1", "user2", "user3"], + "skip_users": ["user4", "user5"], + "timestamp_column_name": "timestamp", + "userid_column_name": "username" +} +``` \ No newline at end of file diff --git a/docs/source/modules/examples/digital_fingerprinting/dfp_training.md b/docs/source/modules/examples/digital_fingerprinting/dfp_training.md new file mode 100644 index 0000000000..b2bd94292b --- /dev/null +++ b/docs/source/modules/examples/digital_fingerprinting/dfp_training.md @@ -0,0 +1,25 @@ +## DFP Training Module + +This module function is used for model training. + +### Configurable Parameters + +- **feature_columns** (list): List of feature columns to train on. +- **epochs** (int): Number of epochs to train for. +- **model_kwargs** (dict): Keyword arguments to pass to the model (see `dfencoder.AutoEncoder`). +- **validation_size** (float): Size of the validation set. + +### Example JSON Configuration + +```json +{ + "feature_columns": ["feature_1", "feature_2", "feature_3"], + "epochs": 100, + "model_kwargs": { + "hidden_layers": [128, 64, 32], + "dropout_rate": 0.5, + "activation": "relu" + }, + "validation_size": 0.2 +} +``` \ No newline at end of file diff --git a/docs/source/modules/examples/digital_fingerprinting/dfp_training_pipe.md b/docs/source/modules/examples/digital_fingerprinting/dfp_training_pipe.md new file mode 100644 index 0000000000..2d3776f384 --- /dev/null +++ b/docs/source/modules/examples/digital_fingerprinting/dfp_training_pipe.md @@ -0,0 +1,65 @@ +## DFP Training Pipe Module + +This module function consolidates multiple DFP pipeline modules relevant to the training process into a single module. + +### Configurable Parameters + +- **timestamp_column_name** (str): Name of the timestamp column used in the data. +- **cache_dir** (str): Directory to cache the rolling window data. +- **batching_options** (dict): Options for batching the data. +- **user_splitting_options** (dict): Options for splitting the data by user. +- **stream_aggregation_options** (dict): Options for aggregating the data by stream. +- **preprocessing_options** (dict): Options for preprocessing the data. +- **dfencoder_options** (dict): Options for configuring the data frame encoder, used for training the model. +- **mlflow_writer_options** (dict): Options for the MLflow model writer, which is responsible for saving the trained + model. + +### Example JSON Configuration + +```json +{ + "timestamp_column_name": "timestamp", + "cache_dir": "/path/to/cache", + "batching_options": { + "start_time": "2022-01-01", + "end_time": "2022-12-31", + "sampling_rate_s": 60, + "period": "1d" + }, + "user_splitting_options": { + "userid_column_name": "username", + "fallback_username": "generic_user", + "include_generic": false, + "include_individual": false, + "only_users": [ + "user1", + "user2" + ], + "skip_users": [] + }, + "stream_aggregation_options": { + "aggregation_span": "60d", + "cache_mode": "batch" + }, + "preprocessing_options": { + "schema": { + "column1": "float", + "column2": "float" + } + }, + "dfencoder_options": { + "hidden_layers": [ + 128, + 64, + 32 + ], + "dropout_rate": 0.5, + "activation": "relu" + }, + "mlflow_writer_options": { + "mlflow_tracking_uri": "http://localhost:5000", + "experiment_name": "my_experiment", + "artifact_root": "/path/to/artifact/root" + } +} +``` \ No newline at end of file diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference.py index ee4236cbe3..bd85237f25 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference.py @@ -77,6 +77,7 @@ def process_task(control_message: MessageControl, task: dict): user_id = control_message.get_metadata("user_id") payload = control_message.payload() + with payload.mutable_dataframe() as dfm: df_user = dfm.to_pandas() df_user[timestamp_column_name] = pd.to_datetime(df_user[timestamp_column_name], utc=True) @@ -89,8 +90,9 @@ def process_task(control_message: MessageControl, task: dict): loaded_model = model_cache.load_model(client) - except Exception: # TODO - logger.exception("Error trying to get model") + # TODO(Devin): Recovery strategy should be more robust/configurable in practice + except Exception: + logger.exception(f"Error retrieving model for user {user_id}, discarding training message.") return None post_model_time = time.time() diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_preprocessing.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_preprocessing.py deleted file mode 100644 index 5c2005c015..0000000000 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_preprocessing.py +++ /dev/null @@ -1,78 +0,0 @@ -# Copyright (c) 2022-2023, NVIDIA CORPORATION. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import logging - -import dfp.modules.dfp_data_prep # noqa: F401 -import dfp.modules.dfp_rolling_window # noqa: F401 -import dfp.modules.dfp_split_users # noqa: F401 -import mrc - -import morpheus.modules.file_batcher # noqa: F401 -import morpheus.modules.file_to_df # noqa: F401 -from morpheus.utils.module_ids import FILE_BATCHER -from morpheus.utils.module_ids import FILE_TO_DF -from morpheus.utils.module_ids import MORPHEUS_MODULE_NAMESPACE -from morpheus.utils.module_utils import get_module_config -from morpheus.utils.module_utils import get_config_with_overrides -from morpheus.utils.module_utils import load_module -from morpheus.utils.module_utils import register_module - -from ..utils.module_ids import DFP_DATA_PREP -from ..utils.module_ids import DFP_PREPROCESSING -from ..utils.module_ids import DFP_ROLLING_WINDOW -from ..utils.module_ids import DFP_SPLIT_USERS - -logger = logging.getLogger("morpheus.{}".format(__name__)) - - -@register_module(DFP_PREPROCESSING, MORPHEUS_MODULE_NAMESPACE) -def dfp_preprocessing(builder: mrc.Builder): - """ - This module function allows for the consolidation of multiple dfp pipeline preprocessing modules - into a single module. - - Parameters - ---------- - builder : mrc.Builder - Pipeline budler instance. - """ - - config = get_module_config(DFP_PREPROCESSING, builder) - config["module_id"] = DFP_PREPROCESSING - config["namespace"] = MORPHEUS_MODULE_NAMESPACE - config["module_name"] = "dfp_preprocessing" - - file_batcher_conf = get_config_with_overrides(config, FILE_BATCHER, "file_batcher") - file_to_df_conf = get_config_with_overrides(config, FILE_TO_DF, "file_to_df") - dfp_split_users_conf = get_config_with_overrides(config, DFP_SPLIT_USERS, "dfp_split_users") - dfp_rolling_window_conf = get_config_with_overrides(config, DFP_ROLLING_WINDOW, "dfp_rolling_window") - dfp_data_prep_conf = get_config_with_overrides(config, DFP_DATA_PREP, "dfp_data_prep") - - # Load modules - file_batcher_module = load_module(file_batcher_conf, builder=builder) - file_to_dataframe_module = load_module(file_to_df_conf, builder=builder) - dfp_split_users_modules = load_module(dfp_split_users_conf, builder=builder) - dfp_rolling_window_module = load_module(dfp_rolling_window_conf, builder=builder) - dfp_data_prep_module = load_module(dfp_data_prep_conf, builder=builder) - - # Make an edge between the modules. - builder.make_edge(file_batcher_module.output_port("output"), file_to_dataframe_module.input_port("input")) - builder.make_edge(file_to_dataframe_module.output_port("output"), dfp_split_users_modules.input_port("input")) - builder.make_edge(dfp_split_users_modules.output_port("output"), dfp_rolling_window_module.input_port("input")) - builder.make_edge(dfp_rolling_window_module.output_port("output"), dfp_data_prep_module.input_port("input")) - - # Register input and output port for a module. - builder.register_module_input("input", file_batcher_module.input_port("input")) - builder.register_module_output("output", dfp_data_prep_module.output_port("output")) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_rolling_window.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_rolling_window.py index eef1eba759..a23cdf275b 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_rolling_window.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_rolling_window.py @@ -101,7 +101,7 @@ def try_build_window(message: MessageMeta, user_id: str) -> typing.Union[Message with message.mutable_dataframe() as dfm: incoming_df = dfm.to_pandas() - # TODO(Devin): note cuDF does not support tz aware datetimes (?) + # Note cuDF does not support tz aware datetimes (?) incoming_df[timestamp_column_name] = pd.to_datetime(incoming_df[timestamp_column_name], utc=True) if (not user_cache.append_dataframe(incoming_df=incoming_df)): diff --git a/morpheus/modules/file_batcher.py b/morpheus/modules/file_batcher.py index 44c7fcff6f..1bc06e9d2a 100644 --- a/morpheus/modules/file_batcher.py +++ b/morpheus/modules/file_batcher.py @@ -177,7 +177,7 @@ def generate_cms_for_batch_periods(control_message: MessageControl, period_gb, n "strategy": "aggregate", "files": filenames, "n_groups": n_groups, - "batcher_config": { # TODO(Devin): remove this + "batcher_config": { # TODO(Devin): Remove this when we're able to attach config to the loader "timestamp_column_name": config.get("timestamp_column_name"), "schema": config.get("schema"), "file_type": config.get("file_type"), diff --git a/morpheus/modules/serialize.py b/morpheus/modules/serialize.py index 38fbc03831..cab77201e9 100644 --- a/morpheus/modules/serialize.py +++ b/morpheus/modules/serialize.py @@ -43,6 +43,15 @@ def serialize(builder: mrc.Builder): ---------- builder : mrc.Builder mrc Builder object. + + Notes + ---------- + Configurable parameters: + - include : str (Regex to include columns) + - exclude : List[str] (List of regex to exclude columns) + - fixed_columns : bool (If true, the columns are fixed and not determined at runtime) + - columns : List[str] (List of columns to include) + - use_cpp : bool (If true, use C++ to serialize) """ config = builder.get_current_module_config() diff --git a/morpheus/modules/write_to_file.py b/morpheus/modules/write_to_file.py index 74f11ff6d0..f52d785559 100644 --- a/morpheus/modules/write_to_file.py +++ b/morpheus/modules/write_to_file.py @@ -47,6 +47,15 @@ def write_to_file(builder: mrc.Builder): ---------- builder : mrc.Builder mrc Builder object. + + Notes + ---------- + Configurable parameters: + - filename : str (Path to output file) + - overwrite : bool (If true, overwrite the file if it exists) + - flush : bool (If true, flush the file after each write) + - file_type : FileTypes (Type of file to write) + - include_index_col : bool (If true, include the index column) """ config = builder.get_current_module_config() From 429041cde33da014733823086ab7787ca368a855 Mon Sep 17 00:00:00 2001 From: Devin Robison Date: Wed, 15 Mar 2023 17:49:01 -0600 Subject: [PATCH 096/157] Docs updates --- docs/source/modules/core/file_batcher.md | 24 ++-- docs/source/modules/core/file_to_df.md | 12 +- .../modules/core/filter_control_message.md | 8 +- docs/source/modules/core/filter_detections.md | 14 +- .../modules/core/mlflow_model_writer.md | 10 +- docs/source/modules/core/serializer.md | 10 +- docs/source/modules/core/write_to_file.md | 10 +- .../digital_fingerprinting/dfp_data_prep.md | 14 +- .../digital_fingerprinting/dfp_deployment.md | 4 +- .../digital_fingerprinting/dfp_inference.md | 6 +- .../dfp_inference_pipe.md | 92 ++++++++++--- .../dfp_postprocessing.md | 2 +- .../digital_fingerprinting/dfp_preproc.md | 67 +++++++--- .../dfp_rolling_window.md | 14 +- .../digital_fingerprinting/dfp_split_users.md | 14 +- .../digital_fingerprinting/dfp_training.md | 48 +++++-- .../dfp_training_pipe.md | 126 +++++++++++++----- 17 files changed, 315 insertions(+), 160 deletions(-) diff --git a/docs/source/modules/core/file_batcher.md b/docs/source/modules/core/file_batcher.md index 516da65c6a..1bc430de92 100644 --- a/docs/source/modules/core/file_batcher.md +++ b/docs/source/modules/core/file_batcher.md @@ -4,20 +4,20 @@ This module loads the input files, removes files that are older than the chosen ### Configurable Parameters -- **batching_options**: A dictionary containing the following options: - - **end_time**: End time of the time window (datetime or string format) - - **iso_date_regex_pattern**: Regex pattern for ISO date matching - - **parser_kwargs**: Additional arguments for the parser (dictionary) - - **period**: Time period for grouping files (e.g., '1d' for 1 day) - - **sampling_rate_s**: Sampling rate in seconds (integer) - - **start_time**: Start time of the time window (datetime or string format) -- **cache_dir**: Cache directory (string) -- **file_type**: File type (string) -- **filter_nulls**: Whether to filter null values (boolean) +- `batching_options`: A dictionary containing the following options: + - `end_time`: End time of the time window (datetime or string format) + - `iso_date_regex_pattern`: Regex pattern for ISO date matching + - `parser_kwargs`: Additional arguments for the parser (dictionary) + - `period`: Time period for grouping files (e.g., '1d' for 1 day) + - `sampling_rate_s`: Sampling rate in seconds (integer) + - `start_time`: Start time of the time window (datetime or string format) +- `cache_dir`: Cache directory (string) +- `file_type`: File type (string) +- `filter_nulls`: Whether to filter null values (boolean) - `True`: Filter null values - `False`: Don't filter null values -- **schema**: Data schema (dictionary) -- **timestamp_column_name**: Name of the timestamp column (string) +- `schema`: Data schema (dictionary) +- `timestamp_column_name`: Name of the timestamp column (string) ### Example JSON Configuration diff --git a/docs/source/modules/core/file_to_df.md b/docs/source/modules/core/file_to_df.md index 5d95a66bf3..9185ab2d33 100644 --- a/docs/source/modules/core/file_to_df.md +++ b/docs/source/modules/core/file_to_df.md @@ -4,12 +4,12 @@ This module reads data from the batched files into a dataframe after receiving i ### Configurable Parameters -- **cache_dir** (str): Directory to cache the rolling window data. -- **file_type** (str): Type of the input file. -- **filter_null** (bool): Whether to filter out null values. -- **parser_kwargs** (dict): Keyword arguments to pass to the parser. -- **schema** (dict): Schema of the input data. -- **timestamp_column_name** (str): Name of the timestamp column. +- `cache_dir` (str): Directory to cache the rolling window data. +- `file_type` (str): Type of the input file. +- `filter_null` (bool): Whether to filter out null values. +- `parser_kwargs` (dict): Keyword arguments to pass to the parser. +- `schema` (dict): Schema of the input data. +- `timestamp_column_name` (str): Name of the timestamp column. ### Example JSON Configuration diff --git a/docs/source/modules/core/filter_control_message.md b/docs/source/modules/core/filter_control_message.md index 8ff2d9d4fe..c5c891c818 100644 --- a/docs/source/modules/core/filter_control_message.md +++ b/docs/source/modules/core/filter_control_message.md @@ -4,10 +4,10 @@ When the requirements are met, this module gently discards the control messages. ### Configurable Parameters -- **enable_task_filtering** (bool): Enables filtering based on task type. -- **enable_data_type_filtering** (bool): Enables filtering based on data type. -- **filter_task_type** (str): The task type to be used as a filter. -- **filter_data_type** (str): The data type to be used as a filter. +- `enable_task_filtering` (bool): Enables filtering based on task type. +- `enable_data_type_filtering` (bool): Enables filtering based on data type. +- `filter_task_type` (str): The task type to be used as a filter. +- `filter_data_type` (str): The data type to be used as a filter. ### Example JSON Configuration diff --git a/docs/source/modules/core/filter_detections.md b/docs/source/modules/core/filter_detections.md index 74e9796820..ecd98dafb7 100644 --- a/docs/source/modules/core/filter_detections.md +++ b/docs/source/modules/core/filter_detections.md @@ -6,13 +6,13 @@ The Filter Detections module is used to filter rows from a dataframe based on va ### Configurable Parameters -- **field_name** (str): Name of the field to filter on. Defaults to `probs`. -- **threshold** (float): Threshold value to filter on. Defaults to `0.5`. -- **filter_source** (str): Source of the filter field. Defaults to `AUTO`. -- **copy** (bool): Whether to copy the rows or slice them. Defaults to `True`. -- **schema** (dict): Schema configuration. - - **input_message_type** (str): Pickled message type. - - **encoding** (str): Encoding used to pickle the message type. +- `field_name` (str): Name of the field to filter on. Defaults to `probs`. +- `threshold` (float): Threshold value to filter on. Defaults to `0.5`. +- `filter_source` (str): Source of the filter field. Defaults to `AUTO`. +- `copy` (bool): Whether to copy the rows or slice them. Defaults to `True`. +- `schema` (dict): Schema configuration. + - `input_message_type` (str): Pickled message type. + - `encoding` (str): Encoding used to pickle the message type. ### Example JSON Configuration diff --git a/docs/source/modules/core/mlflow_model_writer.md b/docs/source/modules/core/mlflow_model_writer.md index 85b62949b8..150243ebb4 100644 --- a/docs/source/modules/core/mlflow_model_writer.md +++ b/docs/source/modules/core/mlflow_model_writer.md @@ -4,11 +4,11 @@ This module uploads trained models to the MLflow server. ### Configurable Parameters -- **model_name_formatter** (str): Formatter for the model name. -- **experiment_name_formatter** (str): Formatter for the experiment name. -- **conda_env** (str): Conda environment for the model. -- **timestamp_column_name** (str): Name of the timestamp column. -- **databricks_permissions** (dict): Permissions for the model. +- `model_name_formatter` (str): Formatter for the model name. +- `experiment_name_formatter` (str): Formatter for the experiment name. +- `conda_env` (str): Conda environment for the model. +- `timestamp_column_name` (str): Name of the timestamp column. +- `databricks_permissions` (dict): Permissions for the model. ### Example JSON Configuration diff --git a/docs/source/modules/core/serializer.md b/docs/source/modules/core/serializer.md index 37a143f9f0..b0cd20b9fb 100644 --- a/docs/source/modules/core/serializer.md +++ b/docs/source/modules/core/serializer.md @@ -4,11 +4,11 @@ This module filters columns from a `MultiMessage` object, emitting a `MessageMet ### Configurable Parameters -- **include** (str): Regex to include columns. -- **exclude** (List[str]): List of regex patterns to exclude columns. -- **fixed_columns** (bool): If true, the columns are fixed and not determined at runtime. -- **columns** (List[str]): List of columns to include. -- **use_cpp** (bool): If true, use C++ to serialize. +- `include` (str): Regex to include columns. +- `exclude` (List[str]): List of regex patterns to exclude columns. +- `fixed_columns` (bool): If true, the columns are fixed and not determined at runtime. +- `columns` (List[str]): List of columns to include. +- `use_cpp` (bool): If true, use C++ to serialize. ### Example JSON Configuration diff --git a/docs/source/modules/core/write_to_file.md b/docs/source/modules/core/write_to_file.md index ae45fa0c34..a159166818 100644 --- a/docs/source/modules/core/write_to_file.md +++ b/docs/source/modules/core/write_to_file.md @@ -4,11 +4,11 @@ This module writes messages to a file. ### Configurable Parameters -- **filename** (str): Path to the output file. -- **overwrite** (bool): If true, overwrite the file if it exists. -- **flush** (bool): If true, flush the file after each write. -- **file_type** (FileTypes): Type of file to write. -- **include_index_col** (bool): If true, include the index column. +- `filename` (str): Path to the output file. +- `overwrite` (bool): If true, overwrite the file if it exists. +- `flush` (bool): If true, flush the file after each write. +- `file_type` (FileTypes): Type of file to write. +- `include_index_col` (bool): If true, include the index column. ### Example JSON Configuration diff --git a/docs/source/modules/examples/digital_fingerprinting/dfp_data_prep.md b/docs/source/modules/examples/digital_fingerprinting/dfp_data_prep.md index b231c0126d..efc0e27c0a 100644 --- a/docs/source/modules/examples/digital_fingerprinting/dfp_data_prep.md +++ b/docs/source/modules/examples/digital_fingerprinting/dfp_data_prep.md @@ -4,18 +4,20 @@ This module function prepares data for either inference or model training. ### Configurable Parameters -- **schema**: Schema of the data (dictionary) -- **timestamp_column_name**: Name of the timestamp column (string, default: 'timestamp') +- `schema`: (dict) + - `schema_str`: (str) Serialized string representing the schema. + - `encoding`: (str) Encoding type for the schema_str. + - `input_message_type`: (str) Pickled message type. +- `timestamp_column_name`: Name of the timestamp column (string, default: 'timestamp') ### Example JSON Configuration ```json { "schema": { - "column1": "int", - "column2": "float", - "column3": "string", - "timestamp": "datetime" + "schema_str": "cPickle schema string", + "encoding": "latin1", + "input_message_type": "message type" }, "timestamp_column_name": "timestamp" } diff --git a/docs/source/modules/examples/digital_fingerprinting/dfp_deployment.md b/docs/source/modules/examples/digital_fingerprinting/dfp_deployment.md index 89f50084fe..8690a04729 100644 --- a/docs/source/modules/examples/digital_fingerprinting/dfp_deployment.md +++ b/docs/source/modules/examples/digital_fingerprinting/dfp_deployment.md @@ -4,8 +4,8 @@ This module function sets up a pipeline builder instance. ### Configurable Parameters -- **training_options**: Options for the training pipeline module, including settings and configurations specific to the training process (dictionary). -- **inference_options**: Options for the inference pipeline module, including settings and configurations specific to the inference process (dictionary). +- `training_options`: Options for the training pipeline module, including settings and configurations specific to the training process (dictionary). +- `inference_options`: Options for the inference pipeline module, including settings and configurations specific to the inference process (dictionary). ### Example JSON Configuration diff --git a/docs/source/modules/examples/digital_fingerprinting/dfp_inference.md b/docs/source/modules/examples/digital_fingerprinting/dfp_inference.md index 1843cc3432..0120eef520 100644 --- a/docs/source/modules/examples/digital_fingerprinting/dfp_inference.md +++ b/docs/source/modules/examples/digital_fingerprinting/dfp_inference.md @@ -4,9 +4,9 @@ This module function performs the inference process. ### Configurable Parameters -- **model_name_formatter**: Formatter for model names (string). -- **fallback_username**: Fallback user to use if no model is found for a user (string). -- **timestamp_column_name**: Name of the timestamp column (string). +- `model_name_formatter`: Formatter for model names (string). +- `fallback_username`: Fallback user to use if no model is found for a user (string). +- `timestamp_column_name`: Name of the timestamp column (string). ### Example JSON Configuration diff --git a/docs/source/modules/examples/digital_fingerprinting/dfp_inference_pipe.md b/docs/source/modules/examples/digital_fingerprinting/dfp_inference_pipe.md index 8992042415..14e3632947 100644 --- a/docs/source/modules/examples/digital_fingerprinting/dfp_inference_pipe.md +++ b/docs/source/modules/examples/digital_fingerprinting/dfp_inference_pipe.md @@ -1,33 +1,83 @@ -## DFP Inference Pipe Module +## dfp_inference_pipe -This module function allows for the consolidation of multiple dfp pipeline modules relevant to the inference process into a single module. +This module function allows for the consolidation of multiple dfp pipeline modules relevant to the inference process +into a single module. ### Configurable Parameters -- **batching_options** (dict): Options for batching the data, including start and end times, sampling rate, and other settings. -- **cache_dir** (string): Directory to cache the rolling window data. -- **detection_criteria** (dict): Criteria for filtering detections, such as threshold and field_name. -- **inference_options** (dict): Options for the inference module, including model settings and other configurations. -- **num_output_ports** (int): Number of output ports for the module. -- **preprocessing_options** (dict): Options for preprocessing the data, including schema and timestamp column name. -- **stream_aggregation_options** (dict): Options for aggregating the data by stream, including aggregation span and cache settings. -- **timestamp_column_name** (string): Name of the timestamp column in the input data. -- **user_splitting_options** (dict): Options for splitting the data by user, including filtering and user ID column name. -- **write_to_file_options** (dict): Options for writing the detections to a file, such as filename and overwrite settings. +- `timestamp_column_name` (str): Name of the column containing timestamps. +- `cache_dir` (str): Directory used for caching intermediate results. +- `batching_options` (dict): Options for batching files. + - `end_time` (str): End time of the time range to process. + - `iso_date_regex_pattern` (str): ISO date regex pattern. + - `parser_kwargs` (dict): Keyword arguments to pass to the parser. + - `period` (str): Time period to batch the data. + - `sampling_rate_s` (float): Sampling rate in seconds. + - `start_time` (str): Start time of the time range to process. +- `user_splitting_options` (dict): Options for splitting data by user. + - `fallback_username` (str): Fallback user to use if no model is found for a user. + - `include_generic` (bool): Include generic models in the results. + - `include_individual` (bool): Include individual models in the results. + - `only_users` (List[str]): List of users to include in the results. + - `skip_users` (List[str]): List of users to exclude from the results. + - `userid_column_name` (str): Column name for the user ID. +- `stream_aggregation_options` (dict): Options for aggregating data by stream. + - `timestamp_column_name` (str): Name of the column containing timestamps. + - `cache_mode` (str): Cache mode to use. + - `trigger_on_min_history` (bool): Trigger on minimum history. + - `aggregation_span` (str): Aggregation span. + - `trigger_on_min_increment` (bool): Trigger on minimum increment. + - `cache_to_disk` (bool): Cache to disk. +- `preprocessing_options` (dict): Options for preprocessing data. +- `inference_options` (dict): Options for configuring the inference process. + - `model_name_formatter` (str): Formatter for the model name. + - `fallback_username` (str): Fallback user to use if no model is found for a user. + - `timestamp_column_name` (str): Name of the column containing timestamps. +- `detection_criteria` (dict): Criteria for filtering detections. +- `write_to_file_options` (dict): Options for writing results to a file. ### Example JSON Configuration ```json { - "batching_options": {...}, - "cache_dir": "/path/to/cache", - "detection_criteria": {...}, - "inference_options": {...}, - "num_output_ports": 2, - "preprocessing_options": {...}, - "stream_aggregation_options": {...}, "timestamp_column_name": "timestamp", - "user_splitting_options": {...}, - "write_to_file_options": {...} + "cache_dir": "/tmp/cache", + "batching_options": { + "end_time": "2022-01-01T00:00:00Z", + "iso_date_regex_pattern": "\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z", + "parser_kwargs": {}, + "period": "1D", + "sampling_rate_s": 1.0, + "start_time": "2021-01-01T00:00:00Z" + }, + "user_splitting_options": { + "fallback_username": "generic", + "include_generic": true, + "include_individual": true, + "only_users": [ + "user_a", + "user_b" + ], + "skip_users": [ + "user_c" + ], + "userid_column_name": "user_id" + }, + "stream_aggregation_options": { + "timestamp_column_name": "timestamp", + "cache_mode": "MEMORY", + "trigger_on_min_history": true, + "aggregation_span": "1D", + "trigger_on_min_increment": true, + "cache_to_disk": false + }, + "preprocessing_options": {}, + "inference_options": { + "model_name_formatter": "{model_name}", + "fallback_username": "generic", + "timestamp_column_name": "timestamp" + }, + "detection_criteria": {}, + "write_to_file_options": {} } ``` \ No newline at end of file diff --git a/docs/source/modules/examples/digital_fingerprinting/dfp_postprocessing.md b/docs/source/modules/examples/digital_fingerprinting/dfp_postprocessing.md index 0d606c770f..8cc04e4e42 100644 --- a/docs/source/modules/examples/digital_fingerprinting/dfp_postprocessing.md +++ b/docs/source/modules/examples/digital_fingerprinting/dfp_postprocessing.md @@ -4,7 +4,7 @@ This module function performs postprocessing tasks after the inference process. ### Configurable Parameters -- **timestamp_column_name** (string): Name of the timestamp column in the input data. +- `timestamp_column_name` (string): Name of the timestamp column in the input data. ### Example JSON Configuration diff --git a/docs/source/modules/examples/digital_fingerprinting/dfp_preproc.md b/docs/source/modules/examples/digital_fingerprinting/dfp_preproc.md index f50fe6e0e4..200527993e 100644 --- a/docs/source/modules/examples/digital_fingerprinting/dfp_preproc.md +++ b/docs/source/modules/examples/digital_fingerprinting/dfp_preproc.md @@ -1,37 +1,66 @@ -## DFP Preprocessing Module +## dfp_preproc -This module function consolidates multiple DFP pipeline modules relevant to the inference/training process into a single module. +This module function allows for the consolidation of multiple dfp pipeline modules relevant to inference/training +process into a single module. ### Configurable Parameters -- **cache_dir** (string): Directory used for caching intermediate results. -- **timestamp_column_name** (string): Name of the column containing timestamps. -- **pre_filter_options** (dict): Options for pre-filtering control messages. -- **batching_options** (dict): Options for batching files. -- **user_splitting_options** (dict): Options for splitting data by user. -- **supported_loaders** (dict): Supported data loaders for different file types. +- `cache_dir` (str): Directory used for caching intermediate results. +- `timestamp_column_name` (str): Name of the column containing timestamps. +- `pre_filter_options` (dict): Options for pre-filtering control messages. + - `enable_task_filtering` (bool): Enables filtering based on task type. + - `filter_task_type` (str): The task type to be used as a filter. + - `enable_data_filtering` (bool): Enables filtering based on data type. + - `filter_data_type` (str): The data type to be used as a filter. +- `batching_options` (dict): Options for batching files. + - `end_time` (str): End time of the time range to process. + - `iso_date_regex_pattern` (str): ISO date regex pattern. + - `parser_kwargs` (dict): Keyword arguments to pass to the parser. + - `period` (str): Time period to batch the data. + - `sampling_rate_s` (float): Sampling rate in seconds. + - `start_time` (str): Start time of the time range to process. +- `user_splitting_options` (dict): Options for splitting data by user. + - `fallback_username` (str): Fallback user to use if no model is found for a user. + - `include_generic` (bool): Include generic models in the results. + - `include_individual` (bool): Include individual models in the results. + - `only_users` (List[str]): List of users to include in the results. + - `skip_users` (List[str]): List of users to exclude from the results. + - `userid_column_name` (str): Column name for the user ID. +- `supported_loaders` (dict): Supported data loaders for different file types. ### Example JSON Configuration ```json { - "cache_dir": "/path/to/cache", + "cache_dir": "/tmp/cache", "timestamp_column_name": "timestamp", "pre_filter_options": { - "option1": "value1", - "option2": "value2" + "enable_task_filtering": true, + "filter_task_type": "task_a", + "enable_data_filtering": true, + "filter_data_type": "type_a" }, "batching_options": { - "option1": "value1", - "option2": "value2" + "end_time": "2022-01-01T00:00:00Z", + "iso_date_regex_pattern": "\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z", + "parser_kwargs": {}, + "period": "1D", + "sampling_rate_s": 1.0, + "start_time": "2021-01-01T00:00:00Z" }, "user_splitting_options": { - "option1": "value1", - "option2": "value2" + "fallback_username": "generic", + "include_generic": true, + "include_individual": true, + "only_users": [ + "user_a", + "user_b" + ], + "skip_users": [ + "user_c" + ], + "userid_column_name": "user_id" }, - "supported_loaders": { - "file_type_1": "loader_1", - "file_type_2": "loader_2" - } + "supported_loaders": {} } ``` \ No newline at end of file diff --git a/docs/source/modules/examples/digital_fingerprinting/dfp_rolling_window.md b/docs/source/modules/examples/digital_fingerprinting/dfp_rolling_window.md index a92ac92065..4f3b276c1a 100644 --- a/docs/source/modules/examples/digital_fingerprinting/dfp_rolling_window.md +++ b/docs/source/modules/examples/digital_fingerprinting/dfp_rolling_window.md @@ -4,13 +4,13 @@ This module function splits the data based on user IDs. ### Configurable Parameters -- **fallback_username**: The user ID to use if the user ID is not found (string, default: 'generic_user') -- **include_generic**: Whether to include a generic user ID in the output (boolean, default: `False`) -- **include_individual**: Whether to include individual user IDs in the output (boolean, default: `False`) -- **only_users**: List of user IDs to include in the output; other user IDs will be excluded (list, default: `[]`). *Note: You can specify either `only_users` or `skip_users`, but not both.* -- **skip_users**: List of user IDs to exclude from the output (list, default: `[]`). *Note: You can specify either `only_users` or `skip_users`, but not both.* -- **timestamp_column_name**: Name of the column containing timestamps (string, default: 'timestamp') -- **userid_column_name**: Name of the column containing user IDs (string, default: 'username') +- `fallback_username`: The user ID to use if the user ID is not found (string, default: 'generic_user') +- `include_generic`: Whether to include a generic user ID in the output (boolean, default: `False`) +- `include_individual`: Whether to include individual user IDs in the output (boolean, default: `False`) +- `only_users`: List of user IDs to include in the output; other user IDs will be excluded (list, default: `[]`). *Note: You can specify either `only_users` or `skip_users`, but not both.* +- `skip_users`: List of user IDs to exclude from the output (list, default: `[]`). *Note: You can specify either `only_users` or `skip_users`, but not both.* +- `timestamp_column_name`: Name of the column containing timestamps (string, default: 'timestamp') +- `userid_column_name`: Name of the column containing user IDs (string, default: 'username') ### Example JSON Configuration diff --git a/docs/source/modules/examples/digital_fingerprinting/dfp_split_users.md b/docs/source/modules/examples/digital_fingerprinting/dfp_split_users.md index 0eefb03ebc..c69050e478 100644 --- a/docs/source/modules/examples/digital_fingerprinting/dfp_split_users.md +++ b/docs/source/modules/examples/digital_fingerprinting/dfp_split_users.md @@ -4,13 +4,13 @@ This module function splits the data based on user IDs. ### Configurable Parameters -- **fallback_username**: The user ID to use if the user ID is not found (string, default: 'generic_user') -- **include_generic**: Whether to include a generic user ID in the output (boolean, default: `False`) -- **include_individual**: Whether to include individual user IDs in the output (boolean, default: `False`) -- **only_users**: List of user IDs to include in the output; other user IDs will be excluded (list, default: `[]`) -- **skip_users**: List of user IDs to exclude from the output (list, default: `[]`) -- **timestamp_column_name**: Name of the column containing timestamps (string, default: 'timestamp') -- **userid_column_name**: Name of the column containing user IDs (string, default: 'username') +- `fallback_username`: The user ID to use if the user ID is not found (string, default: 'generic_user') +- `include_generic`: Whether to include a generic user ID in the output (boolean, default: `False`) +- `include_individual`: Whether to include individual user IDs in the output (boolean, default: `False`) +- `only_users`: List of user IDs to include in the output; other user IDs will be excluded (list, default: `[]`) +- `skip_users`: List of user IDs to exclude from the output (list, default: `[]`) +- `timestamp_column_name`: Name of the column containing timestamps (string, default: 'timestamp') +- `userid_column_name`: Name of the column containing user IDs (string, default: 'username') ### Example JSON Configuration diff --git a/docs/source/modules/examples/digital_fingerprinting/dfp_training.md b/docs/source/modules/examples/digital_fingerprinting/dfp_training.md index b2bd94292b..1b985bd3de 100644 --- a/docs/source/modules/examples/digital_fingerprinting/dfp_training.md +++ b/docs/source/modules/examples/digital_fingerprinting/dfp_training.md @@ -1,25 +1,45 @@ -## DFP Training Module +# DFP Training Module -This module function is used for model training. +This module function is responsible for training the model. -### Configurable Parameters +## Configurable Parameters -- **feature_columns** (list): List of feature columns to train on. -- **epochs** (int): Number of epochs to train for. -- **model_kwargs** (dict): Keyword arguments to pass to the model (see `dfencoder.AutoEncoder`). -- **validation_size** (float): Size of the validation set. +- `feature_columns` (list): List of feature columns to train on. +- `epochs` (int): Number of epochs to train for. +- `model_kwargs` (dict): Keyword arguments to pass to the model (see dfencoder.AutoEncoder). +- `validation_size` (float): Size of the validation set. -### Example JSON Configuration +## JSON Example ```json { - "feature_columns": ["feature_1", "feature_2", "feature_3"], - "epochs": 100, + "feature_columns": [ + "column1", + "column2", + "column3" + ], + "epochs": 50, "model_kwargs": { - "hidden_layers": [128, 64, 32], - "dropout_rate": 0.5, - "activation": "relu" + "encoder_layers": [ + 64, + 32 + ], + "decoder_layers": [ + 32, + 64 + ], + "activation": "relu", + "swap_p": 0.1, + "lr": 0.001, + "lr_decay": 0.9, + "batch_size": 32, + "verbose": 1, + "optimizer": "adam", + "scalar": "min_max", + "min_cats": 10, + "progress_bar": false, + "device": "cpu" }, - "validation_size": 0.2 + "validation_size": 0.1 } ``` \ No newline at end of file diff --git a/docs/source/modules/examples/digital_fingerprinting/dfp_training_pipe.md b/docs/source/modules/examples/digital_fingerprinting/dfp_training_pipe.md index 2d3776f384..fe8ff97ab7 100644 --- a/docs/source/modules/examples/digital_fingerprinting/dfp_training_pipe.md +++ b/docs/source/modules/examples/digital_fingerprinting/dfp_training_pipe.md @@ -4,14 +4,32 @@ This module function consolidates multiple DFP pipeline modules relevant to the ### Configurable Parameters -- **timestamp_column_name** (str): Name of the timestamp column used in the data. -- **cache_dir** (str): Directory to cache the rolling window data. -- **batching_options** (dict): Options for batching the data. -- **user_splitting_options** (dict): Options for splitting the data by user. -- **stream_aggregation_options** (dict): Options for aggregating the data by stream. -- **preprocessing_options** (dict): Options for preprocessing the data. -- **dfencoder_options** (dict): Options for configuring the data frame encoder, used for training the model. -- **mlflow_writer_options** (dict): Options for the MLflow model writer, which is responsible for saving the trained +- `timestamp_column_name` (str): Name of the timestamp column used in the data. +- `cache_dir` (str): Directory to cache the rolling window data. +- `batching_options` (dict): Options for batching files. + - `end_time` (str): End time of the time range to process. + - `iso_date_regex_pattern` (str): ISO date regex pattern. + - `parser_kwargs` (dict): Keyword arguments to pass to the parser. + - `period` (str): Time period to batch the data. + - `sampling_rate_s` (float): Sampling rate in seconds. + - `start_time` (str): Start time of the time range to process. +- `user_splitting_options` (dict): Options for splitting data by user. + - `fallback_username` (str): Fallback user to use if no model is found for a user. + - `include_generic` (bool): Include generic models in the results. + - `include_individual` (bool): Include individual models in the results. + - `only_users` (List[str]): List of users to include in the results. + - `skip_users` (List[str]): List of users to exclude from the results. + - `userid_column_name` (str): Column name for the user ID. +- `stream_aggregation_options` (dict): Options for aggregating data by stream. + - `timestamp_column_name` (str): Name of the column containing timestamps. + - `cache_mode` (str): Cache mode to use. + - `trigger_on_min_history` (bool): Trigger on minimum history. + - `aggregation_span` (str): Aggregation span. + - `trigger_on_min_increment` (bool): Trigger on minimum increment. + - `cache_to_disk` (bool): Cache to disk. +- `preprocessing_options` (dict): Options for preprocessing the data. +- `dfencoder_options` (dict): Options for configuring the data frame encoder, used for training the model. +- `mlflow_writer_options` (dict): Options for the MLflow model writer, which is responsible for saving the trained model. ### Example JSON Configuration @@ -19,47 +37,83 @@ This module function consolidates multiple DFP pipeline modules relevant to the ```json { "timestamp_column_name": "timestamp", - "cache_dir": "/path/to/cache", + "cache_dir": "/tmp/cache", "batching_options": { - "start_time": "2022-01-01", - "end_time": "2022-12-31", + "end_time": "2023-03-01T00:00:00", + "iso_date_regex_pattern": "\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}", + "parser_kwargs": {}, + "period": "1min", "sampling_rate_s": 60, - "period": "1d" + "start_time": "2023-02-01T00:00:00" }, "user_splitting_options": { - "userid_column_name": "username", - "fallback_username": "generic_user", - "include_generic": false, - "include_individual": false, - "only_users": [ - "user1", - "user2" - ], - "skip_users": [] + "fallback_username": "generic", + "include_generic": true, + "include_individual": true, + "only_users": [], + "skip_users": [], + "userid_column_name": "user_id" }, "stream_aggregation_options": { - "aggregation_span": "60d", - "cache_mode": "batch" + "timestamp_column_name": "timestamp", + "cache_mode": "memory", + "trigger_on_min_history": 1, + "aggregation_span": "1min", + "trigger_on_min_increment": 1, + "cache_to_disk": false }, "preprocessing_options": { - "schema": { - "column1": "float", - "column2": "float" - } + "enable_task_filtering": true, + "filter_task_type": "taskA", + "enable_data_filtering": true, + "filter_data_type": "typeA" }, "dfencoder_options": { - "hidden_layers": [ - 128, - 64, - 32 + "feature_columns": [ + "column1", + "column2" ], - "dropout_rate": 0.5, - "activation": "relu" + "epochs": 10, + "validation_size": 0.2, + "model_kwargs": { + "encoder_layers": [ + 128, + 64 + ], + "decoder_layers": [ + 64, + 128 + ], + "activation": "relu", + "swap_p": 0.1, + "lr": 0.001, + "lr_decay": 0.99, + "batch_size": 256, + "verbose": 1, + "optimizer": "adam", + "scalar": "minmax", + "min_cats": 2, + "progress_bar": true, + "device": "cpu" + } }, "mlflow_writer_options": { - "mlflow_tracking_uri": "http://localhost:5000", - "experiment_name": "my_experiment", - "artifact_root": "/path/to/artifact/root" + "model_name_formatter": "trained_model_{timestamp}", + "experiment_name_formatter": "training_experiment_{timestamp}", + "conda_env": "path/to/conda_env.yml", + "timestamp_column_name": "timestamp", + "databricks_permissions": { + "read_users": [ + "user1", + "user2" + ], + "write_users": [ + "user1" + ], + "manage_users": [ + "user1" + ] + } } } ``` \ No newline at end of file From 27c7fe17929f5d627cb1d7f4b80343777bb0d695 Mon Sep 17 00:00:00 2001 From: Devin Robison Date: Wed, 15 Mar 2023 17:54:45 -0600 Subject: [PATCH 097/157] Add top level module documentation file --- docs/source/modules/morpheus_modules.md | 31 +++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 docs/source/modules/morpheus_modules.md diff --git a/docs/source/modules/morpheus_modules.md b/docs/source/modules/morpheus_modules.md new file mode 100644 index 0000000000..bf673449c7 --- /dev/null +++ b/docs/source/modules/morpheus_modules.md @@ -0,0 +1,31 @@ +# Morpheus Module Documentation + +This is the top-level documentation for all available Morpheus modules. + +## Table of Contents + +- [Core Modules](#core-modules) +- [Digital Fingerprinting Modules](#digital-fingerprinting-modules) + +## Core Modules + +- [File Batcher](./core/file_batcher.md) +- [File to DataFrame](./core/file_to_df.md) +- [Filter Control Message](./core/filter_control_message.md) +- [Filter Detections](./core/filter_detections.md) +- [MLflow Model Writer](./core/mlflow_model_writer.md) +- [Serializer](./core/serializer.md) +- [Write to File](./core/write_to_file.md) + +## Digital Fingerprinting Modules + +- [DFP Data Preparation](./examples/digital_fingerprinting/dfp_data_prep.md) +- [DFP Deployment](./examples/digital_fingerprinting/dfp_deployment.md) +- [DFP Inference](./examples/digital_fingerprinting/dfp_inference.md) +- [DFP Inference Pipe](./examples/digital_fingerprinting/dfp_inference_pipe.md) +- [DFP Postprocessing](./examples/digital_fingerprinting/dfp_postprocessing.md) +- [DFP Preprocessing](./examples/digital_fingerprinting/dfp_preproc.md) +- [DFP Rolling Window](./examples/digital_fingerprinting/dfp_rolling_window.md) +- [DFP Split Users](./examples/digital_fingerprinting/dfp_split_users.md) +- [DFP Training](./examples/digital_fingerprinting/dfp_training.md) +- [DFP Training Pipe](./examples/digital_fingerprinting/dfp_training_pipe.md) From c617371eab8512f24d5290f31023878b58dcd3a1 Mon Sep 17 00:00:00 2001 From: bsuryadevara Date: Thu, 16 Mar 2023 09:42:15 -0500 Subject: [PATCH 098/157] trivial changes to dfp demo files --- .../digital_fingerprinting/demo/README.md | 17 ++++-- .../demo/cm_app/__init__.py | 1 + .../demo/cm_app/helper.py | 61 +++++++++++-------- .../demo/cm_app/static/training.js | 1 - .../demo/cm_app/templates/training.html | 10 +-- .../demo/cm_app/views.py | 31 +++++++++- .../demo/cm_app/webapp.py | 4 +- 7 files changed, 84 insertions(+), 41 deletions(-) diff --git a/examples/digital_fingerprinting/demo/README.md b/examples/digital_fingerprinting/demo/README.md index 9224319e38..653c249b31 100644 --- a/examples/digital_fingerprinting/demo/README.md +++ b/examples/digital_fingerprinting/demo/README.md @@ -1,7 +1,8 @@ +### GUI Setup for Submitting Control Messages #### Kafka Setup -Start Kafka service. +Start Kafka service to publish control messages to kafka topic ``` cd ~/examples/digital_fingerprinting/production @@ -10,21 +11,26 @@ docker-compose up kafka zookeeper ``` ##### Create Kafka Topic + +Create Kafka topic `test_cm` to submit control messages from `cm_app`. ``` docker exec -it kafka kafka-topics --create --topic test_cm --bootstrap-server localhost:9092 ``` -Verify created topic is receiving messages. +Make sure the topic you created is getting messages. ``` docker exec -it kafka kafka-console-consumer --topic test_cm --from-beginning --bootstrap-server localhost:9092 ``` #### Flask Server Setup +Install flask python module to run the demo. + ``` pip install flask ``` +Navigate to the bin directory and execute start script. ``` cd ~/examples/digital_fingerprinting/demo/bin @@ -32,8 +38,11 @@ bash start.sh ``` #### Endpoint URL's +Flexibility to demonstrate the range of control message creation options. ``` http://localhost:3000 -http://localhost:3000/training ``` - +Generates control messages for training purposes exclusively with some user-specified parameters. +``` +http://localhost:3000/training # +``` diff --git a/examples/digital_fingerprinting/demo/cm_app/__init__.py b/examples/digital_fingerprinting/demo/cm_app/__init__.py index 8acb4fef52..0627110835 100644 --- a/examples/digital_fingerprinting/demo/cm_app/__init__.py +++ b/examples/digital_fingerprinting/demo/cm_app/__init__.py @@ -1,2 +1,3 @@ import flask + app = flask.Flask(__name__) diff --git a/examples/digital_fingerprinting/demo/cm_app/helper.py b/examples/digital_fingerprinting/demo/cm_app/helper.py index 82a36ca6b5..dd3fefcf78 100644 --- a/examples/digital_fingerprinting/demo/cm_app/helper.py +++ b/examples/digital_fingerprinting/demo/cm_app/helper.py @@ -1,41 +1,50 @@ -from confluent_kafka import Producer import json import logging -logging.basicConfig() -logger = logging.getLogger("logger") +logger = logging.getLogger(__name__) -def delivery_report(err, msg): - """ Called once for each message produced to indicate delivery result. - Triggered by poll() or flush(). """ - if err is not None: - print('Message delivery failed: {}'.format(err)) - else: - print('Message delivered to {} [{}]'.format(msg.topic(), msg.partition())) +class KafkaWriter: -def publish_message(message): - p = Producer({'bootstrap.servers': 'localhost:9092'}) + def __init__(self, kafka_topic, batch_size, producer): + self._kafka_topic = kafka_topic + self._batch_size = batch_size + self._producer = producer - p.poll(0) + @property + def producer(self): + return self._producer - # Asynchronously produce a message. The delivery report callback will - # be triggered from the call to poll() above, or flush() below, when the - # message has been successfully delivered or failed permanently. - p.produce('test_cm', message.encode('utf-8')) + def write_data(self, message): + self.producer.produce(self._kafka_topic, message.encode('utf-8')) + if len(self.producer) >= self._batch_size: + logger.info( + "Batch reached, calling poll... producer unsent: %s", + len(self.producer), + ) + self.producer.flush() + + def close(self): + logger.info("Closing kafka writer...") + if self.producer is not None: + self.producer.flush() + logger.info("Closing kafka writer...Done") - # Wait for any outstanding messages to be delivered and delivery report - # callbacks to be triggered. - p.flush() def process_cm(request): control_messages_json = request.form.get("control-messages-json") - publish_message(control_messages_json) - logging.error(control_messages_json) - data = { - "status": "Successfully published task to kafka topic.", + + logging.info("Received control message: {}".format(control_messages_json)) + + return control_messages_json + + +def generate_success_message(control_messages_json): + sucess_message = { + "status": "Successfully published control message to kafka topic.", "status_code": 200, "control_messages": json.loads(control_messages_json) } - data = json.dumps(data, indent=4) - return data \ No newline at end of file + + sucess_message = json.dumps(sucess_message, indent=4) + return sucess_message diff --git a/examples/digital_fingerprinting/demo/cm_app/static/training.js b/examples/digital_fingerprinting/demo/cm_app/static/training.js index 0c4602a692..c87d0189c8 100644 --- a/examples/digital_fingerprinting/demo/cm_app/static/training.js +++ b/examples/digital_fingerprinting/demo/cm_app/static/training.js @@ -4,7 +4,6 @@ $(document).ready(function() { submitForm(); }); - // Function to convert inputs-container and child data to JSON function submitForm() { // get all the input fields in the inputs-container div diff --git a/examples/digital_fingerprinting/demo/cm_app/templates/training.html b/examples/digital_fingerprinting/demo/cm_app/templates/training.html index afc77981bf..cc1db41c80 100644 --- a/examples/digital_fingerprinting/demo/cm_app/templates/training.html +++ b/examples/digital_fingerprinting/demo/cm_app/templates/training.html @@ -23,11 +23,11 @@

DFP Integrated Training Demo

- -
-
- -
+ +
+
+ +
diff --git a/examples/digital_fingerprinting/demo/cm_app/views.py b/examples/digital_fingerprinting/demo/cm_app/views.py index 2dc00161d2..97238a01ec 100644 --- a/examples/digital_fingerprinting/demo/cm_app/views.py +++ b/examples/digital_fingerprinting/demo/cm_app/views.py @@ -1,25 +1,50 @@ +import logging + +from confluent_kafka import Producer from flask import render_template from flask import request -from cm_app.helper import publish_message +from cm_app.helper import KafkaWriter from cm_app.helper import process_cm +from cm_app.helper import generate_success_message from . import app +kafka_writer = None + + +@app.before_first_request +def setup(): + app.logger.setLevel(logging.INFO) + producer = Producer({'bootstrap.servers': 'localhost:9092'}) + app.logger.info("Initialized Kafka producer") + global kafka_writer + kafka_writer = KafkaWriter(kafka_topic="test_cm", batch_size=1, producer=producer) + app.logger.info("Initialized Kafka writer") + @app.route('/', methods=["GET", "POST"]) def submit_messages(): if request.method == "POST": - return process_cm(request) + control_messages_json = process_cm(request) + global kafka_writer + kafka_writer.write_data(control_messages_json) + sucess_message = generate_success_message(control_message_json) + return sucess_message if request.method == "GET": return render_template("submit_messages.html") + @app.route('/training', methods=["GET", "POST"]) def training(): if request.method == "POST": - return process_cm(request) + control_messages_json = process_cm(request) + global kafka_writer + kafka_writer.write_data(control_messages_json) + sucess_message = generate_success_message(control_messages_json) + return sucess_message if request.method == "GET": return render_template("training.html") diff --git a/examples/digital_fingerprinting/demo/cm_app/webapp.py b/examples/digital_fingerprinting/demo/cm_app/webapp.py index 09bd06088f..9ddaeedc17 100644 --- a/examples/digital_fingerprinting/demo/cm_app/webapp.py +++ b/examples/digital_fingerprinting/demo/cm_app/webapp.py @@ -1,3 +1,3 @@ # Entry point for the application. -from . import app -from . import views +from . import app # noqa: F401 +from . import views # noqa: F401 From e893322ca5e6c2215beda0970e94a08da45dc31e Mon Sep 17 00:00:00 2001 From: bsuryadevara Date: Thu, 16 Mar 2023 11:22:37 -0500 Subject: [PATCH 099/157] renamed multiport file name --- .../benchmarks/test_bench_e2e_dfp_pipeline.py | 4 +- .../morpheus/dfp_modules_pipeline.py | 4 +- .../dfp_modules_streaming_pipeline.py | 4 +- ...le_stage.py => multiport_modules_stage.py} | 2 +- tests/test_multiport_modules_stage.py | 94 +++++++++++++++++++ 5 files changed, 101 insertions(+), 7 deletions(-) rename morpheus/stages/general/{multi_port_module_stage.py => multiport_modules_stage.py} (99%) create mode 100755 tests/test_multiport_modules_stage.py diff --git a/examples/digital_fingerprinting/production/morpheus/benchmarks/test_bench_e2e_dfp_pipeline.py b/examples/digital_fingerprinting/production/morpheus/benchmarks/test_bench_e2e_dfp_pipeline.py index ac9b87cc29..1c36a57824 100644 --- a/examples/digital_fingerprinting/production/morpheus/benchmarks/test_bench_e2e_dfp_pipeline.py +++ b/examples/digital_fingerprinting/production/morpheus/benchmarks/test_bench_e2e_dfp_pipeline.py @@ -43,7 +43,7 @@ from morpheus.config import Config from morpheus.pipeline.linear_pipeline import LinearPipeline from morpheus.pipeline.pipeline import Pipeline -from morpheus.stages.general.multi_port_module_stage import MultiPortModuleStage +from morpheus.stages.general.multiport_modules_stage import MultiPortModulesStage from morpheus.stages.input.control_message_file_source_stage import ControlMessageFileSourceStage from morpheus.stages.output.write_to_file_stage import WriteToFileStage from morpheus.stages.postprocess.filter_detections_stage import FilterDetectionsStage @@ -77,7 +77,7 @@ def dfp_modules_pipeline(pipe_config: Config, # Here we add a wrapped module that implements the DFP Deployment dfp_deployment_stage = pipeline.add_stage( - MultiPortModuleStage(pipe_config, + MultiPortModulesStage(pipe_config, modules_conf, input_port_name="input", output_port_name_prefix="output", diff --git a/examples/digital_fingerprinting/production/morpheus/dfp_modules_pipeline.py b/examples/digital_fingerprinting/production/morpheus/dfp_modules_pipeline.py index fdc18a4de5..cf98cc843c 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp_modules_pipeline.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp_modules_pipeline.py @@ -29,7 +29,7 @@ from morpheus.config import Config from morpheus.pipeline.pipeline import Pipeline from morpheus.stages.general.monitor_stage import MonitorStage -from morpheus.stages.general.multi_port_module_stage import MultiPortModuleStage +from morpheus.stages.general.multiport_modules_stage import MultiPortModulesStage from morpheus.stages.input.control_message_file_source_stage import ControlMessageFileSourceStage @@ -159,7 +159,7 @@ def run_pipeline(source: str, source_stage = pipeline.add_stage(ControlMessageFileSourceStage(config, filenames=list(kwargs["input_file"]))) dfp_deployment_stage = pipeline.add_stage( - MultiPortModuleStage(config, + MultiPortModulesStage(config, dfp_deployment_module_config, input_port_name="input", output_port_name_prefix="output", diff --git a/examples/digital_fingerprinting/production/morpheus/dfp_modules_streaming_pipeline.py b/examples/digital_fingerprinting/production/morpheus/dfp_modules_streaming_pipeline.py index 6fe752de86..5b4b76ac15 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp_modules_streaming_pipeline.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp_modules_streaming_pipeline.py @@ -29,7 +29,7 @@ from morpheus.config import Config from morpheus.pipeline.pipeline import Pipeline from morpheus.stages.general.monitor_stage import MonitorStage -from morpheus.stages.general.multi_port_module_stage import MultiPortModuleStage +from morpheus.stages.general.multiport_modules_stage import MultiPortModulesStage from morpheus.stages.input.control_message_kafka_source_stage import ControlMessageKafkaSourceStage @@ -178,7 +178,7 @@ def run_pipeline(source: str, disable_pre_filtering=kwargs["disable_pre_filtering"])) dfp_deployment_stage = pipeline.add_stage( - MultiPortModuleStage(config, + MultiPortModulesStage(config, dfp_deployment_module_config, input_port_name="input", output_port_name_prefix="output", diff --git a/morpheus/stages/general/multi_port_module_stage.py b/morpheus/stages/general/multiport_modules_stage.py similarity index 99% rename from morpheus/stages/general/multi_port_module_stage.py rename to morpheus/stages/general/multiport_modules_stage.py index b53940209c..dc65f37273 100644 --- a/morpheus/stages/general/multi_port_module_stage.py +++ b/morpheus/stages/general/multiport_modules_stage.py @@ -25,7 +25,7 @@ logger = logging.getLogger(__name__) -class MultiPortModuleStage(Stage): +class MultiPortModulesStage(Stage): """ Loads an existing, registered, MRC SegmentModule and wraps it as a Morpheus Stage. diff --git a/tests/test_multiport_modules_stage.py b/tests/test_multiport_modules_stage.py new file mode 100755 index 0000000000..74b3b983b6 --- /dev/null +++ b/tests/test_multiport_modules_stage.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python +# SPDX-FileCopyrightText: Copyright (c) 2023 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import typing +from unittest import mock + +import mrc +import pytest + +from morpheus.stages.general.multiport_modules_stage import MultiPortModulesStage +from morpheus.utils.module_utils import mrc_version + +module_config = { + "module_id": "TestSimpleModule", "module_name": "test_simple_module", "namespace": "test_morpheus_modules" +} + + +@pytest.mark.use_python +def test_constructor(config): + + mod_stage = MultiPortModulesStage(config, module_config, input_port_name="input", + output_port_name_prefix="output", + num_output_ports=num_output_ports) + + assert mod_stage.name == "test_simple_module" + + # Just ensure that we get a valid non-empty tuple + accepted_types = mod_stage.accepted_types() + print(accepted_types) + assert isinstance(accepted_types, tuple) + assert len(accepted_types) > 0 + assert accepted_types[0] == typing.Any + + pytest.raises(NotImplementedError, mod_stage._get_cpp_module_node, None) + + +@pytest.mark.use_python +def test_build_single_before_module_registration(config): + + mock_node = mock.MagicMock() + mock_segment = mock.MagicMock() + mock_module = mock.MagicMock() + mock_input_stream = mock.MagicMock() + + mock_segment.load_module.return_value = mock_module + mock_segment.make_node_full.return_value = mock_node + + mod_stage = LinearModulesStage(config, module_config, input_port_name="test_in", output_port_name="test_out") + + with pytest.raises(Exception): + mod_stage._build_single(mock_segment, mock_input_stream) + + +def register_test_module(): + registry = mrc.ModuleRegistry + + def module_init_fn(builder: mrc.Builder): + pass + + registry.register_module("TestSimpleModule", "test_morpheus_modules", mrc_version, module_init_fn) + + +@pytest.mark.use_python +def test_build_single_after_module_registration(config): + + register_test_module() + + mock_node = mock.MagicMock() + mock_segment = mock.MagicMock() + mock_module = mock.MagicMock() + mock_input_stream = mock.MagicMock() + + mock_segment.load_module.return_value = mock_module + mock_segment.make_node_full.return_value = mock_node + + mod_stage = LinearModulesStage(config, module_config, input_port_name="test_in", output_port_name="test_out") + + mod_stage._build_single(mock_segment, mock_input_stream) + + mock_segment.load_module.assert_called_once() + mock_segment.make_edge.assert_called_once() From 4e3ff4628309c2881dcebe34de475da595406bd1 Mon Sep 17 00:00:00 2001 From: Devin Robison Date: Thu, 16 Mar 2023 13:24:37 -0600 Subject: [PATCH 100/157] Add simple module user guide --- docs/source/developer_guide/architecture.md | 6 + docs/source/developer_guide/guides.md | 45 ++++++-- .../guides/7_simple_python_module.md | 107 ++++++++++++++++++ docs/source/index.rst | 7 ++ docs/source/stages/morpheus_stages.md | 65 +++++++++++ 5 files changed, 222 insertions(+), 8 deletions(-) create mode 100644 docs/source/developer_guide/guides/7_simple_python_module.md create mode 100644 docs/source/stages/morpheus_stages.md diff --git a/docs/source/developer_guide/architecture.md b/docs/source/developer_guide/architecture.md index 2efcb910b2..7f6ae9ef9f 100644 --- a/docs/source/developer_guide/architecture.md +++ b/docs/source/developer_guide/architecture.md @@ -49,6 +49,12 @@ A stage is the fundamental building block in Morpheus and is responsible for per While stages are very flexible, they all comprise three main pieces: identification, type inference, and node creation. +## Morpheus Modules +Modules, introduced in the 23.03 release, introduce a new method for defining units of work which are compact, +composable, nestable, and fully reusable. Once a module has been defined and registered, it can be used in new and +existing pipelines as either a new ModuleStage or loaded directly within the context of an existing stage using +`builder.load_module(...)`. + ### Identification The stage identifier is a unique string used in both logging and creating the stage from the CLI. diff --git a/docs/source/developer_guide/guides.md b/docs/source/developer_guide/guides.md index 1a242c31ad..657733b98c 100644 --- a/docs/source/developer_guide/guides.md +++ b/docs/source/developer_guide/guides.md @@ -17,11 +17,40 @@ limitations under the License. # Developer Guides -Morpheus includes several stages to choose from when building a custom pipeline, which can be included and configured to suit your needs. However, there are likely going to be situations that require writing a custom stage. Morpheus stages are written in Python and optionally may include a C++ implementation. The following guides outline how to create your own stages in both Python and C++. - -* [Simple Python Stage](./guides/1_simple_python_stage.md) -* [Real-World Application: Phishing Detection](./guides/2_real_world_phishing.md) -* [Simple C++ Stage](./guides/3_simple_cpp_stage.md) -* [Creating a C++ Source Stage](./guides/4_source_cpp_stage.md) -* [Digital Fingerprinting (DFP)](./guides/5_digital_fingerprinting.md) -* [Digital Fingerprinting (DFP) Reference](./guides/6_digital_fingerprinting_reference.md) +## Morpheus Stages + +Morpheus includes a number of pre-defined stage implementations to choose from when building a custom +pipeline, each of which can be included and configured to suit your application. + +- [List of available Morpheus stages](../stages/morpheus_stages.md) + +However, there are likely going to be situations that require writing a custom stage. Morpheus stages are written in +Python and optionally may include a C++ implementation. The following guides outline how to create your own stages +in both Python and C++. + +- [Simple Python Stage](./guides/1_simple_python_stage.md) +- [Real-World Application: Phishing Detection](./guides/2_real_world_phishing.md) +- [Simple C++ Stage](./guides/3_simple_cpp_stage.md) +- [Creating a C++ Source Stage](./guides/4_source_cpp_stage.md) + +## Morpheus Modules + +Morpheus includes, as of version 23.03, a number of pre-defined module implementations to choose from when building a +custom pipeline. Modules can be thought of as units of work, which exist at a lower level than stages. Modules can +be defined, registered, chained, nested, and loaded at runtime. Modules can be written in Python or C++. + +- [List of available Morpheus modules](../modules/morpheus_modules.md) + +However, there are likely going to be situations that require writing a custom module, either for creating your own +reusable work units, or for creating a new compound module from a set of existing primitives. The following guides +will walk through the process of creating a custom module in Python and C++. + +- [Simple Python Module](./guides/7_simple_python_module.md) +- [Simple C++ Module](./guides/8_simple_cpp_module.md) +- [Nested Modules](./guides/9_nested_modules.md) +- [Module Chaining](./guides/10_module_chaining.md) + +## Example Workflows + +- [Digital Fingerprinting (DFP)](./guides/5_digital_fingerprinting.md) +- [Digital Fingerprinting (DFP) Reference](./guides/6_digital_fingerprinting_reference.md) \ No newline at end of file diff --git a/docs/source/developer_guide/guides/7_simple_python_module.md b/docs/source/developer_guide/guides/7_simple_python_module.md new file mode 100644 index 0000000000..b761670b90 --- /dev/null +++ b/docs/source/developer_guide/guides/7_simple_python_module.md @@ -0,0 +1,107 @@ +# Introduction: Creating a Morpheus Module + +## Background + +Morpheus makes use of the MRC graph-execution framework. Morpheus pipelines are built on top of MRC pipelines, which are +comprised of collections of nodes and edges, called segments (think sub-graphs), which can in turn be connected by +ingress/egress ports. In many common cases, an MRC pipeline will consist of only a single segment. While Morpheus +Stages are the primary building blocks of Morpheus pipelines, Morpheus Modules can be thought of as a way to define +basic units of work, which can, in turn, be composed and (re)used to build more complex stages. Modules can be +written in Python or C++. + +## The Passthrough Module + +The `passthrough` module is a simple module that takes a single input port and a single output port. It simply +passes it forward, in much the same way that the example stage defined in the [Simple Python Stage](. +/guides/1_simple_python_stage.md) does; however, it only defines actual unit of work, and must then be loaded either as +its own Morpheus stage, or within the context of another stage in order to be used. + +### Module Definition and Registration + +`my_test_module.py` + +```python +import mrc +from mrc.core import operators as ops + +from morpheus.utils.module_utils import register_module + + +@register_module("my_test_module", "my_module_namespace") +def my_test_module_initialization(builder: mrc.Builder): + module_config = builder.get_current_module_config() # Get the module configuration + + def on_data(data): + return data + + def node_fn(observable: mrc.Observable, subscriber: mrc.Subscriber): + observable.pipe(ops.map(on_data)).subscribe(subscriber) + + node = builder.make_node("my_test_module_forwarding_node", mrc.core.operators.build(node_fn)) + + builder.register_module_input("input_0", node) + builder.register_module_output("output_0", node) +``` + +Here, we define a module, or rather a blueprint for creating a module, named `my_test_module` in the +`my_module_namespace` namespace. The `register_module` decorator is used to register the module with the system and +make it available to be loaded by other modules, stages, or pipelines. The `register_module` decorator takes two +parameters: the name of the module, and the namespace in which the module is defined. The namespace is used to avoid +naming collisions between core morpheus, custom, and third party modules. + +The `my_test_module_initialization` function is called by the Morpheus module loader when the module is loaded. It +then creates a new instance of the module, which creates the appropriate MRC nodes and edges, and registers inputs +and outputs that other modules or MRC nodes can connect to. + +Note that we also obtain a 'module_config' object from the builder. This object is a dictionary that contains all +configuration parameters that were passed to the module when it was loaded. This is useful for allowing modules to +customize their behavior based on runtime parameters. We will see an example of this in the next section. + +### Loading the Module + +After a module has been defined and registered, it can be loaded by other modules or stages. Below we +illustrate this process, in both cases: first usage within another module, and second we'll load the module we just +created as simple stage, a process that specializes the general behavior of the existing `LinearModuleStage`. + +`my_test_module_consumer.py` + +```python +@register_module("my_test_module_consumer", "my_module_namespace") +def my_test_module_consumer_initialization(builder: mrc.Builder): + consumer_module_config = builder.get_current_module_config() # Get the module configuration + module_config = { + "some_configuration_parameter": "some_value" + } + + my_test_module = builder.load_module("my_test_module", "my_module_namespace", "module_instance_name", module_config) + + builder.register_module_input("input_0", my_test_module.input_port("input_0")) + builder.register_module_output("output_0", my_test_module.output_port("output_0")) +``` + +Here, we've defined a new module that loads the `my_test_module` module that we defined above, and then connects +directly to its input and output ports. Obviously this is a trivial example, but it illustrates the basic process and +ease of use when loading and incorporating modules into existing workflows. + +`my_test_module_consumer_stage.py` + +```python +class MyPassthroughModuleWrapper(SinglePortStage): + # ... stage implementation + def _build_single(self, builder: mrc.Builder, input_stream: StreamPair) -> StreamPair: + module_config = { + "some_configuration_parameter": "some_value" + } + + my_module = builder.load_module("my_test_module", "my_module_namespace", "module_instance_name", module_config) + + module_in_stream = my_module.input_port("input_0") + module_out_stream = my_module.output_port("output_0") + + builder.make_edge(input_stream[0], module_in_stream) + + return module_out_stream, self._output_type +``` + +Here, we've defined a new stage that loads the `my_test_module` module that we defined above, and then wraps its +input and output connections. \ No newline at end of file diff --git a/docs/source/index.rst b/docs/source/index.rst index 75d8c76018..f8abbd9c28 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -104,6 +104,13 @@ Deploying Morpheus py_api _lib/index +.. toctree:: + :caption: Morpheus Modules + :maxdepth: 20 + :hidden: + + modules/morpheus_modules + .. toctree:: :maxdepth: 20 :caption: Extra Information diff --git a/docs/source/stages/morpheus_stages.md b/docs/source/stages/morpheus_stages.md new file mode 100644 index 0000000000..3d9be94a99 --- /dev/null +++ b/docs/source/stages/morpheus_stages.md @@ -0,0 +1,65 @@ +# Stages Documentation + +## Boundary + +- [Linear Boundary Stage](./boundary/linear_boundary_stage.md) + +## General + +- [Broadcast Stage](./general/broadcast_stage.md) +- [Buffer Stage](./general/buffer_stage.md) +- [Delay Stage](./general/delay_stage.md) +- [Linear Modules Stage](./general/linear_modules_stage.md) +- [Monitor Stage](./general/monitor_stage.md) +- [Multi Port Module Stage](./general/multi_port_module_stage.md) +- [Trigger Stage](./general/trigger_stage.md) + +## Inference + +- [Auto Encoder Inference Stage](./inference/auto_encoder_inference_stage.md) +- [Identity Inference Stage](./inference/identity_inference_stage.md) +- [Inference Stage](./inference/inference_stage.md) +- [PyTorch Inference Stage](./inference/pytorch_inference_stage.md) +- [Triton Inference Stage](./inference/triton_inference_stage.md) + +## Input + +- [AppShield Source Stage](./input/appshield_source_stage.md) +- [Autoencoder Source Stage](./input/autoencoder_source_stage.md) +- [Azure Source Stage](./input/azure_source_stage.md) +- [Cloud Trail Source Stage](./input/cloud_trail_source_stage.md) +- [Control Message File Source Stage](./input/control_message_file_source_stage.md) +- [Control Message Kafka Source Stage](./input/control_message_kafka_source_stage.md) +- [Duo Source Stage](./input/duo_source_stage.md) +- [File Source Stage](./input/file_source_stage.md) +- [Kafka Source Stage](./input/kafka_source_stage.md) + +## Output + +- [Write To File Stage](./output/write_to_file_stage.md) +- [Write To Kafka Stage](./output/write_to_kafka_stage.md) + +## Postprocess + +- [Add Classifications Stage](./postprocess/add_classifications_stage.md) +- [Add Scores Stage](./postprocess/add_scores_stage.md) +- [Filter Detections Stage](./postprocess/filter_detections_stage.md) +- [Generate Viz Frames Stage](./postprocess/generate_viz_frames_stage.md) +- [ML Flow Drift Stage](./postprocess/ml_flow_drift_stage.md) +- [Serialize Stage](./postprocess/serialize_stage.md) +- [Timeseries Stage](./postprocess/timeseries_stage.md) +- [Validation Stage](./postprocess/validation_stage.md) + +## Preprocess + +- [Deserialize Stage](./preprocess/deserialize_stage.md) +- [Drop Null Stage](./preprocess/drop_null_stage.md) +- [Preprocess AE Stage](./preprocess/preprocess_ae_stage.md) +- [Preprocess Base Stage](./preprocess/preprocess_base_stage.md) +- [Preprocess FIL Stage](./preprocess/preprocess_fil_stage.md) +- [Preprocess NLP Stage](./preprocess/preprocess_nlp_stage.md) +- [Train AE Stage](./preprocess/train_ae_stage.md) + +## Training + +- [Training Stage](./training/training_stage.md) \ No newline at end of file From b100160be7c73895ef963b4853b125cf828b9137 Mon Sep 17 00:00:00 2001 From: Devin Robison Date: Thu, 16 Mar 2023 14:04:37 -0600 Subject: [PATCH 101/157] Add simple c++ module example --- .../guides/7_simple_python_module.md | 19 +- .../guides/8_simple_cpp_module.md | 184 ++++++++++++++++++ .../_lib/src/modules/data_loader_module.cpp | 2 - 3 files changed, 202 insertions(+), 3 deletions(-) create mode 100644 docs/source/developer_guide/guides/8_simple_cpp_module.md diff --git a/docs/source/developer_guide/guides/7_simple_python_module.md b/docs/source/developer_guide/guides/7_simple_python_module.md index b761670b90..4eb786d91f 100644 --- a/docs/source/developer_guide/guides/7_simple_python_module.md +++ b/docs/source/developer_guide/guides/7_simple_python_module.md @@ -1,4 +1,21 @@ -# Introduction: Creating a Morpheus Module + + +# Creating a Python Morpheus Module ## Background diff --git a/docs/source/developer_guide/guides/8_simple_cpp_module.md b/docs/source/developer_guide/guides/8_simple_cpp_module.md new file mode 100644 index 0000000000..988e697ec4 --- /dev/null +++ b/docs/source/developer_guide/guides/8_simple_cpp_module.md @@ -0,0 +1,184 @@ + + +# Creating a C++ Morpheus Module + +## Background + +See [Simple Python Module](./7_simple_python_module.md) for an introduction to Morpheus modules. + +## The Passthrough Module + +The following example will create a simple C++ module that passes through the input data without modification. This +module will be written in C++ and would be compiled into the morpheus core library. + +`my_test_module.hpp` + +```c++ +#pragma once +#include +#include +#include + +namespace morpheus { +#pragma GCC visibility push(default) +class MyTestModule: public mrc::modules::SegmentModule, public mrc::modules::PersistentModule +{ + using type_t = MyTestModule; + + public: + DataLoaderModule(std::string module_name); + DataLoaderModule(std::string module_name, nlohmann::json _config); + + protected: + void initialize(mrc::segment::Builder& builder) override; + std::string module_type_name() const override; + + private: + int my_persistent_value{0}; +}; +#pragma GCC visibility pop +} // namespace morpheus +``` + +`my_test_module.cpp` + +```c++ +#include +#include +#include +#include + +#include + +namespace morpheus { +MyTestModule::MyTestModule(std::string module_name) : SegmentModule(module_name) {} + +MyTestModule::MyTestModule(std::string module_name, nlohmann::json _config) : + SegmentModule(std::move(module_name), std::move(_config)) +{} + +void MyTestModule::initialize(mrc::segment::Builder& builder) { + auto passthrough_node = builder.make_node>("passthrough_node", + rxcpp::operators::map([this](std::shared_ptr data) { + return data; + })); + + register_input_port("input_0", passthrough_node); + register_output_port("output_0", passthrough_node); +} + +std::string MyTestModule::module_type_name() const +{ + return std::string(::mrc::boost_type_name()); +} +} +``` + +`my_test_module_registration.cpp` + +```c++ +#include "mrc/modules/module_registry.hpp" + +#include "my_test_module.hpp" + +int main(int argc, char** argv) { + const std::vector release_version = {1, 0, 0}; + + auto module_constructor = [](std::string module_name, nlohmann::json config) { + return std::make_shared(module_name, config); + }; + + ModuleRegistry::register_module("my_test_module", "my_module_namespace", release_version, module_constructor); +} +``` + +### Advanced Topics -- Dynamic Module Creation and Loading + +```c++ +#include "mrc/version.hpp" +#include "mrc/modules/module_registry.hpp" + +#include "my_test_module.hpp" + +extern "C" { + +const std::vector TestModuleVersion{mrc_VERSION_MAJOR, mrc_VERSION_MINOR, mrc_VERSION_PATCH}; + +const char* MODULES[] = {"my_test_module::my_module_namespace"}; + + bool MRC_MODULE_entrypoint_load() // NOLINT +{ + using namespace mrc::modules; + + try + { + ModuleRegistry::register_module( + "my_test_module", + "my_module_namespace" + TestModuleVersion, + [](std::string module_name, nlohmann::json config) { + return std::make_shared(std::move(module_name), std::move(config)); + }); + } catch (...) + { + return false; + } + + return true; +} + +bool MRC_MODULE_entrypoint_unload() // NOLINT +{ + using namespace mrc::modules; + + try + { + ModuleRegistry::unregister_module("my_test_module", "my_module_namespace"); + } catch (...) + { + return false; + } + + return true; +} +``` + +The above code is an example of how to declare a shared module that can be loaded at runtime. If we assume this +snippet is compile into `my_test_module.so`, we can load dynamically load the module at runtime using the following: + +```c++ +#include "mrc/modules/module_registry.hpp" + +std::string get_modules_path() { + return std::string{YOUR_MODULES_PATH}; +} + +int main(int argc, char** argv) { + using namespace mrc::modules; + + auto plugin = PluginModule::create_or_acquire("my_test_module.so"); + plugin->set_library_directory(get_modules_path()); + plugin->load(); + + std::string module_namespace{"my_module_namespace"}; + std::string module_name{"my_test_module"}; + + ModuleRegistry::contains_namespace(module_namespace); // Should be true + ModuleRegistry::contains(module_name, module_namespace); // Should be true +} +``` \ No newline at end of file diff --git a/morpheus/_lib/src/modules/data_loader_module.cpp b/morpheus/_lib/src/modules/data_loader_module.cpp index 60eab984a3..820975dd29 100644 --- a/morpheus/_lib/src/modules/data_loader_module.cpp +++ b/morpheus/_lib/src/modules/data_loader_module.cpp @@ -18,8 +18,6 @@ #include "morpheus/modules/data_loader_module.hpp" #include "morpheus/io/data_loader_registry.hpp" -#include "morpheus/io/loaders/all.hpp" -#include "morpheus/messages/meta.hpp" #include #include From d7bddd72c7e18484bfe50d9ca8977efe0348291b Mon Sep 17 00:00:00 2001 From: Devin Robison Date: Thu, 16 Mar 2023 14:43:31 -0600 Subject: [PATCH 102/157] Morpheus modules python updates --- docs/source/developer_guide/guides.md | 2 - .../guides/7_simple_python_module.md | 106 +++++++++++++++++- .../guides/8_simple_cpp_module.md | 13 +++ 3 files changed, 118 insertions(+), 3 deletions(-) diff --git a/docs/source/developer_guide/guides.md b/docs/source/developer_guide/guides.md index 657733b98c..20cbefead8 100644 --- a/docs/source/developer_guide/guides.md +++ b/docs/source/developer_guide/guides.md @@ -47,8 +47,6 @@ will walk through the process of creating a custom module in Python and C++. - [Simple Python Module](./guides/7_simple_python_module.md) - [Simple C++ Module](./guides/8_simple_cpp_module.md) -- [Nested Modules](./guides/9_nested_modules.md) -- [Module Chaining](./guides/10_module_chaining.md) ## Example Workflows diff --git a/docs/source/developer_guide/guides/7_simple_python_module.md b/docs/source/developer_guide/guides/7_simple_python_module.md index 4eb786d91f..229a33278a 100644 --- a/docs/source/developer_guide/guides/7_simple_python_module.md +++ b/docs/source/developer_guide/guides/7_simple_python_module.md @@ -121,4 +121,108 @@ class MyPassthroughModuleWrapper(SinglePortStage): ``` Here, we've defined a new stage that loads the `my_test_module` module that we defined above, and then wraps its -input and output connections. \ No newline at end of file +input and output connections. + +### Module Chaining and Nesting + +Modules can be arbitrarily nested, and can be chained together to create more complex modules. For example, lets +define a slightly more interesting module that takes an integer input, `i`, and outputs `(i^2 + 3*i)`. +For this, we'll define three new modules, `my_square_module` and `my_times_three_module`, that perform the +appropriate operations, and `my_compound_op_module` which wraps them both. We'll then construct a single new module as a +composition of these three modules. + +```python +import mrc +from mrc.core import operators as ops + +from morpheus.utils.module_utils import register_module + + +## Create and register our two new component modules +# ========================================== +@register_module("my_square_module", "my_module_namespace") +def my_test_module_initialization(builder: mrc.Builder): + def on_data(data: int): + return data ** 2 + + def node_fn(observable: mrc.Observable, subscriber: mrc.Subscriber): + observable.pipe(ops.map(on_data)).subscribe(subscriber) + + node = builder.make_node("square", mrc.core.operators.build(node_fn)) + + builder.register_module_input("input_0", node) + builder.register_module_output("output_0", node) + + +@register_module("my_times_three_module", "my_module_namespace") +def my_test_module_initialization(builder: mrc.Builder): + def on_data(data: int): + return 3 * data + + def node_fn(observable: mrc.Observable, subscriber: mrc.Subscriber): + observable.pipe(ops.map(on_data)).subscribe(subscriber) + + node = builder.make_node("times_two", mrc.core.operators.build(node_fn)) + + builder.register_module_input("input_0", node) + builder.register_module_output("output_0", node) + + +## Create and register our new compound operator -- illustrates module chaining +@register_module("my_compound_op_module", "my_module_namespace") +def my_compound_op(builder: mrc.Builder): + square_module = builder.load_module("my_square_module", "my_module_namespace", "square_module") + times_three_module = builder.load_module("my_times_three_module", "my_module_namespace", "times_three_module") + + builder.make_edge(square_module.output_port("output_0"), times_three_module.input_port("input_0")) + + builder.register_module_input("input_0", square_module.input_port("input_0")) + builder.register_module_output("output_0", times_three_module.output_port("output_0")) + + +## Create and register our new compound module -- illustrates module nesting +@register_module("my_compound_module", "my_module_namespace") +def my_compound_module(builder: mrc.Builder): + op_module = builder.load_module("my_compound_op_module", "my_module_namespace", "op_module") + + builder.register_module_input("input_0", op_module.input_port("input_0")) + builder.register_module_output("output_0", op_module.output_port("output_0")) +``` + +`my_compound_module_consumer_stage.py` + +```python +class MyCompoundOpModuleWrapper(SinglePortStage): + # ... stage implementation + def _build_single(self, builder: mrc.Builder, input_stream: StreamPair) -> StreamPair: + module_config = {} + + my_module = builder.load_module("my_compound_module", "my_module_namespace", "module_instance_name", + module_config) + + module_in_stream = my_module.input_port("input_0") + module_out_stream = my_module.output_port("output_0") + + builder.make_edge(input_stream[0], module_in_stream) + + return module_out_stream, self._output_type +``` + +### Wrapping Modules in Practice + +While we have created new stages for our example modules here, in general we would not define an entirely new stage +just to wrap a module. Instead, we would use the `LinearModuleStage` to wrap the module: + +```python +from morpheus.stages.general.linear_modules_stage import LinearModulesStage + +config = Config() # Morpheus config +module_config = { + "module_id": "my_compound_module", + "module_namespace": "my_module_namespace", + "module_instance_name": "module_instance_name", + ... other module config params... +} + +pipeline.add_stage(LinearModulesStage(config, module_config, input_port_name="input_0", output_port_name="output_0")) +``` \ No newline at end of file diff --git a/docs/source/developer_guide/guides/8_simple_cpp_module.md b/docs/source/developer_guide/guides/8_simple_cpp_module.md index 988e697ec4..52d33df4ff 100644 --- a/docs/source/developer_guide/guides/8_simple_cpp_module.md +++ b/docs/source/developer_guide/guides/8_simple_cpp_module.md @@ -26,6 +26,11 @@ See [Simple Python Module](./7_simple_python_module.md) for an introduction to M The following example will create a simple C++ module that passes through the input data without modification. This module will be written in C++ and would be compiled into the morpheus core library. +**Note**: One thing that is different with respect to c++ modules, is that they are assumed to be stateless by default, +meaning that the module itself can be released after the initialize function as been called. If you need a module +whose state is persisted across the lifetime of the pipeline, you will also need to inherit from the PersistentModule +class, which will cause the pipeline to hold a reference to the module until the pipeline is destroyed. + `my_test_module.hpp` ```c++ @@ -156,6 +161,14 @@ bool MRC_MODULE_entrypoint_unload() // NOLINT return true; } + +unsigned int MRC_MODULE_entrypoint_list(const char** result) // NOLINT +{ + *result = (const char*)(&MODULES); + + return 1; // Number of modules +} + ``` The above code is an example of how to declare a shared module that can be loaded at runtime. If we assume this From a94671b5a05d1599c262a9bd51419d103e661fc9 Mon Sep 17 00:00:00 2001 From: Devin Robison Date: Thu, 16 Mar 2023 14:56:00 -0600 Subject: [PATCH 103/157] Docs naming tweak --- docs/source/developer_guide/guides.md | 5 +++-- .../{7_simple_python_module.md => 7_python_modules.md} | 0 .../guides/{8_simple_cpp_module.md => 8_cpp_modules.md} | 0 3 files changed, 3 insertions(+), 2 deletions(-) rename docs/source/developer_guide/guides/{7_simple_python_module.md => 7_python_modules.md} (100%) rename docs/source/developer_guide/guides/{8_simple_cpp_module.md => 8_cpp_modules.md} (100%) diff --git a/docs/source/developer_guide/guides.md b/docs/source/developer_guide/guides.md index 20cbefead8..04bf580edd 100644 --- a/docs/source/developer_guide/guides.md +++ b/docs/source/developer_guide/guides.md @@ -45,8 +45,9 @@ However, there are likely going to be situations that require writing a custom m reusable work units, or for creating a new compound module from a set of existing primitives. The following guides will walk through the process of creating a custom module in Python and C++. -- [Simple Python Module](./guides/7_simple_python_module.md) -- [Simple C++ Module](./guides/8_simple_cpp_module.md) +- [Python Module](./guides/7_python_modules.md) +- [C++ Module](./guides/8_cpp_modules.md) +- ## Example Workflows diff --git a/docs/source/developer_guide/guides/7_simple_python_module.md b/docs/source/developer_guide/guides/7_python_modules.md similarity index 100% rename from docs/source/developer_guide/guides/7_simple_python_module.md rename to docs/source/developer_guide/guides/7_python_modules.md diff --git a/docs/source/developer_guide/guides/8_simple_cpp_module.md b/docs/source/developer_guide/guides/8_cpp_modules.md similarity index 100% rename from docs/source/developer_guide/guides/8_simple_cpp_module.md rename to docs/source/developer_guide/guides/8_cpp_modules.md From 5701239febadb4bf3ee0f66d4dd6b207732b9102 Mon Sep 17 00:00:00 2001 From: Devin Robison Date: Thu, 16 Mar 2023 15:26:53 -0600 Subject: [PATCH 104/157] Remove dfp benchmarking results -- they're not comparing the right things --- .../production/morpheus/benchmarks/README.md | 20 +------------------ 1 file changed, 1 insertion(+), 19 deletions(-) diff --git a/examples/digital_fingerprinting/production/morpheus/benchmarks/README.md b/examples/digital_fingerprinting/production/morpheus/benchmarks/README.md index 2741318038..2947b7b4c3 100644 --- a/examples/digital_fingerprinting/production/morpheus/benchmarks/README.md +++ b/examples/digital_fingerprinting/production/morpheus/benchmarks/README.md @@ -104,25 +104,7 @@ The console output should look like this: -------------------------------------------------------------------------------------------------------- benchmark: 19 tests -------------------------------------------------------------------------------------------------------- Name (time in ms) Min Max Mean StdDev Median IQR Outliers OPS Rounds Iterations ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- -test_dfp_modules_duo_payload_only_load_e2e 226.3854 (1.0) 283.0055 (1.0) 259.3731 (1.0) 24.3098 (1.0) 269.2701 (1.0) 40.5459 (1.0) 1;0 3.8554 (1.0) 5 1 -test_dfp_modules_duo_payload_inference_e2e 976.1599 (4.31) 1,147.7819 (4.06) 1,067.5186 (4.12) 65.2043 (2.68) 1,088.5716 (4.04) 86.9582 (2.14) 2;0 0.9368 (0.24) 5 1 -test_dfp_stages_duo_inference_e2e 1,040.1275 (4.59) 1,328.9118 (4.70) 1,158.5368 (4.47) 127.0640 (5.23) 1,127.6553 (4.19) 223.5278 (5.51) 1;0 0.8632 (0.22) 5 1 -test_dfp_modules_azure_payload_inference_e2e 1,075.9931 (4.75) 1,313.8863 (4.64) 1,163.2758 (4.48) 90.5340 (3.72) 1,142.0053 (4.24) 95.3948 (2.35) 1;0 0.8596 (0.22) 5 1 -test_dfp_stages_azure_inference_e2e 1,102.1970 (4.87) 1,436.8655 (5.08) 1,243.6478 (4.79) 147.9676 (6.09) 1,164.8561 (4.33) 246.8259 (6.09) 1;0 0.8041 (0.21) 5 1 -test_dfp_modules_duo_streaming_inference_e2e 1,261.8304 (5.57) 1,406.6397 (4.97) 1,333.9344 (5.14) 52.9789 (2.18) 1,324.8074 (4.92) 62.6631 (1.55) 2;0 0.7497 (0.19) 5 1 -test_dfp_modules_azure_streaming_inference_e2e 1,332.5694 (5.89) 1,506.8211 (5.32) 1,415.3912 (5.46) 67.6594 (2.78) 1,417.5592 (5.26) 101.9428 (2.51) 2;0 0.7065 (0.18) 5 1 -test_dfp_modules_duo_streaming_only_load_e2e 1,805.8288 (7.98) 2,354.6001 (8.32) 2,045.9313 (7.89) 199.3942 (8.20) 2,045.7892 (7.60) 202.2794 (4.99) 2;0 0.4888 (0.13) 5 1 -test_dfp_modules_duo_payload_training_e2e 9,037.7003 (39.92) 9,836.9510 (34.76) 9,367.2792 (36.12) 330.3668 (13.59) 9,207.2873 (34.19) 502.7229 (12.40) 1;0 0.1068 (0.03) 5 1 -test_dfp_modules_duo_payload_lti_e2e 9,954.3053 (43.97) 10,534.4838 (37.22) 10,247.6966 (39.51) 246.8732 (10.16) 10,224.6111 (37.97) 434.5221 (10.72) 2;0 0.0976 (0.03) 5 1 -test_dfp_modules_azure_payload_training_e2e 11,542.1990 (50.98) 11,704.6100 (41.36) 11,625.2338 (44.82) 72.5717 (2.99) 11,648.4413 (43.26) 130.2369 (3.21) 2;0 0.0860 (0.02) 5 1 -test_dfp_modules_azure_payload_lti_e2e 12,414.6397 (54.84) 13,634.3140 (48.18) 13,112.0041 (50.55) 492.8452 (20.27) 13,270.1088 (49.28) 763.9778 (18.84) 2;0 0.0763 (0.02) 5 1 -test_dfp_stages_duo_training_e2e 15,892.6129 (70.20) 16,538.2125 (58.44) 16,301.0573 (62.85) 242.4913 (9.98) 16,351.5376 (60.73) 212.1910 (5.23) 1;1 0.0613 (0.02) 5 1 -test_dfp_modules_duo_streaming_training_e2e 27,783.2057 (122.73) 28,387.4788 (100.31) 27,956.0751 (107.78) 249.0318 (10.24) 27,853.2863 (103.44) 253.7971 (6.26) 1;0 0.0358 (0.01) 5 1 -test_dfp_stages_azure_training_e2e 28,264.0585 (124.85) 29,443.4046 (104.04) 28,879.5257 (111.34) 476.5615 (19.60) 28,900.8030 (107.33) 781.7848 (19.28) 2;0 0.0346 (0.01) 5 1 -test_dfp_modules_duo_streaming_payload_e2e 29,466.8204 (130.16) 30,338.3991 (107.20) 29,855.7080 (115.11) 377.8633 (15.54) 29,864.8365 (110.91) 669.7878 (16.52) 2;0 0.0335 (0.01) 5 1 -test_dfp_modules_duo_streaming_lti_e2e 30,443.9077 (134.48) 31,385.2542 (110.90) 30,875.1344 (119.04) 334.9455 (13.78) 30,853.1295 (114.58) 258.4034 (6.37) 2;1 0.0324 (0.01) 5 1 -test_dfp_modules_azure_streaming_training_e2e 51,950.9638 (229.48) 52,498.6271 (185.50) 52,257.2178 (201.48) 259.6411 (10.68) 52,317.4839 (194.29) 494.9443 (12.21) 1;0 0.0191 (0.00) 5 1 -test_dfp_modules_azure_streaming_lti_e2e 54,148.7980 (239.19) 54,953.7450 (194.18) 54,525.3318 (210.22) 313.7135 (12.90) 54,540.2730 (202.55) 473.5052 (11.68) 2;0 0.0183 (0.00) 5 1 +TODO: Add benchmark results here -- need to do different runs for training vs inference vs everything else ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- ``` From ce38b6f8001186922572c4ee604369f313d0e748 Mon Sep 17 00:00:00 2001 From: Devin Robison Date: Thu, 16 Mar 2023 15:59:33 -0600 Subject: [PATCH 105/157] Benchmark fixes --- .../developer_guide/guides/8_cpp_modules.md | 4 +- .../morpheus/benchmarks/modules_conf.json | 207 ------------------ .../benchmarks/test_bench_e2e_dfp_pipeline.py | 2 +- 3 files changed, 4 insertions(+), 209 deletions(-) delete mode 100644 examples/digital_fingerprinting/production/morpheus/benchmarks/modules_conf.json diff --git a/docs/source/developer_guide/guides/8_cpp_modules.md b/docs/source/developer_guide/guides/8_cpp_modules.md index 52d33df4ff..52eaf38dce 100644 --- a/docs/source/developer_guide/guides/8_cpp_modules.md +++ b/docs/source/developer_guide/guides/8_cpp_modules.md @@ -112,7 +112,9 @@ int main(int argc, char** argv) { } ``` -### Advanced Topics -- Dynamic Module Creation and Loading +## Advanced Topics + +### Dynamic Module Creation and Loading ```c++ #include "mrc/version.hpp" diff --git a/examples/digital_fingerprinting/production/morpheus/benchmarks/modules_conf.json b/examples/digital_fingerprinting/production/morpheus/benchmarks/modules_conf.json deleted file mode 100644 index 6610c331d5..0000000000 --- a/examples/digital_fingerprinting/production/morpheus/benchmarks/modules_conf.json +++ /dev/null @@ -1,207 +0,0 @@ -{ - "module_id": "DFPDeployment", - "module_name": "dfp_deployment", - "namespace": "morpheus", - "fsspec": { - "loaders": [ - { - "id": "fsspec" - } - ] - }, - "DFPTrainingPipe": { - "DFPPreproc": { - "FileBatcher": { - "period": "D", - "sampling_rate_s": 0, - "start_time": "2022-08-01 00:00:00+00:00", - "end_time": "2022-09-30 00:00:00+00:00", - "iso_date_regex_pattern": "(?P\\d{4})-(?P\\d{1,2})-(?P\\d{1,2})T(?P\\d{1,2})(:|_)(?P\\d{1,2})(:|_)(?P\\d{1,2})(?P\\.\\d{1,6})?Z", - "timestamp_column_name": "timestamp", - "parser_kwargs": { - "lines": false, - "orient": "records" - }, - "cache_dir": "./.cache/dfp", - "filter_null": true, - "file_type": "JSON", - "schema": { - "schema_str": "\u0080\u0004\u0095e\u0003\u0000\u0000\u0000\u0000\u0000\u0000\u008c\u001amorpheus.utils.column_info\u0094\u008c\u0014DataFrameInputSchema\u0094\u0093\u0094)\u0081\u0094}\u0094(\u008c\fjson_columns\u0094]\u0094(\u008c\raccess_device\u0094\u008c\u000bapplication\u0094\u008c\u000bauth_device\u0094\u008c\u0004user\u0094e\u008c\u000bcolumn_info\u0094]\u0094(h\u0000\u008c\u000eDateTimeColumn\u0094\u0093\u0094)\u0081\u0094}\u0094(\u008c\u0004name\u0094\u008c\ttimestamp\u0094\u008c\u0005dtype\u0094\u008c\bdatetime\u0094\u008c\bdatetime\u0094\u0093\u0094\u008c\ninput_name\u0094\u008c\ttimestamp\u0094ubh\u0000\u008c\fRenameColumn\u0094\u0093\u0094)\u0081\u0094}\u0094(h\u0011\u008c\busername\u0094h\u0013\u008c\bbuiltins\u0094\u008c\u0003str\u0094\u0093\u0094h\u0017\u008c\tuser.name\u0094ubh\u001a)\u0081\u0094}\u0094(h\u0011\u008c\u0013accessdevicebrowser\u0094h\u0013h h\u0017\u008c\u0015access_device.browser\u0094ubh\u001a)\u0081\u0094}\u0094(h\u0011\u008c\u000eaccessdeviceos\u0094h\u0013h h\u0017\u008c\u0010access_device.os\u0094ubh\u0000\u008c\u000fStringCatColumn\u0094\u0093\u0094)\u0081\u0094}\u0094(h\u0011\u008c\blocation\u0094h\u0013h \u008c\rinput_columns\u0094]\u0094(\u008c\u001baccess_device.location.city\u0094\u008c\u001caccess_device.location.state\u0094\u008c\u001eaccess_device.location.country\u0094e\u008c\u0003sep\u0094\u008c\u0002, \u0094ubh\u001a)\u0081\u0094}\u0094(h\u0011\u008c\u000eauthdevicename\u0094h\u0013h h\u0017\u008c\u0010auth_device.name\u0094ubh\u0000\u008c\nBoolColumn\u0094\u0093\u0094)\u0081\u0094}\u0094(h\u0011\u008c\u0006result\u0094h\u0013h\u001e\u008c\u0004bool\u0094\u0093\u0094h\u0017h>\u008c\tvalue_map\u0094}\u0094(\u008c\u0007success\u0094\u0088\u008c\u0007SUCCESS\u0094\u0088\u008c\u0006denied\u0094\u0089\u008c\u0006DENIED\u0094\u0089\u008c\u0005FRAUD\u0094\u0089uubh\u0000\u008c\nColumnInfo\u0094\u0093\u0094)\u0081\u0094}\u0094(h\u0011\u008c\u0006reason\u0094h\u0013h ube\u008c\u0010preserve_columns\u0094N\u008c\nrow_filter\u0094Nub.", - "encoding": "latin1" - }, - "task_type": "training" - }, - "file_to_df": { - "loaders": [ - { - "id": "file_to_df" - } - ], - "module_name": "dfp_file_to_df_dataloader_tra" - }, - "DFPSplitUsers": { - "include_generic": true, - "include_individual": false, - "skip_users": [], - "only_users": [], - "timestamp_column_name": "timestamp", - "userid_column_name": "username", - "fallback_username": "generic_user" - } - }, - "DFPRollingWindow": { - "min_history": 300, - "min_increment": 300, - "max_history": "60d", - "cache_dir": "./.cache/dfp", - "timestamp_column_name": "timestamp" - }, - "DFPDataPrep": { - "timestamp_column_name": "timestamp", - "schema": { - "schema_str": "\u0080\u0004\u0095\u00c9\u0002\u0000\u0000\u0000\u0000\u0000\u0000\u008c\u001amorpheus.utils.column_info\u0094\u008c\u0014DataFrameInputSchema\u0094\u0093\u0094)\u0081\u0094}\u0094(\u008c\fjson_columns\u0094]\u0094\u008c\u000bcolumn_info\u0094]\u0094(h\u0000\u008c\nColumnInfo\u0094\u0093\u0094)\u0081\u0094}\u0094(\u008c\u0004name\u0094\u008c\ttimestamp\u0094\u008c\u0005dtype\u0094\u008c\bdatetime\u0094\u008c\bdatetime\u0094\u0093\u0094ubh\n)\u0081\u0094}\u0094(h\r\u008c\busername\u0094h\u000f\u008c\bbuiltins\u0094\u008c\u0003str\u0094\u0093\u0094ubh\n)\u0081\u0094}\u0094(h\r\u008c\u0013accessdevicebrowser\u0094h\u000fh\u0018ubh\n)\u0081\u0094}\u0094(h\r\u008c\u000eaccessdeviceos\u0094h\u000fh\u0018ubh\n)\u0081\u0094}\u0094(h\r\u008c\u000eauthdevicename\u0094h\u000fh\u0018ubh\n)\u0081\u0094}\u0094(h\r\u008c\u0006result\u0094h\u000fh\u0016\u008c\u0004bool\u0094\u0093\u0094ubh\n)\u0081\u0094}\u0094(h\r\u008c\u0006reason\u0094h\u000fh\u0018ubh\u0000\u008c\u000fIncrementColumn\u0094\u0093\u0094)\u0081\u0094}\u0094(h\r\u008c\blogcount\u0094h\u000fh\u0016\u008c\u0003int\u0094\u0093\u0094\u008c\ninput_name\u0094h\u000e\u008c\u000egroupby_column\u0094h\u0015\u008c\u0006period\u0094\u008c\u0001D\u0094ubh\u0000\u008c\fCustomColumn\u0094\u0093\u0094)\u0081\u0094}\u0094(h\r\u008c\flocincrement\u0094h\u000fh0\u008c\u0011process_column_fn\u0094\u008c\tfunctools\u0094\u008c\u0007partial\u0094\u0093\u0094h\u0000\u008c\u0014create_increment_col\u0094\u0093\u0094\u0085\u0094R\u0094(h?)}\u0094\u008c\u000bcolumn_name\u0094\u008c\blocation\u0094sNt\u0094bube\u008c\u0010preserve_columns\u0094\u008c\u0002re\u0094\u008c\b_compile\u0094\u0093\u0094\u008c\u000b(_batch_id)\u0094K \u0086\u0094R\u0094\u008c\nrow_filter\u0094Nub.", - "encoding": "latin1" - } - }, - "DFPTraining": { - "model_kwargs": { - "encoder_layers": [ - 512, - 500 - ], - "decoder_layers": [ - 512 - ], - "activation": "relu", - "swap_p": 0.2, - "lr": 0.001, - "lr_decay": 0.99, - "batch_size": 512, - "verbose": false, - "optimizer": "sgd", - "scaler": "standard", - "min_cats": 1, - "progress_bar": false, - "device": "cuda" - }, - "feature_columns": [ - "accessdevicebrowser", - "accessdeviceos", - "authdevicename", - "reason", - "result", - "locincrement", - "logcount" - ], - "epochs": 30, - "validation_size": 0.1 - }, - "MLFlowModelWriter": { - "model_name_formatter": "DFP-duo-{user_id}", - "experiment_name_formatter": "dfp/duo/training/{reg_model_name}", - "timestamp_column_name": "timestamp", - "conda_env": { - "channels": [ - "defaults", - "conda-forge" - ], - "dependencies": [ - "python=3.8", - "pip" - ], - "pip": [ - "mlflow", - "dfencoder" - ], - "name": "mlflow-env" - }, - "databricks_permissions": null - } - }, - "DFPInferencePipe": { - "DFPPreproc": { - "FileBatcher": { - "period": "D", - "sampling_rate_s": 0, - "start_time": "2022-08-01 00:00:00+00:00", - "end_time": "2022-09-30 00:00:00+00:00", - "iso_date_regex_pattern": "(?P\\d{4})-(?P\\d{1,2})-(?P\\d{1,2})T(?P\\d{1,2})(:|_)(?P\\d{1,2})(:|_)(?P\\d{1,2})(?P\\.\\d{1,6})?Z", - "timestamp_column_name": "timestamp", - "parser_kwargs": { - "lines": false, - "orient": "records" - }, - "cache_dir": "./.cache/dfp", - "filter_null": true, - "file_type": "JSON", - "schema": { - "schema_str": "\u0080\u0004\u0095e\u0003\u0000\u0000\u0000\u0000\u0000\u0000\u008c\u001amorpheus.utils.column_info\u0094\u008c\u0014DataFrameInputSchema\u0094\u0093\u0094)\u0081\u0094}\u0094(\u008c\fjson_columns\u0094]\u0094(\u008c\raccess_device\u0094\u008c\u000bapplication\u0094\u008c\u000bauth_device\u0094\u008c\u0004user\u0094e\u008c\u000bcolumn_info\u0094]\u0094(h\u0000\u008c\u000eDateTimeColumn\u0094\u0093\u0094)\u0081\u0094}\u0094(\u008c\u0004name\u0094\u008c\ttimestamp\u0094\u008c\u0005dtype\u0094\u008c\bdatetime\u0094\u008c\bdatetime\u0094\u0093\u0094\u008c\ninput_name\u0094\u008c\ttimestamp\u0094ubh\u0000\u008c\fRenameColumn\u0094\u0093\u0094)\u0081\u0094}\u0094(h\u0011\u008c\busername\u0094h\u0013\u008c\bbuiltins\u0094\u008c\u0003str\u0094\u0093\u0094h\u0017\u008c\tuser.name\u0094ubh\u001a)\u0081\u0094}\u0094(h\u0011\u008c\u0013accessdevicebrowser\u0094h\u0013h h\u0017\u008c\u0015access_device.browser\u0094ubh\u001a)\u0081\u0094}\u0094(h\u0011\u008c\u000eaccessdeviceos\u0094h\u0013h h\u0017\u008c\u0010access_device.os\u0094ubh\u0000\u008c\u000fStringCatColumn\u0094\u0093\u0094)\u0081\u0094}\u0094(h\u0011\u008c\blocation\u0094h\u0013h \u008c\rinput_columns\u0094]\u0094(\u008c\u001baccess_device.location.city\u0094\u008c\u001caccess_device.location.state\u0094\u008c\u001eaccess_device.location.country\u0094e\u008c\u0003sep\u0094\u008c\u0002, \u0094ubh\u001a)\u0081\u0094}\u0094(h\u0011\u008c\u000eauthdevicename\u0094h\u0013h h\u0017\u008c\u0010auth_device.name\u0094ubh\u0000\u008c\nBoolColumn\u0094\u0093\u0094)\u0081\u0094}\u0094(h\u0011\u008c\u0006result\u0094h\u0013h\u001e\u008c\u0004bool\u0094\u0093\u0094h\u0017h>\u008c\tvalue_map\u0094}\u0094(\u008c\u0007success\u0094\u0088\u008c\u0007SUCCESS\u0094\u0088\u008c\u0006denied\u0094\u0089\u008c\u0006DENIED\u0094\u0089\u008c\u0005FRAUD\u0094\u0089uubh\u0000\u008c\nColumnInfo\u0094\u0093\u0094)\u0081\u0094}\u0094(h\u0011\u008c\u0006reason\u0094h\u0013h ube\u008c\u0010preserve_columns\u0094N\u008c\nrow_filter\u0094Nub.", - "encoding": "latin1" - }, - "task_type": "inference" - }, - "file_to_df": { - "loaders": [ - { - "id": "file_to_df" - } - ], - "module_name": "dfp_file_to_df_dataloader_inf" - }, - "DFPSplitUsers": { - "include_generic": true, - "include_individual": false, - "skip_users": [], - "only_users": [], - "timestamp_column_name": "timestamp", - "userid_column_name": "username", - "fallback_username": "generic_user" - } - }, - "DFPRollingWindow": { - "min_history": 1, - "min_increment": 0, - "max_history": "1d", - "cache_dir": "./.cache/dfp", - "timestamp_column_name": "timestamp" - }, - "DFPDataPrep": { - "timestamp_column_name": "timestamp", - "schema": { - "schema_str": "\u0080\u0004\u0095\u00c9\u0002\u0000\u0000\u0000\u0000\u0000\u0000\u008c\u001amorpheus.utils.column_info\u0094\u008c\u0014DataFrameInputSchema\u0094\u0093\u0094)\u0081\u0094}\u0094(\u008c\fjson_columns\u0094]\u0094\u008c\u000bcolumn_info\u0094]\u0094(h\u0000\u008c\nColumnInfo\u0094\u0093\u0094)\u0081\u0094}\u0094(\u008c\u0004name\u0094\u008c\ttimestamp\u0094\u008c\u0005dtype\u0094\u008c\bdatetime\u0094\u008c\bdatetime\u0094\u0093\u0094ubh\n)\u0081\u0094}\u0094(h\r\u008c\busername\u0094h\u000f\u008c\bbuiltins\u0094\u008c\u0003str\u0094\u0093\u0094ubh\n)\u0081\u0094}\u0094(h\r\u008c\u0013accessdevicebrowser\u0094h\u000fh\u0018ubh\n)\u0081\u0094}\u0094(h\r\u008c\u000eaccessdeviceos\u0094h\u000fh\u0018ubh\n)\u0081\u0094}\u0094(h\r\u008c\u000eauthdevicename\u0094h\u000fh\u0018ubh\n)\u0081\u0094}\u0094(h\r\u008c\u0006result\u0094h\u000fh\u0016\u008c\u0004bool\u0094\u0093\u0094ubh\n)\u0081\u0094}\u0094(h\r\u008c\u0006reason\u0094h\u000fh\u0018ubh\u0000\u008c\u000fIncrementColumn\u0094\u0093\u0094)\u0081\u0094}\u0094(h\r\u008c\blogcount\u0094h\u000fh\u0016\u008c\u0003int\u0094\u0093\u0094\u008c\ninput_name\u0094h\u000e\u008c\u000egroupby_column\u0094h\u0015\u008c\u0006period\u0094\u008c\u0001D\u0094ubh\u0000\u008c\fCustomColumn\u0094\u0093\u0094)\u0081\u0094}\u0094(h\r\u008c\flocincrement\u0094h\u000fh0\u008c\u0011process_column_fn\u0094\u008c\tfunctools\u0094\u008c\u0007partial\u0094\u0093\u0094h\u0000\u008c\u0014create_increment_col\u0094\u0093\u0094\u0085\u0094R\u0094(h?)}\u0094\u008c\u000bcolumn_name\u0094\u008c\blocation\u0094sNt\u0094bube\u008c\u0010preserve_columns\u0094\u008c\u0002re\u0094\u008c\b_compile\u0094\u0093\u0094\u008c\u000b(_batch_id)\u0094K \u0086\u0094R\u0094\u008c\nrow_filter\u0094Nub.", - "encoding": "latin1" - } - }, - "DFPInference": { - "model_name_formatter": "DFP-duo-{user_id}", - "fallback_username": "generic_user", - "timestamp_column_name": "timestamp" - }, - "FilterDetections": { - "field_name": "mean_abs_z", - "threshold": 2.0, - "filter_source": "DATAFRAME", - "schema": { - "input_message_type": "\u0080\u0004\u00954\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u008c\u001fmorpheus.messages.multi_message\u0094\u008c\fMultiMessage\u0094\u0093\u0094.", - "encoding": "latin1" - } - }, - "DFPPostProcessing": { - "timestamp_column_name": "timestamp" - }, - "Serialize": { - "exclude": [ - "batch_count", - "origin_hash", - "_row_hash", - "_batch_id" - ], - "use_cpp": true - }, - "WriteToFile": { - "filename": "dfp_detections_duo.csv", - "overwrite": true - } - }, - "num_output_ports": 2 -} \ No newline at end of file diff --git a/examples/digital_fingerprinting/production/morpheus/benchmarks/test_bench_e2e_dfp_pipeline.py b/examples/digital_fingerprinting/production/morpheus/benchmarks/test_bench_e2e_dfp_pipeline.py index ac9b87cc29..032c62102b 100644 --- a/examples/digital_fingerprinting/production/morpheus/benchmarks/test_bench_e2e_dfp_pipeline.py +++ b/examples/digital_fingerprinting/production/morpheus/benchmarks/test_bench_e2e_dfp_pipeline.py @@ -88,7 +88,7 @@ def dfp_modules_pipeline(pipe_config: Config, pipeline.run() if not reuse_cache: - cache_dir = modules_conf["DFPInferencePipe"]["DFPPreproc"]["FileBatcher"]["cache_dir"] + cache_dir = modules_conf["inference_options"]["cache_dir"] purge_cache(dir=cache_dir) From 19d88eea18799d9a0a7f367671d996334978cac0 Mon Sep 17 00:00:00 2001 From: Bhargav Suryadevara Date: Fri, 17 Mar 2023 14:50:16 +0000 Subject: [PATCH 106/157] trivial changes --- tests/io/test_loader_registry.py | 2 + tests/messages/test_control_message.py | 70 +++------ tests/modules/test_file_batcher.py | 189 +++++++++++++++++++++++++ tests/modules/test_morpheus_modules.py | 122 +++++----------- tests/test_linear_modules_stage.py | 1 - tests/test_multiport_modules_stage.py | 32 +++-- 6 files changed, 270 insertions(+), 146 deletions(-) create mode 100644 tests/modules/test_file_batcher.py diff --git a/tests/io/test_loader_registry.py b/tests/io/test_loader_registry.py index 1c8f934a85..2bff61cd11 100644 --- a/tests/io/test_loader_registry.py +++ b/tests/io/test_loader_registry.py @@ -38,6 +38,7 @@ def test_loader_registry_contains(): def test_loader_registry_register_loader(): + def test_loader(control_message: messages.MessageControl, task: dict): task_properties = task['properties'] if ('files' not in task_properties): @@ -73,6 +74,7 @@ def test_loader(control_message: messages.MessageControl, task: dict): def test_loader_registry_unregister_loader(): + def test_loader(control_message: messages.MessageControl, task: dict): task_properties = task['properties'] if ('files' not in task_properties): diff --git a/tests/messages/test_control_message.py b/tests/messages/test_control_message.py index 1cd06b9d99..650ba1941a 100644 --- a/tests/messages/test_control_message.py +++ b/tests/messages/test_control_message.py @@ -31,38 +31,26 @@ def test_control_message_init(): @pytest.mark.usefixtures("config_only_cpp") def test_control_message_get(): - raw_control_message = _messages.MessageControl( - { - "test": "test_rcm", - "tasks": [ - { - "type": "load", - "properties": { - "loader_id": "payload" - } - } - ] - } - ) - control_message = messages.MessageControl( - { - "test": "test_cm", - "tasks": [ - { - "type": "load", - "properties": { - "loader_id": "payload" - } - } - ] - } - ) + raw_control_message = _messages.MessageControl({ + "test": "test_rcm", "tasks": [{ + "type": "load", "properties": { + "loader_id": "payload" + } + }] + }) + control_message = messages.MessageControl({ + "test": "test_cm", "tasks": [{ + "type": "load", "properties": { + "loader_id": "payload" + } + }] + }) assert "test" not in raw_control_message.config() - assert(raw_control_message.has_task("load")) + assert (raw_control_message.has_task("load")) assert "test" not in control_message.config() - assert(control_message.has_task("load")) + assert (control_message.has_task("load")) @pytest.mark.usefixtures("config_only_cpp") @@ -71,33 +59,19 @@ def test_control_message_set(): control_message = messages.MessageControl() raw_control_message.config({ - "test": "test_rcm", - "tasks": [ - { - "type": "load", - "properties": { - "loader_id": "payload" - } - } - ] - }) - control_message.config({ - "test": "test_cm", - "tasks": [ - { - "type": "load", - "properties": { - "loader_id": "payload" - } + "test": "test_rcm", "tasks": [{ + "type": "load", "properties": { + "loader_id": "payload" } - ] + }] }) + control_message.config({"test": "test_cm", "tasks": [{"type": "load", "properties": {"loader_id": "payload"}}]}) assert "test" not in raw_control_message.config() assert (raw_control_message.has_task("load")) assert "test" not in control_message.config() - assert(control_message.has_task("load")) + assert (control_message.has_task("load")) @pytest.mark.usefixtures("config_only_cpp") diff --git a/tests/modules/test_file_batcher.py b/tests/modules/test_file_batcher.py new file mode 100644 index 0000000000..ab6e1cbf8b --- /dev/null +++ b/tests/modules/test_file_batcher.py @@ -0,0 +1,189 @@ +#!/usr/bin/env python +# SPDX-FileCopyrightText: Copyright (c) 2023, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import time + +import mrc +import cudf +import tempfile +import os + +import morpheus.modules.file_batcher # Used to load and register morpheus modules +import morpheus.messages as messages + + +def on_next(control_msg): + pass + + +def on_error(): + pass + + +def on_complete(): + pass + + +def test_contains_namespace(): + registry = mrc.ModuleRegistry + + assert registry.contains_namespace("morpheus") + + +def test_is_version_compatible(): + registry = mrc.ModuleRegistry + + release_version = [int(x) for x in mrc.__version__.split(".")] + old_release_version = [22, 10, 0] + no_version_patch = [22, 10] + no_version_minor_and_patch = [22] + + assert registry.is_version_compatible(release_version) + assert registry.is_version_compatible(old_release_version) is not True + assert registry.is_version_compatible(no_version_patch) is not True + assert registry.is_version_compatible(no_version_minor_and_patch) is not True + + +def test_get_module(): + registry = mrc.ModuleRegistry + + fn_constructor = registry.get_module_constructor("FileBatcher", "morpheus") + assert fn_constructor is not None + + config = {} + module_instance = fn_constructor("ModuleFileBatcherTest", config) + + +packet_count = 1 +packets_received = 0 + + +def test_file_batcher_module(tmp_path): + registry = mrc.ModuleRegistry + + fn_constructor = registry.get_module_constructor("FileBatcher", "morpheus") + assert fn_constructor is not None + + input_filepaths = [] + filenames = [ + "TEST_2022-08-22T21_06_16.397Z.json", + "TEST_2022-08-22T00_01_32.097Z.json", + "TEST_2022-08-22T03_13_34.617Z.json", + "TEST_2022-08-23T06_12_04.524Z.json", + "TEST_2022-08-23T09_06_36.465Z.json", + "TEST_2022-08-23T12_23_47.260Z.json", + "TEST_2022-08-24T15_07_25.933Z.json", + "TEST_2022-08-24T18_06_17.979Z.json", + "TEST_2022-08-24T21_10_23.207Z.json" + ] + + for filename in filenames: + input_filepaths.append(os.path.join(tmp_path, filename)) + + def init_wrapper(builder: mrc.Builder): + + df = cudf.DataFrame({ + 'files': input_filepaths, + }, columns=['files']) + + def gen_data(): + global packet_count + config = { + "tasks": [], + "metadata": { + "data_type": "payload", + "batching_options": { + "start_time": "2022-08-20", "end_time": "2022-08-24", "period": "D", "sampling_rate_s": 0 + } + } + } + + payload = messages.MessageMeta(df) + msg = messages.MessageControl(config) + msg.payload(payload) + + yield msg + + def _on_next(control_msg): + global packets_received + packets_received += 1 + assert (control_msg.payload().df == df) + + source = builder.make_source("source", gen_data) + + config = { + "module_id": "FileBatcher", "module_name": "test_file_batcher", "namespace": "morpheus" +} + # This will unpack the config and forward its payload (MessageMeta) to the sink + file_batcher_module = builder.load_module("FileBatcher", "morpheus", "ModuleFileBatcherTest", config) + + sink = builder.make_sink("sink", _on_next, on_error, on_complete) + + builder.make_edge(source, file_batcher_module.input_port("input")) + builder.make_edge(file_batcher_module.output_port("output"), sink) + + pipeline = mrc.Pipeline() + pipeline.make_segment("main", init_wrapper) + + options = mrc.Options() + options.topology.user_cpuset = "0-1" + + executor = mrc.Executor(options) + executor.register_pipeline(pipeline) + executor.start() + executor.join() + + assert (packets_received == 3) + + for f in files: + os.remove(f[0]) + + +def test_file_loader_module(): + global packets_received + packets_received = 0 + + df = cudf.DataFrame( + { + 'col1': [1, 2, 3, 4, 5], + 'col2': [1.1, 2.2, 3.3, 4.4, 5.5], + 'col3': ['a', 'b', 'c', 'd', 'e'], + 'col4': [True, False, True, False, True] + }, + columns=['col1', 'col2', 'col3', 'col4']) + + files = [] + file_types = ["csv", "parquet", "orc"] + for ftype in file_types: + _tempfile = tempfile.NamedTemporaryFile(suffix=f".{ftype}", delete=False) + filename = _tempfile.name + + if ftype == "csv": + df.to_csv(filename, index=False) + elif ftype == "parquet": + df.to_parquet(filename) + elif ftype == "orc": + df.to_orc(filename) + + files.append((filename, ftype)) + + + +if (__name__ == "__main__"): + test_contains_namespace() + test_is_version_compatible() + test_get_module() + test_file_batcher_module() diff --git a/tests/modules/test_morpheus_modules.py b/tests/modules/test_morpheus_modules.py index 01995563ae..8012b1bc3b 100644 --- a/tests/modules/test_morpheus_modules.py +++ b/tests/modules/test_morpheus_modules.py @@ -68,18 +68,12 @@ def test_get_module(): def test_get_module_with_bad_config_no_loaders(): + def init_wrapper(builder: mrc.Builder): + def gen_data(): for i in range(packet_count): - config = { - "tasks": [{ - "type": "load", - "properties": { - "loader_id": "payload", - "strategy": "aggregate" - } - }] - } + config = {"tasks": [{"type": "load", "properties": {"loader_id": "payload", "strategy": "aggregate"}}]} msg = messages.MessageControl(config) yield msg @@ -112,32 +106,24 @@ def gen_data(): def test_get_module_with_bad_loader_type(): + def init_wrapper(builder: mrc.Builder): + def gen_data(): for i in range(packet_count): - config = { - "tasks": [{ - "type": "load", - "properties": { - "loader_id": "payload", - "strategy": "aggregate" - } - }] - } + config = {"tasks": [{"type": "load", "properties": {"loader_id": "payload", "strategy": "aggregate"}}]} msg = messages.MessageControl(config) yield msg source = builder.make_source("source", gen_data) - config = {"loaders": [ - { - "id": "not_a_loader(tm)", - "properties": { - "file_types": "something", - "prop2": "something else" + config = { + "loaders": [{ + "id": "not_a_loader(tm)", "properties": { + "file_types": "something", "prop2": "something else" } - } - ]} + }] + } # This will unpack the config and forward it's payload (MessageMeta) to the sink data_loader = builder.load_module("DataLoader", "morpheus", "ModuleDataLoaderTest", config) @@ -155,15 +141,15 @@ def gen_data(): def test_get_module_with_bad_control_message(): + def init_wrapper(builder: mrc.Builder): + def gen_data(): for i in range(packet_count): config = { "tasks": [{ - "type": "load", - "properties": { - "loader_id": "not_a_loader(tm)", - "strategy": "aggregate" + "type": "load", "properties": { + "loader_id": "not_a_loader(tm)", "strategy": "aggregate" } }] } @@ -172,15 +158,7 @@ def gen_data(): source = builder.make_source("source", gen_data) - config = {"loaders": [ - { - "id": "payload", - "properties": { - "file_types": "something", - "prop2": "something else" - } - } - ]} + config = {"loaders": [{"id": "payload", "properties": {"file_types": "something", "prop2": "something else"}}]} # This will unpack the config and forward its payload (MessageMeta) to the sink data_loader = builder.load_module("DataLoader", "morpheus", "ModuleDataLoaderTest", config) @@ -226,15 +204,7 @@ def init_wrapper(builder: mrc.Builder): def gen_data(): global packet_count - config = { - "tasks": [{ - "type": "load", - "properties": { - "loader_id": "payload", - "strategy": "aggregate" - } - }] - } + config = {"tasks": [{"type": "load", "properties": {"loader_id": "payload", "strategy": "aggregate"}}]} payload = messages.MessageMeta(df) for i in range(packet_count): @@ -250,15 +220,7 @@ def _on_next(control_msg): source = builder.make_source("source", gen_data) - config = {"loaders": [ - { - "id": "payload", - "properties": { - "file_types": "something", - "prop2": "something else" - } - } - ]} + config = {"loaders": [{"id": "payload", "properties": {"file_types": "something", "prop2": "something else"}}]} # This will unpack the config and forward its payload (MessageMeta) to the sink data_loader = builder.load_module("DataLoader", "morpheus", "ModuleDataLoaderTest", config) @@ -285,12 +247,14 @@ def test_file_loader_module(): global packets_received packets_received = 0 - df = cudf.DataFrame({ - 'col1': [1, 2, 3, 4, 5], - 'col2': [1.1, 2.2, 3.3, 4.4, 5.5], - 'col3': ['a', 'b', 'c', 'd', 'e'], - 'col4': [True, False, True, False, True] - }, columns=['col1', 'col2', 'col3', 'col4']) + df = cudf.DataFrame( + { + 'col1': [1, 2, 3, 4, 5], + 'col2': [1.1, 2.2, 3.3, 4.4, 5.5], + 'col3': ['a', 'b', 'c', 'd', 'e'], + 'col4': [True, False, True, False, True] + }, + columns=['col1', 'col2', 'col3', 'col4']) files = [] file_types = ["csv", "parquet", "orc"] @@ -308,6 +272,7 @@ def test_file_loader_module(): files.append((filename, ftype)) def init_wrapper(builder: mrc.Builder): + def gen_data(): global packet_count @@ -317,14 +282,9 @@ def gen_data(): "tasks": [{ "type": "load", "properties": { - "loader_id": "file", - "strategy": "aggregate", - "files": [ - { - "path": f[0], - "type": f[1] - } - ] + "loader_id": "file", "strategy": "aggregate", "files": [{ + "path": f[0], "type": f[1] + }] } }] } @@ -336,13 +296,9 @@ def gen_data(): "tasks": [{ "type": "load", "properties": { - "loader_id": "file", - "strategy": "aggregate", - "files": [ - { - "path": f[0], - } - ] + "loader_id": "file", "strategy": "aggregate", "files": [{ + "path": f[0], + }] } }] } @@ -361,15 +317,7 @@ def _on_next(control_msg): source = builder.make_source("source", gen_data) - config = {"loaders": [ - { - "id": "file", - "properties": { - "file_types": "something", - "prop2": "something else" - } - } - ]} + config = {"loaders": [{"id": "file", "properties": {"file_types": "something", "prop2": "something else"}}]} # This will unpack the config and forward its payload (MessageMeta) to the sink data_loader = builder.load_module("DataLoader", "morpheus", "ModuleDataLoaderTest", config) diff --git a/tests/test_linear_modules_stage.py b/tests/test_linear_modules_stage.py index 1363043477..32f00cdbe2 100755 --- a/tests/test_linear_modules_stage.py +++ b/tests/test_linear_modules_stage.py @@ -37,7 +37,6 @@ def test_constructor(config): # Just ensure that we get a valid non-empty tuple accepted_types = mod_stage.accepted_types() - print(accepted_types) assert isinstance(accepted_types, tuple) assert len(accepted_types) > 0 assert accepted_types[0] == typing.Any diff --git a/tests/test_multiport_modules_stage.py b/tests/test_multiport_modules_stage.py index 74b3b983b6..88f7340eae 100755 --- a/tests/test_multiport_modules_stage.py +++ b/tests/test_multiport_modules_stage.py @@ -24,22 +24,23 @@ from morpheus.utils.module_utils import mrc_version module_config = { - "module_id": "TestSimpleModule", "module_name": "test_simple_module", "namespace": "test_morpheus_modules" + "module_id": "TestMultiPortModule", "module_name": "test_multiport_module", "namespace": "test_morpheus_modules" } @pytest.mark.use_python def test_constructor(config): - mod_stage = MultiPortModulesStage(config, module_config, input_port_name="input", - output_port_name_prefix="output", - num_output_ports=num_output_ports) + mod_stage = MultiPortModulesStage(config, + module_config, + input_port_name="input", + output_port_name_prefix="output", + num_output_ports=2) - assert mod_stage.name == "test_simple_module" + assert mod_stage.name == "test_multiport_module" # Just ensure that we get a valid non-empty tuple accepted_types = mod_stage.accepted_types() - print(accepted_types) assert isinstance(accepted_types, tuple) assert len(accepted_types) > 0 assert accepted_types[0] == typing.Any @@ -58,7 +59,11 @@ def test_build_single_before_module_registration(config): mock_segment.load_module.return_value = mock_module mock_segment.make_node_full.return_value = mock_node - mod_stage = LinearModulesStage(config, module_config, input_port_name="test_in", output_port_name="test_out") + mod_stage = MultiPortModulesStage(config, + module_config, + input_port_name="input", + output_port_name_prefix="output", + num_output_ports=2) with pytest.raises(Exception): mod_stage._build_single(mock_segment, mock_input_stream) @@ -70,7 +75,7 @@ def register_test_module(): def module_init_fn(builder: mrc.Builder): pass - registry.register_module("TestSimpleModule", "test_morpheus_modules", mrc_version, module_init_fn) + registry.register_module("TestMultiPortModule", "test_morpheus_modules", mrc_version, module_init_fn) @pytest.mark.use_python @@ -86,9 +91,16 @@ def test_build_single_after_module_registration(config): mock_segment.load_module.return_value = mock_module mock_segment.make_node_full.return_value = mock_node - mod_stage = LinearModulesStage(config, module_config, input_port_name="test_in", output_port_name="test_out") + num_output_ports = 2 - mod_stage._build_single(mock_segment, mock_input_stream) + mod_stage = MultiPortModulesStage(config, + module_config, + input_port_name="input", + output_port_name_prefix="output", + num_output_ports=num_output_ports) + out_stream_pairs = mod_stage._build_single(mock_segment, mock_input_stream) + + assert len(out_stream_pairs) == num_output_ports mock_segment.load_module.assert_called_once() mock_segment.make_edge.assert_called_once() From 789759e073d878f5977bd70b28298adfd4a484eb Mon Sep 17 00:00:00 2001 From: bsuryadevara Date: Fri, 17 Mar 2023 15:04:51 -0500 Subject: [PATCH 107/157] added human in loop dfp demo gui --- .../digital_fingerprinting/demo/README.md | 7 +- .../demo/cm_app/static/review/results.css | 73 +++++++ .../demo/cm_app/static/review/results.js | 186 ++++++++++++++++++ .../demo/cm_app/static/submit_messages.css | 72 +++---- .../demo/cm_app/static/training.js | 38 ++-- .../demo/cm_app/templates/review/results.html | 31 +++ .../demo/cm_app/templates/training.html | 6 +- .../demo/cm_app/views.py | 15 +- .../benchmarks/test_bench_e2e_dfp_pipeline.py | 8 +- .../morpheus/dfp/modules/dfp_deployment.py | 11 +- .../dfp/modules/dfp_inference_pipe.py | 29 ++- .../morpheus/dfp/modules/dfp_preproc.py | 15 +- .../morpheus/dfp/modules/dfp_split_users.py | 3 +- .../morpheus/dfp/modules/dfp_training_pipe.py | 11 +- .../morpheus/dfp/utils/config_generator.py | 10 +- .../morpheus/dfp_modules_pipeline.py | 8 +- .../dfp_modules_streaming_pipeline.py | 8 +- tests/test_multiport_modules_stage.py | 106 ---------- 18 files changed, 416 insertions(+), 221 deletions(-) create mode 100644 examples/digital_fingerprinting/demo/cm_app/static/review/results.css create mode 100644 examples/digital_fingerprinting/demo/cm_app/static/review/results.js create mode 100644 examples/digital_fingerprinting/demo/cm_app/templates/review/results.html delete mode 100755 tests/test_multiport_modules_stage.py diff --git a/examples/digital_fingerprinting/demo/README.md b/examples/digital_fingerprinting/demo/README.md index 653c249b31..59423dd2ac 100644 --- a/examples/digital_fingerprinting/demo/README.md +++ b/examples/digital_fingerprinting/demo/README.md @@ -44,5 +44,10 @@ http://localhost:3000 ``` Generates control messages for training purposes exclusively with some user-specified parameters. ``` -http://localhost:3000/training # +http://localhost:3000/training +``` + +Submit training messages after reviewing inference results +``` +http://localhost:3000/review/results ``` diff --git a/examples/digital_fingerprinting/demo/cm_app/static/review/results.css b/examples/digital_fingerprinting/demo/cm_app/static/review/results.css new file mode 100644 index 0000000000..1d4551fa98 --- /dev/null +++ b/examples/digital_fingerprinting/demo/cm_app/static/review/results.css @@ -0,0 +1,73 @@ +body { + font-family: Arial, sans-serif; + margin: 0; + padding: 0; + } + form { + display: flex; + justify-content: flex-start; + align-items: center; + margin-left: 20px; +} + input[type="file"] { + margin-top: 10px; + display: block; + margin-bottom: 10px; + } + + label { + display: block; + margin-bottom: 5px; + margin-left: 25px; + } + select { + display: block; + margin-bottom: -40px; + margin-left: 25px; + width: 30%; + height: 100px; + border: 1px solid #ccc; + padding: 5px; + } + button { + display: block; + margin: 25px; + padding: 10px 20px; + background-color: #8bb521; + color: black; + border: none; + border-radius: 4px; + cursor: pointer; + display: inline-block; + margin-right: 0px; + } + button:hover { + background-color: #380e0ec1; + } + table { + border-collapse: collapse; + margin: 20px; + width: calc(100% - 40px); + } + th, td { + border: 1px solid #ccc; + padding: 5px; + text-align: left; + } + th { + background-color: #8bb521; + color: black; + } + .hidden { + display: none; + } + .marked { + background-color: rgb(243, 243, 161); + } + .alert { + background-color: #f8d7da; + border: 1px solid #f5c6cb; + color: #721c24; + padding: 10px; + margin-bottom: 10px; + } diff --git a/examples/digital_fingerprinting/demo/cm_app/static/review/results.js b/examples/digital_fingerprinting/demo/cm_app/static/review/results.js new file mode 100644 index 0000000000..b37a18fd9f --- /dev/null +++ b/examples/digital_fingerprinting/demo/cm_app/static/review/results.js @@ -0,0 +1,186 @@ +let columnIndexes = []; + +// Function to read and display CSV file contents +function handleFileSelect() { + const select = document.getElementById('columns'); + select.innerHTML = ''; + const file = document.getElementById('csv-file').files[0]; + const reader = new FileReader(); + reader.readAsText(file); + reader.onload = function(event) { + const csv = event.target.result; + const rows = csv.split('\n'); + const table = document.createElement('table'); + table.border = '1'; + const headerRow = document.createElement('tr'); + const headerCells = parseCSVRow(rows[0]); + + // Create a new header cell for the checkboxes + const markHeaderCell = document.createElement('th'); + markHeaderCell.innerHTML = 'Mark'; + headerRow.appendChild(markHeaderCell); + + for (let j = 0; j < headerCells.length; j++) { + const headerCell = document.createElement('th'); + headerCell.innerHTML = headerCells[j]; + headerRow.appendChild(headerCell); + const option = document.createElement('option'); + option.value = j; + option.text = headerCells[j]; + document.getElementById('columns').add(option); + } + table.appendChild(headerRow); + for (let i = 1; i < rows.length; i++) { + const cells = parseCSVRow(rows[i]); + // skip rows with no data + if (cells.length === 1 && cells[0] === '') { + continue; + } + const row = document.createElement('tr'); + + // Add checkbox as a cell in the first column + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.value = i; + checkbox.onclick = toggleMark; + const markCell = document.createElement('td'); + markCell.appendChild(checkbox); + row.appendChild(markCell); + + for (let j = 0; j < cells.length; j++) { + const cell = document.createElement('td'); + cell.innerHTML = cells[j]; + row.appendChild(cell); + } + table.appendChild(row); + } + const csvContent = document.getElementById('csv-content'); + csvContent.innerHTML = ''; // Clear existing content + csvContent.appendChild(table); + }; +} + +// Function to parse a row of CSV data, accounting for commas within double quotes +function parseCSVRow(row) { + const cells = []; + let inQuotes = false; + let start = 0; + for (let i = 0; i < row.length; i++) { + if (row[i] === '"') { + inQuotes = !inQuotes; + } else if (row[i] === ',' && !inQuotes) { + cells.push(row.substring(start, i)); + start = i + 1; + } + } + cells.push(row.substring(start)); + return cells; +} + +// Function to toggle column visibility +function toggleColumn() { + const select = document.getElementById('columns'); + columnIndexes = []; + for (let i = 0; i < select.options.length; i++) { + if (select.options[i].selected) { + columnIndexes.push(i); + } + } + const table = document.querySelector('table'); + const cells = table.querySelectorAll('td,th'); + for (let i = 0; i < cells.length; i++) { + const columnIndex = getColumnIndex(cells[i]); + if (columnIndexes.includes(columnIndex)) { + cells[i].classList.remove('hidden'); + } else { + cells[i].classList.add('hidden'); + } + } +} +// Function to get the index of the column containing a cell + + +// Function to toggle row mark +function toggleMark() { + const row = this.parentNode.parentNode; + if (this.checked) { + row.classList.add('marked'); + } else { + row.classList.remove('marked'); + } +} + +function getColumnIndex(cell) { + const row = cell.parentNode; + const headerRow = row.parentNode.querySelector('tr:first-child'); + const cells = row.querySelectorAll('td,th'); + for (let i = 0; i < cells.length; i++) { + if (cells[i] === cell) { + return i - 1; + } + } + return -1; +} + +function saveMarked() { + // Check if any rows have been marked + if ($('#csv-content tr.marked').length === 0) { + alert('Please select at least one row before saving.'); + return; + } + // Ask user for filename + const filename = prompt("Enter filename:"); + + const table = document.querySelector('table'); + const rows = table.querySelectorAll('tr:not(:first-child)'); + const selectedRows = []; + for (let i = 0; i < rows.length; i++) { + if (rows[i].classList.contains('marked')) { + const cells = rows[i].querySelectorAll('td:not(:first-child),th:not(:first-child)'); + const selectedCells = []; + for (let j = 0; j < cells.length; j++) { + const columnIndex = getColumnIndex(cells[j]); + if (columnIndexes.includes(columnIndex)) { + selectedCells.push(cells[j].innerHTML); + } + } + selectedRows.push(selectedCells.join(',')); + } + } + + // Add header to CSV + const headerCells = []; + const select = document.getElementById('columns'); + for (let i = 0; i < select.options.length; i++) { + if (select.options[i].selected) { + headerCells.push(select.options[i].text); + } + } + const header = headerCells.join(','); + const csvContent = 'data:text/csv;charset=utf-8,' + header + '\n' + selectedRows.join('\n'); + + // Create a link and click it to download the CSV file + const encodedUri = encodeURI(csvContent); + const link = document.createElement('a'); + link.setAttribute('href', encodedUri); + link.setAttribute('download', `${filename}.csv`); + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + // Display message to navigate to download location and copy file location manually + alert(`The file "${filename}.csv" has been downloaded. Please navigate to the download location and copy the file location manually.`); + + $('#submit-button').prop('disabled', false); + } + + function submitTrainingMessage() { + // Make HTTP GET request + $.ajax({ + url: "/training", + method: "GET", + success: function(response) { + window.location.href = "/training"; + } + }); + } diff --git a/examples/digital_fingerprinting/demo/cm_app/static/submit_messages.css b/examples/digital_fingerprinting/demo/cm_app/static/submit_messages.css index cb5daf33e9..fd3003b764 100644 --- a/examples/digital_fingerprinting/demo/cm_app/static/submit_messages.css +++ b/examples/digital_fingerprinting/demo/cm_app/static/submit_messages.css @@ -1,31 +1,12 @@ -.banner { - position:relative; - height: 200px; - max-width: 100%; - background: url("https://developer-blogs.nvidia.com/wp-content/uploads/2022/10/router-box-featured.jpg"); - background-size:auto; - display: flex; - justify-content: center; - align-items: center; - text-align: center; - } - .banner::after { - content: ""; - background-color: #8bb521(0, 0, 0, 0.4); - position: absolute; - width: 100%; - height: 100%; - } - - h1 { - position: absolute; - margin: 0; - font-size: 50px; - color: white; - z-index: 2; - line-height: 83px; - } -/* */ +h1 { + position: relative; + margin: 0; + padding: 0 20px; + line-height: 150px; + font-size: 36px; + font-weight: bold; + color: rgb(255, 255, 255); +} /* Style for the form element */ @@ -55,7 +36,7 @@ select { #banner { width: 100%; } - + /* Style for the button elements */ button { margin-top: 10px; @@ -63,7 +44,7 @@ button { padding: 5px 10px; border-radius: 5px; background-color: #8bb521; - color: #fff; + color: black; border: none; } @@ -85,6 +66,23 @@ input { margin-bottom: 15px; } +.hidden { + display: none; + } + .marked { + background-color: yellow; + } + + table { + border-collapse: collapse; + margin: 20px; + width: calc(100% - 40px); + } + th, td { + border: 1px solid #ccc; + padding: 5px; + text-align: left; + } /* Style for the task and property elements */ .task, .property { @@ -111,16 +109,6 @@ input { height: 100%; } - h1 { - position: absolute; - margin: 0; - font-size: 50px; - color: white; - z-index: 2; - line-height: 83px; - } -/* */ - /* Style for the form element */ form { @@ -153,7 +141,7 @@ button { padding: 5px 10px; border-radius: 5px; background-color: #8bb521; - color: #fff; + color: black; border: none; } @@ -182,4 +170,4 @@ input { padding: 10px; border: 1px solid #8bb521; border-radius: 5px; -} \ No newline at end of file +} diff --git a/examples/digital_fingerprinting/demo/cm_app/static/training.js b/examples/digital_fingerprinting/demo/cm_app/static/training.js index c87d0189c8..1cddcf6a30 100644 --- a/examples/digital_fingerprinting/demo/cm_app/static/training.js +++ b/examples/digital_fingerprinting/demo/cm_app/static/training.js @@ -1,23 +1,23 @@ -$(document).ready(function() { - +$(document).ready(function() { + $("#submit").click(function() { submitForm(); }); - + // Function to convert inputs-container and child data to JSON function submitForm() { // get all the input fields in the inputs-container div const inputs = $('#inputs-container :input'); - + // create an empty object to hold the field values const formData = {}; - + // loop through the inputs and add their values to the formData object inputs.each(function() { formData[this.name] = $(this).val(); }); - let loadTask = {"type": "load", + let loadTask = {"type": "load", "properties": { "loader_id": "fsspec", "files": formData["files"].split(","), @@ -26,10 +26,10 @@ $(document).ready(function() { "type": "training", "properties": {} }; - + let tasks = [loadTask, trainingTask]; let samplingRate = parseInt(formData["sampling_rate_s"]); - + let batching_options ={} batching_options["period"] = formData["period"]; batching_options["sampling_rate_s"] = samplingRate; @@ -40,14 +40,24 @@ $(document).ready(function() { "data_type": "payload", "batching_options": batching_options }; - + let controlMessage = {"inputs": [{"tasks": tasks, "metadata": metadata}]}; - + // Submit form as JSON var jsonString = JSON.stringify(controlMessage, null, 2); - console.error(jsonString); + $('#control-messages-json').val(jsonString); - + } - - }); \ No newline at end of file + + }); + + function checkDateTime() { + var startDate = new Date(document.getElementById("start_time").value); + var endDate = new Date(document.getElementById("end_time").value); + if (startDate > endDate) { + alert("Start time cannot be greater than end time"); + return false; + } + return true; + } diff --git a/examples/digital_fingerprinting/demo/cm_app/templates/review/results.html b/examples/digital_fingerprinting/demo/cm_app/templates/review/results.html new file mode 100644 index 0000000000..f2b8c304ca --- /dev/null +++ b/examples/digital_fingerprinting/demo/cm_app/templates/review/results.html @@ -0,0 +1,31 @@ + + + + Review DFP Inference Results + + + + + + + + + + +
+ +
+ +
+
+
+ + + +
+ + diff --git a/examples/digital_fingerprinting/demo/cm_app/templates/training.html b/examples/digital_fingerprinting/demo/cm_app/templates/training.html index cc1db41c80..c9d96365be 100644 --- a/examples/digital_fingerprinting/demo/cm_app/templates/training.html +++ b/examples/digital_fingerprinting/demo/cm_app/templates/training.html @@ -11,7 +11,7 @@ -
+
@@ -27,7 +27,7 @@

DFP Integrated Training Demo

-
+
@@ -35,4 +35,4 @@

DFP Integrated Training Demo

- \ No newline at end of file + diff --git a/examples/digital_fingerprinting/demo/cm_app/views.py b/examples/digital_fingerprinting/demo/cm_app/views.py index 97238a01ec..29905915e3 100644 --- a/examples/digital_fingerprinting/demo/cm_app/views.py +++ b/examples/digital_fingerprinting/demo/cm_app/views.py @@ -1,11 +1,11 @@ import logging +from cm_app.helper import KafkaWriter +from cm_app.helper import generate_success_message +from cm_app.helper import process_cm from confluent_kafka import Producer from flask import render_template from flask import request -from cm_app.helper import KafkaWriter -from cm_app.helper import process_cm -from cm_app.helper import generate_success_message from . import app @@ -29,7 +29,7 @@ def submit_messages(): control_messages_json = process_cm(request) global kafka_writer kafka_writer.write_data(control_messages_json) - sucess_message = generate_success_message(control_message_json) + sucess_message = generate_success_message(control_messages_json) return sucess_message if request.method == "GET": @@ -48,3 +48,10 @@ def training(): if request.method == "GET": return render_template("training.html") + + +@app.route('/review/results', methods=["GET"]) +def reviewresults(): + + if request.method == "GET": + return render_template("review/results.html") diff --git a/examples/digital_fingerprinting/production/morpheus/benchmarks/test_bench_e2e_dfp_pipeline.py b/examples/digital_fingerprinting/production/morpheus/benchmarks/test_bench_e2e_dfp_pipeline.py index 1c36a57824..96b1708ad8 100644 --- a/examples/digital_fingerprinting/production/morpheus/benchmarks/test_bench_e2e_dfp_pipeline.py +++ b/examples/digital_fingerprinting/production/morpheus/benchmarks/test_bench_e2e_dfp_pipeline.py @@ -78,10 +78,10 @@ def dfp_modules_pipeline(pipe_config: Config, # Here we add a wrapped module that implements the DFP Deployment dfp_deployment_stage = pipeline.add_stage( MultiPortModulesStage(pipe_config, - modules_conf, - input_port_name="input", - output_port_name_prefix="output", - num_output_ports=modules_conf["num_output_ports"])) + modules_conf, + input_port_name="input", + output_port_name_prefix="output", + num_output_ports=modules_conf["num_output_ports"])) pipeline.add_edge(source_stage, dfp_deployment_stage) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_deployment.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_deployment.py index 37017e261a..c15e78d22f 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_deployment.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_deployment.py @@ -64,11 +64,14 @@ def dfp_deployment(builder: mrc.Builder): dfp_training_pipe_conf = module_config["training_options"] dfp_inference_pipe_conf = module_config["inference_options"] - fsspec_dataloader_module = builder.load_module(DATA_LOADER, "morpheus", "fsspec_dataloader", - fsspec_dataloader_conf) - dfp_training_pipe_module = builder.load_module(DFP_TRAINING_PIPE, "morpheus", "dfp_training_pipe", + fsspec_dataloader_module = builder.load_module(DATA_LOADER, "morpheus", "fsspec_dataloader", fsspec_dataloader_conf) + dfp_training_pipe_module = builder.load_module(DFP_TRAINING_PIPE, + "morpheus", + "dfp_training_pipe", dfp_training_pipe_conf) - dfp_inference_pipe_module = builder.load_module(DFP_INFERENCE_PIPE, "morpheus", "dfp_inference_pipe", + dfp_inference_pipe_module = builder.load_module(DFP_INFERENCE_PIPE, + "morpheus", + "dfp_inference_pipe", dfp_inference_pipe_conf) # Create broadcast node to fork the pipeline. diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference_pipe.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference_pipe.py index ae7bc54e0f..3def6e56c9 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference_pipe.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference_pipe.py @@ -76,8 +76,7 @@ def dfp_inference_pipe(builder: mrc.Builder): "batching_options": config.get("batching_options", {}), "cache_dir": cache_dir, "pre_filter_options": { - "enable_task_filtering": True, - "filter_task_type": "inference" + "enable_task_filtering": True, "filter_task_type": "inference" }, "timestamp_column_name": ts_column_name, "user_splitting_options": config.get("user_splitting_options", {}), @@ -122,20 +121,13 @@ def dfp_inference_pipe(builder: mrc.Builder): inference_model_defaults = {} # placeholder for future defaults dfp_inference_conf = merge_dictionaries(inference_model_options, inference_model_defaults) - detection_criteria_defaults = { - "field_name": "mean_abs_z", - "threshold": 2.0, - "filter_source": "DATAFRAME" - } + detection_criteria_defaults = {"field_name": "mean_abs_z", "threshold": 2.0, "filter_source": "DATAFRAME"} filter_detections_conf = merge_dictionaries(detection_criteria, detection_criteria_defaults) post_processing_defaults = {} # placeholder for future defaults dfp_post_proc_conf = merge_dictionaries(post_processing_options, post_processing_defaults) - serialize_defaults = { - "exclude": ['batch_count', 'origin_hash', '_row_hash', '_batch_id'], - "use_cpp": True - } + serialize_defaults = {"exclude": ['batch_count', 'origin_hash', '_row_hash', '_batch_id'], "use_cpp": True} serialize_conf = merge_dictionaries(serialize_options, serialize_defaults) write_to_file_defaults = { @@ -145,17 +137,22 @@ def dfp_inference_pipe(builder: mrc.Builder): # Load modules preproc_module = builder.load_module(DFP_PREPROC, "morpheus", "dfp_preproc", preproc_conf) - dfp_rolling_window_module = builder.load_module(DFP_ROLLING_WINDOW, "morpheus", "dfp_rolling_window", + dfp_rolling_window_module = builder.load_module(DFP_ROLLING_WINDOW, + "morpheus", + "dfp_rolling_window", dfp_rolling_window_conf) dfp_data_prep_module = builder.load_module(DFP_DATA_PREP, "morpheus", "dfp_data_prep", dfp_data_prep_conf) dfp_inference_module = builder.load_module(DFP_INFERENCE, "morpheus", "dfp_inference", dfp_inference_conf) - filter_detections_module = builder.load_module(FILTER_DETECTIONS, "morpheus", "filter_detections", + filter_detections_module = builder.load_module(FILTER_DETECTIONS, + "morpheus", + "filter_detections", filter_detections_conf) - dfp_post_proc_module = builder.load_module(DFP_POST_PROCESSING, "morpheus", "dfp_post_processing", + dfp_post_proc_module = builder.load_module(DFP_POST_PROCESSING, + "morpheus", + "dfp_post_processing", dfp_post_proc_conf) serialize_module = builder.load_module(SERIALIZE, "morpheus", "serialize", serialize_conf) - write_to_file_module = builder.load_module(WRITE_TO_FILE, "morpheus", "write_to_file", - write_to_file_conf) + write_to_file_module = builder.load_module(WRITE_TO_FILE, "morpheus", "write_to_file", write_to_file_conf) # Make an edge between the modules. builder.make_edge(preproc_module.output_port("output"), dfp_rolling_window_module.input_port("input")) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_preproc.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_preproc.py index 25913c57ae..7dbffd4f44 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_preproc.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_preproc.py @@ -94,18 +94,19 @@ def dfp_preproc(builder: mrc.Builder): } file_to_df_conf = merge_dictionaries(supported_loaders, file_to_df_defaults) - dfp_split_users_default = { - "fallback_username": config.get("fallback_username", "generic_user") - } + dfp_split_users_default = {"fallback_username": config.get("fallback_username", "generic_user")} dfp_split_users_conf = merge_dictionaries(splitting_opts, dfp_split_users_default) - filter_control_message_module = builder.load_module(FILTER_CONTROL_MESSAGE, "morpheus", "filter_control_message", + filter_control_message_module = builder.load_module(FILTER_CONTROL_MESSAGE, + "morpheus", + "filter_control_message", pre_filter_conf) file_batcher_module = builder.load_module(FILE_BATCHER, "morpheus", "file_batcher", file_batcher_conf) - file_to_df_dataloader_module = builder.load_module(DATA_LOADER, "morpheus", "dfp_file_to_df_dataloader", + file_to_df_dataloader_module = builder.load_module(DATA_LOADER, + "morpheus", + "dfp_file_to_df_dataloader", file_to_df_conf) - dfp_split_users_module = builder.load_module(DFP_SPLIT_USERS, "morpheus", "dfp_split_users", - dfp_split_users_conf) + dfp_split_users_module = builder.load_module(DFP_SPLIT_USERS, "morpheus", "dfp_split_users", dfp_split_users_conf) # Make an edge between the modules. builder.make_edge(filter_control_message_module.output_port("output"), file_batcher_module.input_port("input")) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_split_users.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_split_users.py index 40707485f5..bb3a759958 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_split_users.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_split_users.py @@ -113,7 +113,8 @@ def generate_split_dataframes(df: pd.DataFrame): if (include_individual): split_dataframes.update( - {username: user_df for username, user_df in df.groupby(userid_column_name, sort=False)}) + {username: user_df + for username, user_df in df.groupby(userid_column_name, sort=False)}) return split_dataframes diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training_pipe.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training_pipe.py index e9b1cdde8b..01dcd530e9 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training_pipe.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training_pipe.py @@ -70,8 +70,7 @@ def dfp_training_pipe(builder: mrc.Builder): "batching_options": config.get("batching_options", {}), "cache_dir": cache_dir, "pre_filter_options": { - "enable_task_filtering": True, - "filter_task_type": "training" + "enable_task_filtering": True, "filter_task_type": "training" }, "timestamp_column_name": ts_column_name, "user_splitting_options": config.get("user_splitting_options", {}), @@ -130,11 +129,15 @@ def dfp_training_pipe(builder: mrc.Builder): # Load modules preproc_module = builder.load_module(DFP_PREPROC, "morpheus", "dfp_preproc", preproc_conf) - dfp_rolling_window_module = builder.load_module(DFP_ROLLING_WINDOW, "morpheus", "dfp_rolling_window", + dfp_rolling_window_module = builder.load_module(DFP_ROLLING_WINDOW, + "morpheus", + "dfp_rolling_window", dfp_rolling_window_conf) dfp_data_prep_module = builder.load_module(DFP_DATA_PREP, "morpheus", "dfp_data_prep", dfp_data_prep_conf) dfp_training_module = builder.load_module(DFP_TRAINING, "morpheus", "dfp_training", dfp_training_conf) - mlflow_model_writer_module = builder.load_module(MLFLOW_MODEL_WRITER, "morpheus", "mlflow_model_writer", + mlflow_model_writer_module = builder.load_module(MLFLOW_MODEL_WRITER, + "morpheus", + "mlflow_model_writer", mlflow_model_writer_conf) # Make an edge between the modules. diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py b/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py index b85c2b9de8..df03dc7b63 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/utils/config_generator.py @@ -78,8 +78,7 @@ def infer_module_conf(self): "userid_column_name": self._config.ae.userid_column_name }, "stream_aggregation_options": { - "aggregation_span": self._dfp_arg_parser.duration, - "cache_to_disk": False + "aggregation_span": self._dfp_arg_parser.duration, "cache_to_disk": False }, "preprocessing_options": { "schema": { @@ -129,8 +128,7 @@ def train_module_conf(self): "userid_column_name": self._config.ae.userid_column_name }, "stream_aggregation_options": { - "aggregation_span": self._dfp_arg_parser.duration, - "cache_to_disk": False + "aggregation_span": self._dfp_arg_parser.duration, "cache_to_disk": False }, "preprocessing_options": { "schema": { @@ -138,9 +136,7 @@ def train_module_conf(self): } }, "dfencoder_options": { - "feature_columns": self._config.ae.feature_columns, - "epochs": 30, - "validation_size": 0.10 + "feature_columns": self._config.ae.feature_columns, "epochs": 30, "validation_size": 0.10 }, "mlflow_writer_options": { "model_name_formatter": self._dfp_arg_parser.model_name_formatter, diff --git a/examples/digital_fingerprinting/production/morpheus/dfp_modules_pipeline.py b/examples/digital_fingerprinting/production/morpheus/dfp_modules_pipeline.py index cf98cc843c..8ff1c4e014 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp_modules_pipeline.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp_modules_pipeline.py @@ -160,10 +160,10 @@ def run_pipeline(source: str, dfp_deployment_stage = pipeline.add_stage( MultiPortModulesStage(config, - dfp_deployment_module_config, - input_port_name="input", - output_port_name_prefix="output", - num_output_ports=2)) + dfp_deployment_module_config, + input_port_name="input", + output_port_name_prefix="output", + num_output_ports=2)) train_moniter_stage = pipeline.add_stage( MonitorStage(config, description="DFP Training Pipeline rate", smoothing=0.001)) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp_modules_streaming_pipeline.py b/examples/digital_fingerprinting/production/morpheus/dfp_modules_streaming_pipeline.py index 5b4b76ac15..b21d71f2db 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp_modules_streaming_pipeline.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp_modules_streaming_pipeline.py @@ -179,10 +179,10 @@ def run_pipeline(source: str, dfp_deployment_stage = pipeline.add_stage( MultiPortModulesStage(config, - dfp_deployment_module_config, - input_port_name="input", - output_port_name_prefix="output", - num_output_ports=num_output_ports)) + dfp_deployment_module_config, + input_port_name="input", + output_port_name_prefix="output", + num_output_ports=num_output_ports)) train_moniter_stage = pipeline.add_stage( MonitorStage(config, description="DFP Training Pipeline rate", smoothing=0.001)) diff --git a/tests/test_multiport_modules_stage.py b/tests/test_multiport_modules_stage.py deleted file mode 100755 index 88f7340eae..0000000000 --- a/tests/test_multiport_modules_stage.py +++ /dev/null @@ -1,106 +0,0 @@ -#!/usr/bin/env python -# SPDX-FileCopyrightText: Copyright (c) 2023 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import typing -from unittest import mock - -import mrc -import pytest - -from morpheus.stages.general.multiport_modules_stage import MultiPortModulesStage -from morpheus.utils.module_utils import mrc_version - -module_config = { - "module_id": "TestMultiPortModule", "module_name": "test_multiport_module", "namespace": "test_morpheus_modules" -} - - -@pytest.mark.use_python -def test_constructor(config): - - mod_stage = MultiPortModulesStage(config, - module_config, - input_port_name="input", - output_port_name_prefix="output", - num_output_ports=2) - - assert mod_stage.name == "test_multiport_module" - - # Just ensure that we get a valid non-empty tuple - accepted_types = mod_stage.accepted_types() - assert isinstance(accepted_types, tuple) - assert len(accepted_types) > 0 - assert accepted_types[0] == typing.Any - - pytest.raises(NotImplementedError, mod_stage._get_cpp_module_node, None) - - -@pytest.mark.use_python -def test_build_single_before_module_registration(config): - - mock_node = mock.MagicMock() - mock_segment = mock.MagicMock() - mock_module = mock.MagicMock() - mock_input_stream = mock.MagicMock() - - mock_segment.load_module.return_value = mock_module - mock_segment.make_node_full.return_value = mock_node - - mod_stage = MultiPortModulesStage(config, - module_config, - input_port_name="input", - output_port_name_prefix="output", - num_output_ports=2) - - with pytest.raises(Exception): - mod_stage._build_single(mock_segment, mock_input_stream) - - -def register_test_module(): - registry = mrc.ModuleRegistry - - def module_init_fn(builder: mrc.Builder): - pass - - registry.register_module("TestMultiPortModule", "test_morpheus_modules", mrc_version, module_init_fn) - - -@pytest.mark.use_python -def test_build_single_after_module_registration(config): - - register_test_module() - - mock_node = mock.MagicMock() - mock_segment = mock.MagicMock() - mock_module = mock.MagicMock() - mock_input_stream = mock.MagicMock() - - mock_segment.load_module.return_value = mock_module - mock_segment.make_node_full.return_value = mock_node - - num_output_ports = 2 - - mod_stage = MultiPortModulesStage(config, - module_config, - input_port_name="input", - output_port_name_prefix="output", - num_output_ports=num_output_ports) - - out_stream_pairs = mod_stage._build_single(mock_segment, mock_input_stream) - - assert len(out_stream_pairs) == num_output_ports - mock_segment.load_module.assert_called_once() - mock_segment.make_edge.assert_called_once() From b58cb0b456b97fb78e27b108b9ae9b918826a3fa Mon Sep 17 00:00:00 2001 From: bsuryadevara Date: Fri, 17 Mar 2023 15:10:17 -0500 Subject: [PATCH 108/157] removed file_batcher test --- tests/modules/test_file_batcher.py | 189 ----------------------------- 1 file changed, 189 deletions(-) delete mode 100644 tests/modules/test_file_batcher.py diff --git a/tests/modules/test_file_batcher.py b/tests/modules/test_file_batcher.py deleted file mode 100644 index ab6e1cbf8b..0000000000 --- a/tests/modules/test_file_batcher.py +++ /dev/null @@ -1,189 +0,0 @@ -#!/usr/bin/env python -# SPDX-FileCopyrightText: Copyright (c) 2023, NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import time - -import mrc -import cudf -import tempfile -import os - -import morpheus.modules.file_batcher # Used to load and register morpheus modules -import morpheus.messages as messages - - -def on_next(control_msg): - pass - - -def on_error(): - pass - - -def on_complete(): - pass - - -def test_contains_namespace(): - registry = mrc.ModuleRegistry - - assert registry.contains_namespace("morpheus") - - -def test_is_version_compatible(): - registry = mrc.ModuleRegistry - - release_version = [int(x) for x in mrc.__version__.split(".")] - old_release_version = [22, 10, 0] - no_version_patch = [22, 10] - no_version_minor_and_patch = [22] - - assert registry.is_version_compatible(release_version) - assert registry.is_version_compatible(old_release_version) is not True - assert registry.is_version_compatible(no_version_patch) is not True - assert registry.is_version_compatible(no_version_minor_and_patch) is not True - - -def test_get_module(): - registry = mrc.ModuleRegistry - - fn_constructor = registry.get_module_constructor("FileBatcher", "morpheus") - assert fn_constructor is not None - - config = {} - module_instance = fn_constructor("ModuleFileBatcherTest", config) - - -packet_count = 1 -packets_received = 0 - - -def test_file_batcher_module(tmp_path): - registry = mrc.ModuleRegistry - - fn_constructor = registry.get_module_constructor("FileBatcher", "morpheus") - assert fn_constructor is not None - - input_filepaths = [] - filenames = [ - "TEST_2022-08-22T21_06_16.397Z.json", - "TEST_2022-08-22T00_01_32.097Z.json", - "TEST_2022-08-22T03_13_34.617Z.json", - "TEST_2022-08-23T06_12_04.524Z.json", - "TEST_2022-08-23T09_06_36.465Z.json", - "TEST_2022-08-23T12_23_47.260Z.json", - "TEST_2022-08-24T15_07_25.933Z.json", - "TEST_2022-08-24T18_06_17.979Z.json", - "TEST_2022-08-24T21_10_23.207Z.json" - ] - - for filename in filenames: - input_filepaths.append(os.path.join(tmp_path, filename)) - - def init_wrapper(builder: mrc.Builder): - - df = cudf.DataFrame({ - 'files': input_filepaths, - }, columns=['files']) - - def gen_data(): - global packet_count - config = { - "tasks": [], - "metadata": { - "data_type": "payload", - "batching_options": { - "start_time": "2022-08-20", "end_time": "2022-08-24", "period": "D", "sampling_rate_s": 0 - } - } - } - - payload = messages.MessageMeta(df) - msg = messages.MessageControl(config) - msg.payload(payload) - - yield msg - - def _on_next(control_msg): - global packets_received - packets_received += 1 - assert (control_msg.payload().df == df) - - source = builder.make_source("source", gen_data) - - config = { - "module_id": "FileBatcher", "module_name": "test_file_batcher", "namespace": "morpheus" -} - # This will unpack the config and forward its payload (MessageMeta) to the sink - file_batcher_module = builder.load_module("FileBatcher", "morpheus", "ModuleFileBatcherTest", config) - - sink = builder.make_sink("sink", _on_next, on_error, on_complete) - - builder.make_edge(source, file_batcher_module.input_port("input")) - builder.make_edge(file_batcher_module.output_port("output"), sink) - - pipeline = mrc.Pipeline() - pipeline.make_segment("main", init_wrapper) - - options = mrc.Options() - options.topology.user_cpuset = "0-1" - - executor = mrc.Executor(options) - executor.register_pipeline(pipeline) - executor.start() - executor.join() - - assert (packets_received == 3) - - for f in files: - os.remove(f[0]) - - -def test_file_loader_module(): - global packets_received - packets_received = 0 - - df = cudf.DataFrame( - { - 'col1': [1, 2, 3, 4, 5], - 'col2': [1.1, 2.2, 3.3, 4.4, 5.5], - 'col3': ['a', 'b', 'c', 'd', 'e'], - 'col4': [True, False, True, False, True] - }, - columns=['col1', 'col2', 'col3', 'col4']) - - files = [] - file_types = ["csv", "parquet", "orc"] - for ftype in file_types: - _tempfile = tempfile.NamedTemporaryFile(suffix=f".{ftype}", delete=False) - filename = _tempfile.name - - if ftype == "csv": - df.to_csv(filename, index=False) - elif ftype == "parquet": - df.to_parquet(filename) - elif ftype == "orc": - df.to_orc(filename) - - files.append((filename, ftype)) - - - -if (__name__ == "__main__"): - test_contains_namespace() - test_is_version_compatible() - test_get_module() - test_file_batcher_module() From c02d5cd7df84d188a16ca945c6d1d0dfb0bb20fd Mon Sep 17 00:00:00 2001 From: Devin Robison Date: Fri, 17 Mar 2023 14:41:39 -0600 Subject: [PATCH 109/157] Merge Bhargav's PR, add start of control messages docs --- docs/source/developer_guide/guides.md | 8 +- .../guides/9_control_messages.md | 106 ++++++++++++++++++ .../include/morpheus/messages/control.hpp | 2 - morpheus/_lib/src/messages/control.cpp | 86 +------------- 4 files changed, 112 insertions(+), 90 deletions(-) create mode 100644 docs/source/developer_guide/guides/9_control_messages.md diff --git a/docs/source/developer_guide/guides.md b/docs/source/developer_guide/guides.md index 04bf580edd..cc941580e7 100644 --- a/docs/source/developer_guide/guides.md +++ b/docs/source/developer_guide/guides.md @@ -45,9 +45,11 @@ However, there are likely going to be situations that require writing a custom m reusable work units, or for creating a new compound module from a set of existing primitives. The following guides will walk through the process of creating a custom module in Python and C++. -- [Python Module](./guides/7_python_modules.md) -- [C++ Module](./guides/8_cpp_modules.md) -- +- [Python Modules](./guides/7_python_modules.md) +- [C++ Modules](./guides/8_cpp_modules.md) + +## Morpheus Control messages +- [Control Messages Overview](./guides/9_control_messages.md) ## Example Workflows diff --git a/docs/source/developer_guide/guides/9_control_messages.md b/docs/source/developer_guide/guides/9_control_messages.md new file mode 100644 index 0000000000..777d2f8cf5 --- /dev/null +++ b/docs/source/developer_guide/guides/9_control_messages.md @@ -0,0 +1,106 @@ + + +# Morpheus Control Messages + +## Background + +Control Messages, introduced in version 23.03, provide a solution for numerous use cases that were previously +unattainable. This new paradigm enhances the capabilities of Morpheus pipelines by enabling more reactive, event-driven +operations. Control Messages involve sending message objects to a pipeline, which can represent a wide range of +concepts, from raw data to explicit directives for loading data from specified sources or initiating out-of-band +inference or training tasks. The pipeline's behavior can dynamically adapt based on the design; some stages may +disregard messages they are not intended to process, while others act according to the message type and content. + +This approach unlocks various new applications for Morpheus pipelines. For instance, Control Messages can +facilitate real-time data processing and analysis, allowing pipelines to respond promptly to time-sensitive events or +data streams. Additionally, they can support adaptive machine learning models that continuously update and refine their +predictions based on incoming data. Furthermore, Control Messages can improve resource allocation and efficiency by +enabling on-demand data processing and task execution. Overall, the introduction of Control Messages in Morpheus +pipelines paves the way for more versatile and responsive software solutions, catering to a broader range of +requirements and use cases. + +## Anatomy of a Control Message + +Control Messages a fairly simple objects that contain `tasks`, `metadata`, and possibly `payload` data. Currently +tasks can be one of the following: `TRAINING`, `INFERENCE`, or `OTHER`. Metadata is a dictionary of key-value pairs +that can be used to provide additional information about the message. Payload is a Morpheus MessageMeta object that can +be used to move raw data. Each of these elements can be accessed via API as the message flows through the pipeline. + +## Anatomy of a Control Message + +Control Messages are straightforward objects that contain `tasks`, `metadata`, and possibly `payload` data. Tasks can be +one of the following: `TRAINING`, `INFERENCE`, or `OTHER`. Metadata is a dictionary of key-value pairs that provide +additional information about the message and must be JSON serializable. Payload is a Morpheus MessageMeta object that +can be used to move raw data. Each of these elements can be accessed via the API as the message flows through the +pipeline. + +### Working with Tasks + +Control Messages can handle tasks such as `training`, `inference`, and a catchall category `other`. Tasks can be added, +checked for +existence, or removed from the Control Message using methods like `add_task`, `has_task`, and `pop_task`. + +```python +import morpheus._lib.messages as messages + +task_data = { + "....": "...." +} + +msg = messages.MessageControl() +msg.add_task("training", task_data) +if msg.has_task("training"): + task = msg.pop_task("training") +``` + +### Managing Metadata + +Metadata is a set of key-value pairs that offer supplementary information about the Control Message and must be JSON +serializable. You can set, check, and retrieve metadata values using the `set_metadata`, `has_metadata`, +and `get_metadata` methods, respectively. + +```python +import morpheus._lib.messages as messages + +msg = messages.MessageControl() +msg.set_metadata("description", "This is a sample control message.") +if msg.has_metadata("description"): + description = msg.get_metadata("description") +``` + +### Handling Payloads + +The payload of a Control Message is a Morpheus MessageMeta object that can carry raw data. You can set or retrieve the +payload using the `payload` method, which can accept a MessageMeta instance or return the payload +itself. + +```python +import cudf +import morpheus._lib.messages as messages + +data = cudf.DataFrame() # some data + +msg_meta = messages.MessageMeta(data) +msg = messages.MessageControl() + +msg.payload(msg_meta) + +retrieved_payload = msg.payload() + +msg_meta = retrieved_payload # True +``` \ No newline at end of file diff --git a/morpheus/_lib/include/morpheus/messages/control.hpp b/morpheus/_lib/include/morpheus/messages/control.hpp index a5f596a699..3fd23da2e9 100644 --- a/morpheus/_lib/include/morpheus/messages/control.hpp +++ b/morpheus/_lib/include/morpheus/messages/control.hpp @@ -27,8 +27,6 @@ class MessageMeta; #pragma GCC visibility push(default) enum class ControlMessageType { - CUSTOM, - DATA, INFERENCE, NONE, TRAINING diff --git a/morpheus/_lib/src/messages/control.cpp b/morpheus/_lib/src/messages/control.cpp index 8d8e56cbd9..9f9ed73bb5 100644 --- a/morpheus/_lib/src/messages/control.cpp +++ b/morpheus/_lib/src/messages/control.cpp @@ -25,91 +25,7 @@ namespace py = pybind11; namespace morpheus { -const std::string MessageControl::s_config_schema = R"( -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "ControlMessage", - "type": "object", - "required": ["tasks"], - "properties": { - "tasks": { - "type": "array", - "items": { - "type": "object", - "required": ["type", "properties"], - "properties": { - "type": { - "type": "string", - "enum": ["load", "inference", "training"] - }, - "properties": { - "type": "object", - "allOf": [ - { - "if": { - "properties": { - "type": { "const": "load" }, - "loader_id": { "const": "file" } - } - }, - "then": { - "required": ["loader_id", "strategy", "files"], - "properties": { - "loader_id": { "type": "string", "enum": ["file"] }, - "strategy": { "type": "string" }, - "files": { - "type": "array", - "items": { - "type": "object", - "required": ["path", "type"], - "properties": { - "path": { "type": "string" }, - "type": { "type": "string" } - } - } - } - } - } - }, - { - "if": { - "properties": { - "type": { "const": "load" }, - "loader_id": { "const": "file_list" } - } - }, - "then": { - "required": ["loader_id", "strategy", "directories"], - "properties": { - "loader_id": { "type": "string", "enum": ["file_list"] }, - "strategy": { "type": "string" }, - "directories": { - "type": "array", - "items": { "type": "string" } - } - } - } - }, - { - "if": { - "properties": { - "type": { "enum": ["inference", "training"] } - } - }, - "then": { - "properties": { - "params": { "type": "object" } - } - } - } - ] - } - } - } - } - } -} -)"; +const std::string MessageControl::s_config_schema = R"()"; MessageControl::MessageControl() : m_config({{"metadata", nlohmann::json::object()}}) {} From 24296890d61f56722be997594c60d805d8982ad0 Mon Sep 17 00:00:00 2001 From: Devin Robison Date: Fri, 17 Mar 2023 14:46:16 -0600 Subject: [PATCH 110/157] Update copyright headers --- docs/source/modules/core/file_batcher.md | 17 +++++++++++++++++ docs/source/modules/core/file_to_df.md | 17 +++++++++++++++++ .../modules/core/filter_control_message.md | 17 +++++++++++++++++ docs/source/modules/core/filter_detections.md | 17 +++++++++++++++++ docs/source/modules/core/mlflow_model_writer.md | 17 +++++++++++++++++ docs/source/modules/core/serializer.md | 17 +++++++++++++++++ docs/source/modules/core/write_to_file.md | 17 +++++++++++++++++ .../digital_fingerprinting/dfp_data_prep.md | 17 +++++++++++++++++ .../digital_fingerprinting/dfp_deployment.md | 17 +++++++++++++++++ .../digital_fingerprinting/dfp_inference.md | 17 +++++++++++++++++ .../dfp_inference_pipe.md | 17 +++++++++++++++++ .../dfp_postprocessing.md | 17 +++++++++++++++++ .../digital_fingerprinting/dfp_preproc.md | 17 +++++++++++++++++ .../dfp_rolling_window.md | 17 +++++++++++++++++ .../digital_fingerprinting/dfp_split_users.md | 17 +++++++++++++++++ .../digital_fingerprinting/dfp_training.md | 17 +++++++++++++++++ .../digital_fingerprinting/dfp_training_pipe.md | 17 +++++++++++++++++ docs/source/modules/morpheus_modules.md | 17 +++++++++++++++++ 18 files changed, 306 insertions(+) diff --git a/docs/source/modules/core/file_batcher.md b/docs/source/modules/core/file_batcher.md index 1bc430de92..26f7419707 100644 --- a/docs/source/modules/core/file_batcher.md +++ b/docs/source/modules/core/file_batcher.md @@ -1,3 +1,20 @@ + + ## File Batcher Module This module loads the input files, removes files that are older than the chosen window of time, and then groups the remaining files by period that fall inside the window. diff --git a/docs/source/modules/core/file_to_df.md b/docs/source/modules/core/file_to_df.md index 9185ab2d33..ddff8b58bd 100644 --- a/docs/source/modules/core/file_to_df.md +++ b/docs/source/modules/core/file_to_df.md @@ -1,3 +1,20 @@ + + ## File to DataFrame Module This module reads data from the batched files into a dataframe after receiving input from the "FileBatcher" module. In addition to loading data from the disk, it has the ability to load the file content from S3 buckets. diff --git a/docs/source/modules/core/filter_control_message.md b/docs/source/modules/core/filter_control_message.md index c5c891c818..8cd0fce8be 100644 --- a/docs/source/modules/core/filter_control_message.md +++ b/docs/source/modules/core/filter_control_message.md @@ -1,3 +1,20 @@ + + ## Filter Control Message Module When the requirements are met, this module gently discards the control messages. diff --git a/docs/source/modules/core/filter_detections.md b/docs/source/modules/core/filter_detections.md index ecd98dafb7..3299318e76 100644 --- a/docs/source/modules/core/filter_detections.md +++ b/docs/source/modules/core/filter_detections.md @@ -1,3 +1,20 @@ + + ## Filter Detections Module Filter message by a classification threshold. diff --git a/docs/source/modules/core/mlflow_model_writer.md b/docs/source/modules/core/mlflow_model_writer.md index 150243ebb4..bc272a00f9 100644 --- a/docs/source/modules/core/mlflow_model_writer.md +++ b/docs/source/modules/core/mlflow_model_writer.md @@ -1,3 +1,20 @@ + + ## MLflow Model Writer Module This module uploads trained models to the MLflow server. diff --git a/docs/source/modules/core/serializer.md b/docs/source/modules/core/serializer.md index b0cd20b9fb..df40581647 100644 --- a/docs/source/modules/core/serializer.md +++ b/docs/source/modules/core/serializer.md @@ -1,3 +1,20 @@ + + ## Serialize Module This module filters columns from a `MultiMessage` object, emitting a `MessageMeta`. diff --git a/docs/source/modules/core/write_to_file.md b/docs/source/modules/core/write_to_file.md index a159166818..83a8e6e657 100644 --- a/docs/source/modules/core/write_to_file.md +++ b/docs/source/modules/core/write_to_file.md @@ -1,3 +1,20 @@ + + ## WriteToFile Module This module writes messages to a file. diff --git a/docs/source/modules/examples/digital_fingerprinting/dfp_data_prep.md b/docs/source/modules/examples/digital_fingerprinting/dfp_data_prep.md index efc0e27c0a..4db6d577a5 100644 --- a/docs/source/modules/examples/digital_fingerprinting/dfp_data_prep.md +++ b/docs/source/modules/examples/digital_fingerprinting/dfp_data_prep.md @@ -1,3 +1,20 @@ + + ## DFP Data Prep Module This module function prepares data for either inference or model training. diff --git a/docs/source/modules/examples/digital_fingerprinting/dfp_deployment.md b/docs/source/modules/examples/digital_fingerprinting/dfp_deployment.md index 8690a04729..dd0f7ff8b0 100644 --- a/docs/source/modules/examples/digital_fingerprinting/dfp_deployment.md +++ b/docs/source/modules/examples/digital_fingerprinting/dfp_deployment.md @@ -1,3 +1,20 @@ + + ## Pipeline Module This module function sets up a pipeline builder instance. diff --git a/docs/source/modules/examples/digital_fingerprinting/dfp_inference.md b/docs/source/modules/examples/digital_fingerprinting/dfp_inference.md index 0120eef520..e598594bbc 100644 --- a/docs/source/modules/examples/digital_fingerprinting/dfp_inference.md +++ b/docs/source/modules/examples/digital_fingerprinting/dfp_inference.md @@ -1,3 +1,20 @@ + + ## DFP Inference Module This module function performs the inference process. diff --git a/docs/source/modules/examples/digital_fingerprinting/dfp_inference_pipe.md b/docs/source/modules/examples/digital_fingerprinting/dfp_inference_pipe.md index 14e3632947..f1b759256a 100644 --- a/docs/source/modules/examples/digital_fingerprinting/dfp_inference_pipe.md +++ b/docs/source/modules/examples/digital_fingerprinting/dfp_inference_pipe.md @@ -1,3 +1,20 @@ + + ## dfp_inference_pipe This module function allows for the consolidation of multiple dfp pipeline modules relevant to the inference process diff --git a/docs/source/modules/examples/digital_fingerprinting/dfp_postprocessing.md b/docs/source/modules/examples/digital_fingerprinting/dfp_postprocessing.md index 8cc04e4e42..83a71c6bdb 100644 --- a/docs/source/modules/examples/digital_fingerprinting/dfp_postprocessing.md +++ b/docs/source/modules/examples/digital_fingerprinting/dfp_postprocessing.md @@ -1,3 +1,20 @@ + + ## DFP Postprocessing Module This module function performs postprocessing tasks after the inference process. diff --git a/docs/source/modules/examples/digital_fingerprinting/dfp_preproc.md b/docs/source/modules/examples/digital_fingerprinting/dfp_preproc.md index 200527993e..54cd25314d 100644 --- a/docs/source/modules/examples/digital_fingerprinting/dfp_preproc.md +++ b/docs/source/modules/examples/digital_fingerprinting/dfp_preproc.md @@ -1,3 +1,20 @@ + + ## dfp_preproc This module function allows for the consolidation of multiple dfp pipeline modules relevant to inference/training diff --git a/docs/source/modules/examples/digital_fingerprinting/dfp_rolling_window.md b/docs/source/modules/examples/digital_fingerprinting/dfp_rolling_window.md index 4f3b276c1a..c207ab2a2e 100644 --- a/docs/source/modules/examples/digital_fingerprinting/dfp_rolling_window.md +++ b/docs/source/modules/examples/digital_fingerprinting/dfp_rolling_window.md @@ -1,3 +1,20 @@ + + ## DFP Split Users Module This module function splits the data based on user IDs. diff --git a/docs/source/modules/examples/digital_fingerprinting/dfp_split_users.md b/docs/source/modules/examples/digital_fingerprinting/dfp_split_users.md index c69050e478..f9dd7f5592 100644 --- a/docs/source/modules/examples/digital_fingerprinting/dfp_split_users.md +++ b/docs/source/modules/examples/digital_fingerprinting/dfp_split_users.md @@ -1,3 +1,20 @@ + + ## DFP Split Users Module This module function splits the data based on user IDs. diff --git a/docs/source/modules/examples/digital_fingerprinting/dfp_training.md b/docs/source/modules/examples/digital_fingerprinting/dfp_training.md index 1b985bd3de..dc3aee4711 100644 --- a/docs/source/modules/examples/digital_fingerprinting/dfp_training.md +++ b/docs/source/modules/examples/digital_fingerprinting/dfp_training.md @@ -1,3 +1,20 @@ + + # DFP Training Module This module function is responsible for training the model. diff --git a/docs/source/modules/examples/digital_fingerprinting/dfp_training_pipe.md b/docs/source/modules/examples/digital_fingerprinting/dfp_training_pipe.md index fe8ff97ab7..510e57f79d 100644 --- a/docs/source/modules/examples/digital_fingerprinting/dfp_training_pipe.md +++ b/docs/source/modules/examples/digital_fingerprinting/dfp_training_pipe.md @@ -1,3 +1,20 @@ + + ## DFP Training Pipe Module This module function consolidates multiple DFP pipeline modules relevant to the training process into a single module. diff --git a/docs/source/modules/morpheus_modules.md b/docs/source/modules/morpheus_modules.md index bf673449c7..6f5add1c05 100644 --- a/docs/source/modules/morpheus_modules.md +++ b/docs/source/modules/morpheus_modules.md @@ -1,3 +1,20 @@ + + # Morpheus Module Documentation This is the top-level documentation for all available Morpheus modules. From 831bf36a95d2eb2b2b0123bcd0c9f51bc95e1413 Mon Sep 17 00:00:00 2001 From: Devin Robison Date: Fri, 17 Mar 2023 17:24:04 -0600 Subject: [PATCH 111/157] Sphinx updates, formatting fixes, docs updates, etc... --- .../_templates/custom-module-template.rst | 1 + docs/source/conf.py | 4 +- docs/source/developer_guide/architecture.md | 102 +++++++---- docs/source/developer_guide/guides/index.rst | 3 + .../digital_fingerprinting/dfp_deployment.md | 159 +++++++++++++++++- docs/source/modules/morpheus_modules.md | 9 +- .../morpheus/dfp/modules/dfp_data_prep.py | 4 +- .../morpheus/dfp/modules/dfp_deployment.py | 51 +++++- .../morpheus/dfp/modules/dfp_inference.py | 2 +- .../dfp/modules/dfp_inference_pipe.py | 15 +- .../dfp/modules/dfp_postprocessing.py | 4 +- .../morpheus/dfp/modules/dfp_preproc.py | 37 +++- .../dfp/modules/dfp_rolling_window.py | 20 +-- .../morpheus/dfp/modules/dfp_split_users.py | 18 +- .../morpheus/dfp/modules/dfp_training.py | 12 +- .../morpheus/dfp/modules/dfp_training_pipe.py | 22 ++- morpheus/messages/message_control.py | 3 + morpheus/modules/__init__.py | 2 - morpheus/modules/file_batcher.py | 27 +-- morpheus/modules/file_to_df.py | 18 +- morpheus/modules/filter_control_message.py | 14 +- morpheus/modules/filter_detections.py | 18 +- morpheus/modules/mlflow_model_writer.py | 2 +- morpheus/modules/write_to_file.py | 4 +- morpheus/utils/module_utils.py | 6 +- 25 files changed, 411 insertions(+), 146 deletions(-) diff --git a/docs/source/_templates/custom-module-template.rst b/docs/source/_templates/custom-module-template.rst index 9a45a8c2b3..294d78de53 100644 --- a/docs/source/_templates/custom-module-template.rst +++ b/docs/source/_templates/custom-module-template.rst @@ -4,6 +4,7 @@ .. automodule:: {{ fullname }} :members: + :undoc-members: :exclude-members: {{ classes | join(", ") }} {% block attributes %} diff --git a/docs/source/conf.py b/docs/source/conf.py index 243dc7ded0..2e5cae3cd4 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -294,8 +294,8 @@ def setup(app): # The following is used by sphinx.ext.linkcode to provide links to github linkcode_resolve = make_linkcode_resolve( 'morpheus', 'https://github.com/nv-morpheus/Morpheus' - 'morpheus/-/blob/{revision}/' - '{package}/{path}#L{lineno}') + 'morpheus/-/blob/{revision}/' + '{package}/{path}#L{lineno}') # Set the default role for interpreted code (anything surrounded in `single # backticks`) to be a python object. See diff --git a/docs/source/developer_guide/architecture.md b/docs/source/developer_guide/architecture.md index 7f6ae9ef9f..29a1b402f7 100644 --- a/docs/source/developer_guide/architecture.md +++ b/docs/source/developer_guide/architecture.md @@ -18,56 +18,100 @@ limitations under the License. # Morpheus Architecture ## Overview + The organization of Morpheus can be broken down into four different layers. Working from the top down: + * Orchestration Layer - * Responsible for coordinating pipelines and facilitating communication. - * That is, monitoring pipelines, transferring messages between pipelines, starting and stopping pipelines, assigning resources to pipelines, and so on. - * Plays a large role in multi-machine pipelines but works out of the box for single-machine pipelines. + * Responsible for coordinating pipelines and facilitating communication. + * That is, monitoring pipelines, transferring messages between pipelines, starting and stopping pipelines, + assigning resources to pipelines, and so on. + * Plays a large role in multi-machine pipelines but works out of the box for single-machine pipelines. * Pipeline Layer - * Composed of one or more stages connected by edges. - * Data moves between stages using buffered channels, and the pipeline will automatically handle backpressure by monitoring the amount of data in each edge buffer. + * Composed of one or more stages connected by edges. + * Data moves between stages using buffered channels, and the pipeline will automatically handle backpressure by + monitoring the amount of data in each edge buffer. * Stage Layer - * Main building blocks in Morpheus. - * Responsible for executing a specific function on incoming data from previous stages in the pipeline. - * Isolated from each other and can be thought of as black boxes. - * Composed of one or more nodes, connected by edges. - * All nodes are guaranteed to operate on the same machine, in the same process space. + * Main building blocks in Morpheus. + * Responsible for executing a specific function on incoming data from previous stages in the pipeline. + * Isolated from each other and can be thought of as black boxes. + * Composed of one or more nodes, connected by edges. + * All nodes are guaranteed to operate on the same machine, in the same process space. +* Module Layer + * TODO: Add details about modules. * Node Layer - * Smallest building block in Morpheus. - * Each node operates on the same thread. - * Composed of one or more operators in the reactive programming style. + * Smallest building block in Morpheus. + * Each node operates on the same thread. + * Composed of one or more operators in the reactive programming style. ## Pipeline Details -Pipelines are a collection of one or more stages that are connected via edges. Data flows from one stage to the next across these edges using buffers. We utilize these buffers to allow stages to process messages at different rates. Once each stage is done processing a message, the pipeline will move it onto the next stage's buffer for processing. This process continues until the message has made it through the entire pipeline. -The main goal of the pipeline is to maximize throughput via parallel execution of the stages. So we can utilize hardware optimally and avoid processing individual messages sequentially. Given a multi-stage pipeline consisting of stages 1 and 2. Stage 1 collects its first message from its data source and begins processing it. Once Stage 1 is done with its first message, the resulting output message will be forwarded to Stage 2. At this point, Stage 1 immediately begins processing the next input to the pipeline, while Stage 2 begins work on the output of Stage 1. This allows for multiple messages to be in flight in the pipeline at a time, increasing parallelization. +Pipelines are a collection of one or more stages that are connected via edges. Data flows from one stage to the next +across these edges using buffers. We utilize these buffers to allow stages to process messages at different rates. Once +each stage is done processing a message, the pipeline will move it onto the next stage's buffer for processing. This +process continues until the message has made it through the entire pipeline. + +The main goal of the pipeline is to maximize throughput via parallel execution of the stages. So we can utilize hardware +optimally and avoid processing individual messages sequentially. Given a multi-stage pipeline consisting of stages 1 and +2. Stage 1 collects its first message from its data source and begins processing it. Once Stage 1 is done with its first +message, the resulting output message will be forwarded to Stage 2. At this point, Stage 1 immediately begins processing +the next input to the pipeline, while Stage 2 begins work on the output of Stage 1. This allows for multiple messages to +be in flight in the pipeline at a time, increasing parallelization. -Utilizing buffers between stages in this way does come at a cost. Increasing the size of the buffers helps improve parallelization by ensuring all stages have some work to do. But this also increases latency since messages can sit in a buffer waiting to be processed. The inverse is also true. Decreasing the buffer sizes improves latency, but can starve some stages of work to do, decreasing parallelization. The pipeline has to walk a fine line of keeping all stages supplied with data with the smallest buffers possible. +Utilizing buffers between stages in this way does come at a cost. Increasing the size of the buffers helps improve +parallelization by ensuring all stages have some work to do. But this also increases latency since messages can sit in a +buffer waiting to be processed. The inverse is also true. Decreasing the buffer sizes improves latency, but can starve +some stages of work to do, decreasing parallelization. The pipeline has to walk a fine line of keeping all stages +supplied with data with the smallest buffers possible. ## Stage Details -A stage is the fundamental building block in Morpheus and is responsible for performing all of the work in a pipeline. A stage can encapsulate any piece of functionality and is capable of integrating with any service or external library. This freedom allows stages to range from very small Python map functions up to very complex inference stages, which connect to services and work in multiple threads. For example, Morpheus has simple stages for actions like reading and writing to a file and more complex stages like the Triton inference stage, which can send many asynchronous inference requests using shared device memory. -While stages are very flexible, they all comprise three main pieces: identification, type inference, and node creation. +A stage is the fundamental building block in Morpheus and is responsible for performing all of the work in a pipeline. A +stage can encapsulate any piece of functionality and is capable of integrating with any service or external library. +This freedom allows stages to range from very small Python map functions up to very complex inference stages, which +connect to services and work in multiple threads. For example, Morpheus has simple stages for actions like reading and +writing to a file and more complex stages like the Triton inference stage, which can send many asynchronous inference +requests using shared device memory. -## Morpheus Modules -Modules, introduced in the 23.03 release, introduce a new method for defining units of work which are compact, -composable, nestable, and fully reusable. Once a module has been defined and registered, it can be used in new and -existing pipelines as either a new ModuleStage or loaded directly within the context of an existing stage using -`builder.load_module(...)`. +While stages are very flexible, they all comprise three main pieces: identification, type inference, and node creation. ### Identification + The stage identifier is a unique string used in both logging and creating the stage from the CLI. ### Type Inference -To perform work, each stage needs to know what type of data it will be operating on. Since Morpheus can pass any type of data from stage to stage, the pipeline must ensure compatible types at every edge connection between stages. This process is called stage type inference and is performed during the pipeline build phase. -Stage type inference is necessary because the output type of some stages may depend on the output type of the previous stage. For example, consider a simple pass through stage that passes the input message to the next stage unmodified. If our pass through stage is preceded by a stage generating a string, its output type will be a string. Instead, if it's preceded by a stage generating an integer, its output type will be an integer. +To perform work, each stage needs to know what type of data it will be operating on. Since Morpheus can pass any type of +data from stage to stage, the pipeline must ensure compatible types at every edge connection between stages. This +process is called stage type inference and is performed during the pipeline build phase. + +Stage type inference is necessary because the output type of some stages may depend on the output type of the previous +stage. For example, consider a simple pass through stage that passes the input message to the next stage unmodified. If +our pass through stage is preceded by a stage generating a string, its output type will be a string. Instead, if it's +preceded by a stage generating an integer, its output type will be an integer. -Due to the dynamic nature of the output type of a stage, stages must specify a type inference function that accepts an input type and returns the output type. Starting at the source stages, the pipeline will use this function to determine the output type of the source stages. This result will then be passed to the type inference function of the next stage, and so on until the input and output types of every stage in the pipeline have been determined. +Due to the dynamic nature of the output type of a stage, stages must specify a type inference function that accepts an +input type and returns the output type. Starting at the source stages, the pipeline will use this function to determine +the output type of the source stages. This result will then be passed to the type inference function of the next stage, +and so on until the input and output types of every stage in the pipeline have been determined. -After the build phase, the output types of stages cannot be changed. Returning a different type than specified during the build phase will result in undefined behavior. +After the build phase, the output types of stages cannot be changed. Returning a different type than specified during +the build phase will result in undefined behavior. ### Node Creation -The most important piece of a stage is node creation. The node creation function is responsible for creating the instances of the nodes which will make up a stage. Like a pipeline, stages can be built up of one or more smaller nodes connected by edges. -The difference between stages and nodes is that stages guarantee that the same machine will run all nodes in the same process space. This allows nodes to optimize the information they pass between themselves to ensure maximum performance. For example, two nodes could pass a raw GPU device pointer between them, allowing maximum performance with minimum overhead. Without this guarantee that both nodes are running in the same process space, passing such a low-level piece of information would be unsafe. +The most important piece of a stage is node creation. The node creation function is responsible for creating the +instances of the nodes which will make up a stage. Like a pipeline, stages can be built up of one or more smaller nodes +connected by edges. + +The difference between stages and nodes is that stages guarantee that the same machine will run all nodes in the same +process space. This allows nodes to optimize the information they pass between themselves to ensure maximum performance. +For example, two nodes could pass a raw GPU device pointer between them, allowing maximum performance with minimum +overhead. Without this guarantee that both nodes are running in the same process space, passing such a low-level piece +of information would be unsafe. + +## Morpheus Modules + +Modules, introduced in the 23.03 release, introduce a new method for defining units of work which are compact, +composable, nestable, and fully reusable. Once a module has been defined and registered, it can be used in new and +existing pipelines as either a new ModuleStage or loaded directly within the context of an existing stage using +`builder.load_module(...)`. diff --git a/docs/source/developer_guide/guides/index.rst b/docs/source/developer_guide/guides/index.rst index 78a486504c..4de406b22f 100644 --- a/docs/source/developer_guide/guides/index.rst +++ b/docs/source/developer_guide/guides/index.rst @@ -28,3 +28,6 @@ Developer Guides ./4_source_cpp_stage.md ./5_digital_fingerprinting.md ./6_digital_fingerprinting_reference.md + ./7_python_modules.md + ./8_cpp_modules.md + ./9_cpp_modules_reference.md diff --git a/docs/source/modules/examples/digital_fingerprinting/dfp_deployment.md b/docs/source/modules/examples/digital_fingerprinting/dfp_deployment.md index dd0f7ff8b0..3620319b5b 100644 --- a/docs/source/modules/examples/digital_fingerprinting/dfp_deployment.md +++ b/docs/source/modules/examples/digital_fingerprinting/dfp_deployment.md @@ -21,16 +21,171 @@ This module function sets up a pipeline builder instance. ### Configurable Parameters -- `training_options`: Options for the training pipeline module, including settings and configurations specific to the training process (dictionary). -- `inference_options`: Options for the inference pipeline module, including settings and configurations specific to the inference process (dictionary). +- training_options (dict): Options for the training pipeline module, including: + - timestamp_column_name (str): Name of the timestamp column used in the data + - cache_dir (str): Directory to cache the rolling window data + - batching_options (dict): Options for batching the data, including: + - end_time (datetime|str): End time of the time window + - iso_date_regex_pattern (str): Regex pattern for ISO date matching + - parser_kwargs (dict): Additional arguments for the parser + - period (str): Time period for grouping files + - sampling_rate_s (int): Sampling rate in seconds + - start_time (datetime|str): Start time of the time window + - user_splitting_options (dict): Options for splitting the data by user, including: + - fallback_username (str): User ID to use if user ID not found (default: 'generic_user') + - include_generic (bool): Include generic user ID in output (default: False) + - include_individual (bool): Include individual user IDs in output (default: False) + - only_users (list): List of user IDs to include in output, others will be excluded (default: []) + - skip_users (list): List of user IDs to exclude from output (default: []) + - timestamp_column_name (str): Name of column containing timestamps (default: 'timestamp') + - userid_column_name (str): Name of column containing user IDs (default: 'username') + - stream_aggregation_options (dict): Options for aggregating the data by stream + - preprocessing_options (dict): Options for preprocessing the data + - dfencoder_options (dict): Options for configuring the data frame encoder, used for training the model + - mlflow_writer_options (dict): Options for the MLflow model writer, responsible for saving the trained model, + including: + - model_name_formatter (str): Format string for the model name, e.g. "model_{timestamp}" + - experiment_name_formatter (str): Format string for the experiment name, e.g. "experiment_{timestamp}" + - timestamp_column_name (str): Name of the timestamp column used in the data + - conda_env (dict): Conda environment settings, including: + - channels (list): List of channels to use for the environment + - dependencies (list): List of dependencies for the environment + - pip (list): List of pip packages to install in the environment + - name (str): Name of the conda environment +- inference_options (dict): Options for the inference pipeline module, including: + - model_name_formatter (str): Format string for the model name, e.g. "model_{timestamp}" + - fallback_username (str): User ID to use if user ID not found (default: 'generic_user') + - timestamp_column_name (str): Name of the timestamp column in the input data + - batching_options (dict): Options for batching the data, including: + [omitted for brevity] + - cache_dir (str): Directory to cache the rolling window data + - detection_criteria (dict): Criteria for filtering detections, such as threshold and field_name + - inference_options (dict): Options for the inference module, including model settings and other configurations + - num_output_ports (int): Number of output ports for the module + - preprocessing_options (dict): Options for preprocessing the data, including schema and timestamp column name + - stream_aggregation_options (dict): Options for aggregating the data by stream, including: + - aggregation_span (int): The time span for the aggregation window, in seconds + - cache_to_disk (bool): Whether to cache the aggregated data to disk + - user_splitting_options (dict): Options for splitting the data by user, including: + [omitted for brevity] + - write_to_file_options (dict): Options for writing the detections to a file, such as filename and overwrite + settings ### Example JSON Configuration ```json { "training_options": { + "timestamp_column_name": "my_timestamp", + "cache_dir": "/path/to/cache/dir", + "batching_options": { + "end_time": "2023-03-17 12:00:00", + "iso_date_regex_pattern": "YYYY-MM-DD", + "parser_kwargs": { + "delimiter": "," + }, + "period": "1h", + "sampling_rate_s": 5, + "start_time": "2023-03-17 11:00:00" + }, + "user_splitting_options": { + "fallback_username": "generic_user", + "include_generic": false, + "include_individual": false, + "only_users": [ + "user1", + "user2" + ], + "skip_users": [ + "user3", + "user4" + ], + "timestamp_column_name": "timestamp", + "userid_column_name": "username" + }, + "stream_aggregation_options": { + "aggregation_span": 60, + "cache_to_disk": true + }, + "preprocessing_options": { + "option1": "value1", + "option2": "value2" + }, + "dfencoder_options": { + "option1": "value1", + "option2": "value2" + }, + "mlflow_writer_options": { + "model_name_formatter": "model_{timestamp}", + "experiment_name_formatter": "experiment_{timestamp}", + "timestamp_column_name": "my_timestamp", + "conda_env": { + "channels": [ + "conda-forge", + "defaults" + ], + "dependencies": [ + "numpy", + "pandas" + ], + "pip": [ + "tensorflow==2.5.0" + ], + "name": "my_conda_env" + } + } }, "inference_options": { + "model_name_formatter": "model_{timestamp}", + "fallback_username": "generic_user", + "timestamp_column_name": "timestamp", + "batching_options": { + "end_time": "2023-03-17 14:00:00", + "iso_date_regex_pattern": "YYYY-MM-DD", + "parser_kwargs": { + "delimiter": "," + }, + "period": "1h", + "sampling_rate_s": 5, + "start_time": "2023-03-17 13:00:00" + }, + "cache_dir": "/path/to/cache/dir", + "detection_criteria": { + "threshold": 0.5, + "field_name": "score" + }, + "inference_options": { + "option1": "value1", + "option2": "value2" + }, + "num_output_ports": 3, + "preprocessing_options": { + "option1": "value1", + "option2": "value2" + }, + "stream_aggregation_options": { + "aggregation_span": 60, + "cache_to_disk": true + }, + "user_splitting_options": { + "fallback_username": "generic_user", + "include_generic": false, + "include_individual": false, + "only_users": [ + "user1", + "user2" + ], + "skip_users": [ + "user3", + "user4" + ], + "timestamp_column_name": "timestamp", + "userid_column_name": "username" + }, + "write_to_file_options": { + "filename": "output.txt", + "overwrite": true + } } } ``` \ No newline at end of file diff --git a/docs/source/modules/morpheus_modules.md b/docs/source/modules/morpheus_modules.md index 6f5add1c05..982ff17fb8 100644 --- a/docs/source/modules/morpheus_modules.md +++ b/docs/source/modules/morpheus_modules.md @@ -15,14 +15,7 @@ See the License for the specific language governing permissions and limitations under the License. --> -# Morpheus Module Documentation - -This is the top-level documentation for all available Morpheus modules. - -## Table of Contents - -- [Core Modules](#core-modules) -- [Digital Fingerprinting Modules](#digital-fingerprinting-modules) +# Modules ## Core Modules diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_data_prep.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_data_prep.py index 06b0607aa9..890d01a912 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_data_prep.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_data_prep.py @@ -33,12 +33,12 @@ @register_module(DFP_DATA_PREP, MORPHEUS_MODULE_NAMESPACE) def dfp_data_prep(builder: mrc.Builder): """ - This module function prepares data for either inference or model training. + Prepare data for either inference or model training. Parameters ---------- builder : mrc.Builder - Pipeline budler instance. + Pipeline builder instance. Notes ---------- diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_deployment.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_deployment.py index c15e78d22f..59998f6447 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_deployment.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_deployment.py @@ -42,10 +42,55 @@ def dfp_deployment(builder: mrc.Builder): Pipeline builder instance. Notes - ---------- + ----- Configurable parameters: - - training_options (dict): Options for the training pipeline module, including settings and configurations specific to the training process. - - inference_options (dict): Options for the inference pipeline module, including settings and configurations specific to the inference process. + - training_options (dict): Options for the training pipeline module, including: + - timestamp_column_name (str): Name of the timestamp column used in the data + - cache_dir (str): Directory to cache the rolling window data + - batching_options (dict): Options for batching the data, including: + - end_time (datetime|str): End time of the time window + - iso_date_regex_pattern (str): Regex pattern for ISO date matching + - parser_kwargs (dict): Additional arguments for the parser + - period (str): Time period for grouping files + - sampling_rate_s (int): Sampling rate in seconds + - start_time (datetime|str): Start time of the time window + - user_splitting_options (dict): Options for splitting the data by user, including: + - fallback_username (str): User ID to use if user ID not found (default: 'generic_user') + - include_generic (bool): Include generic user ID in output (default: False) + - include_individual (bool): Include individual user IDs in output (default: False) + - only_users (list): List of user IDs to include in output, others will be excluded (default: []) + - skip_users (list): List of user IDs to exclude from output (default: []) + - timestamp_column_name (str): Name of column containing timestamps (default: 'timestamp') + - userid_column_name (str): Name of column containing user IDs (default: 'username') + - stream_aggregation_options (dict): Options for aggregating the data by stream + - preprocessing_options (dict): Options for preprocessing the data + - dfencoder_options (dict): Options for configuring the data frame encoder, used for training the model + - mlflow_writer_options (dict): Options for the MLflow model writer, responsible for saving the trained model, including: + - model_name_formatter (str): Format string for the model name, e.g. "model_{timestamp}" + - experiment_name_formatter (str): Format string for the experiment name, e.g. "experiment_{timestamp}" + - timestamp_column_name (str): Name of the timestamp column used in the data + - conda_env (dict): Conda environment settings, including: + - channels (list): List of channels to use for the environment + - dependencies (list): List of dependencies for the environment + - pip (list): List of pip packages to install in the environment + - name (str): Name of the conda environment + - inference_options (dict): Options for the inference pipeline module, including: + - model_name_formatter (str): Format string for the model name, e.g. "model_{timestamp}" + - fallback_username (str): User ID to use if user ID not found (default: 'generic_user') + - timestamp_column_name (str): Name of the timestamp column in the input data + - batching_options (dict): Options for batching the data, including: + [omitted for brevity] + - cache_dir (str): Directory to cache the rolling window data + - detection_criteria (dict): Criteria for filtering detections, such as threshold and field_name + - inference_options (dict): Options for the inference module, including model settings and other configurations + - num_output_ports (int): Number of output ports for the module + - preprocessing_options (dict): Options for preprocessing the data, including schema and timestamp column name + - stream_aggregation_options (dict): Options for aggregating the data by stream, including: + - aggregation_span (int): The time span for the aggregation window, in seconds + - cache_to_disk (bool): Whether to cache the aggregated data to disk + - user_splitting_options (dict): Options for splitting the data by user, including: + [omitted for brevity] + - write_to_file_options (dict): Options for writing the detections to a file, such as filename and overwrite settings """ module_config = builder.get_current_module_config() diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference.py index bd85237f25..3211555b95 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference.py @@ -42,7 +42,7 @@ def dfp_inference(builder: mrc.Builder): Parameters ---------- builder : mrc.Builder - Pipeline budler instance. + Pipeline builder instance. Notes ---------- diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference_pipe.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference_pipe.py index 3def6e56c9..0f848ee9cb 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference_pipe.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference_pipe.py @@ -44,8 +44,7 @@ @register_module(DFP_INFERENCE_PIPE, MORPHEUS_MODULE_NAMESPACE) def dfp_inference_pipe(builder: mrc.Builder): """ - This module function allows for the consolidation of multiple dfp pipeline modules relevant to inference - process into a single module. + This module function consolidates multiple dfp pipeline modules relevant to the inference process into a single module. Parameters ---------- @@ -55,16 +54,16 @@ def dfp_inference_pipe(builder: mrc.Builder): Notes ---------- Configurable parameters: - - batching_options (dict): Options for batching the data, including start and end times, sampling rate, and other settings. - - cache_dir (str): Directory to cache the rolling window data. + - batching_options (dict): Options for batching data, including start and end times, sampling rate, and other settings. + - cache_dir (str): Directory for caching rolling window data. - detection_criteria (dict): Criteria for filtering detections, such as threshold and field_name. - inference_options (dict): Options for the inference module, including model settings and other configurations. - num_output_ports (int): Number of output ports for the module. - - preprocessing_options (dict): Options for preprocessing the data, including schema and timestamp column name. - - stream_aggregation_options (dict): Options for aggregating the data by stream, including aggregation span and cache settings. + - preprocessing_options (dict): Options for preprocessing data, including schema and timestamp column name. + - stream_aggregation_options (dict): Options for aggregating data by stream, including aggregation span and cache settings. - timestamp_column_name (str): Name of the timestamp column in the input data. - - user_splitting_options (dict): Options for splitting the data by user, including filtering and user ID column name. - - write_to_file_options (dict): Options for writing the detections to a file, such as filename and overwrite settings. + - user_splitting_options (dict): Options for splitting data by user, including filtering and user ID column name. + - write_to_file_options (dict): Options for writing detections to a file, such as filename and overwrite settings. """ config = builder.get_current_module_config() diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_postprocessing.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_postprocessing.py index 909f5867a7..75bab7854a 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_postprocessing.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_postprocessing.py @@ -36,12 +36,12 @@ def dfp_postprocessing(builder: mrc.Builder): Parameters ---------- builder : mrc.Builder - Pipeline budler instance. + Pipeline builder instance. Notes ---------- Configurable parameters: - timestamp_column_name: str + - timestamp_column_name (str): Name of the timestamp column in the input data. """ config = builder.get_current_module_config() diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_preproc.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_preproc.py index 7dbffd4f44..99bbf4f3aa 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_preproc.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_preproc.py @@ -35,10 +35,33 @@ logger = logging.getLogger("morpheus.{}".format(__name__)) +def dfp_preproc_module(): + """ + This module function allows for the consolidation of multiple dfp pipeline modules relevant to inference/training + process into a single module. + + Parameters + ---------- + builder : mrc.Builder + Pipeline builder instance. + + Notes + ---------- + Configurable parameters: + - cache_dir : str (Directory used for caching intermediate results) + - timestamp_column_name : str (Name of the column containing timestamps) + - pre_filter_options : dict (Options for pre-filtering control messages) + - batching_options : dict (Options for batching files) + - user_splitting_options : dict (Options for splitting data by user) + - supported_loaders : dict (Supported data loaders for different file types) + """ + return dfp_preproc + + @register_module(DFP_PREPROC, MORPHEUS_MODULE_NAMESPACE) def dfp_preproc(builder: mrc.Builder): """ - This module function allows for the consolidation of multiple dfp pipeline modules relevant to inference/training + This module function consolidates multiple dfp pipeline modules related to inference/training process into a single module. Parameters @@ -49,12 +72,12 @@ def dfp_preproc(builder: mrc.Builder): Notes ---------- Configurable parameters: - - cache_dir : str (Directory used for caching intermediate results) - - timestamp_column_name : str (Name of the column containing timestamps) - - pre_filter_options : dict (Options for pre-filtering control messages) - - batching_options : dict (Options for batching files) - - user_splitting_options : dict (Options for splitting data by user) - - supported_loaders : dict (Supported data loaders for different file types) + - cache_dir (str): Directory for caching intermediate results + - timestamp_column_name (str): Name of the column containing timestamps + - pre_filter_options (dict): Options for pre-filtering control messages + - batching_options (dict): Options for batching files + - user_splitting_options (dict): Options for splitting data by user + - supported_loaders (dict): Supported data loaders for different file types """ config = builder.get_current_module_config() diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_rolling_window.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_rolling_window.py index a23cdf275b..d53a3d7882 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_rolling_window.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_rolling_window.py @@ -38,7 +38,7 @@ @register_module(DFP_ROLLING_WINDOW, MORPHEUS_MODULE_NAMESPACE) def dfp_rolling_window(builder: mrc.Builder): """ - This module function establishes a rolling window to maintain history. + This module function creates a rolling window to maintain history. Parameters ---------- @@ -48,15 +48,15 @@ def dfp_rolling_window(builder: mrc.Builder): Notes ----- Configurable parameters: - - aggregation_span: The time span to aggregate over (e.g., '60d' for 60 days) - - cache_dir: The directory to cache the rolling window data - - cache_to_disk: Whether to cache the rolling window data to disk (default: False) - - cache_mode: The cache mode to use, either 'batch' or 'aggregate' - 'aggregate': Cache the entire rolling window - 'batch': Cache until batch criteria is met and then flush - - timestamp_column_name: The name of the timestamp column (default: 'timestamp') - - trigger_on_min_history: The minimum number of rows required to trigger the rolling window (default: 1) - - trigger_on_min_increment: The minimum number of rows required to trigger the rolling window (default: 0) + - aggregation_span (str): Time span to aggregate over (e.g., '60d' for 60 days) + - cache_dir (str): Directory to cache rolling window data + - cache_to_disk (bool): Cache rolling window data to disk (default: False) + - cache_mode (str): Cache mode, either 'batch' or 'aggregate' + 'aggregate': Cache entire rolling window + 'batch': Cache until batch criteria met, then flush + - timestamp_column_name (str): Name of timestamp column (default: 'timestamp') + - trigger_on_min_history (int): Minimum number of rows required to trigger rolling window (default: 1) + - trigger_on_min_increment (int): Minimum number of rows required to trigger rolling window (default: 0) """ config = builder.get_current_module_config() diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_split_users.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_split_users.py index bb3a759958..96e83421a4 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_split_users.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_split_users.py @@ -35,7 +35,7 @@ @register_module(DFP_SPLIT_USERS, MORPHEUS_MODULE_NAMESPACE) def dfp_split_users(builder: mrc.Builder): """ - This module function split the data based on user Id's. + This module function splits data based on user IDs. Parameters ---------- @@ -43,15 +43,15 @@ def dfp_split_users(builder: mrc.Builder): Pipeline builder instance. Notes - ---------- + ----- Configurable parameters: - - fallback_username: The user ID to use if the user ID is not found (default: 'generic_user') - - include_generic: Whether to include a generic user ID in the output (default: False) - - include_individual: Whether to include individual user IDs in the output (default: False) - - only_users: List of user IDs to include in the output; other user IDs will be excluded (default: []) - - skip_users: List of user IDs to exclude from the output (default: []) - - timestamp_column_name: Name of the column containing timestamps (default: 'timestamp') - - userid_column_name: Name of the column containing user IDs (default: 'username') + - fallback_username (str): User ID to use if user ID not found (default: 'generic_user') + - include_generic (bool): Include generic user ID in output (default: False) + - include_individual (bool): Include individual user IDs in output (default: False) + - only_users (list): List of user IDs to include in output, others will be excluded (default: []) + - skip_users (list): List of user IDs to exclude from output (default: []) + - timestamp_column_name (str): Name of column containing timestamps (default: 'timestamp') + - userid_column_name (str): Name of column containing user IDs (default: 'username') """ config = builder.get_current_module_config() diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training.py index 9d0343f84f..7b5db17d73 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training.py @@ -35,7 +35,7 @@ @register_module(DFP_TRAINING, MORPHEUS_MODULE_NAMESPACE) def dfp_training(builder: mrc.Builder): """ - Model training is done using this module function. + This module function is used for model training. Parameters ---------- @@ -43,12 +43,12 @@ def dfp_training(builder: mrc.Builder): Pipeline builder instance. Notes - ---------- + ----- Configurable parameters: - - feature_columns: List of feature columns to train on - - epochs: Number of epochs to train for - - model_kwargs: Keyword arguments to pass to the model (see dfencoder.AutoEncoder) - - validation_size: Size of the validation set + - feature_columns (list): List of feature columns to train on + - epochs (int): Number of epochs to train for + - model_kwargs (dict): Keyword arguments to pass to the model (see dfencoder.AutoEncoder) + - validation_size (float): Size of the validation set """ config = builder.get_current_module_config() diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training_pipe.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training_pipe.py index 01dcd530e9..fe675297b8 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training_pipe.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training_pipe.py @@ -39,8 +39,7 @@ @register_module(DFP_TRAINING_PIPE, MORPHEUS_MODULE_NAMESPACE) def dfp_training_pipe(builder: mrc.Builder): """ - This module function allows for the consolidation of multiple dfp pipeline modules relevent to training - process into a single module. + This module function consolidates multiple dfp pipeline modules relevant to the training process into a single module. Parameters ---------- @@ -48,17 +47,16 @@ def dfp_training_pipe(builder: mrc.Builder): Pipeline builder instance. Notes - ---------- + ----- Configurable parameters: - - timestamp_column_name (str): Name of the timestamp column used in the data. - - cache_dir (str): Directory to cache the rolling window data. - - batching_options (dict): Options for batching the data. - - user_splitting_options (dict): Options for splitting the data by user. - - stream_aggregation_options (dict): Options for aggregating the data by stream. - - preprocessing_options (dict): Options for preprocessing the data. - - dfencoder_options (dict): Options for configuring the data frame encoder, used for training the model. - - mlflow_writer_options (dict): Options for the MLflow model writer, which is responsible for saving the trained - model. + - timestamp_column_name (str): Name of the timestamp column used in the data + - cache_dir (str): Directory to cache the rolling window data + - batching_options (dict): Options for batching the data + - user_splitting_options (dict): Options for splitting the data by user + - stream_aggregation_options (dict): Options for aggregating the data by stream + - preprocessing_options (dict): Options for preprocessing the data + - dfencoder_options (dict): Options for configuring the data frame encoder, used for training the model + - mlflow_writer_options (dict): Options for the MLflow model writer, responsible for saving the trained model """ config = builder.get_current_module_config() diff --git a/morpheus/messages/message_control.py b/morpheus/messages/message_control.py index 3b7ce22100..c37e91a046 100644 --- a/morpheus/messages/message_control.py +++ b/morpheus/messages/message_control.py @@ -17,6 +17,9 @@ class MessageControl(MessageBase, cpp_class=_messages.MessageControl): + """ + TODO: Add documentation + """ def __init__(self, *arg, **kwargs): super().__init__(*arg, **kwargs) diff --git a/morpheus/modules/__init__.py b/morpheus/modules/__init__.py index e740fb8bca..081b2ae826 100644 --- a/morpheus/modules/__init__.py +++ b/morpheus/modules/__init__.py @@ -11,5 +11,3 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. - -import morpheus._lib.modules diff --git a/morpheus/modules/file_batcher.py b/morpheus/modules/file_batcher.py index 1bc06e9d2a..dd04fe6d40 100644 --- a/morpheus/modules/file_batcher.py +++ b/morpheus/modules/file_batcher.py @@ -46,26 +46,27 @@ def file_batcher(builder: mrc.Builder): This module loads the input files, removes files that are older than the chosen window of time, and then groups the remaining files by period that fall inside the window. + Parameters ---------- - builder : mrc.Builder + builder: mrc.Builder An mrc Builder object. Notes ----- Configurable parameters: - - batching_options (dict): - - end_time (datetime|str): End time of the time window. - - iso_date_regex_pattern (str): Regex pattern for ISO date matching. - - parser_kwargs (dict): Additional arguments for the parser. - - period (str): Time period for grouping files. - - sampling_rate_s (int): Sampling rate in seconds. - - start_time (datetime|str): Start time of the time window. - - cache_dir (str): Cache directory. - - file_type (str): File type. - - filter_nulls (bool): Whether to filter null values. - - schema (dict): Data schema. - - timestamp_column_name (str): Name of the timestamp column. + - batching_options (dict): + - end_time (datetime|str): End time of the time window. + - iso_date_regex_pattern (str): Regex pattern for ISO date matching. + - parser_kwargs (dict): Additional arguments for the parser. + - period (str): Time period for grouping files. + - sampling_rate_s (int): Sampling rate in seconds. + - start_time (datetime|str): Start time of the time window. + - cache_dir (str): Cache directory. + - file_type (str): File type. + - filter_nulls (bool): Whether to filter null values. + - schema (dict): Data schema. + - timestamp_column_name (str): Name of the timestamp column. """ config = builder.get_current_module_config() diff --git a/morpheus/modules/file_to_df.py b/morpheus/modules/file_to_df.py index 179c370ff3..230f6cfea9 100644 --- a/morpheus/modules/file_to_df.py +++ b/morpheus/modules/file_to_df.py @@ -44,8 +44,8 @@ @register_module(FILE_TO_DF, MORPHEUS_MODULE_NAMESPACE) def file_to_df(builder: mrc.Builder): """ - This module reads data from the batched files into a dataframe after receiving input from the "FileBatcher" module. - In addition to loading data from the disk, it has ability to load the file content from S3 buckets. + This module reads data from batched files into a DataFrame after receiving input from the "FileBatcher" module. + It can load file content from both local disk and S3 buckets. Parameters ---------- @@ -53,14 +53,14 @@ def file_to_df(builder: mrc.Builder): mrc Builder object. Notes - ---------- + ----- Configurable parameters: - - cache_dir: Directory to cache the rolling window data - - file_type: Type of the input file - - filter_null: Whether to filter out null values - - parser_kwargs: Keyword arguments to pass to the parser - - schema: Schema of the input data - - timestamp_column_name: Name of the timestamp column + - cache_dir (str): Directory to cache the rolling window data. + - file_type (str): Type of the input file. + - filter_null (bool): Whether to filter out null values. + - parser_kwargs (dict): Keyword arguments to pass to the parser. + - schema (dict): Schema of the input data. + - timestamp_column_name (str): Name of the timestamp column. """ config = builder.get_current_module_config() diff --git a/morpheus/modules/filter_control_message.py b/morpheus/modules/filter_control_message.py index 603dff2c31..cfccbaba0d 100644 --- a/morpheus/modules/filter_control_message.py +++ b/morpheus/modules/filter_control_message.py @@ -28,20 +28,20 @@ @register_module(FILTER_CONTROL_MESSAGE, MORPHEUS_MODULE_NAMESPACE) def filter_control_message(builder: mrc.Builder): """ - When the requirements are met, this module gently discards the control messages. + This module discards control messages based on specified filtering criteria. Parameters ---------- builder : mrc.Builder - mrc Builder object. + An mrc Builder object. Notes - ---------- + ----- Configurable parameters: - - enable_task_filtering : bool (Enables filtering based on task type) - - enable_data_type_filtering : bool (Enables filtering based on data type) - - filter_task_type : str (The task type to be used as a filter) - - filter_data_type : str (The data type to be used as a filter) + - enable_task_filtering (bool): Enables filtering based on task type. + - enable_data_type_filtering (bool): Enables filtering based on data type. + - filter_task_type (str): The task type to be used as a filter. + - filter_data_type (str): The data type to be used as a filter. """ config = builder.get_current_module_config() diff --git a/morpheus/modules/filter_detections.py b/morpheus/modules/filter_detections.py index fbf745fa33..16f878961f 100644 --- a/morpheus/modules/filter_detections.py +++ b/morpheus/modules/filter_detections.py @@ -63,18 +63,18 @@ def filter_detections(builder: mrc.Builder): Parameters ---------- builder : mrc.Builder - mrc Builder object. + An mrc Builder object. Notes - ---------- + ----- Configurable parameters: - - `field_name` (str): Name of the field to filter on. Defaults to `probs`. - - `threshold` (float): Threshold value to filter on. Defaults to `0.5`. - - `filter_source` (str): Source of the filter field. Defaults to `AUTO`. - - `copy` (bool): Whether to copy the rows or slice them. Defaults to `True`. - - `schema` (dict): Schema configuration. - - `input_message_type` (str): Pickled message type. - - `encoding` (str): Encoding used to pickle the message type. + - field_name (str): Name of the field to filter on. Defaults to 'probs'. + - threshold (float): Threshold value to filter on. Defaults to 0.5. + - filter_source (str): Source of the filter field. Defaults to 'AUTO'. + - copy (bool): Whether to copy the rows or slice them. Defaults to True. + - schema (dict): Schema configuration. + - input_message_type (str): Pickled message type. + - encoding (str): Encoding used to pickle the message type. """ config = builder.get_current_module_config() diff --git a/morpheus/modules/mlflow_model_writer.py b/morpheus/modules/mlflow_model_writer.py index d3ec3b3e80..9e9e831ec1 100644 --- a/morpheus/modules/mlflow_model_writer.py +++ b/morpheus/modules/mlflow_model_writer.py @@ -135,7 +135,7 @@ def apply_model_permissions(reg_model_name: str): "access_control_list": [{ "group_name": group, "permission_level": permission } for group, - permission in databricks_permissions.items()] + permission in databricks_permissions.items()] } requests.patch(url=patch_registered_model_permissions_url, diff --git a/morpheus/modules/write_to_file.py b/morpheus/modules/write_to_file.py index f52d785559..d4b9970643 100644 --- a/morpheus/modules/write_to_file.py +++ b/morpheus/modules/write_to_file.py @@ -41,15 +41,13 @@ def write_to_file(builder: mrc.Builder): """ Write all messages to a file. - This module writes messages to a file. - Parameters ---------- builder : mrc.Builder mrc Builder object. Notes - ---------- + ----- Configurable parameters: - filename : str (Path to output file) - overwrite : bool (If true, overwrite the file if it exists) diff --git a/morpheus/utils/module_utils.py b/morpheus/utils/module_utils.py index d0832ad31a..47711f8984 100644 --- a/morpheus/utils/module_utils.py +++ b/morpheus/utils/module_utils.py @@ -80,13 +80,17 @@ def register_module(module_id, namespace): def inner_func(func): # Register a module if not exists in the registry. + @functools.wraps(func) + def wrapped_func(config, builder): # Preserve original function name + return func(config, builder) + if not registry.contains(module_id, namespace): registry.register_module(module_id, namespace, mrc_version, func) logger.debug("Module '{}' was successfully registered with '{}' namespace.".format(module_id, namespace)) else: logger.debug("Module: '{}' already exists in the given namespace '{}'".format(module_id, namespace)) - return func + return wrapped_func return inner_func From 50cccd99e9ee20d60a701524974cfbb32eea05f2 Mon Sep 17 00:00:00 2001 From: Devin Robison Date: Fri, 17 Mar 2023 17:45:31 -0600 Subject: [PATCH 112/157] Docs update --- .../digital_fingerprinting/dfp_deployment.md | 90 +++++++++---------- 1 file changed, 45 insertions(+), 45 deletions(-) diff --git a/docs/source/modules/examples/digital_fingerprinting/dfp_deployment.md b/docs/source/modules/examples/digital_fingerprinting/dfp_deployment.md index 3620319b5b..2145b4d69f 100644 --- a/docs/source/modules/examples/digital_fingerprinting/dfp_deployment.md +++ b/docs/source/modules/examples/digital_fingerprinting/dfp_deployment.md @@ -21,54 +21,54 @@ This module function sets up a pipeline builder instance. ### Configurable Parameters -- training_options (dict): Options for the training pipeline module, including: - - timestamp_column_name (str): Name of the timestamp column used in the data - - cache_dir (str): Directory to cache the rolling window data - - batching_options (dict): Options for batching the data, including: - - end_time (datetime|str): End time of the time window - - iso_date_regex_pattern (str): Regex pattern for ISO date matching - - parser_kwargs (dict): Additional arguments for the parser - - period (str): Time period for grouping files - - sampling_rate_s (int): Sampling rate in seconds - - start_time (datetime|str): Start time of the time window - - user_splitting_options (dict): Options for splitting the data by user, including: - - fallback_username (str): User ID to use if user ID not found (default: 'generic_user') - - include_generic (bool): Include generic user ID in output (default: False) - - include_individual (bool): Include individual user IDs in output (default: False) - - only_users (list): List of user IDs to include in output, others will be excluded (default: []) - - skip_users (list): List of user IDs to exclude from output (default: []) - - timestamp_column_name (str): Name of column containing timestamps (default: 'timestamp') - - userid_column_name (str): Name of column containing user IDs (default: 'username') - - stream_aggregation_options (dict): Options for aggregating the data by stream - - preprocessing_options (dict): Options for preprocessing the data - - dfencoder_options (dict): Options for configuring the data frame encoder, used for training the model - - mlflow_writer_options (dict): Options for the MLflow model writer, responsible for saving the trained model, +- `training_options` (dict): Options for the training pipeline module, including: + - `timestamp_column_name` (str): Name of the timestamp column used in the data + - `cache_dir` (str): Directory to cache the rolling window data + - `batching_options` (dict): Options for batching the data, including: + - `end_time` (datetime|str): End time of the time window + - `iso_date_regex_pattern` (str): Regex pattern for ISO date matching + - `parser_kwargs` (dict): Additional arguments for the parser + - `period` (str): Time period for grouping files + - `sampling_rate_s` (int): Sampling rate in seconds + - `start_time` (datetime|str): Start time of the time window + - `user_splitting_options` (dict): Options for splitting the data by user, including: + - `fallback_username` (str): User ID to use if user ID not found (default: 'generic_user') + - `include_generic` (bool): Include generic user ID in output (default: False) + - `include_individual` (bool): Include individual user IDs in output (default: False) + - `only_users` (list): List of user IDs to include in output, others will be excluded (default: []) + - `skip_users` (list): List of user IDs to exclude from output (default: []) + - `timestamp_column_name` (str): Name of column containing timestamps (default: 'timestamp') + - `userid_column_name` (str): Name of column containing user IDs (default: 'username') + - `stream_aggregation_options` (dict): Options for aggregating the data by stream + - `preprocessing_options` (dict): Options for preprocessing the data + - `dfencoder_options` (dict): Options for configuring the data frame encoder, used for training the model + - `mlflow_writer_options` (dict): Options for the MLflow model writer, responsible for saving the trained model, including: - - model_name_formatter (str): Format string for the model name, e.g. "model_{timestamp}" - - experiment_name_formatter (str): Format string for the experiment name, e.g. "experiment_{timestamp}" - - timestamp_column_name (str): Name of the timestamp column used in the data - - conda_env (dict): Conda environment settings, including: - - channels (list): List of channels to use for the environment - - dependencies (list): List of dependencies for the environment - - pip (list): List of pip packages to install in the environment - - name (str): Name of the conda environment -- inference_options (dict): Options for the inference pipeline module, including: - - model_name_formatter (str): Format string for the model name, e.g. "model_{timestamp}" - - fallback_username (str): User ID to use if user ID not found (default: 'generic_user') - - timestamp_column_name (str): Name of the timestamp column in the input data - - batching_options (dict): Options for batching the data, including: + - `model_name_formatter` (str): Format string for the model name, e.g. "model_{timestamp}" + - `experiment_name_formatter` (str): Format string for the experiment name, e.g. "experiment_{timestamp}" + - `timestamp_column_name` (str): Name of the timestamp column used in the data + - `conda_env` (dict): Conda environment settings, including: + - `channels` (list): List of channels to use for the environment + - `dependencies` (list): List of dependencies for the environment + - `pip` (list): List of pip packages to install in the environment + - `name` (str): Name of the conda environment +- `inference_options` (dict): Options for the inference pipeline module, including: + - `model_name_formatter` (str): Format string for the model name, e.g. "model_{timestamp}" + - `fallback_username` (str): User ID to use if user ID not found (default: 'generic_user') + - `timestamp_column_name` (str): Name of the timestamp column in the input data + - `batching_options` (dict): Options for batching the data, including: [omitted for brevity] - - cache_dir (str): Directory to cache the rolling window data - - detection_criteria (dict): Criteria for filtering detections, such as threshold and field_name - - inference_options (dict): Options for the inference module, including model settings and other configurations - - num_output_ports (int): Number of output ports for the module - - preprocessing_options (dict): Options for preprocessing the data, including schema and timestamp column name - - stream_aggregation_options (dict): Options for aggregating the data by stream, including: - - aggregation_span (int): The time span for the aggregation window, in seconds - - cache_to_disk (bool): Whether to cache the aggregated data to disk - - user_splitting_options (dict): Options for splitting the data by user, including: + - `cache_dir` (str): Directory to cache the rolling window data + - `detection_criteria` (dict): Criteria for filtering detections, such as threshold and field_name + - `inference_options` (dict): Options for the inference module, including model settings and other configurations + - `num_output_ports` (int): Number of output ports for the module + - `preprocessing_options` (dict): Options for preprocessing the data, including schema and timestamp column name + - `stream_aggregation_options` (dict): Options for aggregating the data by stream, including: + - `aggregation_span` (int): The time span for the aggregation window, in seconds + - `cache_to_disk` (bool): Whether to cache the aggregated data to disk + - `user_splitting_options` (dict): Options for splitting the data by user, including: [omitted for brevity] - - write_to_file_options (dict): Options for writing the detections to a file, such as filename and overwrite + - `write_to_file_options` (dict): Options for writing the detections to a file, such as filename and overwrite settings ### Example JSON Configuration From 70d3a0d8147b79425265c31bdb31c86b224fae7d Mon Sep 17 00:00:00 2001 From: bsuryadevara Date: Tue, 21 Mar 2023 16:50:20 -0500 Subject: [PATCH 113/157] added control message documentation --- docs/source/control_message_guide.md | 133 ++++++++++++++++++ docs/source/index.rst | 7 + docs/source/loaders/core/file_to_df_loader.md | 34 +++++ docs/source/loaders/core/fsspec_loader.md | 34 +++++ docs/source/loaders/morpheus_loaders.md | 25 ++++ morpheus/loaders/file_to_df_loader.py | 33 ++++- morpheus/loaders/fsspec_loader.py | 22 +++ morpheus/messages/message_control.py | 6 +- 8 files changed, 289 insertions(+), 5 deletions(-) create mode 100644 docs/source/control_message_guide.md create mode 100644 docs/source/loaders/core/file_to_df_loader.md create mode 100644 docs/source/loaders/core/fsspec_loader.md create mode 100644 docs/source/loaders/morpheus_loaders.md diff --git a/docs/source/control_message_guide.md b/docs/source/control_message_guide.md new file mode 100644 index 0000000000..05cc81bef0 --- /dev/null +++ b/docs/source/control_message_guide.md @@ -0,0 +1,133 @@ + + +# Control Message Documentation + +The control message is a JSON object used in the Morpheus pipeline workflow. It is wrapped in a `MessageControl` object and passed between the Morpheus stages. + +## Components + +The control message has one main component: `inputs`. The inputs component is an array of input objects, each of which represents a separate input to the pipeline. Each input object has the following structure: + +### Inputs +```json +{ + "tasks": [ + // Array of task objects + ], + "metadata": { + // Metadata object + } +} +``` + +#### Tasks + +The tasks component of each input object is an array of task objects, each of which represents a separate task to be executed on the input data. Each task object has the following structure: + +```json +{ + "type": "string", + "properties": { + // Properties object + } +} +``` + + +##### type + +The type field of the task object is a string indicating the type of task to be executed. Currently, the following task types are supported: + +- **load** : Load input data from a specified file or files +- **training** : Train a machine learning model on the input data +- **inference** : Perform inference using a trained machine learning model on the input data + +##### properties +The properties field of the task object is an object containing task-specific properties. The specific properties required for each task type are described below. + +#### Task Properties +##### load +The properties object for a `load` task has the following structure: +```json +{ + "loader_id": "string", + "files": [ + "string" + ] +} +``` + +- **loader_id** (string): The ID of the loader to be used for loading the input data. Currently, only the `fsspec` and `file_to_df` loader is supported. The user has the option to register custom loaders in the dataloader registry and utilize them in the pipeline. +- **files** (array of strings): An array of file paths or glob patterns specifying the input data to be loaded. + +##### training +The properties object for a `training` task can be included as needed. + +##### inference +The properties object for an `inference` task can be included as needed. + +#### metadata +The metadata component of each input object is an object containing metadata information. Properties defined in this metadata component can be accessed anywhere across the stages that consume `MessageControl` objects. + +- **data_type** (string): which is a string indicating the type of data being processed. The supported data types are: + - `payload` : Arbitrary input data + - `Streaming` : Streaming data + +## Example + +This example demonstrates how to add various parameters to control message JSON. Below message contains an array of three task objects: a `load` task, a `training` task, and an `inference` task. The `load` task loads input data from two files specified in the files array to a dataframe using the fsspec loader. The `training` task trains a neural network model with three layers and ReLU activation. The `inference` task performs inference using the trained model with ID `model_001`. The metadata component of the input object indicates that the input data type is `payload`. + +```json +{ + "inputs": [ + { + "tasks": [ + { + "type": "load", + "properties": { + "loader_id": "fsspec", + "files": [ + "/path/to/file1", + "/path/to/file2" + ] + } + }, + { + "type": "training", + "properties": { + "model_type": "neural_network", + "model_params": { + "num_layers": 3, + "activation": "relu" + } + } + }, + { + "type": "inference", + "properties": { + "model_id": "model_001" + } + } + ], + "metadata": { + "data_type": "payload" + } + } + ] +} +``` diff --git a/docs/source/index.rst b/docs/source/index.rst index f8abbd9c28..e1d230867b 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -111,6 +111,13 @@ Deploying Morpheus modules/morpheus_modules +.. toctree:: + :caption: Morpheus Loaders + :maxdepth: 20 + :hidden: + + loaders/morpheus_loaders + .. toctree:: :maxdepth: 20 :caption: Extra Information diff --git a/docs/source/loaders/core/file_to_df_loader.md b/docs/source/loaders/core/file_to_df_loader.md new file mode 100644 index 0000000000..650a7a7efe --- /dev/null +++ b/docs/source/loaders/core/file_to_df_loader.md @@ -0,0 +1,34 @@ + + +## File to DataFrame Loader + +This function is used to load files containing data into a dataframe. Dataframe is created by processing files either using a single thread, multiprocess, dask, or dask_thread. This the function determines the download method to use, and if it starts with "dask," it creates a dask client and uses it to process the files. Otherwise, it uses a single thread or multiprocess to process the files. This function then caches the resulting dataframe using a hash of the file paths. In addition to loading data from the disk, it has the ability to load the file content from S3 buckets. + +### Configurable Parameters + +- `id` (str): Registered loader id. + +### Example JSON Configuration + +```json +{ + "loaders": [{ + "id": "file_to_df" + }] +} +``` diff --git a/docs/source/loaders/core/fsspec_loader.md b/docs/source/loaders/core/fsspec_loader.md new file mode 100644 index 0000000000..e3d81cc284 --- /dev/null +++ b/docs/source/loaders/core/fsspec_loader.md @@ -0,0 +1,34 @@ + + +## Filesystem Spec Loader + +Loads data from external sources using the fsspec library, and returns the updated MessageControl object with payload as MessageMeta, which contains dataframe (with filenames). + +### Configurable Parameters + +- `id` (str): Registered loader id. + +### Example JSON Configuration + +```json +{ + "loaders": [{ + "id": "fsspec" + }] +} +``` diff --git a/docs/source/loaders/morpheus_loaders.md b/docs/source/loaders/morpheus_loaders.md new file mode 100644 index 0000000000..f221ae579b --- /dev/null +++ b/docs/source/loaders/morpheus_loaders.md @@ -0,0 +1,25 @@ + + +# Loaders + +Custom functions called "Loaders" can be utilized by the DataLoader Module to load data into the pipeline. The user can choose to register their own customized loader function and add it to a dataloader registry, which will then become accessible to the DataLoader module during module loading. + +## Core Loaders + +- [File to DataFrame Loader](./core/file_to_df_loader.md) +- [Filesystem Spec Loader](./core/fsspec_loader.md) diff --git a/morpheus/loaders/file_to_df_loader.py b/morpheus/loaders/file_to_df_loader.py index c62a9bc427..11c9378fae 100644 --- a/morpheus/loaders/file_to_df_loader.py +++ b/morpheus/loaders/file_to_df_loader.py @@ -24,16 +24,15 @@ import fsspec import fsspec.utils -from morpheus.messages.message_meta import MessageMeta - import pandas as pd import cudf -from morpheus.messages import MessageControl from morpheus._lib.common import FileTypes from morpheus.cli.utils import str_to_file_type from morpheus.io.deserializers import read_file_to_df +from morpheus.messages import MessageControl +from morpheus.messages.message_meta import MessageMeta from morpheus.utils.column_info import process_dataframe from morpheus.utils.loader_ids import FILE_TO_DF_LOADER from morpheus.utils.loader_utils import register_loader @@ -83,6 +82,32 @@ def close_dask_cluster(): @register_loader(FILE_TO_DF_LOADER) def file_to_df_loader(control_message: MessageControl, task: dict): + """ + This function is used to load files containing data into a dataframe. Dataframe is created by + processing files either using a single thread, multiprocess, dask, or dask_thread. This the function determines + the download method to use, and if it starts with "dask," it creates a dask client and uses it to process the files. + Otherwise, it uses a single thread or multiprocess to process the files. This function then caches the resulting + dataframe using a hash of the file paths. The dataframe is then attached as a payload to a MessageControl objec and + passing on to further stages. + + Parameters + ---------- + message : MessageControl + The MessageControl object containing the pipeline control message. + task : typing.Dict[any, any] + A dictionary representing the current task in the pipeline control message. + + Return + ------ + message : MessageControl + Updated message control object with payload as a dataframe. + + Raises + ------ + RuntimeError : + If no files matched the input strings specified in the task, or if there was an error loading the data. + + """ if task.get("strategy", "aggregate") != "aggregate": raise RuntimeError("Only 'aggregate' strategy is supported for file_to_df loader.") @@ -104,7 +129,7 @@ def file_to_df_loader(control_message: MessageControl, task: dict): cache_dir = config.get("cache_dir", None) download_method: typing.Literal["single_thread", "multiprocess", "dask", - "dask_thread"] = os.environ.get("MORPHEUS_FILE_DOWNLOAD_TYPE", "multiprocess") + "dask_thread"] = os.environ.get("MORPHEUS_FILE_DOWNLOAD_TYPE", "multiprocess") if (cache_dir is None): cache_dir = "./.cache" diff --git a/morpheus/loaders/fsspec_loader.py b/morpheus/loaders/fsspec_loader.py index 84d9f86e95..06c9f00e46 100644 --- a/morpheus/loaders/fsspec_loader.py +++ b/morpheus/loaders/fsspec_loader.py @@ -31,6 +31,28 @@ @register_loader(FSSPEC_LOADER) def fsspec_loader(message: MessageControl, task: dict) -> MessageControl: + """ + Loads data from external sources using the fsspec library, and returns the updated MessageControl + object with payload as MessageMeta, which contains dataframe (with filenames). + + Parameters + ---------- + message : MessageControl + The MessageControl object containing the pipeline control message. + task : typing.Dict[any, any] + A dictionary representing the current task in the pipeline control message. + + Return + ------ + message : MessageControl + Updated message control object with payload as a dataframe (with filenames). + + Raises + ------ + RuntimeError : + If no files matched the input strings specified in the task, or if there was an error loading the data. + + """ files = task.get("files", []) diff --git a/morpheus/messages/message_control.py b/morpheus/messages/message_control.py index c37e91a046..336b527d14 100644 --- a/morpheus/messages/message_control.py +++ b/morpheus/messages/message_control.py @@ -18,7 +18,11 @@ class MessageControl(MessageBase, cpp_class=_messages.MessageControl): """ - TODO: Add documentation + MessageControl is an object that serves as a specification of the tasks to be executed in a pipeline workflow. + The MessageControl is passed between stages of the pipeline, with each stage executing the tasks specified in + the MessageControl configuration. + + MessageControl is capable of carrying payload of the MessageMeta type. """ def __init__(self, *arg, **kwargs): From 8f77b6747234b3568276ed620f05623869f943cd Mon Sep 17 00:00:00 2001 From: bsuryadevara Date: Tue, 21 Mar 2023 17:02:02 -0500 Subject: [PATCH 114/157] added control message documentation --- docs/source/control_message_guide.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/source/control_message_guide.md b/docs/source/control_message_guide.md index 05cc81bef0..dd722dda10 100644 --- a/docs/source/control_message_guide.md +++ b/docs/source/control_message_guide.md @@ -35,7 +35,7 @@ The control message has one main component: `inputs`. The inputs component is an } ``` -#### Tasks +### Tasks The tasks component of each input object is an array of task objects, each of which represents a separate task to be executed on the input data. Each task object has the following structure: @@ -49,7 +49,7 @@ The tasks component of each input object is an array of task objects, each of wh ``` -##### type +#### type The type field of the task object is a string indicating the type of task to be executed. Currently, the following task types are supported: @@ -57,11 +57,11 @@ The type field of the task object is a string indicating the type of task to be - **training** : Train a machine learning model on the input data - **inference** : Perform inference using a trained machine learning model on the input data -##### properties +#### properties The properties field of the task object is an object containing task-specific properties. The specific properties required for each task type are described below. -#### Task Properties -##### load +##### Task Properties +###### load The properties object for a `load` task has the following structure: ```json { @@ -75,13 +75,13 @@ The properties object for a `load` task has the following structure: - **loader_id** (string): The ID of the loader to be used for loading the input data. Currently, only the `fsspec` and `file_to_df` loader is supported. The user has the option to register custom loaders in the dataloader registry and utilize them in the pipeline. - **files** (array of strings): An array of file paths or glob patterns specifying the input data to be loaded. -##### training +###### training The properties object for a `training` task can be included as needed. -##### inference +###### inference The properties object for an `inference` task can be included as needed. -#### metadata +### metadata The metadata component of each input object is an object containing metadata information. Properties defined in this metadata component can be accessed anywhere across the stages that consume `MessageControl` objects. - **data_type** (string): which is a string indicating the type of data being processed. The supported data types are: From e9f43d402859f35e02dca6e25a085edcdcbcc2f9 Mon Sep 17 00:00:00 2001 From: bsuryadevara Date: Tue, 21 Mar 2023 17:03:51 -0500 Subject: [PATCH 115/157] added control message documentation --- docs/source/control_message_guide.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/source/control_message_guide.md b/docs/source/control_message_guide.md index dd722dda10..aa75e72d7f 100644 --- a/docs/source/control_message_guide.md +++ b/docs/source/control_message_guide.md @@ -24,7 +24,7 @@ The control message is a JSON object used in the Morpheus pipeline workflow. It The control message has one main component: `inputs`. The inputs component is an array of input objects, each of which represents a separate input to the pipeline. Each input object has the following structure: ### Inputs -```json +``` { "tasks": [ // Array of task objects @@ -39,7 +39,7 @@ The control message has one main component: `inputs`. The inputs component is an The tasks component of each input object is an array of task objects, each of which represents a separate task to be executed on the input data. Each task object has the following structure: -```json +``` { "type": "string", "properties": { @@ -63,7 +63,7 @@ The properties field of the task object is an object containing task-specific pr ##### Task Properties ###### load The properties object for a `load` task has the following structure: -```json +``` { "loader_id": "string", "files": [ From 28259300f037aa3acdce5e413cc6e33e0a06e720 Mon Sep 17 00:00:00 2001 From: bsuryadevara Date: Tue, 21 Mar 2023 17:16:12 -0500 Subject: [PATCH 116/157] added control message documentation --- docs/source/control_message_guide.md | 47 +++++++++++----------------- 1 file changed, 19 insertions(+), 28 deletions(-) diff --git a/docs/source/control_message_guide.md b/docs/source/control_message_guide.md index aa75e72d7f..96b12982b3 100644 --- a/docs/source/control_message_guide.md +++ b/docs/source/control_message_guide.md @@ -49,42 +49,33 @@ The tasks component of each input object is an array of task objects, each of wh ``` -#### type +- `type` : The type field of the task object is a string indicating the type of task to be executed. Currently, the following task types are supported: -The type field of the task object is a string indicating the type of task to be executed. Currently, the following task types are supported: + - `load` : Load input data from a specified file or files + - `training` : Train a machine learning model on the input data + - `inference` : Perform inference using a trained machine learning model on the input data -- **load** : Load input data from a specified file or files -- **training** : Train a machine learning model on the input data -- **inference** : Perform inference using a trained machine learning model on the input data +- `properties` : The properties field of the task object is an object containing task-specific properties. The specific properties required for each task type are described below. -#### properties -The properties field of the task object is an object containing task-specific properties. The specific properties required for each task type are described below. + - The properties object for a `load` task has the following structure: + ``` + { + "loader_id": "string", + "files": [ + "string" + ] + } + ``` -##### Task Properties -###### load -The properties object for a `load` task has the following structure: -``` -{ - "loader_id": "string", - "files": [ - "string" - ] -} -``` - -- **loader_id** (string): The ID of the loader to be used for loading the input data. Currently, only the `fsspec` and `file_to_df` loader is supported. The user has the option to register custom loaders in the dataloader registry and utilize them in the pipeline. -- **files** (array of strings): An array of file paths or glob patterns specifying the input data to be loaded. - -###### training -The properties object for a `training` task can be included as needed. + - `loader_id` (string): The ID of the loader to be used for loading the input data. Currently, only the `fsspec` and `file_to_df` loader is supported. The user has the option to register custom loaders in the dataloader registry and utilize them in the pipeline. + - `files` (array of strings): An array of file paths or glob patterns specifying the input data to be loaded. -###### inference -The properties object for an `inference` task can be included as needed. + - Incorporate key and value updates to properties objects as required for `training` and `inference` tasks. There is no specified format. -### metadata +### Metadata The metadata component of each input object is an object containing metadata information. Properties defined in this metadata component can be accessed anywhere across the stages that consume `MessageControl` objects. -- **data_type** (string): which is a string indicating the type of data being processed. The supported data types are: +- *data_type* (string): which is a string indicating the type of data being processed. The supported data types are: - `payload` : Arbitrary input data - `Streaming` : Streaming data From 95ccc20eba7cf6894e3a200d612ecdc62acb94b8 Mon Sep 17 00:00:00 2001 From: bsuryadevara Date: Tue, 21 Mar 2023 17:18:28 -0500 Subject: [PATCH 117/157] added control message documentation --- docs/source/control_message_guide.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/source/control_message_guide.md b/docs/source/control_message_guide.md index 96b12982b3..7da83c3269 100644 --- a/docs/source/control_message_guide.md +++ b/docs/source/control_message_guide.md @@ -67,15 +67,15 @@ The tasks component of each input object is an array of task objects, each of wh } ``` - - `loader_id` (string): The ID of the loader to be used for loading the input data. Currently, only the `fsspec` and `file_to_df` loader is supported. The user has the option to register custom loaders in the dataloader registry and utilize them in the pipeline. - - `files` (array of strings): An array of file paths or glob patterns specifying the input data to be loaded. + - `loader_id` : The ID of the loader to be used for loading the input data. Currently, only the `fsspec` and `file_to_df` loader is supported. The user has the option to register custom loaders in the dataloader registry and utilize them in the pipeline. + - `files` : An array of file paths or glob patterns specifying the input data to be loaded. - Incorporate key and value updates to properties objects as required for `training` and `inference` tasks. There is no specified format. ### Metadata The metadata component of each input object is an object containing metadata information. Properties defined in this metadata component can be accessed anywhere across the stages that consume `MessageControl` objects. -- *data_type* (string): which is a string indicating the type of data being processed. The supported data types are: +- `data_type` : which is a string indicating the type of data being processed. The supported data types are: - `payload` : Arbitrary input data - `Streaming` : Streaming data From b29cdcd7c0f3bf87ce10b5924faf6081d8561338 Mon Sep 17 00:00:00 2001 From: bsuryadevara Date: Tue, 21 Mar 2023 17:29:26 -0500 Subject: [PATCH 118/157] trivial changes to loader docs --- morpheus/loaders/file_to_df_loader.py | 2 +- morpheus/loaders/fsspec_loader.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/morpheus/loaders/file_to_df_loader.py b/morpheus/loaders/file_to_df_loader.py index 11c9378fae..5a01d83dd2 100644 --- a/morpheus/loaders/file_to_df_loader.py +++ b/morpheus/loaders/file_to_df_loader.py @@ -100,7 +100,7 @@ def file_to_df_loader(control_message: MessageControl, task: dict): Return ------ message : MessageControl - Updated message control object with payload as a dataframe. + Updated message control object with payload as a MessageMeta. Raises ------ diff --git a/morpheus/loaders/fsspec_loader.py b/morpheus/loaders/fsspec_loader.py index 06c9f00e46..0922dfa463 100644 --- a/morpheus/loaders/fsspec_loader.py +++ b/morpheus/loaders/fsspec_loader.py @@ -45,7 +45,7 @@ def fsspec_loader(message: MessageControl, task: dict) -> MessageControl: Return ------ message : MessageControl - Updated message control object with payload as a dataframe (with filenames). + Updated message control object with payload as a MessageMeta with dataframe containing file names. Raises ------ From 4ec987916074f5d80dbcf62dec4c249358406ec8 Mon Sep 17 00:00:00 2001 From: bsuryadevara Date: Tue, 21 Mar 2023 17:32:43 -0500 Subject: [PATCH 119/157] trivial changes to loader docs --- morpheus/loaders/file_to_df_loader.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/morpheus/loaders/file_to_df_loader.py b/morpheus/loaders/file_to_df_loader.py index 5a01d83dd2..793bea2630 100644 --- a/morpheus/loaders/file_to_df_loader.py +++ b/morpheus/loaders/file_to_df_loader.py @@ -84,11 +84,11 @@ def close_dask_cluster(): def file_to_df_loader(control_message: MessageControl, task: dict): """ This function is used to load files containing data into a dataframe. Dataframe is created by - processing files either using a single thread, multiprocess, dask, or dask_thread. This the function determines + processing files either using a single thread, multiprocess, dask, or dask_thread. This function determines the download method to use, and if it starts with "dask," it creates a dask client and uses it to process the files. Otherwise, it uses a single thread or multiprocess to process the files. This function then caches the resulting - dataframe using a hash of the file paths. The dataframe is then attached as a payload to a MessageControl objec and - passing on to further stages. + dataframe using a hash of the file paths. The dataframe is wrapped in a MessageMeta and then attached as a payload + to a MessageControl objec and passing on to further stages. Parameters ---------- From 8d95b4dba16430d912211a5f73ad3a603c829d58 Mon Sep 17 00:00:00 2001 From: Bhargav Suryadevara Date: Thu, 23 Mar 2023 15:07:32 -0500 Subject: [PATCH 120/157] updated demo GUI and added docs --- .../demo/cm_app/static/dfp.png | Bin 0 -> 242002 bytes .../demo/cm_app/static/submit_messages.css | 25 +++--- .../demo/cm_app/static/training.css | 2 +- .../demo/cm_app/templates/review/results.html | 2 +- .../cm_app/templates/submit_messages.html | 4 +- .../demo/cm_app/templates/training.html | 2 +- .../demo/images/dfp_training_default.png | Bin 0 -> 286238 bytes .../images/dfp_training_msg_submit_resp.png | Bin 0 -> 27849 bytes .../demo/images/dfp_training_user_values.png | Bin 0 -> 288255 bytes .../demo/training_message.md | 74 ++++++++++++++++++ 10 files changed, 91 insertions(+), 18 deletions(-) create mode 100644 examples/digital_fingerprinting/demo/cm_app/static/dfp.png create mode 100644 examples/digital_fingerprinting/demo/images/dfp_training_default.png create mode 100644 examples/digital_fingerprinting/demo/images/dfp_training_msg_submit_resp.png create mode 100644 examples/digital_fingerprinting/demo/images/dfp_training_user_values.png create mode 100644 examples/digital_fingerprinting/demo/training_message.md diff --git a/examples/digital_fingerprinting/demo/cm_app/static/dfp.png b/examples/digital_fingerprinting/demo/cm_app/static/dfp.png new file mode 100644 index 0000000000000000000000000000000000000000..789a84a2e6ed7bde3e700587ef6bd9e6dbc5b051 GIT binary patch literal 242002 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⁢l0y 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

XE!>)hdLu^p^3LrwDJQSu3sP~*IJ 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%0D` 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 Hu0mjfM|}$0 literal 0 HcmV?d00001 diff --git a/examples/digital_fingerprinting/demo/cm_app/static/submit_messages.css b/examples/digital_fingerprinting/demo/cm_app/static/submit_messages.css index fd3003b764..49981f0505 100644 --- a/examples/digital_fingerprinting/demo/cm_app/static/submit_messages.css +++ b/examples/digital_fingerprinting/demo/cm_app/static/submit_messages.css @@ -1,11 +1,14 @@ -h1 { - position: relative; - margin: 0; - padding: 0 20px; - line-height: 150px; - font-size: 36px; +h3 { + text-align: left; + color: black; + font-size: 45px; + font-family: 'Shift', sans-serif; font-weight: bold; - color: rgb(255, 255, 255); + padding-top:0px; + padding-bottom:0px; + background-color: #8bb521; + margin-top: 340px; + margin-left: auto; } @@ -33,10 +36,6 @@ select { border-radius: 5px; } -#banner { - width: 100%; - } - /* Style for the button elements */ button { margin-top: 10px; @@ -92,9 +91,9 @@ input { border-radius: 5px; }.banner { position:relative; - height: 200px; + height: 320px; max-width: 100%; - background: url("https://developer-blogs.nvidia.com/wp-content/uploads/2022/10/router-box-featured.jpg"); + background: url("../static/dfp.png"); background-size:auto; display: flex; justify-content: center; diff --git a/examples/digital_fingerprinting/demo/cm_app/static/training.css b/examples/digital_fingerprinting/demo/cm_app/static/training.css index 6645ac1457..f598881cb1 100644 --- a/examples/digital_fingerprinting/demo/cm_app/static/training.css +++ b/examples/digital_fingerprinting/demo/cm_app/static/training.css @@ -31,4 +31,4 @@ form { } button[type=button]:hover { background-color: #380e0ec1; -} \ No newline at end of file +} diff --git a/examples/digital_fingerprinting/demo/cm_app/templates/review/results.html b/examples/digital_fingerprinting/demo/cm_app/templates/review/results.html index f2b8c304ca..9608e58ce4 100644 --- a/examples/digital_fingerprinting/demo/cm_app/templates/review/results.html +++ b/examples/digital_fingerprinting/demo/cm_app/templates/review/results.html @@ -10,7 +10,7 @@

diff --git a/examples/digital_fingerprinting/demo/cm_app/templates/submit_messages.html b/examples/digital_fingerprinting/demo/cm_app/templates/submit_messages.html index bbf715da35..018145ae19 100644 --- a/examples/digital_fingerprinting/demo/cm_app/templates/submit_messages.html +++ b/examples/digital_fingerprinting/demo/cm_app/templates/submit_messages.html @@ -8,7 +8,7 @@
@@ -21,4 +21,4 @@

DFP Integrated Training Demo

- \ No newline at end of file + diff --git a/examples/digital_fingerprinting/demo/cm_app/templates/training.html b/examples/digital_fingerprinting/demo/cm_app/templates/training.html index c9d96365be..c3b9246fa9 100644 --- a/examples/digital_fingerprinting/demo/cm_app/templates/training.html +++ b/examples/digital_fingerprinting/demo/cm_app/templates/training.html @@ -9,7 +9,7 @@
diff --git a/examples/digital_fingerprinting/demo/images/dfp_training_default.png b/examples/digital_fingerprinting/demo/images/dfp_training_default.png new file mode 100644 index 0000000000000000000000000000000000000000..e7b73a3002f68fcff9a23a25ed410efe56b1e43f GIT binary patch literal 286238 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

;sv4l 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 N4{v6d{M91wTS>k9CGl0C;wJLj; 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

ic+2Fe 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 FzW}3ee|P`@ literal 0 HcmV?d00001 diff --git a/examples/digital_fingerprinting/demo/images/dfp_training_user_values.png b/examples/digital_fingerprinting/demo/images/dfp_training_user_values.png new file mode 100644 index 0000000000000000000000000000000000000000..9526da5df6dae4c392b8a266a156679bedaa17ac GIT binary patch literal 288255 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�d0;n&%Q$?2FzY`CHlS8o8(yryA@ZkOkwJxzO%WY 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

YVO(oSt4BU|9a%<$Zv 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 z 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 L)TZ3De)PWp19 Date: Thu, 23 Mar 2023 14:14:27 -0600 Subject: [PATCH 121/157] Checkpoint -- Modular DFP pipeline walkthrough --- ...modular_pipeline_digital_fingerprinting.md | 430 ++++++++++++++++++ docs/source/modules/core/data_loader.md | 0 2 files changed, 430 insertions(+) create mode 100644 docs/source/developer_guide/guides/modular_pipeline_digital_fingerprinting.md create mode 100644 docs/source/modules/core/data_loader.md diff --git a/docs/source/developer_guide/guides/modular_pipeline_digital_fingerprinting.md b/docs/source/developer_guide/guides/modular_pipeline_digital_fingerprinting.md new file mode 100644 index 0000000000..49967ef7b9 --- /dev/null +++ b/docs/source/developer_guide/guides/modular_pipeline_digital_fingerprinting.md @@ -0,0 +1,430 @@ +# Introduction to Digital Fingerprinting Pipeline in Morpheus + +## Table of Contents + +1. [Introduction](#introduction) +2. [Setting up Morpheus](#setting-up-morpheus) +2. [Morpheus Modules](#morpheus-modules) +3. [dfp_deployment](#dfp_deployment) + 1. [fsspec_dataloader](#fsspec_dataloader) +4. [dfp_training_pipeline](#dfp_training_pipeline) + 1. [dfp_preproc](#dfp_preproc) + 1. [filter_control_messages](#filter_control_messages) + 2. [file_batcher](#file_batcher) + 3. [file_to_df_dataloader](#file_to_df_dataloader) + 4. [dfp_split_users](#dfp_split_users) + 2. [dfp_rolling_window](#dfp_rolling_window) + 3. [dfp_data_prep](#dfp_data_prep) + 4. [dfp_inference](#dfp_inference) + 5. [filter_detections](#filter_detections) + 6. [dfp_post_proc](#dfp_post_proc) + 7. [serialize](#serialize) + 8. [write_to_file](#write_to_file) +5. [dfp_inference_pipeline](#dfp_inference_pipeline) + 1. [dfp_preproc](#dfp_preproc-1) + 1. [filter_control_messages](#filter_control_messages-1) + 2. [file_batcher](#file_batcher-1) + 3. [file_to_df_dataloader](#file_to_df_dataloader-1) + 4. [dfp_split_users](#dfp_split_users-1) + 2. [dfp_rolling_window](#dfp_rolling_window-1) + 3. [dfp_data_prep](#dfp_data_prep-1) + 4. [dfp_training](#dfp_training) + 5. [mlflow_model_writer](#mlflow_model_writer) +6. [Combining modules and creating the pipeline](#combining-modules-and-creating-the-pipeline) +7. [Conclusion](#conclusion) + +## Introduction + +### Motivation + + +This document presents the adaptation of the Digital Fingerprinting pipeline in Morpheus from the existing stage-based +approach to one that is module-based; this process will provide a basis to work through motivation and +usage examples for number of new features found in the 23.03 release. The updated pipeline incorporates extensions +to facilitate event-driven workflows and human-in-the-loop interactions through the use of control messages. +Moreover, it introduces a dynamic method for acquiring and loading data, further enhancing the pipeline's capabilities. + +The pipeline comprises a series of interconnected modules designed to create a versatile split processing pipeline. +This design enables the reception and processing of control messages to perform tasks such as inference against observed +events using either generic or user-specific models. Additionally, the pipeline can train new models based on aggregated +or predefined training data, offering a more adaptable and user-friendly experience. + +### Overview + +At a high level, the pipeline consists of three parts: the front-end file list loader that reads new control messages +from a Kafka topic and expands the described data sources to be processed, and the training and inference pipelines that +process the data. The updated pipeline enables the reception and processing of control messages, allowing for tasks such +as inference against observed events using generic or user-specific models. It also allows for the training of new +models based on aggregated or predefined training data. + +The front-end loader outputs one or more control messages that are passed to the training and inference pipelines. +Messages are either dropped or passed to the next module in the pipeline based on the message type. The updated pipeline +implicitly supports two distinct workflows: inference and training. However, because of the use of control messages, it +can also support a hybrid data processing workflow that handles both streaming data processed in real-time, aggregated, +batched, and subsequently used for training, as well as interleaved self-contained training tasks that specify the +training data to be used and are able to bypass batching and aggregation stages. + +Moreover, the updated pipeline supports human-in-the-loop workflows, such as the ability to manually trigger training or +inference tasks against a specific set of data, and the capacity for real-time labeling of production inference events +that can be injected back into the training pipeline. + +The following content will track the pipeline declared in +`examples/digitial_fingerprinting/production/dfp_morpheus_streaming_pipeline.py` + +```python +# Setup and command line argument parsing +... + +# Create an empty pipeline +pipeline = Pipeline(config) + +# Create our Kafka source stage that we can read control messages from. +source_stage = pipeline.add_stage( + ControlMessageKafkaSourceStage(config, + bootstrap_servers=kwargs["bootstrap_servers"], + input_topic=kwargs["input_topic"], + group_id=kwargs["group_id"], + poll_interval=kwargs["poll_interval"], + disable_commit=kwargs["disable_commit"], + disable_pre_filtering=kwargs["disable_pre_filtering"])) + +# Create our DFP deployment stage that will load our Digital Fingerprinting module into the pipeline. +dfp_deployment_stage = pipeline.add_stage( + MultiPortModulesStage(config, + dfp_deployment_module_config, + input_port_name="input", + output_port_name_prefix="output", + num_output_ports=num_output_ports)) + +# Connect the source stage to the DFP deployment module +pipeline.add_edge(source_stage, dfp_deployment_stage) + +# Run the pipeline +pipeline.run() +``` + +## Setting up Morpheus + + +For a full introduction in how to set up and run morpheus, please refer to +the [Getting Started]() guide. + +## Morpheus Modules + + +For a full introduction to Morpheus modules, please refer to the [Python Modules](7_python_modules.md) +and [C++ Modules](8_cpp_modules.md) guides. + +## DFP Deployment + + +Source: `examples/digitial_fingerprinting/production/morpheus/dfp/modules/dfp_deployment.py` + +This is the top level module that encapsulates the entire Digital Fingerprinting pipeline, it is primarily +responsible for wrapping the training and inference pipelines, providing the correct module interface, and doing +some configuration pre-processing. Since this module is monolithic, it supports a significant number of +configuration options; however, the majority of these have intelligent defaults and are not required to be specified. + +The module consists of three chained sub-modules: + +- `fs_spec_dataloader` + - Responsible for expanding data source declarations into individual files that can be processed by the pipeline. +- `dfp_training` + - Connected to the output of the preprocessing stage. Responsible for handling training based control messages. +- `dfp_inference` + - Connected to the output of the preprocessing stage. Responsible for handling inference based control messages. + +For a complete reference, see: [DFP Deployment](./docs/source/modules/examples/digital_fingerprinting/dfp_deployment.md) + +```python +@register_module(DFP_DEPLOYMENT, MORPHEUS_MODULE_NAMESPACE) +def dfp_deployment(builder: mrc.Builder): + # Setup and configuration parsing + ... + + # Make an edge between modules + builder.make_edge(fsspec_dataloader_module.output_port("output"), broadcast) + builder.make_edge(broadcast, dfp_training_pipe_module.input_port("input")) + builder.make_edge(broadcast, dfp_inference_pipe_module.input_port("input")) + + out_streams = [dfp_training_pipe_module.output_port("output"), dfp_inference_pipe_module.output_port("output")] + + # Register input port for a module. + builder.register_module_input("input", fsspec_dataloader_module.input_port("input")) +``` + +## FS Spec Dataloader + + +Source: `morpheus/loaders/fsspec_loader.py` + +This is an instance of the new DataLoader module, utilizing a pre-defined 'fsspec' style loader. The module is used to +transform regex specified file lists into individual file paths and update the control message with those paths. + +For a complete reference, +see: [DataLoader Module](./docs/source/modules/core/data_loader.md) + +## DFP Training and Inference Pipelines + + +There are a number of modules that are used in both the training and inference pipelines, but which are be +configured independently. We'll introduce Shared modules here and then dive into the unique modules for each pipeline. + +### DFP Preprocessing + + +Source: `examples/digitial_fingerprinting/production/morpheus/dfp/modules/dfp_preproc.py` + +The dfp_preproc module is a functional component within the MORPHEUS framework that combines multiple data filtering and +processing (dfp) pipeline modules related to inference and training. This module simplifies the pipeline by +consolidating various modules into a single, cohesive unit. The dfp_preproc module offers configurability for parameters +such as cache directory, timestamp column name, pre-filter options, batching options, user splitting options, and +supported data loaders for different file types. + +The module itself consists of a series of chained sub-modules, which are connected in a logical sequence: + +- `filter_control_message_module` + - Responsible for early filtering of control messages that should be not processed by the pipeline. +- `file_batcher_module` + - Responsible for batching files, either into a single control message in the case of an encapsulated training + message, + or into a series of control messages in the of streaming data. +- `file_to_df_dataloader_module` + - Responsible for file retrieval and insertion into a cuDF dataframe. +- `dfp_split_users_module` + - Responsible for splitting the dataframe into a series of dataframes, one per user. + +For a complete reference, see: [DFP Preproc](./docs/source/modules/examples/digital_fingerprinting/dfp_preproc.md) + +```python +@register_module(DFP_PREPROC, MORPHEUS_MODULE_NAMESPACE) +def dfp_preproc(builder: mrc.Builder): + # Setup and configuration parsing + ... + + # Connect the modules. + builder.make_edge(filter_control_message_module.output_port("output"), file_batcher_module.input_port("input")) + builder.make_edge(file_batcher_module.output_port("output"), file_to_df_dataloader_module.input_port("input")) + builder.make_edge(file_to_df_dataloader_module.output_port("output"), dfp_split_users_module.input_port("input")) + + # Register input and output port for a module. + builder.register_module_input("input", filter_control_message_module.input_port("input")) + builder.register_module_output("output", dfp_split_users_module.output_port("output")) + +``` + +#### Control Message Filter + + +Source: `morpheus/modules/filter_control_message.py` + +The filter_control_message module is a component designed to discard control messages based on specified filtering +criteria. This module allows users to configure filtering options such as task type and data type. When either task +filtering or data type filtering is enabled, the module processes control messages to verify if they meet the +specified criteria. If the control message does not match the criteria, it is discarded. The module uses a node +function to filter and process control messages, and registers input and output ports to facilitate seamless +integration into the data processing pipeline. + +For a complete reference, see: [Filter Control Message](./modules/core/filter_control_message.md) + +#### File Batcher + + +Source: `morpheus/modules/file_batcher.py` + +The file_batcher module is a component that is responsible for loading input files, filtering out +files older than the specified time window, and grouping the remaining files by periods that fall within the time +window. This module offers configurability for parameters such as batching options, cache directory, file type, +filtering null values, data schema, and the timestamp column name. The file_batcher module processes control messages, +validates them, and generates a list of files with their timestamps. The module then groups files by the given period, +creates control messages for each batch, and sends them downstream for further processing. A node function is used to +handle the processing of control messages, and input and output ports are registered to integrate the module into the +data processing pipeline seamlessly. + +The file batcher is one of the first pipeline components that begins to differ more substantially from the previous +raw-data pipeline, prior to 23.03. In addition to its previous functionality, the file batcher is now control +message aware, and can handle both streaming and encapsulated control messages, a property denoted by the `data_type` +property of the control message's metadata being set as either `streaming` or `payload`. Additionally, the file +batcher's default processing criteria for `period`, `sampling_rate_s`, `start_time`, and `end_time` can now be +overridden by their corresponding values in the control message's `batching_options` metadata entry. + +In the case of streaming data, the file batcher will operate as it did previously, grouping files by the specified +by the `period`, `sampling_rate_s`, `start_time`, and `end_time` properties, creating a control message for each +batch, and forwarding them downstream. In the case of encapsulated data, the file batcher will operate similarly, but +will only create a single control message for the entire payload, and forward it downstream. In this way, it is +possible to attach all the necessary training data to a given training task, and skip any downstream aggregation. + +For a complete reference, see: [File Batcher](./docs/source/modules/core/file_batcher.md) + +```python +@register_module(FILE_BATCHER, MORPHEUS_MODULE_NAMESPACE) +def file_batcher(builder: mrc.Builder): + # Setup and configuration parsing + ... +``` + +#### File to DF DataLoader + + +Source: `morpheus/loaders/file_to_df_dataloader.py` + +This is an instance of the new DataLoader module, utilizing a pre-defined 'file_to_df' style loader. The module is +used to process 'load' tasks that reference files which need to be retrieved, possibly cached, and then loaded into a +cuDF dataframe with is set as the control message payload. + +For a complete reference, +see: [DataLoader Module](./docs/source/modules/core/data_loader.md) + +#### dfp_split_users + + +Source: `examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_split_users.py` + +The dfp_split_users module is responsible for splitting the input data based on +user IDs. The module provides configuration options, such as fallback username, include generic user, include individual +users, and specify lists of user IDs to include or exclude in the output. + +The module processes control messages by extracting the user information from the message +payload, filtering the data based on the provided configuration, and splitting the data by user ID. For each user ID, +the function generates a new control message containing the corresponding data and sends it downstream for further +processing. + +For a complete reference, +see: [DFP Split Users](./docs/source/modules/examples/digital_fingerprinting/dfp_split_users.md) + +```python +@register_module(DFP_SPLIT_USERS, MORPHEUS_MODULE_NAMESPACE) +def dfp_split_users(builder: mrc.Builder): + # Setup and configuration parsing + ... +``` + +### dfp_rolling_window + + + +## DFP Training Pipeline + + + +The DFP Training Pipe module is a consolidated module that integrates several DFP pipeline modules that are essential to +the training process. This module function provides a single entry point to the training pipeline, simplifying the +process of training a model. The module offers configurable parameters for various stages in the pipeline, including +data batching, data preprocessing, and data encoding for model training. Additionally, the MLflow model writer options +allow for the trained model to be saved for future use. + +For a complete reference, see: [DFP Training Pipe](modules/examples/digital_fingerprinting/dfp_training_pipe.md) + +```python +@register_module(DFP_TRAINING_PIPE, MORPHEUS_MODULE_NAMESPACE) +def dfp_training_pipe(builder: mrc.Builder): + # Setup and config parsing + ... + + # Make an edge between the modules. + builder.make_edge(preproc_module.output_port("output"), dfp_rolling_window_module.input_port("input")) + builder.make_edge(dfp_rolling_window_module.output_port("output"), dfp_data_prep_module.input_port("input")) + builder.make_edge(dfp_data_prep_module.output_port("output"), dfp_training_module.input_port("input")) + builder.make_edge(dfp_training_module.output_port("output"), mlflow_model_writer_module.input_port("input")) + + # Register input and output port for a module. + builder.register_module_input("input", preproc_module.input_port("input")) + builder.register_module_output("output", mlflow_model_writer_module.output_port("output")) +``` + +### DFP Preprocessing + + +Source: `morpheus/modules/examples/digital_fingerprinting/dfp_preproc.py` + +#### filter_control_messages + + + +#### file_batcher + + + +#### file_to_df_dataloader + + + +#### dfp_split_users + + + +### dfp_rolling_window + + + +### dfp_data_prep + + + +### dfp_inference + + + +### filter_detections + + + +### dfp_post_proc + + + +### serialize + + + +### write_to_file + + + +## dfp_inference_pipeline + + + +### dfp_preproc + + + +#### filter_control_messages + + + +#### file_batcher + + + +#### file_to_df_dataloader + + + +#### dfp_split_users + + + +### dfp_rolling_window + + + +### dfp_data_prep + + + +### dfp_training + + + +### mlflow_model_writer + + + +## Combining modules and creating the pipeline + + + +## Conclusion + + diff --git a/docs/source/modules/core/data_loader.md b/docs/source/modules/core/data_loader.md new file mode 100644 index 0000000000..e69de29bb2 From 332ef0f89dffd82b1a0d0969556b59a701c9c2dd Mon Sep 17 00:00:00 2001 From: Devin Robison Date: Thu, 23 Mar 2023 15:09:35 -0600 Subject: [PATCH 122/157] Checkpoint Modular Pipeline examples updates --- ...modular_pipeline_digital_fingerprinting.md | 275 ++++++++++++++---- 1 file changed, 211 insertions(+), 64 deletions(-) diff --git a/docs/source/developer_guide/guides/modular_pipeline_digital_fingerprinting.md b/docs/source/developer_guide/guides/modular_pipeline_digital_fingerprinting.md index 49967ef7b9..ef6ee25446 100644 --- a/docs/source/developer_guide/guides/modular_pipeline_digital_fingerprinting.md +++ b/docs/source/developer_guide/guides/modular_pipeline_digital_fingerprinting.md @@ -115,7 +115,7 @@ the [Getting Started]() guide. For a full introduction to Morpheus modules, please refer to the [Python Modules](7_python_modules.md) and [C++ Modules](8_cpp_modules.md) guides. -## DFP Deployment +> ## DFP Deployment Source: `examples/digitial_fingerprinting/production/morpheus/dfp/modules/dfp_deployment.py` @@ -153,7 +153,7 @@ def dfp_deployment(builder: mrc.Builder): builder.register_module_input("input", fsspec_dataloader_module.input_port("input")) ``` -## FS Spec Dataloader +> ### FS Spec Dataloader Source: `morpheus/loaders/fsspec_loader.py` @@ -170,7 +170,7 @@ see: [DataLoader Module](./docs/source/modules/core/data_loader.md) There are a number of modules that are used in both the training and inference pipelines, but which are be configured independently. We'll introduce Shared modules here and then dive into the unique modules for each pipeline. -### DFP Preprocessing +> ### DFP Preprocessing Source: `examples/digitial_fingerprinting/production/morpheus/dfp/modules/dfp_preproc.py` @@ -213,7 +213,7 @@ def dfp_preproc(builder: mrc.Builder): ``` -#### Control Message Filter +> ### Control Message Filter Source: `morpheus/modules/filter_control_message.py` @@ -227,7 +227,7 @@ integration into the data processing pipeline. For a complete reference, see: [Filter Control Message](./modules/core/filter_control_message.md) -#### File Batcher +> ### File Batcher Source: `morpheus/modules/file_batcher.py` @@ -263,7 +263,7 @@ def file_batcher(builder: mrc.Builder): ... ``` -#### File to DF DataLoader +> ### File to DF DataLoader Source: `morpheus/loaders/file_to_df_dataloader.py` @@ -275,7 +275,7 @@ cuDF dataframe with is set as the control message payload. For a complete reference, see: [DataLoader Module](./docs/source/modules/core/data_loader.md) -#### dfp_split_users +> ### DFP Split Users Source: `examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_split_users.py` @@ -299,13 +299,68 @@ def dfp_split_users(builder: mrc.Builder): ... ``` -### dfp_rolling_window +> ### DFP Rolling Window +Source: `examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_rolling_window.py` + +The dfp_rolling_window module is responsible for maintaining a rolling window of historical data, acting as a streaming +caching and batching system. The module provides various configuration options, +such as aggregation span, cache directory, caching options, timestamp column name, and trigger conditions. + +The main functionality of the module is to processes control messages containing data. For each control message, the +function determines the user ID and data type, then tries to build a rolling window with the historical data from the +cache. If enough data is available based on the trigger conditions, the function returns a control message with the +appropriate historical data for further processing. + +The Rolling Window module is another example of a module that has been updated to be control message aware. In that +it will differentiate between streaming and payload control messages, and handle them accordingly. In the case of a +streaming control message, the module will process the message as it did previously, and either cache the streaming data +and return, or if the trigger conditions are met, return a control message with the appropriate historical data. In +the case of a payload control message, the rolling window module will be skipped entirely and simply forward the +message to the next stage. + +The Rolling window module has also been updated to support an additional `batch` mode of operation, in which it will +cache streaming data until the trigger conditions are met, generate a new control message with the all existing data, +flush the cache, and then forward the message downstream. Batch caching is the default mode for the streaming +inference pipeline, and improves performance by reducing the bookkeeping required. This mode of operation is denoted +by the `cache_mode` property of the module's configuration. -## DFP Training Pipeline +For a complete reference, +see: [DFP Rolling Window](./modules/examples/digital_fingerprinting/dfp_rolling_window.md) + +```python +@register_module(DFP_ROLLING_WINDOW, MORPHEUS_MODULE_NAMESPACE) +def dfp_rolling_window(builder: mrc.Builder): + # Setup and configuration parsing + ... +``` + +> ### DFP Data Prep + + +Source: `examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_data_prep.py` + +The dfp_data_prep module is responsible for preparing data for either inference or model training. The module +requires a defined schema for data preparation. + +The main functionality of the module is in the process_features function. For each control message containing data, the +function processes the columns of the data according to the given schema. The processed dataframe is then applied to the +control message payload. + +For a complete reference, see: [DFP Data Prep](./docs/source/modules/examples/digital_fingerprinting/dfp_data_prep.md) + +```python +@register_module(DFP_DATA_PREP, MORPHEUS_MODULE_NAMESPACE) +def dfp_data_prep(builder: mrc.Builder): + # Setup and configuration parsing + ... +``` + +> ## DFP Training Pipeline +Source: `examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training_pipe.py` The DFP Training Pipe module is a consolidated module that integrates several DFP pipeline modules that are essential to the training process. This module function provides a single entry point to the training pipeline, simplifying the @@ -313,6 +368,19 @@ process of training a model. The module offers configurable parameters for vario data batching, data preprocessing, and data encoding for model training. Additionally, the MLflow model writer options allow for the trained model to be saved for future use. +The module itself consists of a series of chained sub-modules, each of which performs a specific task in the training: + +- `preproc` + - Data filerting and preprocessing +- `dfp_rolling_window` + - Data caching and batching +- `dfp_data_prep` + - Data encoding +- `dfp_training` + - Model training +- `mlflow_model_writer` + - Model and telemetry saving to MLflow + For a complete reference, see: [DFP Training Pipe](modules/examples/digital_fingerprinting/dfp_training_pipe.md) ```python @@ -332,99 +400,178 @@ def dfp_training_pipe(builder: mrc.Builder): builder.register_module_output("output", mlflow_model_writer_module.output_port("output")) ``` -### DFP Preprocessing +> ### MLFlow Model Writer - -Source: `morpheus/modules/examples/digital_fingerprinting/dfp_preproc.py` - -#### filter_control_messages - - - -#### file_batcher - - + +Source: `examples/digital_fingerprinting/production/morpheus/dfp/modules/mlflow_model_writer.py` -#### file_to_df_dataloader +The mlflow_model_writer module is responsible for uploading trained models to the MLflow server. - +For each MultiAEMessage received, containing a trained model, the function uploads the model to MLflow along with +associated metadata such as experiment name, run name, parameters, metrics, and the model signature. If the MLflow +server is running on Databricks, the function also applies the required permissions to the registered model. -#### dfp_split_users - - - -### dfp_rolling_window +For a complete reference, +see: [MLFlow Model Writer](./docs/source/modules/examples/digital_fingerprinting/mlflow_model_writer.md) - +```python +@register_module(MLFLOW_MODEL_WRITER, MORPHEUS_MODULE_NAMESPACE) +def mlflow_model_writer(builder: mrc.Builder): + # Setup and configuration parsing + ... +``` -### dfp_data_prep +> ## DFP Inference Pipeline - + +Source: `examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference_pipe.py` -### dfp_inference +The dfp_inference_pipe module function consolidates multiple data fusion pipeline (DFP) modules relevant to the +inference process into a single module. Its purpose is to simplify the creation and configuration of an inference +pipeline by combining all necessary components. - +The module sets up a series of interconnected components that handle various stages of the inference process, such as +preprocessing, rolling window aggregation, data preparation, inference, detection filtering, post-processing, +serialization, and writing the output to a file. -### filter_detections +The module itself consists of a series of chained sub-modules, each of which performs a specific task in the +inference pipeline: - +- `dfp_preproc` + - Data filtering and preprocessing +- `dfp_rolling_window` + - Data caching and batching +- `dfp_data_prep` + - Data encoding +- `dfp_inference` + - Model inference +- `filter_detections` + - Detection filtering +- `dfp_post_proc` + - Detection post-processing +- `serialize` + - Detection serialization +- `write_to_file` + - Detection writing to file + +For a complete reference, see: [DFP Inference Pipe](modules/examples/digital_fingerprinting/dfp_inference_pipe.md) -### dfp_post_proc +```python +@register_module(DFP_INFERENCE_PIPE, MORPHEUS_MODULE_NAMESPACE) +def dfp_inference_pipe(builder: mrc.Builder): + # Setup and config parsing + ... - + # Make an edge between the modules. + builder.make_edge(preproc_module.output_port("output"), dfp_rolling_window_module.input_port("input")) + builder.make_edge(dfp_rolling_window_module.output_port("output"), dfp_data_prep_module.input_port("input")) + builder.make_edge(dfp_data_prep_module.output_port("output"), dfp_inference_module.input_port("input")) + builder.make_edge(dfp_inference_module.output_port("output"), filter_detections_module.input_port("input")) + builder.make_edge(filter_detections_module.output_port("output"), dfp_post_proc_module.input_port("input")) + builder.make_edge(dfp_post_proc_module.output_port("output"), serialize_module.input_port("input")) + builder.make_edge(serialize_module.output_port("output"), write_to_file_module.input_port("input")) -### serialize + # Register input and output port for a module. + builder.register_module_input("input", preproc_module.input_port("input")) + builder.register_module_output("output", write_to_file_module.output_port("output")) +``` - +> ### DFP Inference -### write_to_file + +Source: `examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference.py` - +The dfp_inference module function creates an inference module that retrieves trained models and performs inference on +the input data. The module requires a model_name_formatter and a fallback_username to be configured in its parameters. -## dfp_inference_pipeline +The function defines a get_model method to load the model for a specific user, and a process_task method to handle +individual inference tasks. The process_task method retrieves the user ID, extracts the payload, and converts the +DataFrame to pandas format. It then attempts to load the model for the specified user ID and perform inference using the +loaded model. Finally, it adds any additional columns from the input data to the results DataFrame and creates an output +message with the results and metadata. - +For a complete reference, see: [DFP Inference](modules/examples/digital_fingerprinting/dfp_inference.md) -### dfp_preproc +```python +@register_module(DFP_INFERENCE, MORPHEUS_MODULE_NAMESPACE) +def dfp_inference(builder: mrc.Builder): + # Setup and config parsing + ... +``` - +> ### Filter Detections -#### filter_control_messages + +Source: `morpheus/modules/filter_detections.py` - +The filter_detections module function is designed to filter rows from a DataFrame based on values in a tensor or +DataFrame column according to a specified threshold. Rows are excluded if their associated value in the specified field +is less than or equal to the threshold. -#### file_batcher +This module can operate in two modes, set by the copy argument. When copy=True, rows that meet the filter criteria are +copied into a new DataFrame. When copy=False, sliced views are used instead. - +The function defines the find_detections method to determine the filter source and identify the rows that match the +filter criteria. The filter_copy and filter_slice methods are responsible for handling the filtering process based on +the chosen mode. -#### file_to_df_dataloader +```python +@register_module(FILTER_DETECTIONS, MORPHEUS_MODULE_NAMESPACE) +def filter_detections(builder: mrc.Builder): + # Setup and config parsing + ... +``` - +> ### DFP Post Processing -#### dfp_split_users + +Source: `examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_post_proc.py` - +The dfp_postprocessing module function performs post-processing tasks on the input data. -### dfp_rolling_window +```python +@register_module(DFP_POST_PROCESSING, MORPHEUS_MODULE_NAMESPACE) +def dfp_postprocessing(builder: mrc.Builder): + # Setup and config parsing + ... +``` - +> ### Serialize -### dfp_data_prep + +Source: `morpheus/modules/serialize.py` - +The serialize module function is responsible for filtering columns from a MultiMessage object and emitting a MessageMeta +object. -### dfp_training +The convert_to_df function converts a dataframe to JSON lines. It takes a MultiMessage instance, include_columns (a +pattern for columns to include), exclude_columns (a list of patterns for columns to exclude), and columns (a list of +columns to include). The function filters the columns of the input dataframe based on the include and exclude patterns +and retrieves the metadata of the filtered columns. - +The module function compiles the include and exclude patterns into regular expressions. It then creates a node using the +convert_to_df function with the compiled include and exclude patterns and the specified columns. -### mlflow_model_writer +```python +@register_module(SERIALIZE, MORPHEUS_MODULE_NAMESPACE) +def serialize(builder: mrc.Builder): + # Setup and config parsing + ... +``` - +> ### Write to File -## Combining modules and creating the pipeline + +Source: `morpheus/modules/write_to_file.py` - +The write_to_file module function writes all messages to a file. -## Conclusion +The convert_to_strings function takes a DataFrame (either pandas or cuDF) and converts it into the appropriate string +format based on the file type (JSON or CSV). It checks whether to include the index column or not. - +```python +@register_module(WRITE_TO_FILE, MORPHEUS_MODULE_NAMESPACE) +def write_to_file(builder: mrc.Builder): + # Setup and config parsing + ... +``` From 4ed58708957e57df1a97c7dcefecb213559e13fa Mon Sep 17 00:00:00 2001 From: Devin Robison Date: Thu, 23 Mar 2023 15:14:30 -0600 Subject: [PATCH 123/157] Fix table of contents --- ...modular_pipeline_digital_fingerprinting.md | 41 +++++++++---------- 1 file changed, 20 insertions(+), 21 deletions(-) diff --git a/docs/source/developer_guide/guides/modular_pipeline_digital_fingerprinting.md b/docs/source/developer_guide/guides/modular_pipeline_digital_fingerprinting.md index ef6ee25446..ec1b1c41a4 100644 --- a/docs/source/developer_guide/guides/modular_pipeline_digital_fingerprinting.md +++ b/docs/source/developer_guide/guides/modular_pipeline_digital_fingerprinting.md @@ -3,11 +3,13 @@ ## Table of Contents 1. [Introduction](#introduction) + 1. [Motivation](#motivation) + 2. [Overview](#overview) 2. [Setting up Morpheus](#setting-up-morpheus) -2. [Morpheus Modules](#morpheus-modules) -3. [dfp_deployment](#dfp_deployment) +3. [Morpheus Modules](#morpheus-modules) +4. [dfp_deployment](#dfp_deployment) 1. [fsspec_dataloader](#fsspec_dataloader) -4. [dfp_training_pipeline](#dfp_training_pipeline) +5. [dfp_training_and_inference_pipelines](#dfp_training_and_inference_pipelines) 1. [dfp_preproc](#dfp_preproc) 1. [filter_control_messages](#filter_control_messages) 2. [file_batcher](#file_batcher) @@ -15,29 +17,24 @@ 4. [dfp_split_users](#dfp_split_users) 2. [dfp_rolling_window](#dfp_rolling_window) 3. [dfp_data_prep](#dfp_data_prep) - 4. [dfp_inference](#dfp_inference) - 5. [filter_detections](#filter_detections) - 6. [dfp_post_proc](#dfp_post_proc) - 7. [serialize](#serialize) - 8. [write_to_file](#write_to_file) -5. [dfp_inference_pipeline](#dfp_inference_pipeline) - 1. [dfp_preproc](#dfp_preproc-1) - 1. [filter_control_messages](#filter_control_messages-1) - 2. [file_batcher](#file_batcher-1) - 3. [file_to_df_dataloader](#file_to_df_dataloader-1) - 4. [dfp_split_users](#dfp_split_users-1) - 2. [dfp_rolling_window](#dfp_rolling_window-1) - 3. [dfp_data_prep](#dfp_data_prep-1) - 4. [dfp_training](#dfp_training) - 5. [mlflow_model_writer](#mlflow_model_writer) -6. [Combining modules and creating the pipeline](#combining-modules-and-creating-the-pipeline) -7. [Conclusion](#conclusion) +6. [dfp_training_pipeline](#dfp_training_pipeline) + 1. [dfp_training](#dfp_training) + 2. [mlflow_model_writer](#mlflow_model_writer) +7. [dfp_inference_pipeline](#dfp_inference_pipeline) + 1. [dfp_inference](#dfp_inference) + 2. [filter_detections](#filter_detections) + 3. [dfp_post_proc](#dfp_post_proc) + 4. [serialize](#serialize) + 5. [write_to_file](#write_to_file) ## Introduction + + ### Motivation - + + This document presents the adaptation of the Digital Fingerprinting pipeline in Morpheus from the existing stage-based approach to one that is module-based; this process will provide a basis to work through motivation and usage examples for number of new features found in the 23.03 release. The updated pipeline incorporates extensions @@ -51,6 +48,8 @@ or predefined training data, offering a more adaptable and user-friendly experie ### Overview + + At a high level, the pipeline consists of three parts: the front-end file list loader that reads new control messages from a Kafka topic and expands the described data sources to be processed, and the training and inference pipelines that process the data. The updated pipeline enables the reception and processing of control messages, allowing for tasks such From dad6e621518123c92106584edb639a9d4dac0d65 Mon Sep 17 00:00:00 2001 From: Bhargav Suryadevara Date: Thu, 23 Mar 2023 17:07:23 -0500 Subject: [PATCH 124/157] added demo GUI docs --- .../digital_fingerprinting/demo/README.md | 54 ++++---- .../demo/cm_app/static/submit_messages.js | 63 +++++---- .../images/df_control_msgs_submit_resp.png | Bin 0 -> 37434 bytes .../demo/images/dfp_add_control_message.png | Bin 0 -> 286669 bytes .../images/dfp_control_message_inference.png | Bin 0 -> 50044 bytes .../images/dfp_control_message_training.png | Bin 0 -> 204674 bytes .../demo/images/dfp_review_inf_results.png | Bin 0 -> 300771 bytes .../images/dfp_submit_messages_default.png | Bin 0 -> 278295 bytes .../demo/review_results.md | 5 + .../demo/submit_messages.md | 128 ++++++++++++++++++ .../demo/{training_message.md => training.md} | 0 11 files changed, 192 insertions(+), 58 deletions(-) create mode 100644 examples/digital_fingerprinting/demo/images/df_control_msgs_submit_resp.png create mode 100644 examples/digital_fingerprinting/demo/images/dfp_add_control_message.png create mode 100644 examples/digital_fingerprinting/demo/images/dfp_control_message_inference.png create mode 100644 examples/digital_fingerprinting/demo/images/dfp_control_message_training.png create mode 100644 examples/digital_fingerprinting/demo/images/dfp_review_inf_results.png create mode 100644 examples/digital_fingerprinting/demo/images/dfp_submit_messages_default.png create mode 100644 examples/digital_fingerprinting/demo/review_results.md create mode 100644 examples/digital_fingerprinting/demo/submit_messages.md rename examples/digital_fingerprinting/demo/{training_message.md => training.md} (100%) diff --git a/examples/digital_fingerprinting/demo/README.md b/examples/digital_fingerprinting/demo/README.md index 59423dd2ac..d090dedb74 100644 --- a/examples/digital_fingerprinting/demo/README.md +++ b/examples/digital_fingerprinting/demo/README.md @@ -1,53 +1,47 @@ -### GUI Setup for Submitting Control Messages +### Control Messages Submission Demo Setup -#### Kafka Setup +#### Introduction +This document will provide you instructions for setting up a Control Messages Submission GUI that enables users to create and submit control messages to a Kafka topic, which can then be used for training and evaluating a digital fingerprinting model (AutoEncoder). The Control Messages Submission GUI is a web-based application that provides a user-friendly interface for generating control messages, and it can be set up with the help of this guide. It provides step-by-step instructions for setting up the required dependencies for Kafka, Flask server, and endpoint URLs. By the end of this document, you will have a fully functional demo Control Messages Submission GUI that you can use for your digital fingerprinting workflow. + +#### Requirements -Start Kafka service to publish control messages to kafka topic +To set up the Control Messages Submission GUI, you will need to install the following dependencies: ``` -cd ~/examples/digital_fingerprinting/production +pip install flask confluent_kafka +``` + +#### Kafka Setup -docker-compose up kafka zookeeper +To publish control messages to a Kafka topic, you will need to start the Kafka service first. Navigate to the `~/examples/digital_fingerprinting/`production directory and execute the following command: + +``` +docker-compose up kafka ``` ##### Create Kafka Topic -Create Kafka topic `test_cm` to submit control messages from `cm_app`. +Create a Kafka topic named `test_cm` to submit control messages. Run the following command to create the topic: ``` docker exec -it kafka kafka-topics --create --topic test_cm --bootstrap-server localhost:9092 ``` -Make sure the topic you created is getting messages. +To ensure that the topic is receiving messages, run the following command: ``` docker exec -it kafka kafka-console-consumer --topic test_cm --from-beginning --bootstrap-server localhost:9092 ``` #### Flask Server Setup -Install flask python module to run the demo. - +To set up the Flask server for the Control Messages Submission GUI, navigate to the `bin` directory and execute the `start.sh` script: ``` -pip install flask -``` - -Navigate to the bin directory and execute start script. -``` -cd ~/examples/digital_fingerprinting/demo/bin - bash start.sh ``` -#### Endpoint URL's -Flexibility to demonstrate the range of control message creation options. -``` -http://localhost:3000 -``` -Generates control messages for training purposes exclusively with some user-specified parameters. -``` -http://localhost:3000/training -``` - -Submit training messages after reviewing inference results -``` -http://localhost:3000/review/results -``` +#### Access GUI +- `http://localhost:3000` : This URL provides flexibility to demonstrate the range of control message creation options. + - See for more information [here](./submit_messages.md) +- `http://localhost:3000/training` : This URL generates control messages exclusively for training purposes with some user-specified parameters. + - See [here](./training.md) for more details on the training GUI. +- `http://localhost:3000/review/results` : This URL is used to submit training messages after reviewing inference results. + - See for more information [here](review_results.md) diff --git a/examples/digital_fingerprinting/demo/cm_app/static/submit_messages.js b/examples/digital_fingerprinting/demo/cm_app/static/submit_messages.js index bc6a9426be..556e8e6bed 100644 --- a/examples/digital_fingerprinting/demo/cm_app/static/submit_messages.js +++ b/examples/digital_fingerprinting/demo/cm_app/static/submit_messages.js @@ -1,77 +1,82 @@ -$(document).ready(function() { - +$(document).ready(function() { + $("#submit").click(function() { convertToJson(); }); - + // Function to convert inputs-container and child data to JSON function convertToJson() { var inputsContainer = $('#inputs-container'); var inputs = inputsContainer.find('.input'); var jsonOutput = {}; jsonOutput.inputs = []; - + inputs.each(function(index) { var input = $(this); var dataType = input.find('select[name="type"]').val(); var metadataContainer = input.find('.metadata-container'); var metadata = metadataContainer.find('.metadata'); var metadataJson = {}; - + metadata.each(function(index) { var metadataItem = $(this); var key = metadataItem.find('input[name="metadata-key"]').val(); var dataType = metadataItem.find('select[name="metadata-type"]').val(); var value = metadataItem.find('input[name="metadata-value"]').val(); - + if (dataType === "text-array") value = value.split(","); if (dataType === "Number") value = parseInt(value) - + if (dataType === "dict") + value = JSON.parse(value) + metadataJson[key] = value; }); - + var tasksContainer = input.find('.tasks-container'); var tasks = tasksContainer.find('.task'); var tasksJson = []; - + tasks.each(function(index) { var task = $(this); var taskType = task.find('select[name="task-type"]').val(); var propertiesContainer = task.find('.properties-container'); var properties = propertiesContainer.find('.property'); var propertiesJson = {}; - + properties.each(function(index) { var property = $(this); var key = property.find('input[name="property-key"]').val(); var dataType = property.find('select[name="property-type"]').val(); var value = property.find('input[name="property-value"]').val(); - + if (dataType === "text-array") value = value.split(","); - + if (dataType === "Number") value = parseInt(value) - + + if (dataType === "dict") + value = JSON.parse(value) + propertiesJson[key] = value; }); - + tasksJson.push({ "type": taskType, "properties": propertiesJson }); }); - + metadataJson['data_type'] = dataType var inputJson = { "metadata": metadataJson, "tasks": tasksJson }; jsonOutput.inputs.push(inputJson); }); - + var jsonString = JSON.stringify(jsonOutput, null, 2); $('#control-messages-json').val(jsonString); } - - - + + + // Add new input button functionality $("#add-input-btn").click(function() { var inputHtml = ` @@ -93,7 +98,7 @@ $(document).ready(function() {

`; $("#inputs-container").append(inputHtml); }); - + // Add new task button functionality using event delegation $("#inputs-container").on("click", ".add-task-btn", function() { var taskHtml = ` @@ -112,7 +117,7 @@ $(document).ready(function() {
`; $(this).parent().find(".tasks-container").append(taskHtml); }); - + // Add new property button functionality $("#inputs-container").on("click", ".add-property-btn", function() { var propertyHtml = ` @@ -125,13 +130,14 @@ $(document).ready(function() { +
`; $(this).siblings(".properties-container").append(propertyHtml); }); - + $("#inputs-container").on("click", ".add-metadata-btn", function() { var metadataHtml = ` `; $(this).siblings(".metadata-container").append(metadataHtml); }); - + // Remove input button functionality using event delegation $("#inputs-container").on("click", ".remove-input-btn", function() { $(this).parent().remove(); }); - + // Remove task button functionality using event delegation $("#inputs-container").on("click", ".remove-task-btn", function() { $(this).parent().remove(); }); - + // Remove property button functionality using event delegation $("#inputs-container").on("click", ".remove-property-btn", function() { $(this).parent().remove(); }); - + // Remove metadata button functionality using event delegation $("#inputs-container").on("click", ".remove-metadata-btn", function() { $(this).parent().remove(); }); - - }); \ No newline at end of file + + }); diff --git a/examples/digital_fingerprinting/demo/images/df_control_msgs_submit_resp.png b/examples/digital_fingerprinting/demo/images/df_control_msgs_submit_resp.png new file mode 100644 index 0000000000000000000000000000000000000000..43f3eba91d7e0f459f9c78430e3fd32ad80a7e77 GIT binary patch literal 37434 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 z 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

>Qse!u)@JJX;NFQqGL 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μ{opl8S8thlCh>Ne+uq6v2qH4r_@f$K2TuL%F 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

o&&;yd5PPQKT!Wfk!kR`K!E+ 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 DEKl&N literal 0 HcmV?d00001 diff --git a/examples/digital_fingerprinting/demo/review_results.md b/examples/digital_fingerprinting/demo/review_results.md new file mode 100644 index 0000000000..cafe6a7b77 --- /dev/null +++ b/examples/digital_fingerprinting/demo/review_results.md @@ -0,0 +1,5 @@ +# Review Inference Results GUI + +TODO (Bhargav) Need to decide on how to send the selected/flagged logs for retraining using submit control message feature. + +![DFP Review Inference Results](./images/dfp_review_inf_results.png) diff --git a/examples/digital_fingerprinting/demo/submit_messages.md b/examples/digital_fingerprinting/demo/submit_messages.md new file mode 100644 index 0000000000..6763eee60b --- /dev/null +++ b/examples/digital_fingerprinting/demo/submit_messages.md @@ -0,0 +1,128 @@ +# Control Message GUI + +## Introduction +This document provides a comprehensive guide on how to use a Control Messages Submission GUI for digital fingerprinting workflow. With this GUI, users can create and submit control messages to a Kafka topic for load, training, inference of a digital fingerprinting workflow. The GUI provides extensive options for control message creation, such as adding multiple tasks, configuring key-value parameters at task and control message levels, and also provides option for selecting the data type (payload or streaming). In addition, users can easily remove tasks and submit multiple control messages with just a click. By the end of this document, you will have a working Control Messages Submission GUI that will enhance your digital fingerprinting workflow usage experience. + +## Home +The UI will look like a dynamic form with various buttons and input fields that allow the user to specify their own metadata, tasks, and properties. The form will then generate a control message. This UI will allow the user to create any number of control messages with metadata and tasks with their own properties. + +![DFP Control Messages Default](./images/dfp_submit_messages_default.png) + +By clicking on the `Add Control Message` button adds a new control message to the form. Each control message has a type selector and three buttons, one to add "metadata", to add "tasks" and the other to remove control message. + +![DFP Add Control Message](./images/dfp_add_control_message.png) +- `Type`: A user may provide a control message of either the "streaming" or "payload" kind. Pipeline handles the message in accordance with the type provided. +- `Add Metadata`: button adds a new metadata section to the control message. Each metadata section has a key selector, a data type selector, a value input field, and a `Remove` button. +- `Add Task`: button adds a new task section to the input. Each task section has a type selector, a `Properties` section, and a `Remove` button. + - `Add Property`: button inside the `Properties` section adds a new property section to the task. Each property section has a key input field, a data type selector, a value input field, and a `Remove` button. + + +## Example +Let's just create an example with some digital fingerprinting pipeline properties to generate multiple control messages. + +Note: By default DataType is set to "Text". If the DataType is set to "Array," string with commas in it will be changed to an array. Similar to this, a dictionary will be created from a json string whose DataType is set to an object. + +### Control Message Training + +Here we build a control message for training with below parameters. + + - Metadata Properties: + - `Type` (Selector): `payload` + - `batching_options` (Object): `{"period": "D", "sampling_rate_s": 0, "start_time": "2023-03-01", "end_time": "2023-03-23"}` + - Task Properties: + - `Type` (Selector): `Load` + - `files` (Array): ```/workspace/examples/data/dfp/duo-training-data/*.json``` + - Task Properties: + - `Type` (Selector): `Training` + - `userid_column_name` (Text): ```username``` + - `timestamp_column_name` (Text): ```timestamp``` + +![DFP Control Message Training](./images/dfp_control_message_training.png) + +### Control Message Inference + +Here we build a control message for inference with below parameters. + + - Metadata Properties: + - `Type` (Selector): `streaming` + - `batching_options` (Object): `{"period": "D", "sampling_rate_s": 0, "start_time": "2023-03-01", "end_time": "2023-03-23"}` + - Task Properties: + - `Type` (Selector): `Load` + - `files` (Array): ```/workspace/examples/data/dfp/duo-inference-data/*.json``` + - Task Properties: + - `Type` (Selector): `Inference` + - `userid_column_name` (Text): ```username``` + - `timestamp_column_name` (Text): ```timestamp``` + +![DFP Control Message Inference](./images/dfp_control_message_inference.png) + + +#### Submit + +Response to a user submitted action + +![DFP Control Message Submission Response](./images/df_control_msgs_submit_resp.png) + +Finally, GUI generates a list of control messages, which is displayed below. +```json +{ + "inputs": [ + { + "metadata": { + "batching_options": { + "period": "D", + "sampling_rate_s": 0, + "start_time": "2023-03-01", + "end_time": "2023-03-23" + }, + "data_type": "payload" + }, + "tasks": [ + { + "type": "load", + "properties": { + "files": [ + "/workspace/examples/data/dfp/duo-training-data/*.json" + ] + } + }, + { + "type": "training", + "properties": { + "userid_column_name": "username", + "timestamp_column_name": "timestamp" + } + } + ] + }, + { + "metadata": { + "batching_options": { + "period": "D", + "sampling_rate_s": 0, + "start_time": "2023-03-01", + "end_time": "2023-03-23" + }, + "data_type": "streaming" + }, + "tasks": [ + { + "type": "load", + "properties": { + "files": [ + "/workspace/examples/data/dfp/duo-inference-data/*.json" + ] + } + }, + { + "type": "inference", + "properties": { + "userid_column_name": "username", + "timestamp_column_name": "timestamp" + } + } + ] + } + ] +} +``` diff --git a/examples/digital_fingerprinting/demo/training_message.md b/examples/digital_fingerprinting/demo/training.md similarity index 100% rename from examples/digital_fingerprinting/demo/training_message.md rename to examples/digital_fingerprinting/demo/training.md From b4531fa8d9de2717750467250361d88f69b13a08 Mon Sep 17 00:00:00 2001 From: Bhargav Suryadevara Date: Thu, 23 Mar 2023 17:10:33 -0500 Subject: [PATCH 125/157] Update submit_messages.md --- examples/digital_fingerprinting/demo/submit_messages.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/digital_fingerprinting/demo/submit_messages.md b/examples/digital_fingerprinting/demo/submit_messages.md index 6763eee60b..161569a053 100644 --- a/examples/digital_fingerprinting/demo/submit_messages.md +++ b/examples/digital_fingerprinting/demo/submit_messages.md @@ -1,4 +1,4 @@ -# Control Message GUI +# Multi Control Message GUI ## Introduction This document provides a comprehensive guide on how to use a Control Messages Submission GUI for digital fingerprinting workflow. With this GUI, users can create and submit control messages to a Kafka topic for load, training, inference of a digital fingerprinting workflow. The GUI provides extensive options for control message creation, such as adding multiple tasks, configuring key-value parameters at task and control message levels, and also provides option for selecting the data type (payload or streaming). In addition, users can easily remove tasks and submit multiple control messages with just a click. By the end of this document, you will have a working Control Messages Submission GUI that will enhance your digital fingerprinting workflow usage experience. From 595b46b7324b9331c8325b9a76b4ddee0a1d7971 Mon Sep 17 00:00:00 2001 From: Bhargav Suryadevara Date: Thu, 23 Mar 2023 17:14:16 -0500 Subject: [PATCH 126/157] Update README.md --- examples/digital_fingerprinting/demo/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/digital_fingerprinting/demo/README.md b/examples/digital_fingerprinting/demo/README.md index d090dedb74..ea3c688bfc 100644 --- a/examples/digital_fingerprinting/demo/README.md +++ b/examples/digital_fingerprinting/demo/README.md @@ -1,7 +1,7 @@ ### Control Messages Submission Demo Setup #### Introduction -This document will provide you instructions for setting up a Control Messages Submission GUI that enables users to create and submit control messages to a Kafka topic, which can then be used for training and evaluating a digital fingerprinting model (AutoEncoder). The Control Messages Submission GUI is a web-based application that provides a user-friendly interface for generating control messages, and it can be set up with the help of this guide. It provides step-by-step instructions for setting up the required dependencies for Kafka, Flask server, and endpoint URLs. By the end of this document, you will have a fully functional demo Control Messages Submission GUI that you can use for your digital fingerprinting workflow. +This document will provide you instructions for setting up a Control Messages Submission GUI that enables users to create and submit control messages to a Kafka topic, which can then be used for training, evaluating and inference on a digital fingerprinting model (AutoEncoder). The Control Messages Submission GUI is a web-based application that provides a user-friendly interface for generating control messages, and it can be set up with the help of this guide. It provides step-by-step instructions for setting up the required dependencies for Kafka, Flask server, and endpoint URLs. By the end of this document, you will have a fully functional demo Control Messages Submission GUI that you can use for your digital fingerprinting workflow. #### Requirements From 0767968eefa75b2ec50577489367ac3a653e43ca Mon Sep 17 00:00:00 2001 From: Bhargav Suryadevara Date: Fri, 24 Mar 2023 09:25:07 -0500 Subject: [PATCH 127/157] Update README.md --- examples/digital_fingerprinting/demo/README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/examples/digital_fingerprinting/demo/README.md b/examples/digital_fingerprinting/demo/README.md index ea3c688bfc..744870b6d3 100644 --- a/examples/digital_fingerprinting/demo/README.md +++ b/examples/digital_fingerprinting/demo/README.md @@ -1,7 +1,7 @@ ### Control Messages Submission Demo Setup #### Introduction -This document will provide you instructions for setting up a Control Messages Submission GUI that enables users to create and submit control messages to a Kafka topic, which can then be used for training, evaluating and inference on a digital fingerprinting model (AutoEncoder). The Control Messages Submission GUI is a web-based application that provides a user-friendly interface for generating control messages, and it can be set up with the help of this guide. It provides step-by-step instructions for setting up the required dependencies for Kafka, Flask server, and endpoint URLs. By the end of this document, you will have a fully functional demo Control Messages Submission GUI that you can use for your digital fingerprinting workflow. +This document will provide you instructions for setting up a Control Messages Submission GUI that enables users to create and submit control messages to a Kafka topic, which can then be used for training, evaluating and inference on a digital fingerprinting model (AutoEncoder). The Control Messages Submission GUI is a web-based application that provides a user-friendly interface for generating control messages, and it can be set up with the help of this guide. It provides step-by-step instructions for setting up the required dependencies for Kafka and Flask server. By the end of this document, you will have a fully functional demo Control Messages Submission GUI. #### Requirements @@ -13,7 +13,7 @@ pip install flask confluent_kafka #### Kafka Setup -To publish control messages to a Kafka topic, you will need to start the Kafka service first. Navigate to the `~/examples/digital_fingerprinting/`production directory and execute the following command: +To publish control messages to a Kafka topic, you will need to start the Kafka service first. Navigate to the `~/examples/digital_fingerprinting/production` directory and execute the following command: ``` docker-compose up kafka @@ -31,9 +31,9 @@ To ensure that the topic is receiving messages, run the following command: docker exec -it kafka kafka-console-consumer --topic test_cm --from-beginning --bootstrap-server localhost:9092 ``` -#### Flask Server Setup +#### Flask Setup -To set up the Flask server for the Control Messages Submission GUI, navigate to the `bin` directory and execute the `start.sh` script: +To set up the Flask for the Control Messages Submission GUI, navigate to the `bin` directory and execute the `start.sh` script: ``` bash start.sh ``` From 0c4a2f2376886ff00f9ce8805592197a6be295d7 Mon Sep 17 00:00:00 2001 From: Bhargav Suryadevara Date: Fri, 24 Mar 2023 09:35:50 -0500 Subject: [PATCH 128/157] Update submit_messages.md --- .../demo/submit_messages.md | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/examples/digital_fingerprinting/demo/submit_messages.md b/examples/digital_fingerprinting/demo/submit_messages.md index 161569a053..21dcfe7a20 100644 --- a/examples/digital_fingerprinting/demo/submit_messages.md +++ b/examples/digital_fingerprinting/demo/submit_messages.md @@ -4,27 +4,27 @@ This document provides a comprehensive guide on how to use a Control Messages Submission GUI for digital fingerprinting workflow. With this GUI, users can create and submit control messages to a Kafka topic for load, training, inference of a digital fingerprinting workflow. The GUI provides extensive options for control message creation, such as adding multiple tasks, configuring key-value parameters at task and control message levels, and also provides option for selecting the data type (payload or streaming). In addition, users can easily remove tasks and submit multiple control messages with just a click. By the end of this document, you will have a working Control Messages Submission GUI that will enhance your digital fingerprinting workflow usage experience. ## Home -The UI will look like a dynamic form with various buttons and input fields that allow the user to specify their own metadata, tasks, and properties. The form will then generate a control message. This UI will allow the user to create any number of control messages with metadata and tasks with their own properties. +The UI will look like a dynamic form with various buttons and input fields that allow the user to specify their own metadata, tasks, and properties. The form will then generate a control message. This GUI will allow the user to create any number of control messages with metadata and tasks with their own properties. ![DFP Control Messages Default](./images/dfp_submit_messages_default.png) -By clicking on the `Add Control Message` button adds a new control message to the form. Each control message has a type selector and three buttons, one to add "metadata", to add "tasks" and the other to remove control message. +By clicking on the `Add Control Message` button adds a new control message to the form. Each control message has a type selector and three buttons, one to add metadata properties, to add task and the other to remove control message. ![DFP Add Control Message](./images/dfp_add_control_message.png) -- `Type`: A user may provide a control message of either the "streaming" or "payload" kind. Pipeline handles the message in accordance with the type provided. +- `Type`: A user may select a control message of either the `streaming` or `payload` kind. In the backend digital fingerprinting workflow handles the message in accordance with the type provided. - `Add Metadata`: button adds a new metadata section to the control message. Each metadata section has a key selector, a data type selector, a value input field, and a `Remove` button. -- `Add Task`: button adds a new task section to the input. Each task section has a type selector, a `Properties` section, and a `Remove` button. - - `Add Property`: button inside the `Properties` section adds a new property section to the task. Each property section has a key input field, a data type selector, a value input field, and a `Remove` button. +- `Add Task`: button adds a new task section to the control message. Each task section has a type selector, a `Properties` section, and a `Remove` button. + - `Add Property`: button inside the `Properties` section adds a new property to the task. Each property has a key input field, a data type selector, a value input field, and a `Remove` button. ## Example -Let's just create an example with some digital fingerprinting pipeline properties to generate multiple control messages. +Let's just create an example with some digital fingerprinting pipeline properties and generate multiple control messages. -Note: By default DataType is set to "Text". If the DataType is set to "Array," string with commas in it will be changed to an array. Similar to this, a dictionary will be created from a json string whose DataType is set to an object. +**Note**: By default DataType is set to `Text`. If the DataType is set to `Array`, string with commas in the value field will be converted to an array. Similar to this, a dictionary will be created from a json string whose DataType is set to an `Object`. ### Control Message Training -Here we build a control message for training with below parameters. +How to construct the control message for training with the following parameters is shown below. - Metadata Properties: - `Type` (Selector): `payload` @@ -41,7 +41,7 @@ Here we build a control message for training with below parameters. ### Control Message Inference -Here we build a control message for inference with below parameters. +How to construct the control message for inference with the following parameters is shown below.. - Metadata Properties: - `Type` (Selector): `streaming` @@ -59,11 +59,11 @@ Here we build a control message for inference with below parameters. #### Submit -Response to a user submitted action +Response to a user submitted action as shown below. ![DFP Control Message Submission Response](./images/df_control_msgs_submit_resp.png) -Finally, GUI generates a list of control messages, which is displayed below. +The list of control messages that the GUI ultimately generates is shown below. ```json { "inputs": [ From eb2742be400d2161cbccf6e1f8de71eb15312783 Mon Sep 17 00:00:00 2001 From: Bhargav Suryadevara Date: Fri, 24 Mar 2023 12:29:46 -0500 Subject: [PATCH 129/157] added deafult config parameter values for modules --- docs/source/control_message_guide.md | 14 +++++++------- docs/source/loaders/core/file_to_df_loader.md | 14 ++++++++++++++ docs/source/loaders/core/fsspec_loader.md | 8 ++++++++ docs/source/loaders/morpheus_loaders.md | 2 ++ docs/source/modules/core/file_batcher.md | 12 ++++++++++-- docs/source/modules/core/file_to_df.md | 12 +++++++++++- .../source/modules/core/filter_control_message.md | 9 ++++++++- docs/source/modules/core/filter_detections.md | 11 ++++++++++- docs/source/modules/core/mlflow_model_writer.md | 9 ++++++++- docs/source/modules/core/serializer.md | 12 +++++++++++- docs/source/modules/core/write_to_file.md | 12 +++++++++++- .../digital_fingerprinting/dfp_data_prep.md | 8 +++++++- .../digital_fingerprinting/dfp_inference.md | 10 +++++++++- .../digital_fingerprinting/dfp_postprocessing.md | 8 +++++++- .../digital_fingerprinting/dfp_split_users.md | 15 ++++++++++++++- .../digital_fingerprinting/dfp_training.md | 10 +++++++++- examples/digital_fingerprinting/demo/README.md | 2 +- 17 files changed, 147 insertions(+), 21 deletions(-) diff --git a/docs/source/control_message_guide.md b/docs/source/control_message_guide.md index 7da83c3269..f346f3647c 100644 --- a/docs/source/control_message_guide.md +++ b/docs/source/control_message_guide.md @@ -21,9 +21,9 @@ The control message is a JSON object used in the Morpheus pipeline workflow. It ## Components -The control message has one main component: `inputs`. The inputs component is an array of input objects, each of which represents a separate input to the pipeline. Each input object has the following structure: +The primary component `inputs` are used to group control messages together, treating them as if they were inputs in a pipeline. In the form of an array, the inputs component is made up of individual control message objects, each representing a distinct input to the pipeline. Each control message object has the following structure: -### Inputs +### Control Message ``` { "tasks": [ @@ -35,9 +35,9 @@ The control message has one main component: `inputs`. The inputs component is an } ``` -### Tasks +#### Tasks -The tasks component of each input object is an array of task objects, each of which represents a separate task to be executed on the input data. Each task object has the following structure: +The tasks component of each control message object is an array of task objects, each of which represents a separate task to be executed on the input data. Each task object has the following structure: ``` { @@ -67,15 +67,15 @@ The tasks component of each input object is an array of task objects, each of wh } ``` - - `loader_id` : The ID of the loader to be used for loading the input data. Currently, only the `fsspec` and `file_to_df` loader is supported. The user has the option to register custom loaders in the dataloader registry and utilize them in the pipeline. + - `loader_id` : The ID of the loader to be used for loading the input data. Currently, only the `fsspec` and `file_to_df` loaders are supported. The user has the option to register custom loaders in the dataloader registry and utilize them in the pipeline. - `files` : An array of file paths or glob patterns specifying the input data to be loaded. - Incorporate key and value updates to properties objects as required for `training` and `inference` tasks. There is no specified format. -### Metadata +#### Metadata The metadata component of each input object is an object containing metadata information. Properties defined in this metadata component can be accessed anywhere across the stages that consume `MessageControl` objects. -- `data_type` : which is a string indicating the type of data being processed. The supported data types are: +- `data_type` : which is a string indicates how to process the data. The supported data types are: - `payload` : Arbitrary input data - `Streaming` : Streaming data diff --git a/docs/source/loaders/core/file_to_df_loader.md b/docs/source/loaders/core/file_to_df_loader.md index 650a7a7efe..e8392ffccd 100644 --- a/docs/source/loaders/core/file_to_df_loader.md +++ b/docs/source/loaders/core/file_to_df_loader.md @@ -19,6 +19,8 @@ limitations under the License. This function is used to load files containing data into a dataframe. Dataframe is created by processing files either using a single thread, multiprocess, dask, or dask_thread. This the function determines the download method to use, and if it starts with "dask," it creates a dask client and uses it to process the files. Otherwise, it uses a single thread or multiprocess to process the files. This function then caches the resulting dataframe using a hash of the file paths. In addition to loading data from the disk, it has the ability to load the file content from S3 buckets. +**Note** : Loaders receive configuration from `load` task via the [control message](./../../source/control_message_guide.md) during runtime. + ### Configurable Parameters - `id` (str): Registered loader id. @@ -32,3 +34,15 @@ This function is used to load files containing data into a dataframe. Dataframe }] } ``` + +### Default Settings + +| Property | Value | +| -----------------------| ----------| +| cache_dir | ./.cache | +| file_type | JSON | +| filter_null | False | +| parser_kwargs | None | +| timestamp_column_name | timestamp | + +**Note** : The [file_batcher](../../../../morpheus/modules/file_batcher.py) module currently generates tasks internally and assigns them to control messages, and then sends them to a [file_to_df_loader](../../../../morpheus/loaders/file_to_df_loader.py). Having stated that, this loader's configuration is obtained from the [File Batcher](../../modules/core/file_batcher.md) module configuration. diff --git a/docs/source/loaders/core/fsspec_loader.md b/docs/source/loaders/core/fsspec_loader.md index e3d81cc284..fccbb3bed8 100644 --- a/docs/source/loaders/core/fsspec_loader.md +++ b/docs/source/loaders/core/fsspec_loader.md @@ -19,6 +19,8 @@ limitations under the License. Loads data from external sources using the fsspec library, and returns the updated MessageControl object with payload as MessageMeta, which contains dataframe (with filenames). +**Note** : Loaders receive configuration from `load` task via the [control message](./../../source/control_message_guide.md) during runtime. + ### Configurable Parameters - `id` (str): Registered loader id. @@ -32,3 +34,9 @@ Loads data from external sources using the fsspec library, and returns the updat }] } ``` + +### Default Settings + +| Property | Value | +| -------- | ----- | +| files | [] | diff --git a/docs/source/loaders/morpheus_loaders.md b/docs/source/loaders/morpheus_loaders.md index f221ae579b..7d89b5b1a9 100644 --- a/docs/source/loaders/morpheus_loaders.md +++ b/docs/source/loaders/morpheus_loaders.md @@ -19,6 +19,8 @@ limitations under the License. Custom functions called "Loaders" can be utilized by the DataLoader Module to load data into the pipeline. The user can choose to register their own customized loader function and add it to a dataloader registry, which will then become accessible to the DataLoader module during module loading. +**Note** : Loaders receive configuration from `load` task via the [control message](./../../source/control_message_guide.md) during runtime. + ## Core Loaders - [File to DataFrame Loader](./core/file_to_df_loader.md) diff --git a/docs/source/modules/core/file_batcher.md b/docs/source/modules/core/file_batcher.md index 26f7419707..c0e8d9cf2a 100644 --- a/docs/source/modules/core/file_batcher.md +++ b/docs/source/modules/core/file_batcher.md @@ -49,7 +49,7 @@ This module loads the input files, removes files that are older than the chosen "start_time": "2023-03-01T00:00:00" }, "cache_dir": "./file_batcher_cache", - "file_type": "csv", + "file_type": "CSV", "filter_nulls": true, "schema": { "column1": "string", @@ -58,4 +58,12 @@ This module loads the input files, removes files that are older than the chosen }, "timestamp_column_name": "timestamp" } -``` \ No newline at end of file +``` + +### Default Settings + +| Property | Value | +| -------- | ----- | +| iso_date_regex_pattern | (?P\\d{4})-(?P\\d{1,2})-(?P\\d{1,2})T(?P\\d{1,2})(:\\\|_)(?P\\d{1,2})(:\\\|_)(?P\\d{1,2})(?P\\.\d{1,6})?Z| +| period | D | +| sampling_rate_s | 0| diff --git a/docs/source/modules/core/file_to_df.md b/docs/source/modules/core/file_to_df.md index ddff8b58bd..b61500e4b6 100644 --- a/docs/source/modules/core/file_to_df.md +++ b/docs/source/modules/core/file_to_df.md @@ -44,4 +44,14 @@ This module reads data from the batched files into a dataframe after receiving i }, "timestamp_column_name": "timestamp" } -``` \ No newline at end of file +``` + +### Default Settings + +| Property | Value | +| -----------------------| ----------| +| cache_dir | ./.cache | +| file_type | JSON | +| filter_null | False | +| parser_kwargs | None | +| timestamp_column_name | timestamp | diff --git a/docs/source/modules/core/filter_control_message.md b/docs/source/modules/core/filter_control_message.md index 8cd0fce8be..a32b86ad3d 100644 --- a/docs/source/modules/core/filter_control_message.md +++ b/docs/source/modules/core/filter_control_message.md @@ -35,4 +35,11 @@ When the requirements are met, this module gently discards the control messages. "filter_task_type": "specific_task", "filter_data_type": "desired_data_type" } -``` \ No newline at end of file +``` + +### Default Settings + +| Property | Value | +| ----------------------------| -------| +| enable_data_type_filtering | False | +| enable_task_filtering | False | diff --git a/docs/source/modules/core/filter_detections.md b/docs/source/modules/core/filter_detections.md index 3299318e76..6ec975e05d 100644 --- a/docs/source/modules/core/filter_detections.md +++ b/docs/source/modules/core/filter_detections.md @@ -44,4 +44,13 @@ The Filter Detections module is used to filter rows from a dataframe based on va "encoding": "utf-8" } } -``` \ No newline at end of file +``` + +### Default Settings + +| Property | Value | +| -------------| --------| +| copy | False | +| field_name | probs | +| filter_source| AUTO | +| threshold | 0.5 | diff --git a/docs/source/modules/core/mlflow_model_writer.md b/docs/source/modules/core/mlflow_model_writer.md index bc272a00f9..b7e1a9b66d 100644 --- a/docs/source/modules/core/mlflow_model_writer.md +++ b/docs/source/modules/core/mlflow_model_writer.md @@ -40,4 +40,11 @@ This module uploads trained models to the MLflow server. "write": ["write_user1", "write_user2"] } } -``` \ No newline at end of file +``` + +### Default Settings + +| Property | Value | +| -----------------------| ----------| +| databricks_permissions | None | +| timestamp_column_name | timestamp | diff --git a/docs/source/modules/core/serializer.md b/docs/source/modules/core/serializer.md index df40581647..89028294e0 100644 --- a/docs/source/modules/core/serializer.md +++ b/docs/source/modules/core/serializer.md @@ -37,4 +37,14 @@ This module filters columns from a `MultiMessage` object, emitting a `MessageMet "columns": ["column1", "column2", "column3"], "use_cpp": true } -``` \ No newline at end of file +``` + +### Default Settings + +| Property | Value | +| -------------- | ------------------| +| columns | None | +| exclude | [r'^ID$', r'^_ts_']| +| fixed_columns | True | +| include | None | +| use_cpp | False | diff --git a/docs/source/modules/core/write_to_file.md b/docs/source/modules/core/write_to_file.md index 83a8e6e657..9bab6e098c 100644 --- a/docs/source/modules/core/write_to_file.md +++ b/docs/source/modules/core/write_to_file.md @@ -37,4 +37,14 @@ This module writes messages to a file. "file_type": "CSV", "include_index_col": false } -``` \ No newline at end of file +``` + +### Default Settings + +| Property | Value | +| --------------------| -------------- | +| file_type | FileTypes.Auto | +| filename | None | +| flush | False | +| include_index_col | True | +| overwrite | False | diff --git a/docs/source/modules/examples/digital_fingerprinting/dfp_data_prep.md b/docs/source/modules/examples/digital_fingerprinting/dfp_data_prep.md index 4db6d577a5..c18a39ba7b 100644 --- a/docs/source/modules/examples/digital_fingerprinting/dfp_data_prep.md +++ b/docs/source/modules/examples/digital_fingerprinting/dfp_data_prep.md @@ -38,4 +38,10 @@ This module function prepares data for either inference or model training. }, "timestamp_column_name": "timestamp" } -``` \ No newline at end of file +``` + +### Default Settings + +| Property | Value | +| -------- | ----- | +| timestamp_column_name | timestamp | diff --git a/docs/source/modules/examples/digital_fingerprinting/dfp_inference.md b/docs/source/modules/examples/digital_fingerprinting/dfp_inference.md index e598594bbc..adffeb2707 100644 --- a/docs/source/modules/examples/digital_fingerprinting/dfp_inference.md +++ b/docs/source/modules/examples/digital_fingerprinting/dfp_inference.md @@ -33,4 +33,12 @@ This module function performs the inference process. "fallback_username": "generic_user", "timestamp_column_name": "timestamp" } -``` \ No newline at end of file +``` + +### Default Settings + +| Property | Value | +| -------- | ----- | +| fallback_username | generic_user | +| model_name_formatter | None | +| timestamp_column_name | timestamp | diff --git a/docs/source/modules/examples/digital_fingerprinting/dfp_postprocessing.md b/docs/source/modules/examples/digital_fingerprinting/dfp_postprocessing.md index 83a71c6bdb..af193ee5de 100644 --- a/docs/source/modules/examples/digital_fingerprinting/dfp_postprocessing.md +++ b/docs/source/modules/examples/digital_fingerprinting/dfp_postprocessing.md @@ -29,4 +29,10 @@ This module function performs postprocessing tasks after the inference process. { "timestamp_column_name": "timestamp" } -``` \ No newline at end of file +``` + +### Default Settings + +| Property | Value | +| -------- | ----- | +| timestamp_column_name | timestamp | diff --git a/docs/source/modules/examples/digital_fingerprinting/dfp_split_users.md b/docs/source/modules/examples/digital_fingerprinting/dfp_split_users.md index f9dd7f5592..d4f0f56172 100644 --- a/docs/source/modules/examples/digital_fingerprinting/dfp_split_users.md +++ b/docs/source/modules/examples/digital_fingerprinting/dfp_split_users.md @@ -41,4 +41,17 @@ This module function splits the data based on user IDs. "timestamp_column_name": "timestamp", "userid_column_name": "username" } -``` \ No newline at end of file +``` + +### Default Settings + +| Property | Value | +| -------- | ----- | +| fallback_username | generic_user | +| include_generic | False | +| include_individual | False | +| include_individual | False | +| only_users | [] | +| skip_users | [] | +| timestamp_column_name | timestamp | +| userid_column_name | username | diff --git a/docs/source/modules/examples/digital_fingerprinting/dfp_training.md b/docs/source/modules/examples/digital_fingerprinting/dfp_training.md index dc3aee4711..9e3e95f04f 100644 --- a/docs/source/modules/examples/digital_fingerprinting/dfp_training.md +++ b/docs/source/modules/examples/digital_fingerprinting/dfp_training.md @@ -59,4 +59,12 @@ This module function is responsible for training the model. }, "validation_size": 0.1 } -``` \ No newline at end of file +``` + +### Default Settings + +| Property | Value | +| -------- | ----- | +| epochs | 1 | +| model_kwargs | {} | +| validation_size | 0.0 | diff --git a/examples/digital_fingerprinting/demo/README.md b/examples/digital_fingerprinting/demo/README.md index ea3c688bfc..61379d7704 100644 --- a/examples/digital_fingerprinting/demo/README.md +++ b/examples/digital_fingerprinting/demo/README.md @@ -1,7 +1,7 @@ ### Control Messages Submission Demo Setup #### Introduction -This document will provide you instructions for setting up a Control Messages Submission GUI that enables users to create and submit control messages to a Kafka topic, which can then be used for training, evaluating and inference on a digital fingerprinting model (AutoEncoder). The Control Messages Submission GUI is a web-based application that provides a user-friendly interface for generating control messages, and it can be set up with the help of this guide. It provides step-by-step instructions for setting up the required dependencies for Kafka, Flask server, and endpoint URLs. By the end of this document, you will have a fully functional demo Control Messages Submission GUI that you can use for your digital fingerprinting workflow. +This document will provide you instructions for setting up a Control Messages Submission GUI that enables users to create and submit control messages to a Kafka topic, which can then be used for training, evaluating and inference on a digital fingerprinting model (AutoEncoder). The Control Messages Submission GUI is a web-based application that provides a user-friendly interface for generating control messages, and it can be set up with the help of this guide. It provides step-by-step instructions for setting up the required dependencies for Kafka, Flask server, and endpoint URLs. By the end of this document, you will have a fully functional demo Control Messages Submission GUI that you can use for your digital fingerprinting workflow. The Structure of the control message is explained in more detail [here](../../../docs/source/control_message_guide.md). #### Requirements From 0c0591f3bee74ffd61e70a3f807d847c5af09440 Mon Sep 17 00:00:00 2001 From: Devin Robison Date: Fri, 24 Mar 2023 12:23:02 -0600 Subject: [PATCH 130/157] Add data loader module docs --- docs/source/developer_guide/guides.md | 12 +++-- ...odular_pipeline_digital_fingerprinting.md} | 0 .../guides/7_python_modules.md | 2 +- .../developer_guide/guides/8_cpp_modules.md | 2 +- docs/source/modules/core/data_loader.md | 47 +++++++++++++++++++ docs/source/modules/morpheus_modules.md | 1 + morpheus/loaders/__init__.py | 21 +++++++++ morpheus/loaders/file_to_df_loader.py | 11 ++--- morpheus/loaders/fsspec_loader.py | 24 +++++----- morpheus/messages/memory/__init__.py | 18 +++++++ morpheus/modules/__init__.py | 13 +++++ 11 files changed, 126 insertions(+), 25 deletions(-) rename docs/source/developer_guide/guides/{modular_pipeline_digital_fingerprinting.md => 10_modular_pipeline_digital_fingerprinting.md} (100%) diff --git a/docs/source/developer_guide/guides.md b/docs/source/developer_guide/guides.md index cc941580e7..429f52af9a 100644 --- a/docs/source/developer_guide/guides.md +++ b/docs/source/developer_guide/guides.md @@ -35,23 +35,25 @@ in both Python and C++. ## Morpheus Modules -Morpheus includes, as of version 23.03, a number of pre-defined module implementations to choose from when building a -custom pipeline. Modules can be thought of as units of work, which exist at a lower level than stages. Modules can +Morpheus includes, as of version 23.03, a number of pre-defined module implementations to choose from when building a +custom pipeline. Modules can be thought of as units of work, which exist at a lower level than stages. Modules can be defined, registered, chained, nested, and loaded at runtime. Modules can be written in Python or C++. - [List of available Morpheus modules](../modules/morpheus_modules.md) -However, there are likely going to be situations that require writing a custom module, either for creating your own -reusable work units, or for creating a new compound module from a set of existing primitives. The following guides +However, there are likely going to be situations that require writing a custom module, either for creating your own +reusable work units, or for creating a new compound module from a set of existing primitives. The following guides will walk through the process of creating a custom module in Python and C++. - [Python Modules](./guides/7_python_modules.md) - [C++ Modules](./guides/8_cpp_modules.md) ## Morpheus Control messages + - [Control Messages Overview](./guides/9_control_messages.md) ## Example Workflows - [Digital Fingerprinting (DFP)](./guides/5_digital_fingerprinting.md) -- [Digital Fingerprinting (DFP) Reference](./guides/6_digital_fingerprinting_reference.md) \ No newline at end of file +- [Digital Fingerprinting (DFP) Reference](./guides/6_digital_fingerprinting_reference.md) +- [Modular DFP Reference](./guides/10_modular_pipeline_digital_fingerprinting.md) \ No newline at end of file diff --git a/docs/source/developer_guide/guides/modular_pipeline_digital_fingerprinting.md b/docs/source/developer_guide/guides/10_modular_pipeline_digital_fingerprinting.md similarity index 100% rename from docs/source/developer_guide/guides/modular_pipeline_digital_fingerprinting.md rename to docs/source/developer_guide/guides/10_modular_pipeline_digital_fingerprinting.md diff --git a/docs/source/developer_guide/guides/7_python_modules.md b/docs/source/developer_guide/guides/7_python_modules.md index 229a33278a..2c09cad2f8 100644 --- a/docs/source/developer_guide/guides/7_python_modules.md +++ b/docs/source/developer_guide/guides/7_python_modules.md @@ -15,7 +15,7 @@ See the License for the specific language governing permissions and limitations under the License. --> -# Creating a Python Morpheus Module +# Python Morpheus Modules ## Background diff --git a/docs/source/developer_guide/guides/8_cpp_modules.md b/docs/source/developer_guide/guides/8_cpp_modules.md index 52eaf38dce..0e95cd927c 100644 --- a/docs/source/developer_guide/guides/8_cpp_modules.md +++ b/docs/source/developer_guide/guides/8_cpp_modules.md @@ -15,7 +15,7 @@ See the License for the specific language governing permissions and limitations under the License. --> -# Creating a C++ Morpheus Module +# C++ Morpheus Modules ## Background diff --git a/docs/source/modules/core/data_loader.md b/docs/source/modules/core/data_loader.md index e69de29bb2..fe8fc091ed 100644 --- a/docs/source/modules/core/data_loader.md +++ b/docs/source/modules/core/data_loader.md @@ -0,0 +1,47 @@ + + +## Data Loader Module + +This module takes a control message and attempts to process any `load` tasks in the message. The module itself is +configured to use a set of loaders, each of which is responsible for loading a specific type of data. These loaders +are specified in the module configuration file at the time of object construction. + +### Configurable Parameters + +- `loaders`: An array containing entries for `id` and `properties` for each loader. Each `id` is a unique identifier for + the loader and `properties` is a dictionary of properties for that loader. + +### Example JSON Configuration + +```json +{ + "loaders": [ + { + "id": "loader1", + "properties": { + ... loader specific parameters ... + } + }, + { + "id": "loader2", + "properties": { + ... loader specific parameters ... + } + } + ] +} \ No newline at end of file diff --git a/docs/source/modules/morpheus_modules.md b/docs/source/modules/morpheus_modules.md index 982ff17fb8..ca5fc7fa61 100644 --- a/docs/source/modules/morpheus_modules.md +++ b/docs/source/modules/morpheus_modules.md @@ -19,6 +19,7 @@ limitations under the License. ## Core Modules +- [Data Loader](./core/data_loader.md) - [File Batcher](./core/file_batcher.md) - [File to DataFrame](./core/file_to_df.md) - [Filter Control Message](./core/filter_control_message.md) diff --git a/morpheus/loaders/__init__.py b/morpheus/loaders/__init__.py index e69de29bb2..bd1881043f 100644 --- a/morpheus/loaders/__init__.py +++ b/morpheus/loaders/__init__.py @@ -0,0 +1,21 @@ +# Copyright (c) 2022-2023, NVIDIA CORPORATION. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +Morpheus loader definitions, each loader is automatically registered when imported and will be available for use. +""" + +from morpheus.loaders import file_to_df_loader +from morpheus.loaders import fsspec_loader + +__all__ = [] diff --git a/morpheus/loaders/file_to_df_loader.py b/morpheus/loaders/file_to_df_loader.py index 793bea2630..aebe6a4533 100644 --- a/morpheus/loaders/file_to_df_loader.py +++ b/morpheus/loaders/file_to_df_loader.py @@ -88,25 +88,24 @@ def file_to_df_loader(control_message: MessageControl, task: dict): the download method to use, and if it starts with "dask," it creates a dask client and uses it to process the files. Otherwise, it uses a single thread or multiprocess to process the files. This function then caches the resulting dataframe using a hash of the file paths. The dataframe is wrapped in a MessageMeta and then attached as a payload - to a MessageControl objec and passing on to further stages. + to a MessageControl object and passed on to further stages. Parameters ---------- - message : MessageControl + control_message : MessageControl The MessageControl object containing the pipeline control message. task : typing.Dict[any, any] A dictionary representing the current task in the pipeline control message. - Return - ------ + Returns + ------- message : MessageControl Updated message control object with payload as a MessageMeta. Raises ------ - RuntimeError : + RuntimeError: If no files matched the input strings specified in the task, or if there was an error loading the data. - """ if task.get("strategy", "aggregate") != "aggregate": raise RuntimeError("Only 'aggregate' strategy is supported for file_to_df loader.") diff --git a/morpheus/loaders/fsspec_loader.py b/morpheus/loaders/fsspec_loader.py index 0922dfa463..ac1784656b 100644 --- a/morpheus/loaders/fsspec_loader.py +++ b/morpheus/loaders/fsspec_loader.py @@ -30,28 +30,28 @@ @register_loader(FSSPEC_LOADER) -def fsspec_loader(message: MessageControl, task: dict) -> MessageControl: +def fsspec_loader(control_message: MessageControl, task: dict) -> MessageControl: """ - Loads data from external sources using the fsspec library, and returns the updated MessageControl - object with payload as MessageMeta, which contains dataframe (with filenames). + Loads data from external sources using the fsspec library, and returns an updated MessageControl + object with payload as MessageMeta, which contains a dataframe with file names and data. Parameters ---------- - message : MessageControl + control_message : MessageControl The MessageControl object containing the pipeline control message. task : typing.Dict[any, any] A dictionary representing the current task in the pipeline control message. - Return - ------ - message : MessageControl - Updated message control object with payload as a MessageMeta with dataframe containing file names. + Returns + ------- + control_message : MessageControl + An updated MessageControl object with payload as a MessageMeta containing a dataframe + with file names and data. Raises ------ - RuntimeError : + RuntimeError: If no files matched the input strings specified in the task, or if there was an error loading the data. - """ files = task.get("files", []) @@ -69,6 +69,6 @@ def fsspec_loader(message: MessageControl, task: dict) -> MessageControl: df = cudf.DataFrame(full_filenames, columns=['files']) - message.payload(MessageMeta(df=df)) + control_message.payload(MessageMeta(df=df)) - return message + return control_message diff --git a/morpheus/messages/memory/__init__.py b/morpheus/messages/memory/__init__.py index e69de29bb2..6031d84c7d 100644 --- a/morpheus/messages/memory/__init__.py +++ b/morpheus/messages/memory/__init__.py @@ -0,0 +1,18 @@ +# Copyright (c) 2022-2023, NVIDIA CORPORATION. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +Memory classes +""" + +__all__ = [] \ No newline at end of file diff --git a/morpheus/modules/__init__.py b/morpheus/modules/__init__.py index 081b2ae826..056455cc23 100644 --- a/morpheus/modules/__init__.py +++ b/morpheus/modules/__init__.py @@ -11,3 +11,16 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +""" +Morpheus module definitions, each module is automatically registered when imported +""" + +from morpheus.modules import file_batcher +from morpheus.modules import file_to_df +from morpheus.modules import filter_control_message +from morpheus.modules import filter_detections +from morpheus.modules import mlflow_model_writer +from morpheus.modules import serialize +from morpheus.modules import write_to_file + +__all__ = [] From 24070d6485186aebff08dce3ef6fb53bb26449e9 Mon Sep 17 00:00:00 2001 From: Bhargav Suryadevara Date: Fri, 24 Mar 2023 16:06:49 -0500 Subject: [PATCH 131/157] added header to dfp files --- .../digital_fingerprinting/demo/README.md | 16 +++++++++++++ .../digital_fingerprinting/demo/bin/start.sh | 19 ++++++++++++--- .../demo/cm_app/helper.py | 16 +++++++++++++ .../demo/cm_app/static/review/results.css | 17 +++++++++++++ .../demo/cm_app/static/review/results.js | 17 +++++++++++++ .../demo/cm_app/static/submit_messages.css | 17 +++++++++++++ .../demo/cm_app/static/submit_messages.js | 24 +++++++++++++++++-- .../demo/cm_app/static/training.css | 17 +++++++++++++ .../demo/cm_app/static/training.js | 17 +++++++++++++ .../demo/cm_app/templates/review/results.html | 18 ++++++++++++++ .../cm_app/templates/submit_messages.html | 19 ++++++++++++++- .../demo/cm_app/templates/training.html | 17 +++++++++++++ .../demo/cm_app/views.py | 16 +++++++++++++ .../demo/cm_app/webapp.py | 16 +++++++++++++ .../demo/review_results.md | 16 +++++++++++++ .../demo/submit_messages.md | 16 +++++++++++++ .../digital_fingerprinting/demo/training.md | 16 +++++++++++++ 17 files changed, 288 insertions(+), 6 deletions(-) diff --git a/examples/digital_fingerprinting/demo/README.md b/examples/digital_fingerprinting/demo/README.md index daee0532b1..5270a3830d 100644 --- a/examples/digital_fingerprinting/demo/README.md +++ b/examples/digital_fingerprinting/demo/README.md @@ -1,3 +1,19 @@ + + ### Control Messages Submission Demo Setup #### Introduction diff --git a/examples/digital_fingerprinting/demo/bin/start.sh b/examples/digital_fingerprinting/demo/bin/start.sh index fe0be6c615..b8eba29c1d 100644 --- a/examples/digital_fingerprinting/demo/bin/start.sh +++ b/examples/digital_fingerprinting/demo/bin/start.sh @@ -1,3 +1,18 @@ +# SPDX-FileCopyrightText: Copyright (c) 2022-2023, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + #!/bin/sh export set FLASK_APP=webapp @@ -6,7 +21,5 @@ THIS_DIR=$( dirname -- "$( readlink -f -- "$0"; )"; ) APP_PATH="$THIS_DIR/../cm_app" -#$(cd $APP_PATH && python -m flask run) - -# Run this command if default port is already being used. +# Run this command with port 3000. $(cd $APP_PATH && python -m flask run -p 3000) diff --git a/examples/digital_fingerprinting/demo/cm_app/helper.py b/examples/digital_fingerprinting/demo/cm_app/helper.py index dd3fefcf78..36f6a19f8d 100644 --- a/examples/digital_fingerprinting/demo/cm_app/helper.py +++ b/examples/digital_fingerprinting/demo/cm_app/helper.py @@ -1,3 +1,19 @@ +#!/usr/bin/env python +# SPDX-FileCopyrightText: Copyright (c) 2022-2023, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + import json import logging diff --git a/examples/digital_fingerprinting/demo/cm_app/static/review/results.css b/examples/digital_fingerprinting/demo/cm_app/static/review/results.css index 1d4551fa98..9ee5f849c8 100644 --- a/examples/digital_fingerprinting/demo/cm_app/static/review/results.css +++ b/examples/digital_fingerprinting/demo/cm_app/static/review/results.css @@ -1,3 +1,20 @@ +/* +# SPDX-FileCopyrightText: Copyright (c) 2022-2023, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +*/ + body { font-family: Arial, sans-serif; margin: 0; diff --git a/examples/digital_fingerprinting/demo/cm_app/static/review/results.js b/examples/digital_fingerprinting/demo/cm_app/static/review/results.js index b37a18fd9f..13054c3352 100644 --- a/examples/digital_fingerprinting/demo/cm_app/static/review/results.js +++ b/examples/digital_fingerprinting/demo/cm_app/static/review/results.js @@ -1,3 +1,20 @@ +/* +# SPDX-FileCopyrightText: Copyright (c) 2022-2023, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +*/ + let columnIndexes = []; // Function to read and display CSV file contents diff --git a/examples/digital_fingerprinting/demo/cm_app/static/submit_messages.css b/examples/digital_fingerprinting/demo/cm_app/static/submit_messages.css index 49981f0505..a93c675090 100644 --- a/examples/digital_fingerprinting/demo/cm_app/static/submit_messages.css +++ b/examples/digital_fingerprinting/demo/cm_app/static/submit_messages.css @@ -1,3 +1,20 @@ +/* +# SPDX-FileCopyrightText: Copyright (c) 2022-2023, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +*/ + h3 { text-align: left; color: black; diff --git a/examples/digital_fingerprinting/demo/cm_app/static/submit_messages.js b/examples/digital_fingerprinting/demo/cm_app/static/submit_messages.js index 556e8e6bed..5f7af52a47 100644 --- a/examples/digital_fingerprinting/demo/cm_app/static/submit_messages.js +++ b/examples/digital_fingerprinting/demo/cm_app/static/submit_messages.js @@ -1,10 +1,27 @@ +/* +# SPDX-FileCopyrightText: Copyright (c) 2022-2023, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +*/ + $(document).ready(function() { $("#submit").click(function() { convertToJson(); }); - // Function to convert inputs-container and child data to JSON + // Function to convert list of control messages to JSON object function convertToJson() { var inputsContainer = $('#inputs-container'); var inputs = inputsContainer.find('.input'); @@ -18,6 +35,7 @@ $(document).ready(function() { var metadata = metadataContainer.find('.metadata'); var metadataJson = {}; + // add metadata section metadata.each(function(index) { var metadataItem = $(this); var key = metadataItem.find('input[name="metadata-key"]').val(); @@ -38,6 +56,7 @@ $(document).ready(function() { var tasks = tasksContainer.find('.task'); var tasksJson = []; + // add tasks section tasks.each(function(index) { var task = $(this); var taskType = task.find('select[name="task-type"]').val(); @@ -66,6 +85,7 @@ $(document).ready(function() { tasksJson.push({ "type": taskType, "properties": propertiesJson }); }); + // add datatype to metadata metadataJson['data_type'] = dataType var inputJson = { "metadata": metadataJson, "tasks": tasksJson }; jsonOutput.inputs.push(inputJson); @@ -77,7 +97,7 @@ $(document).ready(function() { - // Add new input button functionality + // Add new control message button functionality $("#add-input-btn").click(function() { var inputHtml = `

diff --git a/examples/digital_fingerprinting/demo/cm_app/static/training.css b/examples/digital_fingerprinting/demo/cm_app/static/training.css index f598881cb1..68bc596e78 100644 --- a/examples/digital_fingerprinting/demo/cm_app/static/training.css +++ b/examples/digital_fingerprinting/demo/cm_app/static/training.css @@ -1,3 +1,20 @@ +/* +# SPDX-FileCopyrightText: Copyright (c) 2022-2023, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +*/ + form { display: flex; flex-direction: column; diff --git a/examples/digital_fingerprinting/demo/cm_app/static/training.js b/examples/digital_fingerprinting/demo/cm_app/static/training.js index 1cddcf6a30..0b2aa072d6 100644 --- a/examples/digital_fingerprinting/demo/cm_app/static/training.js +++ b/examples/digital_fingerprinting/demo/cm_app/static/training.js @@ -1,3 +1,20 @@ +/* +# SPDX-FileCopyrightText: Copyright (c) 2022-2023, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +*/ + $(document).ready(function() { $("#submit").click(function() { diff --git a/examples/digital_fingerprinting/demo/cm_app/templates/review/results.html b/examples/digital_fingerprinting/demo/cm_app/templates/review/results.html index 9608e58ce4..6776252ee4 100644 --- a/examples/digital_fingerprinting/demo/cm_app/templates/review/results.html +++ b/examples/digital_fingerprinting/demo/cm_app/templates/review/results.html @@ -1,3 +1,21 @@ + + + diff --git a/examples/digital_fingerprinting/demo/cm_app/templates/submit_messages.html b/examples/digital_fingerprinting/demo/cm_app/templates/submit_messages.html index 018145ae19..5417943433 100644 --- a/examples/digital_fingerprinting/demo/cm_app/templates/submit_messages.html +++ b/examples/digital_fingerprinting/demo/cm_app/templates/submit_messages.html @@ -1,3 +1,20 @@ + + @@ -12,7 +29,7 @@

DFP Integrated Training and Inference

- +
diff --git a/examples/digital_fingerprinting/demo/cm_app/templates/training.html b/examples/digital_fingerprinting/demo/cm_app/templates/training.html index c3b9246fa9..c7f62193b4 100644 --- a/examples/digital_fingerprinting/demo/cm_app/templates/training.html +++ b/examples/digital_fingerprinting/demo/cm_app/templates/training.html @@ -1,3 +1,20 @@ + + diff --git a/examples/digital_fingerprinting/demo/cm_app/views.py b/examples/digital_fingerprinting/demo/cm_app/views.py index 29905915e3..47a8d8aba8 100644 --- a/examples/digital_fingerprinting/demo/cm_app/views.py +++ b/examples/digital_fingerprinting/demo/cm_app/views.py @@ -1,3 +1,19 @@ +#!/usr/bin/env python +# SPDX-FileCopyrightText: Copyright (c) 2022-2023, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + import logging from cm_app.helper import KafkaWriter diff --git a/examples/digital_fingerprinting/demo/cm_app/webapp.py b/examples/digital_fingerprinting/demo/cm_app/webapp.py index 9ddaeedc17..202bb323a4 100644 --- a/examples/digital_fingerprinting/demo/cm_app/webapp.py +++ b/examples/digital_fingerprinting/demo/cm_app/webapp.py @@ -1,3 +1,19 @@ +#!/usr/bin/env python +# SPDX-FileCopyrightText: Copyright (c) 2022-2023, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + # Entry point for the application. from . import app # noqa: F401 from . import views # noqa: F401 diff --git a/examples/digital_fingerprinting/demo/review_results.md b/examples/digital_fingerprinting/demo/review_results.md index cafe6a7b77..f443ea38cc 100644 --- a/examples/digital_fingerprinting/demo/review_results.md +++ b/examples/digital_fingerprinting/demo/review_results.md @@ -1,3 +1,19 @@ + + # Review Inference Results GUI TODO (Bhargav) Need to decide on how to send the selected/flagged logs for retraining using submit control message feature. diff --git a/examples/digital_fingerprinting/demo/submit_messages.md b/examples/digital_fingerprinting/demo/submit_messages.md index 21dcfe7a20..28a0ef625b 100644 --- a/examples/digital_fingerprinting/demo/submit_messages.md +++ b/examples/digital_fingerprinting/demo/submit_messages.md @@ -1,3 +1,19 @@ + + # Multi Control Message GUI ## Introduction diff --git a/examples/digital_fingerprinting/demo/training.md b/examples/digital_fingerprinting/demo/training.md index 43e63157c6..96e287e29b 100644 --- a/examples/digital_fingerprinting/demo/training.md +++ b/examples/digital_fingerprinting/demo/training.md @@ -1,3 +1,19 @@ + + # Training Control Message GUI ## Introduction From efd20bd0331c41035e9fb806329b0254fe95ff5f Mon Sep 17 00:00:00 2001 From: Devin Robison Date: Fri, 24 Mar 2023 15:39:41 -0600 Subject: [PATCH 132/157] Update module docs to support tabular formatting --- docs/source/modules/core/data_loader.md | 15 +- docs/source/modules/core/file_batcher.md | 54 ++-- docs/source/modules/core/file_to_df.md | 24 +- .../modules/core/filter_control_message.md | 13 +- docs/source/modules/core/filter_detections.md | 26 +- .../modules/core/mlflow_model_writer.md | 22 +- docs/source/modules/core/serializer.md | 15 +- docs/source/modules/core/write_to_file.md | 15 +- .../digital_fingerprinting/dfp_data_prep.md | 20 +- .../digital_fingerprinting/dfp_deployment.md | 284 +++++++----------- .../digital_fingerprinting/dfp_inference.md | 8 +- .../dfp_inference_pipe.md | 83 +++-- .../dfp_postprocessing.md | 6 +- .../digital_fingerprinting/dfp_preproc.md | 63 ++-- .../dfp_rolling_window.md | 28 +- .../digital_fingerprinting/dfp_split_users.md | 27 +- .../digital_fingerprinting/dfp_training.md | 10 +- .../dfp_training_pipe.md | 176 ++++------- morpheus/loaders/file_to_df_loader.py | 2 +- 19 files changed, 446 insertions(+), 445 deletions(-) diff --git a/docs/source/modules/core/data_loader.md b/docs/source/modules/core/data_loader.md index fe8fc091ed..5befe55b2e 100644 --- a/docs/source/modules/core/data_loader.md +++ b/docs/source/modules/core/data_loader.md @@ -23,8 +23,16 @@ are specified in the module configuration file at the time of object constructio ### Configurable Parameters -- `loaders`: An array containing entries for `id` and `properties` for each loader. Each `id` is a unique identifier for - the loader and `properties` is a dictionary of properties for that loader. +| Parameter | Type | Description | Example Value | Default Value | +|-----------|-------|---------------------------------------------------|---------------|---------------| +| `loaders` | array | An array containing information on loaders to use | See Below | [] | + +### `loaders` + +| Parameter | Type | Description | Example Value | Default Value | +|--------------|------------|------------------------------------------|----------------------------------------|---------------| +| `id` | string | Unique identifier for the loader | `loader1` | - | +| `properties` | dictionary | Dictionary of properties for that loader | `{... loader specific parameters ...}` | `{}` | ### Example JSON Configuration @@ -44,4 +52,5 @@ are specified in the module configuration file at the time of object constructio } } ] -} \ No newline at end of file +} +``` \ No newline at end of file diff --git a/docs/source/modules/core/file_batcher.md b/docs/source/modules/core/file_batcher.md index 26f7419707..270ee650fc 100644 --- a/docs/source/modules/core/file_batcher.md +++ b/docs/source/modules/core/file_batcher.md @@ -17,24 +17,37 @@ limitations under the License. ## File Batcher Module -This module loads the input files, removes files that are older than the chosen window of time, and then groups the remaining files by period that fall inside the window. +This module loads the input files, removes files that are older than the chosen window of time, and then groups the +remaining files by period that fall inside the window. ### Configurable Parameters -- `batching_options`: A dictionary containing the following options: - - `end_time`: End time of the time window (datetime or string format) - - `iso_date_regex_pattern`: Regex pattern for ISO date matching - - `parser_kwargs`: Additional arguments for the parser (dictionary) - - `period`: Time period for grouping files (e.g., '1d' for 1 day) - - `sampling_rate_s`: Sampling rate in seconds (integer) - - `start_time`: Start time of the time window (datetime or string format) -- `cache_dir`: Cache directory (string) -- `file_type`: File type (string) -- `filter_nulls`: Whether to filter null values (boolean) - - `True`: Filter null values - - `False`: Don't filter null values -- `schema`: Data schema (dictionary) -- `timestamp_column_name`: Name of the timestamp column (string) +| Parameter | Type | Description | Example Value | Default Value | +|-------------------------|------------|-------------------------------|------------------------|---------------| +| `batching_options` | dictionary | Options for batching | See below | - | +| `cache_dir` | string | Cache directory | `./file_batcher_cache` | None | +| `file_type` | string | File type | JSON | JSON | +| `filter_nulls` | boolean | Whether to filter null values | false | false | +| `schema` | dictionary | Data schema | See below | `[Required]` | +| `timestamp_column_name` | string | Name of the timestamp column | timestamp | timestamp | + +### `batching_options` + +| Key | Type | Description | Example Value | Default Value | +|--------------------------|-----------------|-------------------------------------|---------------------------------------------|--------------------------| +| `end_time` | datetime/string | Endtime of the time window | "2023-03-14T23:59:59" | None | +| `iso_date_regex_pattern` | string | Regex pattern for ISO date matching | "\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}" | | +| `parser_kwargs` | dictionary | Additional arguments for the parser | {} | {} | +| `period` | string | Time period for grouping files | "1d" | "1d" | +| `sampling_rate_s` | integer | Sampling rate in seconds | 60 | 60 | +| `start_time` | datetime/string | Start time of the time window | "2023-03-01T00:00:00" | None | + +### `schema` + +| Key | Type | Description | Example Value | Default Value | +|--------------|--------|---------------|---------------|---------------| +| `encoding` | string | Encoding | "latin1" | "latin1" | +| `schema_str` | string | Schema string | "string" | `[Required]` | ### Example JSON Configuration @@ -42,19 +55,18 @@ This module loads the input files, removes files that are older than the chosen { "batching_options": { "end_time": "2023-03-14T23:59:59", - "iso_date_regex_pattern": "\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}", + "iso_date_regex_pattern": "\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}", "parser_kwargs": {}, "period": "1d", "sampling_rate_s": 60, "start_time": "2023-03-01T00:00:00" }, "cache_dir": "./file_batcher_cache", - "file_type": "csv", - "filter_nulls": true, + "file_type": "JSON", + "filter_nulls": false, "schema": { - "column1": "string", - "column2": "int", - "timestamp": "datetime" + "schema_str": "string", + "encoding": "latin1" }, "timestamp_column_name": "timestamp" } diff --git a/docs/source/modules/core/file_to_df.md b/docs/source/modules/core/file_to_df.md index ddff8b58bd..9bd75ced32 100644 --- a/docs/source/modules/core/file_to_df.md +++ b/docs/source/modules/core/file_to_df.md @@ -17,16 +17,19 @@ limitations under the License. ## File to DataFrame Module -This module reads data from the batched files into a dataframe after receiving input from the "FileBatcher" module. In addition to loading data from the disk, it has the ability to load the file content from S3 buckets. +This module reads data from the batched files into a dataframe after receiving input from the "FileBatcher" module. In +addition to loading data from the disk, it has the ability to load the file content from S3 buckets. ### Configurable Parameters -- `cache_dir` (str): Directory to cache the rolling window data. -- `file_type` (str): Type of the input file. -- `filter_null` (bool): Whether to filter out null values. -- `parser_kwargs` (dict): Keyword arguments to pass to the parser. -- `schema` (dict): Schema of the input data. -- `timestamp_column_name` (str): Name of the timestamp column. +| Parameter | Type | Description | Example Value | Default Value | +|-------------------------|------------|--------------------------------------------|----------------------|---------------| +| `cache_dir` | string | Directory to cache the rolling window data | `/path/to/cache` | - | +| `file_type` | string | Type of the input file | `csv` | JSON | +| `filter_null` | boolean | Whether to filter out null values | true | false | +| `parser_kwargs` | dictionary | Keyword arguments to pass to the parser | `{"delimiter": ","}` | - | +| `schema` | dictionary | Schema of the input data | See Below | - | +| `timestamp_column_name` | string | Name of the timestamp column | `timestamp` | - | ### Example JSON Configuration @@ -39,9 +42,8 @@ This module reads data from the batched files into a dataframe after receiving i "delimiter": "," }, "schema": { - "column1": "float", - "column2": "float" + "schema_str": "string", + "encoding": "latin1" }, "timestamp_column_name": "timestamp" -} -``` \ No newline at end of file +} \ No newline at end of file diff --git a/docs/source/modules/core/filter_control_message.md b/docs/source/modules/core/filter_control_message.md index 8cd0fce8be..bf27aaa8ba 100644 --- a/docs/source/modules/core/filter_control_message.md +++ b/docs/source/modules/core/filter_control_message.md @@ -21,10 +21,12 @@ When the requirements are met, this module gently discards the control messages. ### Configurable Parameters -- `enable_task_filtering` (bool): Enables filtering based on task type. -- `enable_data_type_filtering` (bool): Enables filtering based on data type. -- `filter_task_type` (str): The task type to be used as a filter. -- `filter_data_type` (str): The data type to be used as a filter. +| Parameter | Type | Description | Example Value | Default Value | +|------------------------------|---------|--------------------------------------|---------------------|---------------| +| `enable_data_type_filtering` | boolean | Enables filtering based on data type | true | false | +| `enable_task_filtering` | boolean | Enables filtering based on task type | true | false | +| `filter_data_type` | string | The data type to be used as a filter | `desired_data_type` | None | +| `filter_task_type` | string | The task type to be used as a filter | `specific_task` | None | ### Example JSON Configuration @@ -34,5 +36,4 @@ When the requirements are met, this module gently discards the control messages. "enable_data_type_filtering": true, "filter_task_type": "specific_task", "filter_data_type": "desired_data_type" -} -``` \ No newline at end of file +} \ No newline at end of file diff --git a/docs/source/modules/core/filter_detections.md b/docs/source/modules/core/filter_detections.md index 3299318e76..8683fe2e0a 100644 --- a/docs/source/modules/core/filter_detections.md +++ b/docs/source/modules/core/filter_detections.md @@ -19,17 +19,27 @@ limitations under the License. Filter message by a classification threshold. -The Filter Detections module is used to filter rows from a dataframe based on values in a tensor using a specified criteria. Rows in the `meta` dataframe are excluded if their associated value in the `probs` array is less than or equal to `threshold`. +The Filter Detections module is used to filter rows from a dataframe based on values in a tensor using a specified +criteria. Rows in the `meta` dataframe are excluded if their associated value in the `probs` array is less than or equal +to `threshold`. ### Configurable Parameters -- `field_name` (str): Name of the field to filter on. Defaults to `probs`. -- `threshold` (float): Threshold value to filter on. Defaults to `0.5`. -- `filter_source` (str): Source of the filter field. Defaults to `AUTO`. -- `copy` (bool): Whether to copy the rows or slice them. Defaults to `True`. -- `schema` (dict): Schema configuration. - - `input_message_type` (str): Pickled message type. - - `encoding` (str): Encoding used to pickle the message type. +| Parameter | Type | Description | Example Value | Default Value | +|-----------------|------------|----------------------------------------|---------------|---------------| +| `copy` | boolean | Whether to copy the rows or slice them | true | true | +| `field_name` | string | Name of the field to filter on | `probs` | probs | +| `filter_source` | string | Source of the filter field | `AUTO` | AUTO | +| `schema` | dictionary | Schema configuration | See Below | - | +| `threshold` | float | Threshold value to filter on | 0.5 | 0.5 | + +### `schema` + +| Key | Type | Description | Example Value | Default Value | +|----------------------|--------|----------------------|-----------------------|---------------| +| `encoding` | string | Encoding | "latin1" | "latin1" | +| `input_message_type` | string | Pickled message type | `pickle_message_type` | `[Required]` | +| `schema_str` | string | Schema string | "string" | `[Required]` | ### Example JSON Configuration diff --git a/docs/source/modules/core/mlflow_model_writer.md b/docs/source/modules/core/mlflow_model_writer.md index bc272a00f9..614bebbe5d 100644 --- a/docs/source/modules/core/mlflow_model_writer.md +++ b/docs/source/modules/core/mlflow_model_writer.md @@ -21,11 +21,20 @@ This module uploads trained models to the MLflow server. ### Configurable Parameters -- `model_name_formatter` (str): Formatter for the model name. -- `experiment_name_formatter` (str): Formatter for the experiment name. -- `conda_env` (str): Conda environment for the model. -- `timestamp_column_name` (str): Name of the timestamp column. -- `databricks_permissions` (dict): Permissions for the model. +| Parameter | Type | Description | Example Value | Default Value | +|-----------------------------|------------|-----------------------------------|-------------------------------|---------------| +| `conda_env` | string | Conda environment for the model | `path/to/conda_env.yml` | `[Required]` | +| `databricks_permissions` | dictionary | Permissions for the model | See Below | None | +| `experiment_name_formatter` | string | Formatter for the experiment name | `experiment_name_{timestamp}` | `[Required]` | +| `model_name_formatter` | string | Formatter for the model name | `model_name_{timestamp}` | `[Required]` | +| `timestamp_column_name` | string | Name of the timestamp column | `timestamp` | timestamp | + +### `databricks_permissions` + +| Key | Type | Description | Example Value | Default Value | +|---------|-------|--------------------------------------|----------------------------------|---------------| +| `read` | array | List of users with read permissions | `["read_user1", "read_user2"]` | - | +| `write` | array | List of users with write permissions | `["write_user1", "write_user2"]` | - | ### Example JSON Configuration @@ -39,5 +48,4 @@ This module uploads trained models to the MLflow server. "read": ["read_user1", "read_user2"], "write": ["write_user1", "write_user2"] } -} -``` \ No newline at end of file +} \ No newline at end of file diff --git a/docs/source/modules/core/serializer.md b/docs/source/modules/core/serializer.md index df40581647..9193d39778 100644 --- a/docs/source/modules/core/serializer.md +++ b/docs/source/modules/core/serializer.md @@ -21,11 +21,13 @@ This module filters columns from a `MultiMessage` object, emitting a `MessageMet ### Configurable Parameters -- `include` (str): Regex to include columns. -- `exclude` (List[str]): List of regex patterns to exclude columns. -- `fixed_columns` (bool): If true, the columns are fixed and not determined at runtime. -- `columns` (List[str]): List of columns to include. -- `use_cpp` (bool): If true, use C++ to serialize. +| Parameter | Type | Description | Example Value | Default Value | +|-----------------|--------------|--------------------------------------------------------------|-------------------------------------|-----------------------| +| `columns` | list[string] | List of columns to include | `["column1", "column2", "column3"]` | None | +| `exclude` | list[string] | List of regex patterns to exclude columns | `["column_to_exclude"]` | `[r'^ID$', r'^_ts_']` | +| `fixed_columns` | bool | If true, the columns are fixed and not determined at runtime | `true` | true | +| `include` | string | Regex to include columns | `^column` | None | +| `use_cpp` | bool | If true, use C++ to serialize | `true` | false | ### Example JSON Configuration @@ -36,5 +38,4 @@ This module filters columns from a `MultiMessage` object, emitting a `MessageMet "fixed_columns": true, "columns": ["column1", "column2", "column3"], "use_cpp": true -} -``` \ No newline at end of file +} \ No newline at end of file diff --git a/docs/source/modules/core/write_to_file.md b/docs/source/modules/core/write_to_file.md index 83a8e6e657..033dc5d31c 100644 --- a/docs/source/modules/core/write_to_file.md +++ b/docs/source/modules/core/write_to_file.md @@ -21,11 +21,13 @@ This module writes messages to a file. ### Configurable Parameters -- `filename` (str): Path to the output file. -- `overwrite` (bool): If true, overwrite the file if it exists. -- `flush` (bool): If true, flush the file after each write. -- `file_type` (FileTypes): Type of file to write. -- `include_index_col` (bool): If true, include the index column. +| Parameter | Type | Description | Example Value | Default Value | +|---------------------|-----------|------------------------------------------|-----------------|------------------| +| `filename` | string | Path to the output file | `output.csv` | None | +| `file_type` | FileTypes | Type of file to write | `FileTypes.CSV` | `FileTypes.Auto` | +| `flush` | bool | If true, flush the file after each write | `false` | false | +| `include_index_col` | bool | If true, include the index column | `false` | true | +| `overwrite` | bool | If true, overwrite the file if it exists | `true` | false | ### Example JSON Configuration @@ -36,5 +38,4 @@ This module writes messages to a file. "flush": false, "file_type": "CSV", "include_index_col": false -} -``` \ No newline at end of file +} \ No newline at end of file diff --git a/docs/source/modules/examples/digital_fingerprinting/dfp_data_prep.md b/docs/source/modules/examples/digital_fingerprinting/dfp_data_prep.md index 4db6d577a5..85b03e9d9a 100644 --- a/docs/source/modules/examples/digital_fingerprinting/dfp_data_prep.md +++ b/docs/source/modules/examples/digital_fingerprinting/dfp_data_prep.md @@ -21,11 +21,18 @@ This module function prepares data for either inference or model training. ### Configurable Parameters -- `schema`: (dict) - - `schema_str`: (str) Serialized string representing the schema. - - `encoding`: (str) Encoding type for the schema_str. - - `input_message_type`: (str) Pickled message type. -- `timestamp_column_name`: Name of the timestamp column (string, default: 'timestamp') +| Parameter | Type | Description | Example Value | Default Value | +|-------------------------|--------|------------------------------|---------------|---------------| +| `schema` | dict | Schema configuration | See Below | - | +| `timestamp_column_name` | string | Name of the timestamp column | `timestamp` | timestamp | + +#### `schema` + +| Key | Type | Description | Example Value | Default Value | +|----------------------|--------|----------------------------------|---------------------------|---------------| +| `schema_str` | string | Serialized schema string | `"cPickle schema string"` | - | +| `encoding` | string | Encoding used for the schema_str | `"latin1"` | - | +| `input_message_type` | string | Pickled message type | `"message type"` | - | ### Example JSON Configuration @@ -37,5 +44,4 @@ This module function prepares data for either inference or model training. "input_message_type": "message type" }, "timestamp_column_name": "timestamp" -} -``` \ No newline at end of file +} \ No newline at end of file diff --git a/docs/source/modules/examples/digital_fingerprinting/dfp_deployment.md b/docs/source/modules/examples/digital_fingerprinting/dfp_deployment.md index 2145b4d69f..b00c9e9232 100644 --- a/docs/source/modules/examples/digital_fingerprinting/dfp_deployment.md +++ b/docs/source/modules/examples/digital_fingerprinting/dfp_deployment.md @@ -15,177 +15,121 @@ See the License for the specific language governing permissions and limitations under the License. --> -## Pipeline Module +## DFP Deployment Module -This module function sets up a pipeline builder instance. +This module function sets up modular Digital Fingerprinting Pipeline instance. ### Configurable Parameters -- `training_options` (dict): Options for the training pipeline module, including: - - `timestamp_column_name` (str): Name of the timestamp column used in the data - - `cache_dir` (str): Directory to cache the rolling window data - - `batching_options` (dict): Options for batching the data, including: - - `end_time` (datetime|str): End time of the time window - - `iso_date_regex_pattern` (str): Regex pattern for ISO date matching - - `parser_kwargs` (dict): Additional arguments for the parser - - `period` (str): Time period for grouping files - - `sampling_rate_s` (int): Sampling rate in seconds - - `start_time` (datetime|str): Start time of the time window - - `user_splitting_options` (dict): Options for splitting the data by user, including: - - `fallback_username` (str): User ID to use if user ID not found (default: 'generic_user') - - `include_generic` (bool): Include generic user ID in output (default: False) - - `include_individual` (bool): Include individual user IDs in output (default: False) - - `only_users` (list): List of user IDs to include in output, others will be excluded (default: []) - - `skip_users` (list): List of user IDs to exclude from output (default: []) - - `timestamp_column_name` (str): Name of column containing timestamps (default: 'timestamp') - - `userid_column_name` (str): Name of column containing user IDs (default: 'username') - - `stream_aggregation_options` (dict): Options for aggregating the data by stream - - `preprocessing_options` (dict): Options for preprocessing the data - - `dfencoder_options` (dict): Options for configuring the data frame encoder, used for training the model - - `mlflow_writer_options` (dict): Options for the MLflow model writer, responsible for saving the trained model, - including: - - `model_name_formatter` (str): Format string for the model name, e.g. "model_{timestamp}" - - `experiment_name_formatter` (str): Format string for the experiment name, e.g. "experiment_{timestamp}" - - `timestamp_column_name` (str): Name of the timestamp column used in the data - - `conda_env` (dict): Conda environment settings, including: - - `channels` (list): List of channels to use for the environment - - `dependencies` (list): List of dependencies for the environment - - `pip` (list): List of pip packages to install in the environment - - `name` (str): Name of the conda environment -- `inference_options` (dict): Options for the inference pipeline module, including: - - `model_name_formatter` (str): Format string for the model name, e.g. "model_{timestamp}" - - `fallback_username` (str): User ID to use if user ID not found (default: 'generic_user') - - `timestamp_column_name` (str): Name of the timestamp column in the input data - - `batching_options` (dict): Options for batching the data, including: - [omitted for brevity] - - `cache_dir` (str): Directory to cache the rolling window data - - `detection_criteria` (dict): Criteria for filtering detections, such as threshold and field_name - - `inference_options` (dict): Options for the inference module, including model settings and other configurations - - `num_output_ports` (int): Number of output ports for the module - - `preprocessing_options` (dict): Options for preprocessing the data, including schema and timestamp column name - - `stream_aggregation_options` (dict): Options for aggregating the data by stream, including: - - `aggregation_span` (int): The time span for the aggregation window, in seconds - - `cache_to_disk` (bool): Whether to cache the aggregated data to disk - - `user_splitting_options` (dict): Options for splitting the data by user, including: - [omitted for brevity] - - `write_to_file_options` (dict): Options for writing the detections to a file, such as filename and overwrite - settings - -### Example JSON Configuration - -```json -{ - "training_options": { - "timestamp_column_name": "my_timestamp", - "cache_dir": "/path/to/cache/dir", - "batching_options": { - "end_time": "2023-03-17 12:00:00", - "iso_date_regex_pattern": "YYYY-MM-DD", - "parser_kwargs": { - "delimiter": "," - }, - "period": "1h", - "sampling_rate_s": 5, - "start_time": "2023-03-17 11:00:00" - }, - "user_splitting_options": { - "fallback_username": "generic_user", - "include_generic": false, - "include_individual": false, - "only_users": [ - "user1", - "user2" - ], - "skip_users": [ - "user3", - "user4" - ], - "timestamp_column_name": "timestamp", - "userid_column_name": "username" - }, - "stream_aggregation_options": { - "aggregation_span": 60, - "cache_to_disk": true - }, - "preprocessing_options": { - "option1": "value1", - "option2": "value2" - }, - "dfencoder_options": { - "option1": "value1", - "option2": "value2" - }, - "mlflow_writer_options": { - "model_name_formatter": "model_{timestamp}", - "experiment_name_formatter": "experiment_{timestamp}", - "timestamp_column_name": "my_timestamp", - "conda_env": { - "channels": [ - "conda-forge", - "defaults" - ], - "dependencies": [ - "numpy", - "pandas" - ], - "pip": [ - "tensorflow==2.5.0" - ], - "name": "my_conda_env" - } - } - }, - "inference_options": { - "model_name_formatter": "model_{timestamp}", - "fallback_username": "generic_user", - "timestamp_column_name": "timestamp", - "batching_options": { - "end_time": "2023-03-17 14:00:00", - "iso_date_regex_pattern": "YYYY-MM-DD", - "parser_kwargs": { - "delimiter": "," - }, - "period": "1h", - "sampling_rate_s": 5, - "start_time": "2023-03-17 13:00:00" - }, - "cache_dir": "/path/to/cache/dir", - "detection_criteria": { - "threshold": 0.5, - "field_name": "score" - }, - "inference_options": { - "option1": "value1", - "option2": "value2" - }, - "num_output_ports": 3, - "preprocessing_options": { - "option1": "value1", - "option2": "value2" - }, - "stream_aggregation_options": { - "aggregation_span": 60, - "cache_to_disk": true - }, - "user_splitting_options": { - "fallback_username": "generic_user", - "include_generic": false, - "include_individual": false, - "only_users": [ - "user1", - "user2" - ], - "skip_users": [ - "user3", - "user4" - ], - "timestamp_column_name": "timestamp", - "userid_column_name": "username" - }, - "write_to_file_options": { - "filename": "output.txt", - "overwrite": true - } - } -} -``` \ No newline at end of file +| Parameter | Type | Description | Example Value | Default Value | +|---------------------|------|-------------------------------------------|---------------|---------------| +| `inference_options` | dict | Options for the inference pipeline module | See Below | `[Required]` | +| `training_options` | dict | Options for the training pipeline module | See Below | `[Required]` | + +### Training Options Parameters + +| Parameter | Type | Description | Example Value | Default Value | +|------------------------------|------|------------------------------------------------|----------------------|---------------| +| `batching_options` | dict | Options for batching the data | See Below | - | +| `cache_dir` | str | Directory to cache the rolling window data | "/path/to/cache/dir" | ./.cache | +| `dfencoder_options` | dict | Options for configuring the data frame encoder | See Below | - | +| `mlflow_writer_options` | dict | Options for the MLflow model writer | See Below | - | +| `preprocessing_options` | dict | Options for preprocessing the data | See Below | - | +| `stream_aggregation_options` | dict | Options for aggregating the data by stream | See Below | - | +| `timestamp_column_name` | str | Name of the timestamp column used in the data | "my_timestamp" | "timestamp" | +| `user_splitting_options` | dict | Options for splitting the data by user | See Below | - | + +### Inference Options Parameters + +| Parameter | Type | Description | Example Value | Default Value | +|------------------------------|------|------------------------------------------------|----------------------|----------------| +| `batching_options` | dict | Options for batching the data | See Below | - | +| `cache_dir` | str | Directory to cache the rolling window data | "/path/to/cache/dir" | ./.cache | +| `detection_criteria` | dict | Criteria for filtering detections | See Below | - | +| `fallback_username` | str | User ID to use if user ID not found | "generic_user" | "generic_user" | +| `inference_options` | dict | Options for the inference module | See Below | - | +| `model_name_formatter` | str | Format string for the model name | "model_{timestamp}" | `[Required]` | +| `num_output_ports` | int | Number of output ports for the module | 3 | - | +| `timestamp_column_name` | str | Name of the timestamp column in the input data | "timestamp" | "timestamp" | +| `stream_aggregation_options` | dict | Options for aggregating the data by stream | See Below | - | +| `user_splitting_options` | dict | Options for splitting the data by user | See Below | - | +| `write_to_file_options` | dict | Options for writing the detections to a file | See Below | - | + +### `batching_options` + +| Key | Type | Description | Example Value | Default Value | +|--------------------------|-----------------|-------------------------------------|---------------------------------------------|--------------------------| +| `end_time` | datetime/string | Endtime of the time window | "2023-03-14T23:59:59" | None | +| `iso_date_regex_pattern` | string | Regex pattern for ISO date matching | "\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}" | | +| `parser_kwargs` | dictionary | Additional arguments for the parser | {} | {} | +| `period` | string | Time period for grouping files | "1d" | "1d" | +| `sampling_rate_s` | integer | Sampling rate in seconds | 60 | 60 | +| `start_time` | datetime/string | Start time of the time window | "2023-03-01T00:00:00" | None | + +### `dfencoder_options` + +| Parameter | Type | Description | Example Value | Default Value | +|-------------------|-------|----------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------| +| `feature_columns` | list | List of feature columns to train on | ["column1", "column2", "column3"] | - | +| `epochs` | int | Number of epochs to train for | 50 | - | +| `model_kwargs` | dict | Keyword arguments to pass to the model | {"encoder_layers": [64, 32], "decoder_layers": [32, 64], "activation": "relu", "swap_p": 0.1, "lr": 0.001, "lr_decay": 0.9, "batch_size": 32, "verbose": 1, "optimizer": "adam", "scalar": "min_max", "min_cats": 10, "progress_bar": false, "device": "cpu"} | - | +| `validation_size` | float | Size of the validation set | 0.1 | - | + +### `mlflow_writer_options` + +| Key | Type | Description | Example Value | Default Value | +|-----------------------------|------------|-----------------------------------|-------------------------------|---------------| +| `conda_env` | string | Conda environment for the model | `path/to/conda_env.yml` | `[Required]` | +| `databricks_permissions` | dictionary | Permissions for the model | See Below | None | +| `experiment_name_formatter` | string | Formatter for the experiment name | `experiment_name_{timestamp}` | `[Required]` | +| `model_name_formatter` | string | Formatter for the model name | `model_name_{timestamp}` | `[Required]` | +| `timestamp_column_name` | string | Name of the timestamp column | `timestamp` | timestamp | + +### `stream_aggregation_options` + +| Parameter | Type | Description | Example Value | Default Value | +|-------------------------|--------|-------------------------------------------------------------|---------------|---------------| +| `cache_mode` | string | The user ID to use if the user ID is not found | 'batch' | 'batch' | +| `min_history` | int | Minimum history to trigger a new training event | 1 | 1 | +| `max_history` | int | Maximum history to include in a new training event | 0 | 0 | +| `timestamp_column_name` | string | Name of the column containing timestamps | 'timestamp' | 'timestamp' | +| `aggregation_span` | string | Lookback timespan for training data in a new training event | '60d' | '60d' | +| `cache_to_disk` | bool | Whether or not to cache streaming data to disk | false | false | +| `cache_dir` | string | Directory to use for caching streaming data | './.cache' | './.cache' | + +### `user_splitting_options` + +| Key | Type | Description | Example Value | Default Value | +|-------------------------|------|------------------------------------------------------|-----------------------------|----------------| +| `fallback_username` | str | The user ID to use if the user ID is not found | "generic_user" | 'generic_user' | +| `include_generic` | bool | Whether to include a generic user ID in the output | false | False | +| `include_individual` | bool | Whether to include individual user IDs in the output | true | False | +| `only_users` | list | List of user IDs to include; others will be excluded | ["user1", "user2", "user3"] | [] | +| `skip_users` | list | List of user IDs to exclude from the output | ["user4", "user5"] | [] | +| `timestamp_column_name` | str | Name of the column containing timestamps | "timestamp" | 'timestamp' | +| `userid_column_name` | str | Name of the column containing user IDs | "username" | 'username' | + +### `detection_criteria` + +| Key | Type | Description | Example Value | Default Value | +|--------------|-------|------------------------------------------|---------------|---------------| +| `threshold` | float | Threshold for filtering detections | 0.5 | 0.5 | +| `field_name` | str | Name of the field to filter by threshold | "score" | probs | + +### `inference_options` + +| Parameter | Type | Description | Example Value | Default Value | +|-------------------------|--------|------------------------------------------------------|-------------------------|---------------| +| `model_name_formatter` | string | Formatter for model names | "user_{username}_model" | `[Required]` | +| `fallback_username` | string | Fallback user to use if no model is found for a user | "generic_user" | generic_user | +| `timestamp_column_name` | string | Name of the timestamp column | "timestamp" | timestamp | + +### `write_to_file_options` + +| Key | Type | Description | Example Value | Default Value | +|---------------------|-----------|------------------------------------------|-----------------|------------------| +| `filename` | string | Path to the output file | `output.csv` | None | +| `file_type` | FileTypes | Type of file to write | `FileTypes.CSV` | `FileTypes.Auto` | +| `flush` | bool | If true, flush the file after each write | `false` | false | +| `include_index_col` | bool | If true, include the index column | `false` | true | +| `overwrite` | bool | If true, overwrite the file if it exists | `true` | false | diff --git a/docs/source/modules/examples/digital_fingerprinting/dfp_inference.md b/docs/source/modules/examples/digital_fingerprinting/dfp_inference.md index e598594bbc..9ea9c65fec 100644 --- a/docs/source/modules/examples/digital_fingerprinting/dfp_inference.md +++ b/docs/source/modules/examples/digital_fingerprinting/dfp_inference.md @@ -21,9 +21,11 @@ This module function performs the inference process. ### Configurable Parameters -- `model_name_formatter`: Formatter for model names (string). -- `fallback_username`: Fallback user to use if no model is found for a user (string). -- `timestamp_column_name`: Name of the timestamp column (string). +| Parameter | Type | Description | Example Value | Default Value | +|-----------------------|--------|------------------------------------------------------|-------------------------|---------------| +| model_name_formatter | string | Formatter for model names | "user_{username}_model" | `[Required]` | +| fallback_username | string | Fallback user to use if no model is found for a user | "generic_user" | generic_user | +| timestamp_column_name | string | Name of the timestamp column | "timestamp" | timestamp | ### Example JSON Configuration diff --git a/docs/source/modules/examples/digital_fingerprinting/dfp_inference_pipe.md b/docs/source/modules/examples/digital_fingerprinting/dfp_inference_pipe.md index f1b759256a..ddcf77a3fd 100644 --- a/docs/source/modules/examples/digital_fingerprinting/dfp_inference_pipe.md +++ b/docs/source/modules/examples/digital_fingerprinting/dfp_inference_pipe.md @@ -22,36 +22,59 @@ into a single module. ### Configurable Parameters -- `timestamp_column_name` (str): Name of the column containing timestamps. -- `cache_dir` (str): Directory used for caching intermediate results. -- `batching_options` (dict): Options for batching files. - - `end_time` (str): End time of the time range to process. - - `iso_date_regex_pattern` (str): ISO date regex pattern. - - `parser_kwargs` (dict): Keyword arguments to pass to the parser. - - `period` (str): Time period to batch the data. - - `sampling_rate_s` (float): Sampling rate in seconds. - - `start_time` (str): Start time of the time range to process. -- `user_splitting_options` (dict): Options for splitting data by user. - - `fallback_username` (str): Fallback user to use if no model is found for a user. - - `include_generic` (bool): Include generic models in the results. - - `include_individual` (bool): Include individual models in the results. - - `only_users` (List[str]): List of users to include in the results. - - `skip_users` (List[str]): List of users to exclude from the results. - - `userid_column_name` (str): Column name for the user ID. -- `stream_aggregation_options` (dict): Options for aggregating data by stream. - - `timestamp_column_name` (str): Name of the column containing timestamps. - - `cache_mode` (str): Cache mode to use. - - `trigger_on_min_history` (bool): Trigger on minimum history. - - `aggregation_span` (str): Aggregation span. - - `trigger_on_min_increment` (bool): Trigger on minimum increment. - - `cache_to_disk` (bool): Cache to disk. -- `preprocessing_options` (dict): Options for preprocessing data. -- `inference_options` (dict): Options for configuring the inference process. - - `model_name_formatter` (str): Formatter for the model name. - - `fallback_username` (str): Fallback user to use if no model is found for a user. - - `timestamp_column_name` (str): Name of the column containing timestamps. -- `detection_criteria` (dict): Criteria for filtering detections. -- `write_to_file_options` (dict): Options for writing results to a file. +| Parameter | Type | Description | Example Value | Default Value | +|------------------------------|------------|--------------------------------------------------|---------------|---------------| +| `batching_options` | dictionary | Options for batching files. | See below | - | +| `cache_dir` | string | Directory used for caching intermediate results. | `/tmp/cache` | - | +| `detection_criteria` | dictionary | Criteria for filtering detections. | - | - | +| `inference_options` | dictionary | Options for configuring the inference process. | See below | - | +| `preprocessing_options` | dictionary | Options for preprocessing data. | - | - | +| `stream_aggregation_options` | dictionary | Options for aggregating data by stream. | See below | - | +| `timestamp_column_name` | string | Name of the column containing timestamps. | `timestamp` | - | +| `user_splitting_options` | dictionary | Options for splitting data by user. | See below | - | +| `write_to_file_options` | dictionary | Options for writing results to a file. | - | - | + +#### `batching_options` + +| Parameter | Type | Description | Example Value | Default Value | +|--------------------------|--------|------------------------------------------|----------------------------------------------|---------------| +| `end_time` | string | End time of the time range to process. | `2022-01-01T00:00:00Z` | - | +| `iso_date_regex_pattern` | string | ISO date regex pattern. | `\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z` | - | +| `parser_kwargs` | dict | Keyword arguments to pass to the parser. | - | - | +| `period` | string | Time period to batch the data. | `1D` | - | +| `sampling_rate_s` | float | Sampling rate in seconds. | `1.0` | - | +| `start_time` | string | Start time of the time range to process. | `2021-01-01T00:00:00Z` | - | + +#### `user_splitting_options` + +| Parameter | Type | Description | Example Value | Default Value | +|----------------------|---------|-------------------------------------------------------|-----------------------|---------------| +| `fallback_username` | string | Fallback user to use if no model is found for a user. | `generic_user` | generic_user | +| `include_generic` | boolean | Include generic models in the results. | `true` | true | +| `include_individual` | boolean | Include individual models in the results. | `true` | false | +| `only_users` | list | List of users to include in the results. | `["user_a","user_b"]` | - | +| `skip_users` | list | List of users to exclude from the results. | `["user_c"]` | - | +| `userid_column_name` | string | Column | name for the user ID. | user_id | + +### `stream_aggregation_options` + +| Parameter | Type | Description | Example Value | Default Value | +|-------------------------|--------|-------------------------------------------------------------|---------------|---------------| +| `cache_mode` | string | The user ID to use if the user ID is not found | 'batch' | 'batch' | +| `min_history` | int | Minimum history to trigger a new training event | 1 | 1 | +| `max_history` | int | Maximum history to include in a new training event | 0 | 0 | +| `timestamp_column_name` | string | Name of the column containing timestamps | 'timestamp' | 'timestamp' | +| `aggregation_span` | string | Lookback timespan for training data in a new training event | '60d' | '60d' | +| `cache_to_disk` | bool | Whether or not to cache streaming data to disk | false | false | +| `cache_dir` | string | Directory to use for caching streaming data | './.cache' | './.cache' | + +### `inference_options` + +| Parameter | Type | Description | Example Value | Default Value | +|-------------------------|--------|------------------------------------------------------|-------------------------|---------------| +| `model_name_formatter` | string | Formatter for model names | "user_{username}_model" | `[Required]` | +| `fallback_username` | string | Fallback user to use if no model is found for a user | "generic_user" | generic_user | +| `timestamp_column_name` | string | Name of the timestamp column | "timestamp" | timestamp | ### Example JSON Configuration diff --git a/docs/source/modules/examples/digital_fingerprinting/dfp_postprocessing.md b/docs/source/modules/examples/digital_fingerprinting/dfp_postprocessing.md index 83a71c6bdb..993fe7cd21 100644 --- a/docs/source/modules/examples/digital_fingerprinting/dfp_postprocessing.md +++ b/docs/source/modules/examples/digital_fingerprinting/dfp_postprocessing.md @@ -15,13 +15,15 @@ See the License for the specific language governing permissions and limitations under the License. --> -## DFP Postprocessing Module +## DFP Postprocessing Module## DFP Postprocessing Module This module function performs postprocessing tasks after the inference process. ### Configurable Parameters -- `timestamp_column_name` (string): Name of the timestamp column in the input data. +| Parameter | Type | Description | Example Value | Default Value | +|-------------------------|--------|-------------------------------------------------|---------------|---------------| +| `timestamp_column_name` | string | Name of the timestamp column in the input data. | `timestamp` | - | ### Example JSON Configuration diff --git a/docs/source/modules/examples/digital_fingerprinting/dfp_preproc.md b/docs/source/modules/examples/digital_fingerprinting/dfp_preproc.md index 54cd25314d..4a747c9946 100644 --- a/docs/source/modules/examples/digital_fingerprinting/dfp_preproc.md +++ b/docs/source/modules/examples/digital_fingerprinting/dfp_preproc.md @@ -22,28 +22,45 @@ process into a single module. ### Configurable Parameters -- `cache_dir` (str): Directory used for caching intermediate results. -- `timestamp_column_name` (str): Name of the column containing timestamps. -- `pre_filter_options` (dict): Options for pre-filtering control messages. - - `enable_task_filtering` (bool): Enables filtering based on task type. - - `filter_task_type` (str): The task type to be used as a filter. - - `enable_data_filtering` (bool): Enables filtering based on data type. - - `filter_data_type` (str): The data type to be used as a filter. -- `batching_options` (dict): Options for batching files. - - `end_time` (str): End time of the time range to process. - - `iso_date_regex_pattern` (str): ISO date regex pattern. - - `parser_kwargs` (dict): Keyword arguments to pass to the parser. - - `period` (str): Time period to batch the data. - - `sampling_rate_s` (float): Sampling rate in seconds. - - `start_time` (str): Start time of the time range to process. -- `user_splitting_options` (dict): Options for splitting data by user. - - `fallback_username` (str): Fallback user to use if no model is found for a user. - - `include_generic` (bool): Include generic models in the results. - - `include_individual` (bool): Include individual models in the results. - - `only_users` (List[str]): List of users to include in the results. - - `skip_users` (List[str]): List of users to exclude from the results. - - `userid_column_name` (str): Column name for the user ID. -- `supported_loaders` (dict): Supported data loaders for different file types. +| Parameter | Type | Description | Example Value | Default Value | +|--------------------------|------------|--------------------------------------------------|---------------|---------------| +| `cache_dir` | string | Directory used for caching intermediate results. | `/tmp/cache` | - | +| `timestamp_column_name` | string | Name of the column containing timestamps. | `timestamp` | - | +| `pre_filter_options` | dictionary | Options for pre-filtering control messages. | See Below | - | +| `batching_options` | dictionary | Options for batching files. | See Below | - | +| `user_splitting_options` | dictionary | Options for splitting data by user. | See Below | - | +| `supported_loaders` | dictionary | Supported data loaders for different file types. | - | - | + +#### `pre_filter_options` + +| Parameter | Type | Description | Example Value | Default Value | +|-------------------------|---------|---------------------------------------|---------------|---------------| +| `enable_task_filtering` | boolean | Enables filtering based on task type. | `true` | - | +| `filter_task_type` | string | The task type to be used as a filter. | `task_a` | - | +| `enable_data_filtering` | boolean | Enables filtering based on data type. | `true` | - | +| `filter_data_type` | string | The data type to be used as a filter. | `type_a` | - | + +#### `batching_options` + +| Parameter | Type | Description | Example Value | Default Value | +|--------------------------|------------|------------------------------------------|----------------------------------------|---------------| +| `end_time` | string | End time of the time range to process. | `2022-01-01T00:00:00Z` | - | +| `iso_date_regex_pattern` | string | ISO date regex pattern. | `\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z` | - | +| `parser_kwargs` | dictionary | Keyword arguments to pass to the parser. | `{}` | - | +| `period` | string | Time period to batch the data. | `1D` | - | +| `sampling_rate_s` | float | Sampling rate in seconds. | `1.0` | - | +| `start_time` | string | Start time of the time range to process. | `2021-01-01T00:00:00Z` | - | + +#### `user_splitting_options` + +| Parameter | Type | Description | Example Value | Default Value | +|----------------------|---------|-------------------------------------------------------|------------------------|---------------| +| `fallback_username` | string | Fallback user to use if no model is found for a user. | `generic` | - | +| `include_generic` | boolean | Include generic models in the results. | `true` | - | +| `include_individual` | boolean | Include individual models in the results. | `true` | - | +| `only_users` | list | List of users to include in the results. | `["user_a", "user_b"]` | - | +| `skip_users` | list | List of users to exclude from the results. | `["user_c"]` | - | +| `userid_column_name` | string | Column name for the user ID. | `user_id` | - | ### Example JSON Configuration @@ -79,5 +96,5 @@ process into a single module. "userid_column_name": "user_id" }, "supported_loaders": {} -} +} ``` \ No newline at end of file diff --git a/docs/source/modules/examples/digital_fingerprinting/dfp_rolling_window.md b/docs/source/modules/examples/digital_fingerprinting/dfp_rolling_window.md index c207ab2a2e..40d6dc6f74 100644 --- a/docs/source/modules/examples/digital_fingerprinting/dfp_rolling_window.md +++ b/docs/source/modules/examples/digital_fingerprinting/dfp_rolling_window.md @@ -21,24 +21,26 @@ This module function splits the data based on user IDs. ### Configurable Parameters -- `fallback_username`: The user ID to use if the user ID is not found (string, default: 'generic_user') -- `include_generic`: Whether to include a generic user ID in the output (boolean, default: `False`) -- `include_individual`: Whether to include individual user IDs in the output (boolean, default: `False`) -- `only_users`: List of user IDs to include in the output; other user IDs will be excluded (list, default: `[]`). *Note: You can specify either `only_users` or `skip_users`, but not both.* -- `skip_users`: List of user IDs to exclude from the output (list, default: `[]`). *Note: You can specify either `only_users` or `skip_users`, but not both.* -- `timestamp_column_name`: Name of the column containing timestamps (string, default: 'timestamp') -- `userid_column_name`: Name of the column containing user IDs (string, default: 'username') +| Parameter | Type | Description | Example Value | Default Value | +|-----------------------|--------|-------------------------------------------------------------|---------------|---------------| +| cache_mode | string | The user ID to use if the user ID is not found | 'batch' | 'batch' | +| min_history | int | Minimum history to trigger a new training event | 1 | 1 | +| max_history | int | Maximum history to include in a new training event | 0 | 0 | +| timestamp_column_name | string | Name of the column containing timestamps | 'timestamp' | 'timestamp' | +| aggregation_span | string | Lookback timespan for training data in a new training event | '60d' | '60d' | +| cache_to_disk | bool | Whether or not to cache streaming data to disk | false | false | +| cache_dir | string | Directory to use for caching streaming data | './.cache' | './.cache' | ### Example JSON Configuration ```json { - "fallback_username": "generic_user", - "include_generic": false, - "include_individual": true, - "only_users": ["user1", "user2", "user3"], - "skip_users": [], + "cache_mode": "batch", + "min_history": 1, + "max_history": 0, "timestamp_column_name": "timestamp", - "userid_column_name": "username" + "aggregation_span": "60d", + "cache_to_disk": false, + "cache_dir": "./.cache" } ``` \ No newline at end of file diff --git a/docs/source/modules/examples/digital_fingerprinting/dfp_split_users.md b/docs/source/modules/examples/digital_fingerprinting/dfp_split_users.md index f9dd7f5592..6b30c7a04c 100644 --- a/docs/source/modules/examples/digital_fingerprinting/dfp_split_users.md +++ b/docs/source/modules/examples/digital_fingerprinting/dfp_split_users.md @@ -21,13 +21,15 @@ This module function splits the data based on user IDs. ### Configurable Parameters -- `fallback_username`: The user ID to use if the user ID is not found (string, default: 'generic_user') -- `include_generic`: Whether to include a generic user ID in the output (boolean, default: `False`) -- `include_individual`: Whether to include individual user IDs in the output (boolean, default: `False`) -- `only_users`: List of user IDs to include in the output; other user IDs will be excluded (list, default: `[]`) -- `skip_users`: List of user IDs to exclude from the output (list, default: `[]`) -- `timestamp_column_name`: Name of the column containing timestamps (string, default: 'timestamp') -- `userid_column_name`: Name of the column containing user IDs (string, default: 'username') +| Key | Type | Description | Example Value | Default Value | +|-----------------------|------|------------------------------------------------------|-----------------------------|----------------| +| fallback_username | str | The user ID to use if the user ID is not found | "generic_user" | 'generic_user' | +| include_generic | bool | Whether to include a generic user ID in the output | false | False | +| include_individual | bool | Whether to include individual user IDs in the output | true | False | +| only_users | list | List of user IDs to include; others will be excluded | ["user1", "user2", "user3"] | [] | +| skip_users | list | List of user IDs to exclude from the output | ["user4", "user5"] | [] | +| timestamp_column_name | str | Name of the column containing timestamps | "timestamp" | 'timestamp' | +| userid_column_name | str | Name of the column containing user IDs | "username" | 'username' | ### Example JSON Configuration @@ -36,8 +38,15 @@ This module function splits the data based on user IDs. "fallback_username": "generic_user", "include_generic": false, "include_individual": true, - "only_users": ["user1", "user2", "user3"], - "skip_users": ["user4", "user5"], + "only_users": [ + "user1", + "user2", + "user3" + ], + "skip_users": [ + "user4", + "user5" + ], "timestamp_column_name": "timestamp", "userid_column_name": "username" } diff --git a/docs/source/modules/examples/digital_fingerprinting/dfp_training.md b/docs/source/modules/examples/digital_fingerprinting/dfp_training.md index dc3aee4711..0b07e9e63e 100644 --- a/docs/source/modules/examples/digital_fingerprinting/dfp_training.md +++ b/docs/source/modules/examples/digital_fingerprinting/dfp_training.md @@ -21,10 +21,12 @@ This module function is responsible for training the model. ## Configurable Parameters -- `feature_columns` (list): List of feature columns to train on. -- `epochs` (int): Number of epochs to train for. -- `model_kwargs` (dict): Keyword arguments to pass to the model (see dfencoder.AutoEncoder). -- `validation_size` (float): Size of the validation set. +| Parameter | Type | Description | Example Value | Default Value | +|-----------------|-------|----------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------| +| feature_columns | list | List of feature columns to train on | ["column1", "column2", "column3"] | - | +| epochs | int | Number of epochs to train for | 50 | - | +| model_kwargs | dict | Keyword arguments to pass to the model | {"encoder_layers": [64, 32], "decoder_layers": [32, 64], "activation": "relu", "swap_p": 0.1, "lr": 0.001, "lr_decay": 0.9, "batch_size": 32, "verbose": 1, "optimizer": "adam", "scalar": "min_max", "min_cats": 10, "progress_bar": false, "device": "cpu"} | - | +| validation_size | float | Size of the validation set | 0.1 | - | ## JSON Example diff --git a/docs/source/modules/examples/digital_fingerprinting/dfp_training_pipe.md b/docs/source/modules/examples/digital_fingerprinting/dfp_training_pipe.md index 510e57f79d..4371b210b7 100644 --- a/docs/source/modules/examples/digital_fingerprinting/dfp_training_pipe.md +++ b/docs/source/modules/examples/digital_fingerprinting/dfp_training_pipe.md @@ -21,116 +21,66 @@ This module function consolidates multiple DFP pipeline modules relevant to the ### Configurable Parameters -- `timestamp_column_name` (str): Name of the timestamp column used in the data. -- `cache_dir` (str): Directory to cache the rolling window data. -- `batching_options` (dict): Options for batching files. - - `end_time` (str): End time of the time range to process. - - `iso_date_regex_pattern` (str): ISO date regex pattern. - - `parser_kwargs` (dict): Keyword arguments to pass to the parser. - - `period` (str): Time period to batch the data. - - `sampling_rate_s` (float): Sampling rate in seconds. - - `start_time` (str): Start time of the time range to process. -- `user_splitting_options` (dict): Options for splitting data by user. - - `fallback_username` (str): Fallback user to use if no model is found for a user. - - `include_generic` (bool): Include generic models in the results. - - `include_individual` (bool): Include individual models in the results. - - `only_users` (List[str]): List of users to include in the results. - - `skip_users` (List[str]): List of users to exclude from the results. - - `userid_column_name` (str): Column name for the user ID. -- `stream_aggregation_options` (dict): Options for aggregating data by stream. - - `timestamp_column_name` (str): Name of the column containing timestamps. - - `cache_mode` (str): Cache mode to use. - - `trigger_on_min_history` (bool): Trigger on minimum history. - - `aggregation_span` (str): Aggregation span. - - `trigger_on_min_increment` (bool): Trigger on minimum increment. - - `cache_to_disk` (bool): Cache to disk. -- `preprocessing_options` (dict): Options for preprocessing the data. -- `dfencoder_options` (dict): Options for configuring the data frame encoder, used for training the model. -- `mlflow_writer_options` (dict): Options for the MLflow model writer, which is responsible for saving the trained - model. - -### Example JSON Configuration - -```json -{ - "timestamp_column_name": "timestamp", - "cache_dir": "/tmp/cache", - "batching_options": { - "end_time": "2023-03-01T00:00:00", - "iso_date_regex_pattern": "\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}", - "parser_kwargs": {}, - "period": "1min", - "sampling_rate_s": 60, - "start_time": "2023-02-01T00:00:00" - }, - "user_splitting_options": { - "fallback_username": "generic", - "include_generic": true, - "include_individual": true, - "only_users": [], - "skip_users": [], - "userid_column_name": "user_id" - }, - "stream_aggregation_options": { - "timestamp_column_name": "timestamp", - "cache_mode": "memory", - "trigger_on_min_history": 1, - "aggregation_span": "1min", - "trigger_on_min_increment": 1, - "cache_to_disk": false - }, - "preprocessing_options": { - "enable_task_filtering": true, - "filter_task_type": "taskA", - "enable_data_filtering": true, - "filter_data_type": "typeA" - }, - "dfencoder_options": { - "feature_columns": [ - "column1", - "column2" - ], - "epochs": 10, - "validation_size": 0.2, - "model_kwargs": { - "encoder_layers": [ - 128, - 64 - ], - "decoder_layers": [ - 64, - 128 - ], - "activation": "relu", - "swap_p": 0.1, - "lr": 0.001, - "lr_decay": 0.99, - "batch_size": 256, - "verbose": 1, - "optimizer": "adam", - "scalar": "minmax", - "min_cats": 2, - "progress_bar": true, - "device": "cpu" - } - }, - "mlflow_writer_options": { - "model_name_formatter": "trained_model_{timestamp}", - "experiment_name_formatter": "training_experiment_{timestamp}", - "conda_env": "path/to/conda_env.yml", - "timestamp_column_name": "timestamp", - "databricks_permissions": { - "read_users": [ - "user1", - "user2" - ], - "write_users": [ - "user1" - ], - "manage_users": [ - "user1" - ] - } - } -} -``` \ No newline at end of file +| Key | Type | Description | Example Value | Default Value | +|------------------------------|------|-----------------------------------------------------------------------------------------|---------------|---------------| +| `timestamp_column_name` | str | Name of the timestamp column used in the data. | "timestamp" | - | +| `cache_dir` | str | Directory to cache the rolling window data. | "/tmp/cache" | - | +| `batching_options` | dict | Options for batching files. | See Below | - | +| `user_splitting_options` | dict | Options for splitting data by user. | See Below | - | +| `stream_aggregation_options` | dict | Options for aggregating data by stream. | See Below | - | +| `preprocessing_options` | dict | Options for preprocessing the data. | - | - | +| `dfencoder_options` | dict | Options for configuring the data frame encoder, used for training the model. | See Below | - | +| `mlflow_writer_options` | dict | Options for the MLflow model writer, which is responsible for saving the trained model. | See Below | - | + +### `batching_options` + +| Key | Type | Description | Example Value | Default Value | +|--------------------------|-------|------------------------------------------|---------------------------------------------------------|---------------| +| `end_time` | str | End time of the time range to process. | "2023-03-01T00:00:00" | - | +| `iso_date_regex_pattern` | str | ISO date regex pattern. | "\\\\d{4}-\\\\d{2}-\\\\d{2}T\\\\d{2}:\\\\d{2}:\\\\d{2}" | - | +| `parser_kwargs` | dict | Keyword arguments to pass to the parser. | {} | - | +| `period` | str | Time period to batch the data. | "1min" | - | +| `sampling_rate_s` | float | Sampling rate in seconds. | 60 | - | +| `start_time` | str | Start time of the time range to process. | "2023-02-01T00:00:00" | - | + +### `user_splitting_options` + +| Key | Type | Description | Example Value | Default Value | +|----------------------|-----------|-------------------------------------------------------|---------------|---------------| +| `fallback_username` | str | Fallback user to use if no model is found for a user. | "generic" | - | +| `include_generic` | bool | Include generic models in the results. | true | - | +| `include_individual` | bool | Include individual models in the results. | true | - | +| `only_users` | List[str] | List of users to include in the results. | [] | - | +| `skip_users` | List[str] | List of users to exclude from the results. | [] | - | +| `userid_column_name` | str | Column name for the user ID. | "user_id" | - | + +### `stream_aggregation_options` + +| Key | Type | Description | Example Value | Default Value | +|-------------------------|--------|-------------------------------------------------------------|---------------|---------------| +| `cache_mode` | string | The user ID to use if the user ID is not found | 'batch' | 'batch' | +| `min_history` | int | Minimum history to trigger a new training event | 1 | 1 | +| `max_history` | int | Maximum history to include in a new training event | 0 | 0 | +| `timestamp_column_name` | string | Name of the column containing timestamps | 'timestamp' | 'timestamp' | +| `aggregation_span` | string | Lookback timespan for training data in a new training event | '60d' | '60d' | +| `cache_to_disk` | bool | Whether or not to cache streaming data to disk | false | false | +| `cache_dir` | string | Directory to use for caching streaming data | './.cache' | './.cache' | + +### `dfencoder_options` + +| Parameter | Type | Description | Example Value | Default Value | +|-------------------|-------|----------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------| +| `feature_columns` | list | List of feature columns to train on | ["column1", "column2", "column3"] | - | +| `epochs` | int | Number of epochs to train for | 50 | - | +| `model_kwargs` | dict | Keyword arguments to pass to the model | {"encoder_layers": [64, 32], "decoder_layers": [32, 64], "activation": "relu", "swap_p": 0.1, "lr": 0.001, "lr_decay": 0.9, "batch_size": 32, "verbose": 1, "optimizer": "adam", "scalar": "min_max", "min_cats": 10, "progress_bar": false, "device": "cpu"} | - | +| `validation_size` | float | Size of the validation set | 0.1 | - | + +### `mlflow_writer_options` + +| Key | Type | Description | Example Value | Default Value | +|-----------------------------|------------|-----------------------------------|-------------------------------|---------------| +| `conda_env` | string | Conda environment for the model | `path/to/conda_env.yml` | `[Required]` | +| `databricks_permissions` | dictionary | Permissions for the model | See Below | None | +| `experiment_name_formatter` | string | Formatter for the experiment name | `experiment_name_{timestamp}` | `[Required]` | +| `model_name_formatter` | string | Formatter for the model name | `model_name_{timestamp}` | `[Required]` | +| `timestamp_column_name` | string | Name of the timestamp column | `timestamp` | timestamp | diff --git a/morpheus/loaders/file_to_df_loader.py b/morpheus/loaders/file_to_df_loader.py index aebe6a4533..5d1f70443d 100644 --- a/morpheus/loaders/file_to_df_loader.py +++ b/morpheus/loaders/file_to_df_loader.py @@ -120,7 +120,7 @@ def file_to_df_loader(control_message: MessageControl, task: dict): schema_config = config["schema"] schema_str = schema_config["schema_str"] - encoding = schema_config["encoding"] + encoding = schema_config.get("encoding", 'latin1') file_type = config.get("file_type", "JSON") filter_null = config.get("filter_null", False) From d82b6d7f2724d29ca716ce86242d276c294e8a62 Mon Sep 17 00:00:00 2001 From: Bhargav Suryadevara Date: Fri, 24 Mar 2023 16:58:43 -0500 Subject: [PATCH 133/157] added header to dfp files --- examples/digital_fingerprinting/demo/README.md | 2 +- examples/digital_fingerprinting/demo/bin/start.sh | 2 +- examples/digital_fingerprinting/demo/cm_app/helper.py | 2 +- .../demo/cm_app/static/review/results.css | 2 +- .../digital_fingerprinting/demo/cm_app/static/review/results.js | 2 +- .../demo/cm_app/static/submit_messages.css | 2 +- .../demo/cm_app/static/submit_messages.js | 2 +- examples/digital_fingerprinting/demo/cm_app/static/training.css | 2 +- examples/digital_fingerprinting/demo/cm_app/static/training.js | 2 +- .../demo/cm_app/templates/review/results.html | 2 +- .../demo/cm_app/templates/submit_messages.html | 2 +- .../digital_fingerprinting/demo/cm_app/templates/training.html | 2 +- examples/digital_fingerprinting/demo/cm_app/views.py | 2 +- examples/digital_fingerprinting/demo/cm_app/webapp.py | 2 +- examples/digital_fingerprinting/demo/review_results.md | 2 +- examples/digital_fingerprinting/demo/submit_messages.md | 2 +- examples/digital_fingerprinting/demo/training.md | 2 +- 17 files changed, 17 insertions(+), 17 deletions(-) diff --git a/examples/digital_fingerprinting/demo/README.md b/examples/digital_fingerprinting/demo/README.md index 5270a3830d..9641cb3e22 100644 --- a/examples/digital_fingerprinting/demo/README.md +++ b/examples/digital_fingerprinting/demo/README.md @@ -1,5 +1,5 @@ + # Introduction to Digital Fingerprinting Pipeline in Morpheus ## Table of Contents diff --git a/docs/source/stages/morpheus_stages.md b/docs/source/stages/morpheus_stages.md index 3d9be94a99..90e753eccd 100644 --- a/docs/source/stages/morpheus_stages.md +++ b/docs/source/stages/morpheus_stages.md @@ -1,3 +1,20 @@ + + # Stages Documentation ## Boundary diff --git a/examples/digital_fingerprinting/demo/README.md b/examples/digital_fingerprinting/demo/README.md index 744870b6d3..9c0125b3d9 100644 --- a/examples/digital_fingerprinting/demo/README.md +++ b/examples/digital_fingerprinting/demo/README.md @@ -1,3 +1,20 @@ + + ### Control Messages Submission Demo Setup #### Introduction diff --git a/examples/digital_fingerprinting/demo/bin/start.sh b/examples/digital_fingerprinting/demo/bin/start.sh index fe0be6c615..c96075987b 100644 --- a/examples/digital_fingerprinting/demo/bin/start.sh +++ b/examples/digital_fingerprinting/demo/bin/start.sh @@ -1,4 +1,18 @@ #!/bin/sh +# SPDX-FileCopyrightText: Copyright (c) 2023, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. export set FLASK_APP=webapp diff --git a/examples/digital_fingerprinting/demo/cm_app/__init__.py b/examples/digital_fingerprinting/demo/cm_app/__init__.py index 0627110835..4412ea6951 100644 --- a/examples/digital_fingerprinting/demo/cm_app/__init__.py +++ b/examples/digital_fingerprinting/demo/cm_app/__init__.py @@ -1,3 +1,18 @@ +# SPDX-FileCopyrightText: Copyright (c) 2023, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + import flask app = flask.Flask(__name__) diff --git a/examples/digital_fingerprinting/demo/cm_app/helper.py b/examples/digital_fingerprinting/demo/cm_app/helper.py index dd3fefcf78..26b13cd607 100644 --- a/examples/digital_fingerprinting/demo/cm_app/helper.py +++ b/examples/digital_fingerprinting/demo/cm_app/helper.py @@ -1,3 +1,18 @@ +# SPDX-FileCopyrightText: Copyright (c) 2023, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + import json import logging diff --git a/examples/digital_fingerprinting/demo/cm_app/views.py b/examples/digital_fingerprinting/demo/cm_app/views.py index 29905915e3..26a08c02ba 100644 --- a/examples/digital_fingerprinting/demo/cm_app/views.py +++ b/examples/digital_fingerprinting/demo/cm_app/views.py @@ -1,3 +1,18 @@ +# SPDX-FileCopyrightText: Copyright (c) 2023, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + import logging from cm_app.helper import KafkaWriter diff --git a/examples/digital_fingerprinting/demo/cm_app/webapp.py b/examples/digital_fingerprinting/demo/cm_app/webapp.py index 9ddaeedc17..91f9a0e700 100644 --- a/examples/digital_fingerprinting/demo/cm_app/webapp.py +++ b/examples/digital_fingerprinting/demo/cm_app/webapp.py @@ -1,3 +1,18 @@ +# SPDX-FileCopyrightText: Copyright (c) 2023, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + # Entry point for the application. from . import app # noqa: F401 from . import views # noqa: F401 diff --git a/examples/digital_fingerprinting/demo/review_results.md b/examples/digital_fingerprinting/demo/review_results.md index cafe6a7b77..49e9919ca1 100644 --- a/examples/digital_fingerprinting/demo/review_results.md +++ b/examples/digital_fingerprinting/demo/review_results.md @@ -1,3 +1,20 @@ + + # Review Inference Results GUI TODO (Bhargav) Need to decide on how to send the selected/flagged logs for retraining using submit control message feature. diff --git a/examples/digital_fingerprinting/demo/submit_messages.md b/examples/digital_fingerprinting/demo/submit_messages.md index 21dcfe7a20..f10696db95 100644 --- a/examples/digital_fingerprinting/demo/submit_messages.md +++ b/examples/digital_fingerprinting/demo/submit_messages.md @@ -1,3 +1,20 @@ + + # Multi Control Message GUI ## Introduction diff --git a/examples/digital_fingerprinting/demo/training.md b/examples/digital_fingerprinting/demo/training.md index 43e63157c6..47321c8997 100644 --- a/examples/digital_fingerprinting/demo/training.md +++ b/examples/digital_fingerprinting/demo/training.md @@ -1,3 +1,20 @@ + + # Training Control Message GUI ## Introduction diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference.py index e92ae45d48..9d4868a44a 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference.py @@ -15,7 +15,6 @@ import logging import time -import cudf import mrc import pandas as pd from dfp.utils.model_cache import ModelCache @@ -23,6 +22,8 @@ from mlflow.tracking.client import MlflowClient from mrc.core import operators as ops +import cudf + from morpheus.messages import ControlMessage from morpheus.messages.multi_ae_message import MultiAEMessage from morpheus.utils.module_ids import MORPHEUS_MODULE_NAMESPACE diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_postprocessing.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_postprocessing.py index 29e305dba6..464f3846eb 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_postprocessing.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_postprocessing.py @@ -16,10 +16,11 @@ import time from datetime import datetime -import cudf import mrc from mrc.core import operators as ops +import cudf + from morpheus.messages.multi_ae_message import MultiAEMessage from morpheus.utils.module_ids import MORPHEUS_MODULE_NAMESPACE from morpheus.utils.module_utils import register_module diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_preproc.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_preproc.py index 99bbf4f3aa..d1b54dc7ee 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_preproc.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_preproc.py @@ -20,7 +20,6 @@ import morpheus.loaders.file_to_df_loader # noqa: F401 import morpheus.modules.file_batcher # noqa: F401 import morpheus.modules.filter_control_message # noqa: F401 - from morpheus.utils.loader_ids import FILE_TO_DF_LOADER from morpheus.utils.module_ids import DATA_LOADER from morpheus.utils.module_ids import FILE_BATCHER diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_split_users.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_split_users.py index 1180f99a03..dd2361c913 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_split_users.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_split_users.py @@ -23,7 +23,8 @@ import cudf -from morpheus.messages import ControlMessage, MessageMeta +from morpheus.messages import ControlMessage +from morpheus.messages import MessageMeta from morpheus.utils.module_ids import MORPHEUS_MODULE_NAMESPACE from morpheus.utils.module_utils import register_module diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training.py index 92b3fa2d81..3d392af33e 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training.py @@ -20,8 +20,8 @@ import cudf from morpheus.messages import ControlMessage -from morpheus.models.dfencoder import AutoEncoder from morpheus.messages.multi_ae_message import MultiAEMessage +from morpheus.models.dfencoder import AutoEncoder from morpheus.utils.module_ids import MORPHEUS_MODULE_NAMESPACE from morpheus.utils.module_utils import register_module diff --git a/morpheus/_lib/include/morpheus/objects/factory_registry.hpp b/morpheus/_lib/include/morpheus/objects/factory_registry.hpp index 11ce12a607..4f5b36c92e 100644 --- a/morpheus/_lib/include/morpheus/objects/factory_registry.hpp +++ b/morpheus/_lib/include/morpheus/objects/factory_registry.hpp @@ -58,8 +58,7 @@ class FactoryRegistry nlohmann::json config = {}) { std::lock_guard lock(m_mutex); - VLOG(2) << "Retrieving factory constructor: " << name << "(" << mrc::type_name() - << ")"; + VLOG(2) << "Retrieving factory constructor: " << name << "(" << mrc::type_name() << ")"; if (m_object_constructors.count(name) == 0) { @@ -74,8 +73,7 @@ class FactoryRegistry bool throw_if_exists = true) { std::lock_guard lock(m_mutex); - VLOG(2) << "Registering factory constructor: " << name << "(" << mrc::type_name() - << ")"; + VLOG(2) << "Registering factory constructor: " << name << "(" << mrc::type_name() << ")"; if (m_object_constructors.count(name) > 0) { if (throw_if_exists) @@ -89,8 +87,7 @@ class FactoryRegistry static void unregister_factory_fn(const std::string& name, bool throw_if_missing = true) { std::lock_guard lock(m_mutex); - VLOG(2) << "Un-registering factory constructor: " << name << "(" << mrc::type_name() - << ")"; + VLOG(2) << "Un-registering factory constructor: " << name << "(" << mrc::type_name() << ")"; if (m_object_constructors.count(name) == 0) { if (throw_if_missing) diff --git a/morpheus/_lib/src/python_modules/stages.cpp b/morpheus/_lib/src/python_modules/stages.cpp index e23587297c..6d08a2d191 100644 --- a/morpheus/_lib/src/python_modules/stages.cpp +++ b/morpheus/_lib/src/python_modules/stages.cpp @@ -33,9 +33,9 @@ #include "morpheus/utilities/cudf_util.hpp" #include "morpheus/version.hpp" -#include #include #include +#include #include #include // for multiple_inheritance #include // for arg, init, class_, module_, str_attr_accessor, PYBIND11_MODULE, pybind11 @@ -74,7 +74,8 @@ PYBIND11_MODULE(stages, _module) py::class_, mrc::segment::ObjectProperties, - std::shared_ptr>>(_module, "AddScoresStage", py::multiple_inheritance()) + std::shared_ptr>>( + _module, "AddScoresStage", py::multiple_inheritance()) .def( py::init<>(&AddScoresStageInterfaceProxy::init), py::arg("builder"), py::arg("name"), py::arg("idx2label")); @@ -90,7 +91,8 @@ PYBIND11_MODULE(stages, _module) py::class_, mrc::segment::ObjectProperties, - std::shared_ptr>>(_module, "FileSourceStage", py::multiple_inheritance()) + std::shared_ptr>>( + _module, "FileSourceStage", py::multiple_inheritance()) .def(py::init<>(&FileSourceStageInterfaceProxy::init), py::arg("builder"), py::arg("name"), @@ -183,7 +185,8 @@ PYBIND11_MODULE(stages, _module) py::class_, mrc::segment::ObjectProperties, - std::shared_ptr>>(_module, "SerializeStage", py::multiple_inheritance()) + std::shared_ptr>>( + _module, "SerializeStage", py::multiple_inheritance()) .def(py::init<>(&SerializeStageInterfaceProxy::init), py::arg("builder"), py::arg("name"), @@ -204,7 +207,7 @@ PYBIND11_MODULE(stages, _module) py::arg("include_index_col") = true, py::arg("flush") = false); - _module.attr("__version__") = MRC_CONCAT_STR(morpheus_VERSION_MAJOR << "." << morpheus_VERSION_MINOR << "." - << morpheus_VERSION_PATCH); + _module.attr("__version__") = + MRC_CONCAT_STR(morpheus_VERSION_MAJOR << "." << morpheus_VERSION_MINOR << "." << morpheus_VERSION_PATCH); } } // namespace morpheus diff --git a/morpheus/common/__init__.py b/morpheus/common/__init__.py index d540bd890f..33b7bc55b3 100644 --- a/morpheus/common/__init__.py +++ b/morpheus/common/__init__.py @@ -15,14 +15,14 @@ """ # Export symbols from the morpheus._lib.common module. Users should never be directly importing morpheus._lib -from morpheus._lib.common import determine_file_type from morpheus._lib.common import FiberQueue from morpheus._lib.common import FileTypes from morpheus._lib.common import FilterSource -from morpheus._lib.common import read_file_to_df from morpheus._lib.common import Tensor -from morpheus._lib.common import typeid_to_numpy_str from morpheus._lib.common import TypeId +from morpheus._lib.common import determine_file_type +from morpheus._lib.common import read_file_to_df +from morpheus._lib.common import typeid_to_numpy_str from morpheus._lib.common import write_df_to_file __all__ = [ diff --git a/morpheus/messages/message_meta.py b/morpheus/messages/message_meta.py index e88fb05b3f..bb25f7fae5 100644 --- a/morpheus/messages/message_meta.py +++ b/morpheus/messages/message_meta.py @@ -19,6 +19,7 @@ import warnings import pandas as pd + import cudf import morpheus._lib.messages as _messages diff --git a/morpheus/messages/multi_message.py b/morpheus/messages/multi_message.py index 5337e97366..40da7fe867 100644 --- a/morpheus/messages/multi_message.py +++ b/morpheus/messages/multi_message.py @@ -275,8 +275,7 @@ def set_meta(self, columns: typing.Union[None, str, typing.List[str]], value): else: # Need to determine the boolean mask to use indexes with df.loc - row_mask = self._ranges_to_mask(df, - [(self.mess_offset, self.mess_offset + self.mess_count)]) + row_mask = self._ranges_to_mask(df, [(self.mess_offset, self.mess_offset + self.mess_count)]) # Now set the slice df.loc[row_mask, columns] = value diff --git a/morpheus/modules/file_batcher.py b/morpheus/modules/file_batcher.py index d99565be79..bea890ac05 100644 --- a/morpheus/modules/file_batcher.py +++ b/morpheus/modules/file_batcher.py @@ -12,11 +12,11 @@ # See the License for the specific language governing permissions and # limitations under the License. +import datetime import logging import re from collections import namedtuple -import datetime import fsspec import fsspec.utils import mrc @@ -29,8 +29,8 @@ from morpheus.utils.loader_ids import FILE_TO_DF_LOADER from morpheus.utils.module_ids import FILE_BATCHER from morpheus.utils.module_ids import MORPHEUS_MODULE_NAMESPACE -from morpheus.utils.module_utils import register_module from morpheus.utils.module_utils import merge_dictionaries +from morpheus.utils.module_utils import register_module from morpheus.utils.module_utils import to_period_cudf_approximation logger = logging.getLogger(__name__) @@ -100,20 +100,19 @@ def build_fs_filename_df(files, params): end_time = params["end_time"] sampling_rate_s = params["sampling_rate_s"] - if not isinstance(start_time, (str, type(None))) or ( - start_time is not None and not re.match(r"\d{4}-\d{2}-\d{2}", start_time)): + if not isinstance(start_time, (str, type(None))) or (start_time is not None + and not re.match(r"\d{4}-\d{2}-\d{2}", start_time)): raise ValueError(f"Invalid 'start_time' value: {start_time}") - if not isinstance(end_time, (str, type(None))) or ( - end_time is not None and not re.match(r"\d{4}-\d{2}-\d{2}", end_time)): + if not isinstance(end_time, (str, type(None))) or (end_time is not None + and not re.match(r"\d{4}-\d{2}-\d{2}", end_time)): raise ValueError(f"Invalid 'end_time' value: {end_time}") if not isinstance(sampling_rate_s, int) or sampling_rate_s < 0: raise ValueError(f"Invalid 'sampling_rate_s' value: {sampling_rate_s}") if (start_time is not None): - start_time = datetime.datetime.strptime(start_time, '%Y-%m-%d').replace( - tzinfo=datetime.timezone.utc) + start_time = datetime.datetime.strptime(start_time, '%Y-%m-%d').replace(tzinfo=datetime.timezone.utc) if (end_time is not None): end_time = datetime.datetime.strptime(end_time, '%Y-%m-%d').replace(tzinfo=datetime.timezone.utc) @@ -127,8 +126,7 @@ def build_fs_filename_df(files, params): ts = date_extractor(file_object, iso_date_regex) # Exclude any files outside the time window - if ((start_time is not None and ts < start_time) or ( - end_time is not None and ts > end_time)): + if ((start_time is not None and ts < start_time) or (end_time is not None and ts > end_time)): continue ts_and_files.append(TimestampFileObj(ts, file_object.full_name)) diff --git a/morpheus/modules/file_to_df.py b/morpheus/modules/file_to_df.py index 94c2e72540..cf88a845ff 100644 --- a/morpheus/modules/file_to_df.py +++ b/morpheus/modules/file_to_df.py @@ -80,7 +80,7 @@ def file_to_df(builder: mrc.Builder): cache_dir = config.get("cache_dir", None) download_method: typing.Literal["single_thread", "multiprocess", "dask", - "dask_thread"] = os.environ.get("MORPHEUS_FILE_DOWNLOAD_TYPE", "multiprocess") + "dask_thread"] = os.environ.get("MORPHEUS_FILE_DOWNLOAD_TYPE", "multiprocess") if (cache_dir is None): cache_dir = "./.cache" diff --git a/morpheus/modules/mlflow_model_writer.py b/morpheus/modules/mlflow_model_writer.py index 96af2eba02..5d7e0468d0 100644 --- a/morpheus/modules/mlflow_model_writer.py +++ b/morpheus/modules/mlflow_model_writer.py @@ -135,7 +135,7 @@ def apply_model_permissions(reg_model_name: str): "access_control_list": [{ "group_name": group, "permission_level": permission } for group, - permission in databricks_permissions.items()] + permission in databricks_permissions.items()] } requests.patch(url=patch_registered_model_permissions_url, diff --git a/morpheus/modules/serialize.py b/morpheus/modules/serialize.py index cab77201e9..c5b01db0d8 100644 --- a/morpheus/modules/serialize.py +++ b/morpheus/modules/serialize.py @@ -18,9 +18,9 @@ from functools import partial import mrc +import pandas as pd import cudf -import pandas as pd from morpheus.messages import MultiMessage from morpheus.messages.message_meta import MessageMeta diff --git a/morpheus/modules/write_to_file.py b/morpheus/modules/write_to_file.py index 8707d2b564..287de768bc 100644 --- a/morpheus/modules/write_to_file.py +++ b/morpheus/modules/write_to_file.py @@ -102,6 +102,7 @@ def node_fn(obs: mrc.Observable, sub: mrc.Subscriber): # Open up the file handle with open(output_file, "a") as out_file: + def write_to_file(x: MessageMeta): lines = convert_to_strings(x.df) diff --git a/morpheus/stages/general/multiport_modules_stage.py b/morpheus/stages/general/multiport_modules_stage.py index dc65f37273..0ae8725728 100644 --- a/morpheus/stages/general/multiport_modules_stage.py +++ b/morpheus/stages/general/multiport_modules_stage.py @@ -83,7 +83,7 @@ def input_types(self) -> typing.Tuple: Returns input type for the current stage. """ - return (typing.Any,) + return (typing.Any, ) def accepted_types(self) -> typing.Tuple: """ @@ -95,7 +95,7 @@ def accepted_types(self) -> typing.Tuple: Accepted input types. """ - return (typing.Any,) + return (typing.Any, ) def _build(self, builder: mrc.Builder, in_stream_pairs: typing.List[StreamPair]) -> typing.List[StreamPair]: diff --git a/morpheus/stages/input/control_message_file_source_stage.py b/morpheus/stages/input/control_message_file_source_stage.py index 0957d7687c..ee85ef0c21 100644 --- a/morpheus/stages/input/control_message_file_source_stage.py +++ b/morpheus/stages/input/control_message_file_source_stage.py @@ -1,4 +1,4 @@ -# Copyright (c) 2023, NVIDIA CORPORATION. +# Copyright (c) 2022-2023, NVIDIA CORPORATION. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/morpheus/utils/loader_ids.py b/morpheus/utils/loader_ids.py index fc66bd98f5..a878bf31e7 100644 --- a/morpheus/utils/loader_ids.py +++ b/morpheus/utils/loader_ids.py @@ -1,4 +1,4 @@ -# Copyright (c) 2023, NVIDIA CORPORATION. +# Copyright (c) 2022-2023, NVIDIA CORPORATION. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/morpheus/utils/module_utils.py b/morpheus/utils/module_utils.py index d0832ad31a..3ed707a241 100644 --- a/morpheus/utils/module_utils.py +++ b/morpheus/utils/module_utils.py @@ -18,9 +18,10 @@ import typing import mrc -import cudf -import pandas as pd import numpy as np +import pandas as pd + +import cudf logger = logging.getLogger(__name__) diff --git a/tests/io/test_loader_registry.py b/tests/io/test_loader_registry.py index b80217d142..fd9a7c238f 100644 --- a/tests/io/test_loader_registry.py +++ b/tests/io/test_loader_registry.py @@ -38,6 +38,7 @@ def test_loader_registry_contains(): def test_loader_registry_register_loader(): + def test_loader(control_message: messages.ControlMessage, task: dict): task_properties = task['properties'] if ('files' not in task_properties): @@ -73,6 +74,7 @@ def test_loader(control_message: messages.ControlMessage, task: dict): def test_loader_registry_unregister_loader(): + def test_loader(control_message: messages.ControlMessage, task: dict): task_properties = task['properties'] if ('files' not in task_properties): diff --git a/tests/messages/test_control_message.py b/tests/messages/test_control_message.py index 5e66714840..e44001a33c 100644 --- a/tests/messages/test_control_message.py +++ b/tests/messages/test_control_message.py @@ -15,7 +15,9 @@ # limitations under the License. import pytest + import cudf + import morpheus._lib.messages as _messages import morpheus.messages as messages diff --git a/tests/modules/test_morpheus_modules.py b/tests/modules/test_morpheus_modules.py index 285e5542ed..1dc6e852db 100644 --- a/tests/modules/test_morpheus_modules.py +++ b/tests/modules/test_morpheus_modules.py @@ -14,15 +14,16 @@ # See the License for the specific language governing permissions and # limitations under the License. +import os +import tempfile import time import mrc + import cudf -import tempfile -import os -import morpheus.modules # Used to load and register morpheus modules import morpheus.messages as messages +import morpheus.modules # Used to load and register morpheus modules def on_next(control_msg): diff --git a/tests/test_broadcast_stage.py b/tests/test_broadcast_stage.py index bbd16ec84b..e8012caa94 100755 --- a/tests/test_broadcast_stage.py +++ b/tests/test_broadcast_stage.py @@ -1,5 +1,5 @@ #!/usr/bin/env python -# SPDX-FileCopyrightText: Copyright (c) 2023, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-FileCopyrightText: Copyright (c) 2022-2023, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); diff --git a/tests/test_module_utils.py b/tests/test_module_utils.py index 5d1fa3239b..92957dac83 100644 --- a/tests/test_module_utils.py +++ b/tests/test_module_utils.py @@ -36,6 +36,7 @@ def test_mrc_version(): def test_register_module(): + @register_module("TestModule", "test_morpheus_modules") def module_init(builder: mrc.Builder): return True @@ -44,6 +45,7 @@ def module_init(builder: mrc.Builder): # Attempting to register duplicate module raises an error. with pytest.raises(TypeError): + @register_module(None, "test_morpheus_modules") def module_init2(builder: mrc.Builder): pass From 851a95d8c8d3158925f0f25198417de28d054d86 Mon Sep 17 00:00:00 2001 From: Devin Robison Date: Tue, 28 Mar 2023 16:00:54 -0600 Subject: [PATCH 139/157] Flake8 updates, docs cleanup --- .../morpheus/dfp/modules/dfp_data_prep.py | 6 +- .../morpheus/dfp/modules/dfp_deployment.py | 145 ++++++++++++------ .../morpheus/dfp/modules/dfp_inference.py | 10 +- .../dfp/modules/dfp_inference_pipe.py | 32 ++-- .../dfp/modules/dfp_postprocessing.py | 2 - .../morpheus/dfp/modules/dfp_preproc.py | 14 +- .../dfp/modules/dfp_rolling_window.py | 20 +-- .../morpheus/dfp/modules/dfp_split_users.py | 22 +-- .../morpheus/dfp/modules/dfp_training.py | 13 +- .../morpheus/dfp/modules/dfp_training_pipe.py | 76 +++++++-- .../morpheus/dfp_azure_modules_training.py | 2 - .../morpheus/dfp_duo_modules_inference.py | 1 - .../morpheus/dfp_duo_modules_training.py | 2 - morpheus/loaders/file_to_df_loader.py | 8 +- morpheus/messages/memory/__init__.py | 2 +- morpheus/modules/file_batcher.py | 33 ++-- morpheus/modules/filter_control_message.py | 10 +- morpheus/modules/filter_detections.py | 19 ++- morpheus/modules/mlflow_model_writer.py | 22 ++- morpheus/modules/serialize.py | 18 ++- morpheus/modules/write_to_file.py | 14 +- .../control_message_kafka_source_stage.py | 1 - morpheus/utils/module_utils.py | 2 - tests/messages/test_control_message.py | 12 +- tests/modules/test_morpheus_modules.py | 17 +- 25 files changed, 310 insertions(+), 193 deletions(-) diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_data_prep.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_data_prep.py index d9102a182e..b428033555 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_data_prep.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_data_prep.py @@ -42,9 +42,9 @@ def dfp_data_prep(builder: mrc.Builder): Notes ---------- - Configurable parameters: - - schema: Schema of the data - - timestamp_column_name: Name of the timestamp column + Configurable parameters: + - schema: Schema of the data + - timestamp_column_name: Name of the timestamp column """ config = builder.get_current_module_config() diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_deployment.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_deployment.py index 59998f6447..53678c8f27 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_deployment.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_deployment.py @@ -43,54 +43,103 @@ def dfp_deployment(builder: mrc.Builder): Notes ----- - Configurable parameters: - - training_options (dict): Options for the training pipeline module, including: - - timestamp_column_name (str): Name of the timestamp column used in the data - - cache_dir (str): Directory to cache the rolling window data - - batching_options (dict): Options for batching the data, including: - - end_time (datetime|str): End time of the time window - - iso_date_regex_pattern (str): Regex pattern for ISO date matching - - parser_kwargs (dict): Additional arguments for the parser - - period (str): Time period for grouping files - - sampling_rate_s (int): Sampling rate in seconds - - start_time (datetime|str): Start time of the time window - - user_splitting_options (dict): Options for splitting the data by user, including: - - fallback_username (str): User ID to use if user ID not found (default: 'generic_user') - - include_generic (bool): Include generic user ID in output (default: False) - - include_individual (bool): Include individual user IDs in output (default: False) - - only_users (list): List of user IDs to include in output, others will be excluded (default: []) - - skip_users (list): List of user IDs to exclude from output (default: []) - - timestamp_column_name (str): Name of column containing timestamps (default: 'timestamp') - - userid_column_name (str): Name of column containing user IDs (default: 'username') - - stream_aggregation_options (dict): Options for aggregating the data by stream - - preprocessing_options (dict): Options for preprocessing the data - - dfencoder_options (dict): Options for configuring the data frame encoder, used for training the model - - mlflow_writer_options (dict): Options for the MLflow model writer, responsible for saving the trained model, including: - - model_name_formatter (str): Format string for the model name, e.g. "model_{timestamp}" - - experiment_name_formatter (str): Format string for the experiment name, e.g. "experiment_{timestamp}" - - timestamp_column_name (str): Name of the timestamp column used in the data - - conda_env (dict): Conda environment settings, including: - - channels (list): List of channels to use for the environment - - dependencies (list): List of dependencies for the environment - - pip (list): List of pip packages to install in the environment - - name (str): Name of the conda environment - - inference_options (dict): Options for the inference pipeline module, including: - - model_name_formatter (str): Format string for the model name, e.g. "model_{timestamp}" - - fallback_username (str): User ID to use if user ID not found (default: 'generic_user') - - timestamp_column_name (str): Name of the timestamp column in the input data - - batching_options (dict): Options for batching the data, including: - [omitted for brevity] - - cache_dir (str): Directory to cache the rolling window data - - detection_criteria (dict): Criteria for filtering detections, such as threshold and field_name - - inference_options (dict): Options for the inference module, including model settings and other configurations - - num_output_ports (int): Number of output ports for the module - - preprocessing_options (dict): Options for preprocessing the data, including schema and timestamp column name - - stream_aggregation_options (dict): Options for aggregating the data by stream, including: - - aggregation_span (int): The time span for the aggregation window, in seconds - - cache_to_disk (bool): Whether to cache the aggregated data to disk - - user_splitting_options (dict): Options for splitting the data by user, including: - [omitted for brevity] - - write_to_file_options (dict): Options for writing the detections to a file, such as filename and overwrite settings + Configurable Parameters: + - inference_options (dict): Options for the inference pipeline module; Example: See Below; Default: `[Required]` + - training_options (dict): Options for the training pipeline module; Example: See Below; Default: `[Required]` + + Training Options Parameters: + - batching_options (dict): Options for batching the data; Example: See Below + - cache_dir (str): Directory to cache the rolling window data; Example: "/path/to/cache/dir"; Default: ./.cache + - dfencoder_options (dict): Options for configuring the data frame encoder; Example: See Below + - mlflow_writer_options (dict): Options for the MLflow model writer; Example: See Below + - preprocessing_options (dict): Options for preprocessing the data; Example: See Below + - stream_aggregation_options (dict): Options for aggregating the data by stream; Example: See Below + - timestamp_column_name (str): Name of the timestamp column used in the data; Example: "my_timestamp"; Default: + "timestamp" + - user_splitting_options (dict): Options for splitting the data by user; Example: See Below + + Inference Options Parameters: + - batching_options (dict): Options for batching the data; Example: See Below + - cache_dir (str): Directory to cache the rolling window data; Example: "/path/to/cache/dir"; Default: ./.cache + - detection_criteria (dict): Criteria for filtering detections; Example: See Below + - fallback_username (str): User ID to use if user ID not found; Example: "generic_user"; Default: "generic_user" + - inference_options (dict): Options for the inference module; Example: See Below + - model_name_formatter (str): Format string for the model name; Example: "model_{timestamp}"; + Default: `[Required]` + - num_output_ports (int): Number of output ports for the module; Example: 3 + - timestamp_column_name (str): Name of the timestamp column in the input data; Example: "timestamp"; + Default: "timestamp" + - stream_aggregation_options (dict): Options for aggregating the data by stream; Example: See Below + - user_splitting_options (dict): Options for splitting the data by user; Example: See Below + - write_to_file_options (dict): Options for writing the detections to a file; Example: See Below + + batching_options: + - end_time (datetime/string): Endtime of the time window; Example: "2023-03-14T23:59:59"; Default: None + - iso_date_regex_pattern (string): Regex pattern for ISO date matching; + Example: "\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}"; Default: + - parser_kwargs (dictionary): Additional arguments for the parser; Example: {}; Default: {} + - period (string): Time period for grouping files; Example: "1d"; Default: "1d" + - sampling_rate_s (integer): Sampling rate in seconds; Example: 60; Default: 60 + - start_time (datetime/string): Start time of the time window; Example: "2023-03-01T00:00:00"; Default: None + + dfencoder_options: + - feature_columns (list): List of feature columns to train on; Example: ["column1", "column2", "column3"] + - epochs (int): Number of epochs to train for; Example: 50 + - model_kwargs (dict): Keyword arguments to pass to the model; Example: {"encoder_layers": [64, 32], + "decoder_layers": [32, 64], "activation": "relu", "swap_p": 0.1, "lr": 0.001, "lr_decay": 0.9, + "batch_size": 32, "verbose": 1, "optimizer": "adam", "scalar": "min_max", "min_cats": 10, + "progress_bar": false, "device": "cpu"} + - validation_size (float): Size of the validation set; Example: 0.1 + + mlflow_writer_options: + - conda_env (string): Conda environment for the model; Example: `path/to/conda_env.yml`; Default: `[Required]` + - databricks_permissions (dictionary): Permissions for the model; Example: See Below; Default: None + - experiment_name_formatter (string): Formatter for the experiment name; Example: `experiment_name_{timestamp}`; + Default: `[Required]` + - model_name_formatter (string): Formatter for the model name; Example: `model_name_{timestamp}`; + Default: `[Required]` + - timestamp_column_name (string): Name of the timestamp column; Example: `timestamp`; Default: timestamp + + stream_aggregation_options: + - cache_mode (string): The user ID to use if the user ID is not found; Example: 'batch'; Default: 'batch' + - min_history (int): Minimum history to trigger a new training event; Example: 1; Default: 1 + - max_history (int): Maximum history to include in a new training event; Example: 0; Default: 0 + - timestamp_column_name (string): Name of the column containing timestamps; Example: 'timestamp'; + Default: 'timestamp' + - aggregation_span (string): Lookback timespan for training data in a new training event; Example: '60d'; + Default: '60d' + - cache_to_disk (bool): Whether or not to cache streaming data to disk; Example: false; Default: false + - cache_dir (string): Directory to use for caching streaming data; Example: './.cache'; Default: './.cache' + + user_splitting_options: + - fallback_username (str): The user ID to use if the user ID is not found; Example: "generic_user"; + Default: 'generic_user' + - include_generic (bool): Whether to include a generic user ID in the output; Example: false; Default: False + - include_individual (bool): Whether to include individual user IDs in the output; Example: true; Default: False + - only_users (list): List of user IDs to include; others will be excluded; Example: ["user1", "user2", "user3"]; + Default: [] + - skip_users (list): List of user IDs to exclude from the output; Example: ["user4", "user5"]; Default: [] + - timestamp_column_name (str): Name of the column containing timestamps; Example: "timestamp"; + Default: 'timestamp' + - userid_column_name (str): Name of the column containing user IDs; Example: "username"; Default: 'username' + + detection_criteria: + - threshold (float): Threshold for filtering detections; Example: 0.5; Default: 0.5 + - field_name (str): Name of the field to filter by threshold; Example: "score"; Default: probs + + inference_options: + - model_name_formatter (string): Formatter for model names; Example: "user_{username}_model"; + Default: `[Required]` + - fallback_username (string): Fallback user to use if no model is found for a user; Example: "generic_user"; + Default: generic_user + - timestamp_column_name (string): Name of the timestamp column; Example: "timestamp"; Default: timestamp + + write_to_file_options: + - filename (string): Path to the output file; Example: `output.csv`; Default: None + - file_type (FileTypes): Type of file to write; Example: `FileTypes.CSV`; Default: `FileTypes.Auto` + - flush (bool): If true, flush the file after each write; Example: `false`; Default: false + - include_index_col (bool): If true, include the index column; Example: `false`; Default: true + - overwrite (bool): If true, overwrite the file if it exists; Example: `true`; Default: false """ module_config = builder.get_current_module_config() diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference.py index 9d4868a44a..80e2b33cee 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference.py @@ -48,10 +48,12 @@ def dfp_inference(builder: mrc.Builder): Notes ---------- - Configurable parameters: - - model_name_formatter: Formatter for model names - - fallback_username: Fallback user to use if no model is found for a user - - timestamp_column_name: Name of the timestamp column + Configurable parameters: + - model_name_formatter (string): Formatter for model names; Example: "user_{username}_model"; + Default: `[Required]` + - fallback_username (string): Fallback user to use if no model is found for a user; Example: "generic_user"; + Default: generic_user + - timestamp_column_name (string): Name of the timestamp column; Example: "timestamp"; Default: timestamp """ config = builder.get_current_module_config() diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference_pipe.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference_pipe.py index 0f848ee9cb..fb91abd4a8 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference_pipe.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_inference_pipe.py @@ -18,7 +18,7 @@ import dfp.modules.dfp_inference # noqa: F401 import dfp.modules.dfp_postprocessing # noqa: F401 import dfp.modules.dfp_preproc # noqa: F401 -import dfp.modules.dfp_rolling_window +import dfp.modules.dfp_rolling_window # noqa: F401 import mrc import morpheus.modules.filter_detections # noqa: F401 @@ -44,7 +44,8 @@ @register_module(DFP_INFERENCE_PIPE, MORPHEUS_MODULE_NAMESPACE) def dfp_inference_pipe(builder: mrc.Builder): """ - This module function consolidates multiple dfp pipeline modules relevant to the inference process into a single module. + This module function consolidates multiple dfp pipeline modules relevant to the inference process into a single + module. Parameters ---------- @@ -53,17 +54,22 @@ def dfp_inference_pipe(builder: mrc.Builder): Notes ---------- - Configurable parameters: - - batching_options (dict): Options for batching data, including start and end times, sampling rate, and other settings. - - cache_dir (str): Directory for caching rolling window data. - - detection_criteria (dict): Criteria for filtering detections, such as threshold and field_name. - - inference_options (dict): Options for the inference module, including model settings and other configurations. - - num_output_ports (int): Number of output ports for the module. - - preprocessing_options (dict): Options for preprocessing data, including schema and timestamp column name. - - stream_aggregation_options (dict): Options for aggregating data by stream, including aggregation span and cache settings. - - timestamp_column_name (str): Name of the timestamp column in the input data. - - user_splitting_options (dict): Options for splitting data by user, including filtering and user ID column name. - - write_to_file_options (dict): Options for writing detections to a file, such as filename and overwrite settings. + Configurable parameters: + - batching_options (dict): Options for batching the data; Example: See Below + - cache_dir (str): Directory to cache the rolling window data; Example: "/path/to/cache/dir"; + Default: ./.cache + - detection_criteria (dict): Criteria for filtering detections; Example: See Below + - fallback_username (str): User ID to use if user ID not found; Example: "generic_user"; + Default: "generic_user" + - inference_options (dict): Options for the inference module; Example: See Below + - model_name_formatter (str): Format string for the model name; Example: "model_{timestamp}"; + Default: `[Required]` + - num_output_ports (int): Number of output ports for the module; Example: 3 + - timestamp_column_name (str): Name of the timestamp column in the input data; Example: "timestamp"; + Default: "timestamp" + - stream_aggregation_options (dict): Options for aggregating the data by stream; Example: See Below + - user_splitting_options (dict): Options for splitting the data by user; Example: See Below + - write_to_file_options (dict): Options for writing the detections to a file; Example: See Below """ config = builder.get_current_module_config() diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_postprocessing.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_postprocessing.py index 464f3846eb..75bab7854a 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_postprocessing.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_postprocessing.py @@ -19,8 +19,6 @@ import mrc from mrc.core import operators as ops -import cudf - from morpheus.messages.multi_ae_message import MultiAEMessage from morpheus.utils.module_ids import MORPHEUS_MODULE_NAMESPACE from morpheus.utils.module_utils import register_module diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_preproc.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_preproc.py index d1b54dc7ee..f2fc5e25ac 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_preproc.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_preproc.py @@ -70,13 +70,13 @@ def dfp_preproc(builder: mrc.Builder): Notes ---------- - Configurable parameters: - - cache_dir (str): Directory for caching intermediate results - - timestamp_column_name (str): Name of the column containing timestamps - - pre_filter_options (dict): Options for pre-filtering control messages - - batching_options (dict): Options for batching files - - user_splitting_options (dict): Options for splitting data by user - - supported_loaders (dict): Supported data loaders for different file types + Configurable parameters: + - cache_dir (str): Directory for caching intermediate results + - timestamp_column_name (str): Name of the column containing timestamps + - pre_filter_options (dict): Options for pre-filtering control messages + - batching_options (dict): Options for batching files + - user_splitting_options (dict): Options for splitting data by user + - supported_loaders (dict): Supported data loaders for different file types """ config = builder.get_current_module_config() diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_rolling_window.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_rolling_window.py index bbae3d102d..f5b5c9950a 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_rolling_window.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_rolling_window.py @@ -48,15 +48,15 @@ def dfp_rolling_window(builder: mrc.Builder): Notes ----- Configurable parameters: - - aggregation_span (str): Time span to aggregate over (e.g., '60d' for 60 days) - - cache_dir (str): Directory to cache rolling window data - - cache_to_disk (bool): Cache rolling window data to disk (default: False) - - cache_mode (str): Cache mode, either 'batch' or 'aggregate' - 'aggregate': Cache entire rolling window - 'batch': Cache until batch criteria met, then flush - - timestamp_column_name (str): Name of timestamp column (default: 'timestamp') - - trigger_on_min_history (int): Minimum number of rows required to trigger rolling window (default: 1) - - trigger_on_min_increment (int): Minimum number of rows required to trigger rolling window (default: 0) + - cache_mode (string): The user ID to use if the user ID is not found; Example: 'batch'; Default: 'batch' + - min_history (int): Minimum history to trigger a new training event; Example: 1; Default: 1 + - max_history (int): Maximum history to include in a new training event; Example: 0; Default: 0 + - timestamp_column_name (string): Name of the column containing timestamps; Example: 'timestamp'; + Default: 'timestamp' + - aggregation_span (string): Lookback timespan for training data in a new training event; Example: '60d'; + Default: '60d' + - cache_to_disk (bool): Whether to cache streaming data to disk; Example: false; Default: false + - cache_dir (string): Directory to use for caching streaming data; Example: './.cache'; Default: './.cache' """ config = builder.get_current_module_config() @@ -165,7 +165,7 @@ def on_data(control_message: ControlMessage): if (data_type == "payload"): return control_message elif (data_type == "streaming"): - with log_time(logger.debug) as log_info: + with log_time(logger.debug): result = try_build_window(payload, user_id) # Return a MessageMeta if (result is None): diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_split_users.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_split_users.py index dd2361c913..5ce7f5db8d 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_split_users.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_split_users.py @@ -16,7 +16,6 @@ import typing import mrc -import numpy as np import pandas as pd from dfp.utils.logging_timer import log_time from mrc.core import operators as ops @@ -46,13 +45,16 @@ def dfp_split_users(builder: mrc.Builder): Notes ----- Configurable parameters: - - fallback_username (str): User ID to use if user ID not found (default: 'generic_user') - - include_generic (bool): Include generic user ID in output (default: False) - - include_individual (bool): Include individual user IDs in output (default: False) - - only_users (list): List of user IDs to include in output, others will be excluded (default: []) - - skip_users (list): List of user IDs to exclude from output (default: []) - - timestamp_column_name (str): Name of column containing timestamps (default: 'timestamp') - - userid_column_name (str): Name of column containing user IDs (default: 'username') + - fallback_username (str): The user ID to use if the user ID is not found; Example: "generic_user"; + Default: 'generic_user' + - include_generic (bool): Whether to include a generic user ID in the output; Example: false; Default: False + - include_individual (bool): Whether to include individual user IDs in the output; Example: true; Default: False + - only_users (list): List of user IDs to include; others will be excluded; Example: ["user1", "user2", "user3"]; + Default: [] + - skip_users (list): List of user IDs to exclude from the output; Example: ["user4", "user5"]; Default: [] + - timestamp_column_name (str): Name of the column containing timestamps; Example: "timestamp"; + Default: 'timestamp' + - userid_column_name (str): Name of the column containing user IDs; Example: "username"; Default: 'username' """ config = builder.get_current_module_config() @@ -129,7 +131,7 @@ def extract_users(control_message: ControlMessage): control_messages = None # for readability mm = control_message.payload() with mm.mutable_dataframe() as dfm: - with log_time(logger.debug) as log_info: + with log_time(logger.debug): if (isinstance(dfm, cudf.DataFrame)): # Convert to pandas because cudf is slow at this @@ -143,7 +145,7 @@ def extract_users(control_message: ControlMessage): control_messages = generate_control_messages(control_message, split_dataframes) return control_messages - except Exception as e: + except Exception: logger.exception("Error extracting users from message, discarding control message") return [] diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training.py index 3d392af33e..3e573d81df 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training.py @@ -44,11 +44,14 @@ def dfp_training(builder: mrc.Builder): Notes ----- - Configurable parameters: - - feature_columns (list): List of feature columns to train on - - epochs (int): Number of epochs to train for - - model_kwargs (dict): Keyword arguments to pass to the model (see dfencoder.AutoEncoder) - - validation_size (float): Size of the validation set + Configurable Parameters: + - feature_columns (list): List of feature columns to train on; Example: ["column1", "column2", "column3"] + - epochs (int): Number of epochs to train for; Example: 50 + - model_kwargs (dict): Keyword arguments to pass to the model; Example: {"encoder_layers": [64, 32], + "decoder_layers": [32, 64], "activation": "relu", "swap_p": 0.1, "lr": 0.001, "lr_decay": 0.9, + "batch_size": 32, "verbose": 1, "optimizer": "adam", "scalar": "min_max", "min_cats": 10, + "progress_bar": false, "device": "cpu"} + - validation_size (float): Size of the validation set; Example: 0.1 """ config = builder.get_current_module_config() diff --git a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training_pipe.py b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training_pipe.py index fe675297b8..b559acd9fe 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training_pipe.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp/modules/dfp_training_pipe.py @@ -39,7 +39,8 @@ @register_module(DFP_TRAINING_PIPE, MORPHEUS_MODULE_NAMESPACE) def dfp_training_pipe(builder: mrc.Builder): """ - This module function consolidates multiple dfp pipeline modules relevant to the training process into a single module. + This module function consolidates multiple dfp pipeline modules relevant to the training process into a single + module. Parameters ---------- @@ -48,15 +49,70 @@ def dfp_training_pipe(builder: mrc.Builder): Notes ----- - Configurable parameters: - - timestamp_column_name (str): Name of the timestamp column used in the data - - cache_dir (str): Directory to cache the rolling window data - - batching_options (dict): Options for batching the data - - user_splitting_options (dict): Options for splitting the data by user - - stream_aggregation_options (dict): Options for aggregating the data by stream - - preprocessing_options (dict): Options for preprocessing the data - - dfencoder_options (dict): Options for configuring the data frame encoder, used for training the model - - mlflow_writer_options (dict): Options for the MLflow model writer, responsible for saving the trained model + Configurable parameters: + - batching_options (dict): Options for batching the data; Example: See Below + - cache_dir (str): Directory to cache the rolling window data; Example: "/path/to/cache/dir"; + Default: ./.cache + - dfencoder_options (dict): Options for configuring the data frame encoder; Example: See Below + - mlflow_writer_options (dict): Options for the MLflow model writer; Example: See Below + - stream_aggregation_options (dict): Options for aggregating the data by stream; Example: See Below + - timestamp_column_name (str): Name of the timestamp column used in the data; Example: "my_timestamp"; + Default: "timestamp" + - user_splitting_options (dict): Options for splitting the data by user; Example: See Below + + batching_options: + - end_time (datetime/string): Endtime of the time window; Example: "2023-03-14T23:59:59"; Default: None + - iso_date_regex_pattern (string): Regex pattern for ISO date matching; + Example: "\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}"; Default: + - parser_kwargs (dictionary): Additional arguments for the parser; Example: {}; Default: {} + - period (string): Time period for grouping files; Example: "1d"; Default: "1d" + - sampling_rate_s (integer): Sampling rate in seconds; Example: 60; Default: 60 + - start_time (datetime/string): Start time of the time window; Example: "2023-03-01T00:00:00"; Default: N + + dfencoder_options: + - feature_columns (list): List of feature columns to train on; Example: ["column1", "column2", "column3"] + - epochs (int): Number of epochs to train for; Example: 50 + - model_kwargs (dict): Keyword arguments to pass to the model; Example: {"encoder_layers": [64, 32], + "decoder_layers": [32, 64], "activation": "relu", "swap_p": 0.1, "lr": 0.001, "lr_decay": 0.9, + "batch_size": 32, "verbose": 1, "optimizer": "adam", "scalar": "min_max", "min_cats": 10, + "progress_bar": false, "device": "cpu"} + - validation_size (float): Size of the validation set; Example: 0.1 + + mlflow_writer_options: + - conda_env (string): Conda environment for the model; Example: `path/to/conda_env.yml`; + Default: `[Required]` + - databricks_permissions (dictionary): Permissions for the model; Example: See Below; Default: None + - experiment_name_formatter (string): Formatter for the experiment name; + Example: `experiment_name_{timestamp}`; + Default: `[Required]` + - model_name_formatter (string): Formatter for the model name; Example: `model_name_{timestamp}`; + Default: `[Required]` + - timestamp_column_name (string): Name of the timestamp column; Example: `timestamp`; Default: timestamp + + stream_aggregation_options: + - cache_mode (string): The user ID to use if the user ID is not found; Example: 'batch'; Default: 'batch' + - min_history (int): Minimum history to trigger a new training event; Example: 1; Default: 1 + - max_history (int): Maximum history to include in a new training event; Example: 0; Default: 0 + - timestamp_column_name (string): Name of the column containing timestamps; Example: 'timestamp'; + Default: 'timestamp' + - aggregation_span (string): Lookback timespan for training data in a new training event; Example: '60d'; + Default: '60d' + - cache_to_disk (bool): Whether or not to cache streaming data to disk; Example: false; Default: false + - cache_dir (string): Directory to use for caching streaming data; Example: './.cache'; Default: './.cache' + + user_splitting_options: + - fallback_username (str): The user ID to use if the user ID is not found; Example: "generic_user"; + Default: 'generic_user' + - include_generic (bool): Whether to include a generic user ID in the output; Example: false; Default: False + - include_individual (bool): Whether to include individual user IDs in the output; Example: true; + Default: False + - only_users (list): List of user IDs to include; others will be excluded; + Example: ["user1", "user2", "user3"]; + Default: [] + - skip_users (list): List of user IDs to exclude from the output; Example: ["user4", "user5"]; Default: [] + - timestamp_column_name (str): Name of the column containing timestamps; Example: "timestamp"; + Default: 'timestamp' + - userid_column_name (str): Name of the column containing user IDs; Example: "username"; Default: 'username' """ config = builder.get_current_module_config() diff --git a/examples/digital_fingerprinting/production/morpheus/dfp_azure_modules_training.py b/examples/digital_fingerprinting/production/morpheus/dfp_azure_modules_training.py index 203e665df9..6fd95031ee 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp_azure_modules_training.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp_azure_modules_training.py @@ -21,7 +21,6 @@ from dfp.stages.multi_file_source import MultiFileSource from dfp.utils.config_generator import ConfigGenerator from dfp.utils.config_generator import generate_ae_config -from dfp.utils.dfp_arg_parser import DFPArgParser from dfp.utils.schema_utils import Schema from dfp.utils.schema_utils import SchemaBuilder @@ -107,7 +106,6 @@ def run_pipeline(train_users, log_level, sample_rate_s, **kwargs): - dfp_arg_parser = DeriveArgs(skip_user, only_user, start_time, diff --git a/examples/digital_fingerprinting/production/morpheus/dfp_duo_modules_inference.py b/examples/digital_fingerprinting/production/morpheus/dfp_duo_modules_inference.py index e79458c3e5..93f4a78a0d 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp_duo_modules_inference.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp_duo_modules_inference.py @@ -22,7 +22,6 @@ from dfp.stages.multi_file_source import MultiFileSource from dfp.utils.config_generator import ConfigGenerator from dfp.utils.config_generator import generate_ae_config -from dfp.utils.dfp_arg_parser import DFPArgParser from dfp.utils.schema_utils import Schema from dfp.utils.schema_utils import SchemaBuilder diff --git a/examples/digital_fingerprinting/production/morpheus/dfp_duo_modules_training.py b/examples/digital_fingerprinting/production/morpheus/dfp_duo_modules_training.py index 3a84ea8a86..2e1cb2792f 100644 --- a/examples/digital_fingerprinting/production/morpheus/dfp_duo_modules_training.py +++ b/examples/digital_fingerprinting/production/morpheus/dfp_duo_modules_training.py @@ -21,7 +21,6 @@ from dfp.stages.multi_file_source import MultiFileSource from dfp.utils.config_generator import ConfigGenerator from dfp.utils.config_generator import generate_ae_config -from dfp.utils.dfp_arg_parser import DFPArgParser from dfp.utils.schema_utils import Schema from dfp.utils.schema_utils import SchemaBuilder @@ -107,7 +106,6 @@ def run_pipeline(train_users, log_level, sample_rate_s, **kwargs): - dfp_arg_parser = DeriveArgs(skip_user, only_user, start_time, diff --git a/morpheus/loaders/file_to_df_loader.py b/morpheus/loaders/file_to_df_loader.py index 175c91af95..1aca03702a 100644 --- a/morpheus/loaders/file_to_df_loader.py +++ b/morpheus/loaders/file_to_df_loader.py @@ -18,7 +18,6 @@ import multiprocessing as mp import os import pickle -import time import typing from functools import partial @@ -265,13 +264,12 @@ def convert_to_dataframe(filenames: typing.List[str]): if (not filenames): return None - start_time = time.time() - try: + # start_time = time.time() output_df, cache_hit = get_or_create_dataframe_from_s3_batch(filenames) - duration = (time.time() - start_time) * 1000.0 - + # duration = (time.time() - start_time) * 1000.0 + # # logger.debug("S3 objects to DF complete. Rows: %s, Cache: %s, Duration: %s ms", # len(output_df), # "hit" if cache_hit else "miss", diff --git a/morpheus/messages/memory/__init__.py b/morpheus/messages/memory/__init__.py index 6031d84c7d..45df2f3d9e 100644 --- a/morpheus/messages/memory/__init__.py +++ b/morpheus/messages/memory/__init__.py @@ -15,4 +15,4 @@ Memory classes """ -__all__ = [] \ No newline at end of file +__all__ = [] diff --git a/morpheus/modules/file_batcher.py b/morpheus/modules/file_batcher.py index bea890ac05..a67fee4f5e 100644 --- a/morpheus/modules/file_batcher.py +++ b/morpheus/modules/file_batcher.py @@ -54,19 +54,26 @@ def file_batcher(builder: mrc.Builder): Notes ----- - Configurable parameters: - - batching_options (dict): - - end_time (datetime|str): End time of the time window. - - iso_date_regex_pattern (str): Regex pattern for ISO date matching. - - parser_kwargs (dict): Additional arguments for the parser. - - period (str): Time period for grouping files. - - sampling_rate_s (int): Sampling rate in seconds. - - start_time (datetime|str): Start time of the time window. - - cache_dir (str): Cache directory. - - file_type (str): File type. - - filter_nulls (bool): Whether to filter null values. - - schema (dict): Data schema. - - timestamp_column_name (str): Name of the timestamp column. + Configurable Parameters: + - batching_options (dict): Options for batching; See below; Default: - + - cache_dir (str): Cache directory; Example: `./file_batcher_cache`; Default: None + - file_type (str): File type; Example: JSON; Default: JSON + - filter_nulls (bool): Whether to filter null values; Example: false; Default: false + - schema (dict): Data schema; See below; Default: `[Required]` + - timestamp_column_name (str): Name of the timestamp column; Example: timestamp; Default: timestamp + + batching_options: + - end_time (datetime/string): Endtime of the time window; Example: "2023-03-14T23:59:59"; Default: None + - iso_date_regex_pattern (str): Regex pattern for ISO date matching; + Example: "\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}"; Default: + - parser_kwargs (dict): Additional arguments for the parser; Example: {}; Default: {} + - period (str): Time period for grouping files; Example: "1d"; Default: "1d" + - sampling_rate_s (int): Sampling rate in seconds; Example: 60; Default: 60 + - start_time (datetime/string): Start time of the time window; Example: "2023-03-01T00:00:00"; Default: None + + schema: + - encoding (str): Encoding; Example: "latin1"; Default: "latin1" + - schema_str (str): Schema string; Example: "string"; Default: `[Required]` """ config = builder.get_current_module_config() diff --git a/morpheus/modules/filter_control_message.py b/morpheus/modules/filter_control_message.py index 80e920fdf0..bf72ab3d19 100644 --- a/morpheus/modules/filter_control_message.py +++ b/morpheus/modules/filter_control_message.py @@ -37,11 +37,11 @@ def filter_control_message(builder: mrc.Builder): Notes ----- - Configurable parameters: - - enable_task_filtering (bool): Enables filtering based on task type. - - enable_data_type_filtering (bool): Enables filtering based on data type. - - filter_task_type (str): The task type to be used as a filter. - - filter_data_type (str): The data type to be used as a filter. + Configurable Parameters: + - enable_data_type_filtering (bool): Enables filtering based on data type; Example: true; Default: false + - enable_task_filtering (bool): Enables filtering based on task type; Example: true; Default: false + - filter_data_type (str): The data type to be used as a filter; Example: `desired_data_type`; Default: None + - filter_task_type (str): The task type to be used as a filter; Example: `specific_task`; Default: None """ config = builder.get_current_module_config() diff --git a/morpheus/modules/filter_detections.py b/morpheus/modules/filter_detections.py index 8ac33fe5bf..5c782751ce 100644 --- a/morpheus/modules/filter_detections.py +++ b/morpheus/modules/filter_detections.py @@ -67,14 +67,17 @@ def filter_detections(builder: mrc.Builder): Notes ----- - Configurable parameters: - - field_name (str): Name of the field to filter on. Defaults to 'probs'. - - threshold (float): Threshold value to filter on. Defaults to 0.5. - - filter_source (str): Source of the filter field. Defaults to 'AUTO'. - - copy (bool): Whether to copy the rows or slice them. Defaults to True. - - schema (dict): Schema configuration. - - input_message_type (str): Pickled message type. - - encoding (str): Encoding used to pickle the message type. + Configurable Parameters: + - copy (bool): Whether to copy the rows or slice them; Example: true; Default: true + - field_name (str): Name of the field to filter on; Example: `probs`; Default: probs + - filter_source (str): Source of the filter field; Example: `AUTO`; Default: AUTO + - schema (dict): Schema configuration; See Below; Default: - + - threshold (float): Threshold value to filter on; Example: 0.5; Default: 0.5 + + schema: + - encoding (str): Encoding; Example: "latin1"; Default: "latin1" + - input_message_type (str): Pickled message type; Example: `pickle_message_type`; Default: `[Required]` + - schema_str (str): Schema string; Example: "string"; Default: `[Required]` """ config = builder.get_current_module_config() diff --git a/morpheus/modules/mlflow_model_writer.py b/morpheus/modules/mlflow_model_writer.py index 5d7e0468d0..db0c4c3e26 100644 --- a/morpheus/modules/mlflow_model_writer.py +++ b/morpheus/modules/mlflow_model_writer.py @@ -53,13 +53,19 @@ def mlflow_model_writer(builder: mrc.Builder): mrc Builder object. Notes - ---------- - Configurable parameters: - - model_name_formatter: Formatter for the model name - - experiment_name_formatter: Formatter for the experiment name - - conda_env: Conda environment for the model - - timestamp_column_name: Name of the timestamp column - - databricks_permissions: Permissions for the model + ----- + Configurable Parameters: + - conda_env (str): Conda environment for the model; Example: `path/to/conda_env.yml`; Default: `[Required]` + - databricks_permissions (dict): Permissions for the model; See Below; Default: None + - experiment_name_formatter (str): Formatter for the experiment name; + Example: `experiment_name_{timestamp}`; Default: `[Required]` + - model_name_formatter (str): Formatter for the model name; Example: `model_name_{timestamp}`; + Default: `[Required]` + - timestamp_column_name (str): Name of the timestamp column; Example: `timestamp`; Default: timestamp + + databricks_permissions: + - read (array): List of users with read permissions; Example: `["read_user1", "read_user2"]`; Default: - + - write (array): List of users with write permissions; Example: `["write_user1", "write_user2"]`; Default: - """ config = builder.get_current_module_config() @@ -135,7 +141,7 @@ def apply_model_permissions(reg_model_name: str): "access_control_list": [{ "group_name": group, "permission_level": permission } for group, - permission in databricks_permissions.items()] + permission in databricks_permissions.items()] } requests.patch(url=patch_registered_model_permissions_url, diff --git a/morpheus/modules/serialize.py b/morpheus/modules/serialize.py index c5b01db0d8..88d4811b0c 100644 --- a/morpheus/modules/serialize.py +++ b/morpheus/modules/serialize.py @@ -26,7 +26,6 @@ from morpheus.messages.message_meta import MessageMeta from morpheus.utils.module_ids import MORPHEUS_MODULE_NAMESPACE from morpheus.utils.module_ids import SERIALIZE -from morpheus.utils.module_utils import get_module_config from morpheus.utils.module_utils import register_module logger = logging.getLogger(__name__) @@ -45,13 +44,16 @@ def serialize(builder: mrc.Builder): mrc Builder object. Notes - ---------- - Configurable parameters: - - include : str (Regex to include columns) - - exclude : List[str] (List of regex to exclude columns) - - fixed_columns : bool (If true, the columns are fixed and not determined at runtime) - - columns : List[str] (List of columns to include) - - use_cpp : bool (If true, use C++ to serialize) + ----- + Configurable Parameters: + - columns (list[string]): List of columns to include; Example: `["column1", "column2", "column3"]`; + Default: None + - exclude (list[string]): List of regex patterns to exclude columns; Example: `["column_to_exclude"]`; + Default: `[r'^ID$', r'^_ts_']` + - fixed_columns (bool): If true, the columns are fixed and not determined at runtime; Example: `true`; + Default: true + - include (string): Regex to include columns; Example: `^column`; Default: None + - use_cpp (bool): If true, use C++ to serialize; Example: `true`; Default: false """ config = builder.get_current_module_config() diff --git a/morpheus/modules/write_to_file.py b/morpheus/modules/write_to_file.py index 287de768bc..5fb750b29c 100644 --- a/morpheus/modules/write_to_file.py +++ b/morpheus/modules/write_to_file.py @@ -28,7 +28,6 @@ from morpheus.messages.message_meta import MessageMeta from morpheus.utils.module_ids import MORPHEUS_MODULE_NAMESPACE from morpheus.utils.module_ids import WRITE_TO_FILE -from morpheus.utils.module_utils import get_module_config from morpheus.utils.module_utils import register_module logger = logging.getLogger(__name__) @@ -48,12 +47,12 @@ def write_to_file(builder: mrc.Builder): Notes ----- - Configurable parameters: - - filename : str (Path to output file) - - overwrite : bool (If true, overwrite the file if it exists) - - flush : bool (If true, flush the file after each write) - - file_type : FileTypes (Type of file to write) - - include_index_col : bool (If true, include the index column) + Configurable Parameters: + - filename (string): Path to the output file; Example: `output.csv`; Default: None + - file_type (FileTypes): Type of file to write; Example: `FileTypes.CSV`; Default: `FileTypes.Auto` + - flush (bool): If true, flush the file after each write; Example: `false`; Default: false + - include_index_col (bool): If true, include the index column; Example: `false`; Default: true + - overwrite (bool): If true, overwrite the file if it exists; Example: `true`; Default: false """ config = builder.get_current_module_config() @@ -102,7 +101,6 @@ def node_fn(obs: mrc.Observable, sub: mrc.Subscriber): # Open up the file handle with open(output_file, "a") as out_file: - def write_to_file(x: MessageMeta): lines = convert_to_strings(x.df) diff --git a/morpheus/stages/input/control_message_kafka_source_stage.py b/morpheus/stages/input/control_message_kafka_source_stage.py index 6d5c78c3b7..07ab526e36 100644 --- a/morpheus/stages/input/control_message_kafka_source_stage.py +++ b/morpheus/stages/input/control_message_kafka_source_stage.py @@ -15,7 +15,6 @@ import json import logging import time -import typing import confluent_kafka as ck import mrc diff --git a/morpheus/utils/module_utils.py b/morpheus/utils/module_utils.py index 3ed707a241..355dad7e58 100644 --- a/morpheus/utils/module_utils.py +++ b/morpheus/utils/module_utils.py @@ -21,8 +21,6 @@ import numpy as np import pandas as pd -import cudf - logger = logging.getLogger(__name__) registry = mrc.ModuleRegistry diff --git a/tests/messages/test_control_message.py b/tests/messages/test_control_message.py index e44001a33c..44a2a21581 100644 --- a/tests/messages/test_control_message.py +++ b/tests/messages/test_control_message.py @@ -18,22 +18,18 @@ import cudf -import morpheus._lib.messages as _messages import morpheus.messages as messages @pytest.mark.usefixtures("config_only_cpp") def test_control_message_init(): - raw_control_message_one = _messages.ControlMessage() - raw_control_message_two = _messages.ControlMessage({"test": "test"}) - - control_message_one = messages.ControlMessage() - control_message_two = messages.ControlMessage({"test": "test"}) + control_message_one = messages.ControlMessage() # noqa: F841 + control_message_two = messages.ControlMessage({"test": "test"}) # noqa: F841 @pytest.mark.usefixtures("config_only_cpp") def test_control_message_get(): - raw_control_message = _messages.ControlMessage({ + raw_control_message = messages.ControlMessage({ "test": "test_rcm", "tasks": [{ "type": "load", "properties": { "loader_id": "payload" @@ -57,7 +53,7 @@ def test_control_message_get(): @pytest.mark.usefixtures("config_only_cpp") def test_control_message_set(): - raw_control_message = _messages.ControlMessage() + raw_control_message = messages.ControlMessage() control_message = messages.ControlMessage() raw_control_message.config({ diff --git a/tests/modules/test_morpheus_modules.py b/tests/modules/test_morpheus_modules.py index 1dc6e852db..27d77c2374 100644 --- a/tests/modules/test_morpheus_modules.py +++ b/tests/modules/test_morpheus_modules.py @@ -16,14 +16,13 @@ import os import tempfile -import time import mrc import cudf import morpheus.messages as messages -import morpheus.modules # Used to load and register morpheus modules +import morpheus.modules # noqa: F401 def on_next(control_msg): @@ -65,11 +64,10 @@ def test_get_module(): assert fn_constructor is not None config = {} - module_instance = fn_constructor("ModuleDataLoaderTest", config) + module_instance = fn_constructor("ModuleDataLoaderTest", config) # noqa: F841 -- we don't need to use it def test_get_module_with_bad_config_no_loaders(): - def init_wrapper(builder: mrc.Builder): def gen_data(): @@ -100,14 +98,15 @@ def gen_data(): try: executor.start() - assert (False, "This should fail, because no loaders were specified in the config and none were added.") + assert ( # noqa: F631 + False, + "This should fail, because no loaders were specified in the config and none were added.") executor.join() except Exception: pass def test_get_module_with_bad_loader_type(): - def init_wrapper(builder: mrc.Builder): def gen_data(): @@ -136,13 +135,12 @@ def gen_data(): pipeline = mrc.Pipeline() try: pipeline.make_segment("main", init_wrapper) - assert (False, "This should fail, because the loader type is not a valid loader") + assert (False, "This should fail, because the loader type is not a valid loader") # noqa: F631 except Exception: pass def test_get_module_with_bad_control_message(): - def init_wrapper(builder: mrc.Builder): def gen_data(): @@ -179,7 +177,8 @@ def gen_data(): try: executor.start() - assert (False, "We should never get here, because the control message specifies an invalid loader") + assert ( # noqa: F631 + False, "We should never get here, because the control message specifies an invalid loader") # noqa: F631 executor.join() except Exception: pass From d78278aa2a738021662d7780bd54fdefbd4b463a Mon Sep 17 00:00:00 2001 From: Bhargav Suryadevara Date: Tue, 28 Mar 2023 18:05:50 -0500 Subject: [PATCH 140/157] updated module schema docs --- docs/source/loaders/core/file_to_df_loader.md | 66 +++++++--- docs/source/loaders/core/fsspec_loader.md | 39 ++++-- docs/source/loaders/morpheus_loaders.md | 2 +- docs/source/modules/core/data_loader.md | 12 +- docs/source/modules/core/file_batcher.md | 28 ++--- docs/source/modules/core/file_to_df.md | 12 +- .../modules/core/filter_control_message.md | 8 +- docs/source/modules/core/filter_detections.md | 18 +-- .../modules/core/mlflow_model_writer.md | 14 +-- docs/source/modules/core/serializer.md | 10 +- docs/source/modules/core/write_to_file.md | 14 +-- .../digital_fingerprinting/dfp_data_prep.md | 14 +-- .../digital_fingerprinting/dfp_deployment.md | 116 +++++++++--------- .../digital_fingerprinting/dfp_inference.md | 10 +- .../dfp_inference_pipe.md | 72 +++++------ .../dfp_postprocessing.md | 2 +- .../digital_fingerprinting/dfp_preproc.md | 51 ++++---- .../dfp_rolling_window.md | 16 +-- .../digital_fingerprinting/dfp_split_users.md | 27 ++-- .../digital_fingerprinting/dfp_training.md | 14 +-- .../dfp_training_pipe.md | 71 +++++------ morpheus/modules/write_to_file.py | 1 - 22 files changed, 324 insertions(+), 293 deletions(-) diff --git a/docs/source/loaders/core/file_to_df_loader.md b/docs/source/loaders/core/file_to_df_loader.md index e8392ffccd..119a3b7343 100644 --- a/docs/source/loaders/core/file_to_df_loader.md +++ b/docs/source/loaders/core/file_to_df_loader.md @@ -17,15 +17,13 @@ limitations under the License. ## File to DataFrame Loader -This function is used to load files containing data into a dataframe. Dataframe is created by processing files either using a single thread, multiprocess, dask, or dask_thread. This the function determines the download method to use, and if it starts with "dask," it creates a dask client and uses it to process the files. Otherwise, it uses a single thread or multiprocess to process the files. This function then caches the resulting dataframe using a hash of the file paths. In addition to loading data from the disk, it has the ability to load the file content from S3 buckets. +[DataLoader](../../modules/core/data_loader.md) module is used to load data files content into a dataframe using custom loader function. This loader function can be configured to use different processing methods, such as single-threaded, multiprocess, dask, or dask_thread, as determined by the `MORPHEUS_FILE_DOWNLOAD_TYPE` environment variable. When download_method starts with "dask," a dask client is created to process the files, otherwise, a single thread or multiprocess is used. -**Note** : Loaders receive configuration from `load` task via the [control message](./../../source/control_message_guide.md) during runtime. +After processing, the resulting dataframe is cached using a hash of the file paths. This loader also has the ability to load file content from S3 buckets, in addition to loading data from the disk. -### Configurable Parameters +### Example Loader Configuration -- `id` (str): Registered loader id. - -### Example JSON Configuration +Using below configuration while loading DataLoader module, specifies that the DataLoader module should utilize the `file_to_df` loader when loading files into a dataframe. ```json { @@ -35,14 +33,52 @@ This function is used to load files containing data into a dataframe. Dataframe } ``` -### Default Settings +**Note** : Loaders can receive configuration from the `load` task via [control message](../../../source/control_message_guide.md) during runtime. + +### Task Configurable Parameters + +The parameters that can be configured for this specific loader at load task level: + +| Parameter | Type | Description | Example Value | Default Value | +| ------------------ | ---------- | -------------------------------- | ------------------------ | -------------- | +| `batcher_config ` | dictionary | Options for batching | See below | `[Required]` | +| `files` | array | List of files to load | ["/path/to/input/files"] | `[]` | +| `loader_id` | string | Unique identifier for the loader | "file_to_df" | `[Required]` | + + +### `batcher_config` + +| Key | Type | Description | Example Value | Default Value | +|-------------------------|------------|--------------------------------------------|----------------------|---------------| +| `cache_dir` | string | Directory to cache the rolling window data | "/path/to/cache" | `-` | +| `file_type` | string | Type of the input file | "csv" | `"JSON"` | +| `filter_null` | boolean | Whether to filter out null values | true | `false` | +| `parser_kwargs` | dictionary | Keyword arguments to pass to the parser | {"delimiter": ","} | `-` | +| `schema` | dictionary | Schema of the input data | See Below | `-` | +| `timestamp_column_name` | string | Name of the timestamp column | "timestamp" | `-` | -| Property | Value | -| -----------------------| ----------| -| cache_dir | ./.cache | -| file_type | JSON | -| filter_null | False | -| parser_kwargs | None | -| timestamp_column_name | timestamp | +### Example Load Task Configuration + +Below JSON configuration specifies how to pass additional configuration to the loader through a control message task at runtime. + +```json +{ + "type": "load", + "properties": { + "loader_id": "file_to_df", + "files": ["/path/to/input/files"], + "batcher_config": { + "timestamp_column_name": "timestamp_column_name", + "schema": "string", + "file_type": "JSON", + "filter_null": false, + "parser_kwargs": { + "delimiter": "," + }, + "cache_dir": "/path/to/cache" + } + } +} +``` -**Note** : The [file_batcher](../../../../morpheus/modules/file_batcher.py) module currently generates tasks internally and assigns them to control messages, and then sends them to a [file_to_df_loader](../../../../morpheus/loaders/file_to_df_loader.py). Having stated that, this loader's configuration is obtained from the [File Batcher](../../modules/core/file_batcher.md) module configuration. +**Note** : The [file_batcher](../../../../morpheus/modules/file_batcher.py) module currently generates tasks internally and assigns them to control messages, and then sends them to [DataLoader](../../modules/core/data_loader.md) module which uses [file_to_df_loader](../../../../morpheus/loaders/file_to_df_loader.py). Having stated that, this loader configuration is obtained from the [File Batcher](../../modules/core/file_batcher.md) module configuration. diff --git a/docs/source/loaders/core/fsspec_loader.md b/docs/source/loaders/core/fsspec_loader.md index 9ad51df070..069e1f0e50 100644 --- a/docs/source/loaders/core/fsspec_loader.md +++ b/docs/source/loaders/core/fsspec_loader.md @@ -17,15 +17,10 @@ limitations under the License. ## Filesystem Spec Loader -Loads data from external sources using the fsspec library, and returns the updated ControlMessage object with payload as MessageMeta, which contains dataframe (with filenames). +[DataLoader](../../modules/core/data_loader.md) module is configured to use this loader function. It is responsible for loading data from external sources using the fsspec library, and returns the updated ControlMessage object with payload as MessageMeta, which contains dataframe (with filenames). -**Note** : Loaders receive configuration from `load` task via the [control message](./../../source/control_message_guide.md) during runtime. -### Configurable Parameters - -- `id` (str): Registered loader id. - -### Example JSON Configuration +### Example Loader Configuration ```json { @@ -35,8 +30,30 @@ Loads data from external sources using the fsspec library, and returns the updat } ``` -### Default Settings +**Note** : Loaders can receive configuration from the `load` task via [control message](../../../source/control_message_guide.md) during runtime. + +### Task Configurable Parameters + +The parameters that can be configured for this specific loader at load task level: + +| Parameter | Type | Description | Example Value | Default Value | +| ------------------ | ---------- | -------------------------------- | --------------------------------- | -------------- | +| `files` | array | List of files to load | ["/your/input/filepath"] | `[]` | +| `loader_id` | string | Unique identifier for the loader | "file_to_df" | `[Required]` | + + -| Property | Value | -| -------- | ----- | -| files | [] | + +### Example Load Task Configuration + +Below JSON configuration specifies how to pass additional configuration to the loader through a control message task at runtime. + +```json +{ + "type": "load", + "properties": { + "loader_id": "file_to_df", + "files": ["/your/input/filepath"], + } +} +``` diff --git a/docs/source/loaders/morpheus_loaders.md b/docs/source/loaders/morpheus_loaders.md index 7d89b5b1a9..e5af305082 100644 --- a/docs/source/loaders/morpheus_loaders.md +++ b/docs/source/loaders/morpheus_loaders.md @@ -19,7 +19,7 @@ limitations under the License. Custom functions called "Loaders" can be utilized by the DataLoader Module to load data into the pipeline. The user can choose to register their own customized loader function and add it to a dataloader registry, which will then become accessible to the DataLoader module during module loading. -**Note** : Loaders receive configuration from `load` task via the [control message](./../../source/control_message_guide.md) during runtime. +**Note** : Loaders receive configuration from the `load` task via [control message](./../../source/control_message_guide.md) during runtime. ## Core Loaders diff --git a/docs/source/modules/core/data_loader.md b/docs/source/modules/core/data_loader.md index 5befe55b2e..073e665f00 100644 --- a/docs/source/modules/core/data_loader.md +++ b/docs/source/modules/core/data_loader.md @@ -23,16 +23,16 @@ are specified in the module configuration file at the time of object constructio ### Configurable Parameters -| Parameter | Type | Description | Example Value | Default Value | -|-----------|-------|---------------------------------------------------|---------------|---------------| -| `loaders` | array | An array containing information on loaders to use | See Below | [] | +| Parameter | Type | Description | Example Value | Default Value | +|-----------|-------|---------------------------------------------------|---------------|-----------------| +| `loaders` | array | An array containing information on loaders to use | See Below | `[]` | ### `loaders` | Parameter | Type | Description | Example Value | Default Value | |--------------|------------|------------------------------------------|----------------------------------------|---------------| -| `id` | string | Unique identifier for the loader | `loader1` | - | -| `properties` | dictionary | Dictionary of properties for that loader | `{... loader specific parameters ...}` | `{}` | +| `id` | string | Unique identifier for the loader | "loader1" | `-` | +| `properties` | dictionary | Dictionary of properties for that loader | {... loader specific parameters ...} | `{}` | ### Example JSON Configuration @@ -53,4 +53,4 @@ are specified in the module configuration file at the time of object constructio } ] } -``` \ No newline at end of file +``` diff --git a/docs/source/modules/core/file_batcher.md b/docs/source/modules/core/file_batcher.md index 2749bf6a26..66ebb0b76a 100644 --- a/docs/source/modules/core/file_batcher.md +++ b/docs/source/modules/core/file_batcher.md @@ -24,29 +24,29 @@ remaining files by period that fall inside the window. | Parameter | Type | Description | Example Value | Default Value | |-------------------------|------------|-------------------------------|------------------------|---------------| -| `batching_options` | dictionary | Options for batching | See below | - | -| `cache_dir` | string | Cache directory | `./file_batcher_cache` | None | -| `file_type` | string | File type | JSON | JSON | -| `filter_nulls` | boolean | Whether to filter null values | false | false | +| `batching_options` | dictionary | Options for batching | See below | `-` | +| `cache_dir` | string | Cache directory | "./file_batcher_cache" | `None` | +| `file_type` | string | File type | "JSON" | `"JSON"` | +| `filter_nulls` | boolean | Whether to filter null values | false | `false` | | `schema` | dictionary | Data schema | See below | `[Required]` | -| `timestamp_column_name` | string | Name of the timestamp column | timestamp | timestamp | +| `timestamp_column_name` | string | Name of the timestamp column | "timestamp" | `"timestamp"` | ### `batching_options` -| Key | Type | Description | Example Value | Default Value | -|--------------------------|-----------------|-------------------------------------|---------------------------------------------|--------------------------| -| `end_time` | datetime/string | Endtime of the time window | "2023-03-14T23:59:59" | None | -| `iso_date_regex_pattern` | string | Regex pattern for ISO date matching | "\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}" | | -| `parser_kwargs` | dictionary | Additional arguments for the parser | {} | {} | -| `period` | string | Time period for grouping files | "1d" | "1d" | -| `sampling_rate_s` | integer | Sampling rate in seconds | 60 | 60 | -| `start_time` | datetime/string | Start time of the time window | "2023-03-01T00:00:00" | None | +| Key | Type | Description | Example Value | Default Value | +|--------------------------|-----------------|-------------------------------------|---------------------------------------------|----------------------------| +| `end_time` | datetime/string | Endtime of the time window | "2023-03-14T23:59:59" | `None` | +| `iso_date_regex_pattern` | string | Regex pattern for ISO date matching | "\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}" | `` | +| `parser_kwargs` | dictionary | Additional arguments for the parser | {} | `{}` | +| `period` | string | Time period for grouping files | "1d" | `"D"` | +| `sampling_rate_s` | integer | Sampling rate in seconds | 60 | `60` | +| `start_time` | datetime/string | Start time of the time window | "2023-03-01T00:00:00" | `None` | ### `schema` | Key | Type | Description | Example Value | Default Value | |--------------|--------|---------------|---------------|---------------| -| `encoding` | string | Encoding | "latin1" | "latin1" | +| `encoding` | string | Encoding | "latin1" | `"latin1"` | | `schema_str` | string | Schema string | "string" | `[Required]` | ### Example JSON Configuration diff --git a/docs/source/modules/core/file_to_df.md b/docs/source/modules/core/file_to_df.md index 4bf67ca120..7e0e1fb578 100644 --- a/docs/source/modules/core/file_to_df.md +++ b/docs/source/modules/core/file_to_df.md @@ -24,12 +24,12 @@ addition to loading data from the disk, it has the ability to load the file cont | Parameter | Type | Description | Example Value | Default Value | |-------------------------|------------|--------------------------------------------|----------------------|---------------| -| `cache_dir` | string | Directory to cache the rolling window data | `/path/to/cache` | - | -| `file_type` | string | Type of the input file | `csv` | JSON | -| `filter_null` | boolean | Whether to filter out null values | true | false | -| `parser_kwargs` | dictionary | Keyword arguments to pass to the parser | `{"delimiter": ","}` | - | -| `schema` | dictionary | Schema of the input data | See Below | - | -| `timestamp_column_name` | string | Name of the timestamp column | `timestamp` | - | +| `cache_dir` | string | Directory to cache the rolling window data | "/path/to/cache" | `-` | +| `file_type` | string | Type of the input file | "csv" | `"JSON"` | +| `filter_null` | boolean | Whether to filter out null values | true | `false` | +| `parser_kwargs` | dictionary | Keyword arguments to pass to the parser | {"delimiter": ","} | `-` | +| `schema` | dictionary | Schema of the input data | See Below | `-` | +| `timestamp_column_name` | string | Name of the timestamp column | "timestamp" | `-` | ### Example JSON Configuration diff --git a/docs/source/modules/core/filter_control_message.md b/docs/source/modules/core/filter_control_message.md index 84eee96baf..8188afc007 100644 --- a/docs/source/modules/core/filter_control_message.md +++ b/docs/source/modules/core/filter_control_message.md @@ -23,10 +23,10 @@ When the requirements are met, this module gently discards the control messages. | Parameter | Type | Description | Example Value | Default Value | |------------------------------|---------|--------------------------------------|---------------------|---------------| -| `enable_data_type_filtering` | boolean | Enables filtering based on data type | true | false | -| `enable_task_filtering` | boolean | Enables filtering based on task type | true | false | -| `filter_data_type` | string | The data type to be used as a filter | `desired_data_type` | None | -| `filter_task_type` | string | The task type to be used as a filter | `specific_task` | None | +| `enable_data_type_filtering` | boolean | Enables filtering based on data type | true | `false` | +| `enable_task_filtering` | boolean | Enables filtering based on task type | true | `false` | +| `filter_data_type` | string | The data type to be used as a filter | "desired_data_type" | `None` | +| `filter_task_type` | string | The task type to be used as a filter | "specific_task" | `None` | ### Example JSON Configuration diff --git a/docs/source/modules/core/filter_detections.md b/docs/source/modules/core/filter_detections.md index d30532a271..7312983eda 100644 --- a/docs/source/modules/core/filter_detections.md +++ b/docs/source/modules/core/filter_detections.md @@ -25,20 +25,20 @@ to `threshold`. ### Configurable Parameters -| Parameter | Type | Description | Example Value | Default Value | -|-----------------|------------|----------------------------------------|---------------|---------------| -| `copy` | boolean | Whether to copy the rows or slice them | true | true | -| `field_name` | string | Name of the field to filter on | `probs` | probs | -| `filter_source` | string | Source of the filter field | `AUTO` | AUTO | -| `schema` | dictionary | Schema configuration | See Below | - | -| `threshold` | float | Threshold value to filter on | 0.5 | 0.5 | +| Parameter | Type | Description | Example Value | Default Value | +|-----------------|------------|----------------------------------------|---------------|-----------------| +| `copy` | boolean | Whether to copy the rows or slice them | true | `true` | +| `field_name` | string | Name of the field to filter on | "probs" | `probs` | +| `filter_source` | string | Source of the filter field | "AUTO" | `AUTO` | +| `schema` | dictionary | Schema configuration | See Below | `-` | +| `threshold` | float | Threshold value to filter on | 0.5 | `0.5` | ### `schema` | Key | Type | Description | Example Value | Default Value | |----------------------|--------|----------------------|-----------------------|---------------| -| `encoding` | string | Encoding | "latin1" | "latin1" | -| `input_message_type` | string | Pickled message type | `pickle_message_type` | `[Required]` | +| `encoding` | string | Encoding | "latin1" | `latin1` | +| `input_message_type` | string | Pickled message type | "pickle_message_type" | `[Required]` | | `schema_str` | string | Schema string | "string" | `[Required]` | ### Example JSON Configuration diff --git a/docs/source/modules/core/mlflow_model_writer.md b/docs/source/modules/core/mlflow_model_writer.md index 789a792936..e48ae7f648 100644 --- a/docs/source/modules/core/mlflow_model_writer.md +++ b/docs/source/modules/core/mlflow_model_writer.md @@ -23,18 +23,18 @@ This module uploads trained models to the MLflow server. | Parameter | Type | Description | Example Value | Default Value | |-----------------------------|------------|-----------------------------------|-------------------------------|---------------| -| `conda_env` | string | Conda environment for the model | `path/to/conda_env.yml` | `[Required]` | -| `databricks_permissions` | dictionary | Permissions for the model | See Below | None | -| `experiment_name_formatter` | string | Formatter for the experiment name | `experiment_name_{timestamp}` | `[Required]` | -| `model_name_formatter` | string | Formatter for the model name | `model_name_{timestamp}` | `[Required]` | -| `timestamp_column_name` | string | Name of the timestamp column | `timestamp` | timestamp | +| `conda_env` | string | Conda environment for the model | "path/to/conda_env.yml" | `[Required]` | +| `databricks_permissions` | dictionary | Permissions for the model | See Below | `None` | +| `experiment_name_formatter` | string | Formatter for the experiment name | "experiment_name_{timestamp}" | `[Required]` | +| `model_name_formatter` | string | Formatter for the model name | "model_name_{timestamp}" | `[Required]` | +| `timestamp_column_name` | string | Name of the timestamp column | "timestamp" | `timestamp` | ### `databricks_permissions` | Key | Type | Description | Example Value | Default Value | |---------|-------|--------------------------------------|----------------------------------|---------------| -| `read` | array | List of users with read permissions | `["read_user1", "read_user2"]` | - | -| `write` | array | List of users with write permissions | `["write_user1", "write_user2"]` | - | +| `read` | array | List of users with read permissions | ["read_user1", "read_user2"] | `-` | +| `write` | array | List of users with write permissions | ["write_user1", "write_user2"] | `-` | ### Example JSON Configuration diff --git a/docs/source/modules/core/serializer.md b/docs/source/modules/core/serializer.md index e748299f32..daaf01d434 100644 --- a/docs/source/modules/core/serializer.md +++ b/docs/source/modules/core/serializer.md @@ -23,11 +23,11 @@ This module filters columns from a `MultiMessage` object, emitting a `MessageMet | Parameter | Type | Description | Example Value | Default Value | |-----------------|--------------|--------------------------------------------------------------|-------------------------------------|-----------------------| -| `columns` | list[string] | List of columns to include | `["column1", "column2", "column3"]` | None | -| `exclude` | list[string] | List of regex patterns to exclude columns | `["column_to_exclude"]` | `[r'^ID$', r'^_ts_']` | -| `fixed_columns` | bool | If true, the columns are fixed and not determined at runtime | `true` | true | -| `include` | string | Regex to include columns | `^column` | None | -| `use_cpp` | bool | If true, use C++ to serialize | `true` | false | +| `columns` | list[string] | List of columns to include | ["column1", "column2", "column3"] | `None` | +| `exclude` | list[string] | List of regex patterns to exclude columns | ["column_to_exclude"] | `[r'^ID$', r'^_ts_']` | +| `fixed_columns` | bool | If true, the columns are fixed and not determined at runtime | true | `true` | +| `include` | string | Regex to include columns | "^column" | `None` | +| `use_cpp` | bool | If true, use C++ to serialize | true | `false` | ### Example JSON Configuration diff --git a/docs/source/modules/core/write_to_file.md b/docs/source/modules/core/write_to_file.md index 648809f522..0365ee745f 100644 --- a/docs/source/modules/core/write_to_file.md +++ b/docs/source/modules/core/write_to_file.md @@ -21,13 +21,13 @@ This module writes messages to a file. ### Configurable Parameters -| Parameter | Type | Description | Example Value | Default Value | -|---------------------|-----------|------------------------------------------|-----------------|------------------| -| `filename` | string | Path to the output file | `output.csv` | None | -| `file_type` | FileTypes | Type of file to write | `FileTypes.CSV` | `FileTypes.Auto` | -| `flush` | bool | If true, flush the file after each write | `false` | false | -| `include_index_col` | bool | If true, include the index column | `false` | true | -| `overwrite` | bool | If true, overwrite the file if it exists | `true` | false | +| Parameter | Type | Description | Example Value | Default Value | +|---------------------|-----------|------------------------------------------|---------------|------------------| +| `filename` | string | Path to the output file | "output.csv" | `None` | +| `file_type` | string | Type of file to write | "CSV" | `AUTO` | +| `flush` | bool | If true, flush the file after each write | false | `false ` | +| `include_index_col` | bool | If true, include the index column | false | `true` | +| `overwrite` | bool | If true, overwrite the file if it exists | true | `false` | ### Example JSON Configuration diff --git a/docs/source/modules/examples/digital_fingerprinting/dfp_data_prep.md b/docs/source/modules/examples/digital_fingerprinting/dfp_data_prep.md index 8d41ae9b64..b92ed96af8 100644 --- a/docs/source/modules/examples/digital_fingerprinting/dfp_data_prep.md +++ b/docs/source/modules/examples/digital_fingerprinting/dfp_data_prep.md @@ -23,16 +23,16 @@ This module function prepares data for either inference or model training. | Parameter | Type | Description | Example Value | Default Value | |-------------------------|--------|------------------------------|---------------|---------------| -| `schema` | dict | Schema configuration | See Below | - | -| `timestamp_column_name` | string | Name of the timestamp column | `timestamp` | timestamp | +| `schema` | dict | Schema configuration | See Below | `-` | +| `timestamp_column_name` | string | Name of the timestamp column | "timestamp" | `timestamp` | #### `schema` -| Key | Type | Description | Example Value | Default Value | -|----------------------|--------|----------------------------------|---------------------------|---------------| -| `schema_str` | string | Serialized schema string | `"cPickle schema string"` | - | -| `encoding` | string | Encoding used for the schema_str | `"latin1"` | - | -| `input_message_type` | string | Pickled message type | `"message type"` | - | +| Key | Type | Description | Example Value | Default Value | +|----------------------|--------|----------------------------------|-------------------------|---------------| +| `schema_str` | string | Serialized schema string | "cPickle schema string" | `-` | +| `encoding` | string | Encoding used for the schema_str | "latin1" | `-` | +| `input_message_type` | string | Pickled message type | "message type" | `-` | ### Example JSON Configuration diff --git a/docs/source/modules/examples/digital_fingerprinting/dfp_deployment.md b/docs/source/modules/examples/digital_fingerprinting/dfp_deployment.md index b00c9e9232..4f2dd7a5e5 100644 --- a/docs/source/modules/examples/digital_fingerprinting/dfp_deployment.md +++ b/docs/source/modules/examples/digital_fingerprinting/dfp_deployment.md @@ -30,106 +30,106 @@ This module function sets up modular Digital Fingerprinting Pipeline instance. | Parameter | Type | Description | Example Value | Default Value | |------------------------------|------|------------------------------------------------|----------------------|---------------| -| `batching_options` | dict | Options for batching the data | See Below | - | -| `cache_dir` | str | Directory to cache the rolling window data | "/path/to/cache/dir" | ./.cache | -| `dfencoder_options` | dict | Options for configuring the data frame encoder | See Below | - | -| `mlflow_writer_options` | dict | Options for the MLflow model writer | See Below | - | -| `preprocessing_options` | dict | Options for preprocessing the data | See Below | - | -| `stream_aggregation_options` | dict | Options for aggregating the data by stream | See Below | - | -| `timestamp_column_name` | str | Name of the timestamp column used in the data | "my_timestamp" | "timestamp" | -| `user_splitting_options` | dict | Options for splitting the data by user | See Below | - | +| `batching_options` | dict | Options for batching the data | See Below | `-` | +| `cache_dir` | str | Directory to cache the rolling window data | "/path/to/cache/dir" | `./.cache` | +| `dfencoder_options` | dict | Options for configuring the data frame encoder | See Below | `-` | +| `mlflow_writer_options` | dict | Options for the MLflow model writer | See Below | `-` | +| `preprocessing_options` | dict | Options for preprocessing the data | See Below | `-` | +| `stream_aggregation_options` | dict | Options for aggregating the data by stream | See Below | `-` | +| `timestamp_column_name` | str | Name of the timestamp column used in the data | "my_timestamp" | `timestamp` | +| `user_splitting_options` | dict | Options for splitting the data by user | See Below | `-` | ### Inference Options Parameters | Parameter | Type | Description | Example Value | Default Value | |------------------------------|------|------------------------------------------------|----------------------|----------------| -| `batching_options` | dict | Options for batching the data | See Below | - | -| `cache_dir` | str | Directory to cache the rolling window data | "/path/to/cache/dir" | ./.cache | -| `detection_criteria` | dict | Criteria for filtering detections | See Below | - | -| `fallback_username` | str | User ID to use if user ID not found | "generic_user" | "generic_user" | -| `inference_options` | dict | Options for the inference module | See Below | - | +| `batching_options` | dict | Options for batching the data | See Below | `-` | +| `cache_dir` | str | Directory to cache the rolling window data | "/path/to/cache/dir" | `./.cache` | +| `detection_criteria` | dict | Criteria for filtering detections | See Below | `-` | +| `fallback_username` | str | User ID to use if user ID not found | "generic_user" | `generic_user` | +| `inference_options` | dict | Options for the inference module | See Below | `-` | | `model_name_formatter` | str | Format string for the model name | "model_{timestamp}" | `[Required]` | -| `num_output_ports` | int | Number of output ports for the module | 3 | - | -| `timestamp_column_name` | str | Name of the timestamp column in the input data | "timestamp" | "timestamp" | -| `stream_aggregation_options` | dict | Options for aggregating the data by stream | See Below | - | -| `user_splitting_options` | dict | Options for splitting the data by user | See Below | - | -| `write_to_file_options` | dict | Options for writing the detections to a file | See Below | - | +| `num_output_ports` | int | Number of output ports for the module | 3 | `-` | +| `timestamp_column_name` | str | Name of the timestamp column in the input data | "timestamp" | `timestamp` | +| `stream_aggregation_options` | dict | Options for aggregating the data by stream | See Below | `-` | +| `user_splitting_options` | dict | Options for splitting the data by user | See Below | `-` | +| `write_to_file_options` | dict | Options for writing the detections to a file | See Below | `-` | ### `batching_options` -| Key | Type | Description | Example Value | Default Value | -|--------------------------|-----------------|-------------------------------------|---------------------------------------------|--------------------------| -| `end_time` | datetime/string | Endtime of the time window | "2023-03-14T23:59:59" | None | -| `iso_date_regex_pattern` | string | Regex pattern for ISO date matching | "\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}" | | -| `parser_kwargs` | dictionary | Additional arguments for the parser | {} | {} | -| `period` | string | Time period for grouping files | "1d" | "1d" | -| `sampling_rate_s` | integer | Sampling rate in seconds | 60 | 60 | -| `start_time` | datetime/string | Start time of the time window | "2023-03-01T00:00:00" | None | +| Key | Type | Description | Example Value | Default Value | +|--------------------------|-----------------|-------------------------------------|---------------------------------------------|----------------------------| +| `end_time` | datetime/string | Endtime of the time window | "2023-03-14T23:59:59" | `None` | +| `iso_date_regex_pattern` | string | Regex pattern for ISO date matching | "\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}" | `` | +| `parser_kwargs` | dictionary | Additional arguments for the parser | {} | `{}` | +| `period` | string | Time period for grouping files | "1d" | `D` | +| `sampling_rate_s` | integer | Sampling rate in seconds | 60 | `60` | +| `start_time` | datetime/string | Start time of the time window | "2023-03-01T00:00:00" | `None` | ### `dfencoder_options` | Parameter | Type | Description | Example Value | Default Value | |-------------------|-------|----------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------| -| `feature_columns` | list | List of feature columns to train on | ["column1", "column2", "column3"] | - | -| `epochs` | int | Number of epochs to train for | 50 | - | -| `model_kwargs` | dict | Keyword arguments to pass to the model | {"encoder_layers": [64, 32], "decoder_layers": [32, 64], "activation": "relu", "swap_p": 0.1, "lr": 0.001, "lr_decay": 0.9, "batch_size": 32, "verbose": 1, "optimizer": "adam", "scalar": "min_max", "min_cats": 10, "progress_bar": false, "device": "cpu"} | - | -| `validation_size` | float | Size of the validation set | 0.1 | - | +| `feature_columns` | list | List of feature columns to train on | ["column1", "column2", "column3"] | `-` | +| `epochs` | int | Number of epochs to train for | 50 | `-` | +| `model_kwargs` | dict | Keyword arguments to pass to the model | {"encoder_layers": [64, 32], "decoder_layers": [32, 64], "activation": "relu", "swap_p": 0.1, "lr": 0.001, "lr_decay": 0.9, "batch_size": 32, "verbose": 1, "optimizer": "adam", "scalar": "min_max", "min_cats": 10, "progress_bar": false, "device": "cpu"} | `-` | +| `validation_size` | float | Size of the validation set | 0.1 | `-` | ### `mlflow_writer_options` | Key | Type | Description | Example Value | Default Value | |-----------------------------|------------|-----------------------------------|-------------------------------|---------------| -| `conda_env` | string | Conda environment for the model | `path/to/conda_env.yml` | `[Required]` | -| `databricks_permissions` | dictionary | Permissions for the model | See Below | None | -| `experiment_name_formatter` | string | Formatter for the experiment name | `experiment_name_{timestamp}` | `[Required]` | -| `model_name_formatter` | string | Formatter for the model name | `model_name_{timestamp}` | `[Required]` | -| `timestamp_column_name` | string | Name of the timestamp column | `timestamp` | timestamp | +| `conda_env` | string | Conda environment for the model | "path/to/conda_env.yml" | `[Required]` | +| `databricks_permissions` | dictionary | Permissions for the model | See Below | `None` | +| `experiment_name_formatter` | string | Formatter for the experiment name | "experiment_name_{timestamp}" | `[Required]` | +| `model_name_formatter` | string | Formatter for the model name | "model_name_{timestamp}" | `[Required]` | +| `timestamp_column_name` | string | Name of the timestamp column | "timestamp" | `timestamp` | ### `stream_aggregation_options` | Parameter | Type | Description | Example Value | Default Value | |-------------------------|--------|-------------------------------------------------------------|---------------|---------------| -| `cache_mode` | string | The user ID to use if the user ID is not found | 'batch' | 'batch' | -| `min_history` | int | Minimum history to trigger a new training event | 1 | 1 | -| `max_history` | int | Maximum history to include in a new training event | 0 | 0 | -| `timestamp_column_name` | string | Name of the column containing timestamps | 'timestamp' | 'timestamp' | -| `aggregation_span` | string | Lookback timespan for training data in a new training event | '60d' | '60d' | -| `cache_to_disk` | bool | Whether or not to cache streaming data to disk | false | false | -| `cache_dir` | string | Directory to use for caching streaming data | './.cache' | './.cache' | +| `cache_mode` | string | The user ID to use if the user ID is not found | "batch" | `batch` | +| `min_history` | int | Minimum history to trigger a new training event | 1 | `1` | +| `max_history` | int | Maximum history to include in a new training event | 0 | `0` | +| `timestamp_column_name` | string | Name of the column containing timestamps | "timestamp" | `timestamp` | +| `aggregation_span` | string | Lookback timespan for training data in a new training event | "60d" | `60d` | +| `cache_to_disk` | bool | Whether or not to cache streaming data to disk | false | `false` | +| `cache_dir` | string | Directory to use for caching streaming data | "./.cache" | `./.cache` | ### `user_splitting_options` | Key | Type | Description | Example Value | Default Value | |-------------------------|------|------------------------------------------------------|-----------------------------|----------------| -| `fallback_username` | str | The user ID to use if the user ID is not found | "generic_user" | 'generic_user' | -| `include_generic` | bool | Whether to include a generic user ID in the output | false | False | -| `include_individual` | bool | Whether to include individual user IDs in the output | true | False | -| `only_users` | list | List of user IDs to include; others will be excluded | ["user1", "user2", "user3"] | [] | -| `skip_users` | list | List of user IDs to exclude from the output | ["user4", "user5"] | [] | -| `timestamp_column_name` | str | Name of the column containing timestamps | "timestamp" | 'timestamp' | -| `userid_column_name` | str | Name of the column containing user IDs | "username" | 'username' | +| `fallback_username` | str | The user ID to use if the user ID is not found | "generic_user" | `generic_user` | +| `include_generic` | bool | Whether to include a generic user ID in the output | false | `false` | +| `include_individual` | bool | Whether to include individual user IDs in the output | true | `false` | +| `only_users` | list | List of user IDs to include; others will be excluded | ["user1", "user2", "user3"] | `[]` | +| `skip_users` | list | List of user IDs to exclude from the output | ["user4", "user5"] | `[]` | +| `timestamp_column_name` | str | Name of the column containing timestamps | "timestamp" | `timestamp` | +| `userid_column_name` | str | Name of the column containing user IDs | "username" | `username` | ### `detection_criteria` | Key | Type | Description | Example Value | Default Value | |--------------|-------|------------------------------------------|---------------|---------------| -| `threshold` | float | Threshold for filtering detections | 0.5 | 0.5 | -| `field_name` | str | Name of the field to filter by threshold | "score" | probs | +| `threshold` | float | Threshold for filtering detections | 0.5 | `0.5` | +| `field_name` | str | Name of the field to filter by threshold | "score" | `probs` | ### `inference_options` | Parameter | Type | Description | Example Value | Default Value | |-------------------------|--------|------------------------------------------------------|-------------------------|---------------| | `model_name_formatter` | string | Formatter for model names | "user_{username}_model" | `[Required]` | -| `fallback_username` | string | Fallback user to use if no model is found for a user | "generic_user" | generic_user | -| `timestamp_column_name` | string | Name of the timestamp column | "timestamp" | timestamp | +| `fallback_username` | string | Fallback user to use if no model is found for a user | "generic_user" | `generic_user`| +| `timestamp_column_name` | string | Name of the timestamp column | "timestamp" | `timestamp` | ### `write_to_file_options` | Key | Type | Description | Example Value | Default Value | |---------------------|-----------|------------------------------------------|-----------------|------------------| -| `filename` | string | Path to the output file | `output.csv` | None | -| `file_type` | FileTypes | Type of file to write | `FileTypes.CSV` | `FileTypes.Auto` | -| `flush` | bool | If true, flush the file after each write | `false` | false | -| `include_index_col` | bool | If true, include the index column | `false` | true | -| `overwrite` | bool | If true, overwrite the file if it exists | `true` | false | +| `filename` | string | Path to the output file | "output.csv" | `None` | +| `file_type` | string | Type of file to write | "CSV" | `AUTO` | +| `flush` | bool | If true, flush the file after each write | false | `false` | +| `include_index_col` | bool | If true, include the index column | false | `true` | +| `overwrite` | bool | If true, overwrite the file if it exists | true | `false` | diff --git a/docs/source/modules/examples/digital_fingerprinting/dfp_inference.md b/docs/source/modules/examples/digital_fingerprinting/dfp_inference.md index 73b0d9003c..b39fd395e7 100644 --- a/docs/source/modules/examples/digital_fingerprinting/dfp_inference.md +++ b/docs/source/modules/examples/digital_fingerprinting/dfp_inference.md @@ -21,11 +21,11 @@ This module function performs the inference process. ### Configurable Parameters -| Parameter | Type | Description | Example Value | Default Value | -|-----------------------|--------|------------------------------------------------------|-------------------------|---------------| -| model_name_formatter | string | Formatter for model names | "user_{username}_model" | `[Required]` | -| fallback_username | string | Fallback user to use if no model is found for a user | "generic_user" | generic_user | -| timestamp_column_name | string | Name of the timestamp column | "timestamp" | timestamp | +| Parameter | Type | Description | Example Value | Default Value | +|-----------------------|--------|------------------------------------------------------|-------------------------|-----------------| +| model_name_formatter | string | Formatter for model names | "user_{username}_model" | `[Required]` | +| fallback_username | string | Fallback user to use if no model is found for a user | "generic_user" | `generic_user` | +| timestamp_column_name | string | Name of the timestamp column | "timestamp" | `timestamp` | ### Example JSON Configuration diff --git a/docs/source/modules/examples/digital_fingerprinting/dfp_inference_pipe.md b/docs/source/modules/examples/digital_fingerprinting/dfp_inference_pipe.md index ddcf77a3fd..7e997989ce 100644 --- a/docs/source/modules/examples/digital_fingerprinting/dfp_inference_pipe.md +++ b/docs/source/modules/examples/digital_fingerprinting/dfp_inference_pipe.md @@ -24,57 +24,57 @@ into a single module. | Parameter | Type | Description | Example Value | Default Value | |------------------------------|------------|--------------------------------------------------|---------------|---------------| -| `batching_options` | dictionary | Options for batching files. | See below | - | -| `cache_dir` | string | Directory used for caching intermediate results. | `/tmp/cache` | - | -| `detection_criteria` | dictionary | Criteria for filtering detections. | - | - | -| `inference_options` | dictionary | Options for configuring the inference process. | See below | - | -| `preprocessing_options` | dictionary | Options for preprocessing data. | - | - | -| `stream_aggregation_options` | dictionary | Options for aggregating data by stream. | See below | - | -| `timestamp_column_name` | string | Name of the column containing timestamps. | `timestamp` | - | -| `user_splitting_options` | dictionary | Options for splitting data by user. | See below | - | -| `write_to_file_options` | dictionary | Options for writing results to a file. | - | - | +| `batching_options` | dictionary | Options for batching files. | See below | `-` | +| `cache_dir` | string | Directory used for caching intermediate results. | "/tmp/cache" | `-` | +| `detection_criteria` | dictionary | Criteria for filtering detections. | - | `-` | +| `inference_options` | dictionary | Options for configuring the inference process. | See below | `-` | +| `preprocessing_options` | dictionary | Options for preprocessing data. | - | `-` | +| `stream_aggregation_options` | dictionary | Options for aggregating data by stream. | See below | `-` | +| `timestamp_column_name` | string | Name of the column containing timestamps. | "timestamp" | `-` | +| `user_splitting_options` | dictionary | Options for splitting data by user. | See below | `-` | +| `write_to_file_options` | dictionary | Options for writing results to a file. | - | `-` | #### `batching_options` | Parameter | Type | Description | Example Value | Default Value | |--------------------------|--------|------------------------------------------|----------------------------------------------|---------------| -| `end_time` | string | End time of the time range to process. | `2022-01-01T00:00:00Z` | - | -| `iso_date_regex_pattern` | string | ISO date regex pattern. | `\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z` | - | -| `parser_kwargs` | dict | Keyword arguments to pass to the parser. | - | - | -| `period` | string | Time period to batch the data. | `1D` | - | -| `sampling_rate_s` | float | Sampling rate in seconds. | `1.0` | - | -| `start_time` | string | Start time of the time range to process. | `2021-01-01T00:00:00Z` | - | +| `end_time` | string | End time of the time range to process. | "2022-01-01T00:00:00Z" | `-` | +| `iso_date_regex_pattern` | string | ISO date regex pattern. | "\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z" | `-` | +| `parser_kwargs` | dict | Keyword arguments to pass to the parser. | - | `-` | +| `period` | string | Time period to batch the data. | "1D" | `-` | +| `sampling_rate_s` | float | Sampling rate in seconds. | "1.0" | `-` | +| `start_time` | string | Start time of the time range to process. | "2021-01-01T00:00:00Z" | `-` | #### `user_splitting_options` -| Parameter | Type | Description | Example Value | Default Value | -|----------------------|---------|-------------------------------------------------------|-----------------------|---------------| -| `fallback_username` | string | Fallback user to use if no model is found for a user. | `generic_user` | generic_user | -| `include_generic` | boolean | Include generic models in the results. | `true` | true | -| `include_individual` | boolean | Include individual models in the results. | `true` | false | -| `only_users` | list | List of users to include in the results. | `["user_a","user_b"]` | - | -| `skip_users` | list | List of users to exclude from the results. | `["user_c"]` | - | -| `userid_column_name` | string | Column | name for the user ID. | user_id | +| Parameter | Type | Description | Example Value | Default Value | +|----------------------|---------|-------------------------------------------------------|-------------------------|-----------------| +| `fallback_username` | string | Fallback user to use if no model is found for a user. | "generic_user" | `generic_user` | +| `include_generic` | boolean | Include generic models in the results. | true | `true` | +| `include_individual` | boolean | Include individual models in the results. | true | `false` | +| `only_users` | list | List of users to include in the results. | ["user_a","user_b"] | `-` | +| `skip_users` | list | List of users to exclude from the results. | ["user_c"] | `-` | +| `userid_column_name` | string | Column | "name for the user ID." | `user_id` | ### `stream_aggregation_options` | Parameter | Type | Description | Example Value | Default Value | |-------------------------|--------|-------------------------------------------------------------|---------------|---------------| -| `cache_mode` | string | The user ID to use if the user ID is not found | 'batch' | 'batch' | -| `min_history` | int | Minimum history to trigger a new training event | 1 | 1 | -| `max_history` | int | Maximum history to include in a new training event | 0 | 0 | -| `timestamp_column_name` | string | Name of the column containing timestamps | 'timestamp' | 'timestamp' | -| `aggregation_span` | string | Lookback timespan for training data in a new training event | '60d' | '60d' | -| `cache_to_disk` | bool | Whether or not to cache streaming data to disk | false | false | -| `cache_dir` | string | Directory to use for caching streaming data | './.cache' | './.cache' | +| `cache_mode` | string | The user ID to use if the user ID is not found | "batch" | `batch` | +| `min_history` | int | Minimum history to trigger a new training event | 1 | `1` | +| `max_history` | int | Maximum history to include in a new training event | 0 | `0` | +| `timestamp_column_name` | string | Name of the column containing timestamps | "timestamp" | `timestamp` | +| `aggregation_span` | string | Lookback timespan for training data in a new training event | "60d" | `60d` | +| `cache_to_disk` | bool | Whether or not to cache streaming data to disk | false | `false` | +| `cache_dir` | string | Directory to use for caching streaming data | "./.cache" | `./.cache` | ### `inference_options` -| Parameter | Type | Description | Example Value | Default Value | -|-------------------------|--------|------------------------------------------------------|-------------------------|---------------| -| `model_name_formatter` | string | Formatter for model names | "user_{username}_model" | `[Required]` | -| `fallback_username` | string | Fallback user to use if no model is found for a user | "generic_user" | generic_user | -| `timestamp_column_name` | string | Name of the timestamp column | "timestamp" | timestamp | +| Parameter | Type | Description | Example Value | Default Value | +|-------------------------|--------|------------------------------------------------------|-------------------------|-----------------| +| `model_name_formatter` | string | Formatter for model names | "user_{username}_model" | `[Required]` | +| `fallback_username` | string | Fallback user to use if no model is found for a user | "generic_user" | `generic_user` | +| `timestamp_column_name` | string | Name of the timestamp column | "timestamp" | `timestamp` | ### Example JSON Configuration @@ -120,4 +120,4 @@ into a single module. "detection_criteria": {}, "write_to_file_options": {} } -``` \ No newline at end of file +``` diff --git a/docs/source/modules/examples/digital_fingerprinting/dfp_postprocessing.md b/docs/source/modules/examples/digital_fingerprinting/dfp_postprocessing.md index 682cfb3d5b..f1a2242798 100644 --- a/docs/source/modules/examples/digital_fingerprinting/dfp_postprocessing.md +++ b/docs/source/modules/examples/digital_fingerprinting/dfp_postprocessing.md @@ -23,7 +23,7 @@ This module function performs postprocessing tasks after the inference process. | Parameter | Type | Description | Example Value | Default Value | |-------------------------|--------|-------------------------------------------------|---------------|---------------| -| `timestamp_column_name` | string | Name of the timestamp column in the input data. | `timestamp` | - | +| `timestamp_column_name` | string | Name of the timestamp column in the input data. | "timestamp" | `-` | ### Example JSON Configuration diff --git a/docs/source/modules/examples/digital_fingerprinting/dfp_preproc.md b/docs/source/modules/examples/digital_fingerprinting/dfp_preproc.md index 4a747c9946..23d399fe88 100644 --- a/docs/source/modules/examples/digital_fingerprinting/dfp_preproc.md +++ b/docs/source/modules/examples/digital_fingerprinting/dfp_preproc.md @@ -22,45 +22,44 @@ process into a single module. ### Configurable Parameters -| Parameter | Type | Description | Example Value | Default Value | -|--------------------------|------------|--------------------------------------------------|---------------|---------------| -| `cache_dir` | string | Directory used for caching intermediate results. | `/tmp/cache` | - | -| `timestamp_column_name` | string | Name of the column containing timestamps. | `timestamp` | - | -| `pre_filter_options` | dictionary | Options for pre-filtering control messages. | See Below | - | -| `batching_options` | dictionary | Options for batching files. | See Below | - | -| `user_splitting_options` | dictionary | Options for splitting data by user. | See Below | - | -| `supported_loaders` | dictionary | Supported data loaders for different file types. | - | - | +| Parameter | Type | Description | Example Value | Default Value | +|--------------------------|------------|--------------------------------------------------|---------------|----------------| +| `cache_dir` | string | Directory used for caching intermediate results. | "/tmp/cache" | `-` | +| `timestamp_column_name` | string | Name of the column containing timestamps. | "timestamp" | `-` | +| `pre_filter_options` | dictionary | Options for pre-filtering control messages. | See Below | `-` | +| `batching_options` | dictionary | Options for batching files. | See Below | `-` | +| `user_splitting_options` | dictionary | Options for splitting data by user. | See Below | `-` | +| `supported_loaders` | dictionary | Supported data loaders for different file types. | - | `-` | #### `pre_filter_options` | Parameter | Type | Description | Example Value | Default Value | |-------------------------|---------|---------------------------------------|---------------|---------------| -| `enable_task_filtering` | boolean | Enables filtering based on task type. | `true` | - | -| `filter_task_type` | string | The task type to be used as a filter. | `task_a` | - | -| `enable_data_filtering` | boolean | Enables filtering based on data type. | `true` | - | -| `filter_data_type` | string | The data type to be used as a filter. | `type_a` | - | +| `enable_task_filtering` | boolean | Enables filtering based on task type. | true | `-` | +| `filter_task_type` | string | The task type to be used as a filter. | "task_a" | `-` | +| `enable_data_filtering` | boolean | Enables filtering based on data type. | true | `-` | +| `filter_data_type` | string | The data type to be used as a filter. | "type_a" | `-` | #### `batching_options` | Parameter | Type | Description | Example Value | Default Value | |--------------------------|------------|------------------------------------------|----------------------------------------|---------------| -| `end_time` | string | End time of the time range to process. | `2022-01-01T00:00:00Z` | - | -| `iso_date_regex_pattern` | string | ISO date regex pattern. | `\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z` | - | -| `parser_kwargs` | dictionary | Keyword arguments to pass to the parser. | `{}` | - | -| `period` | string | Time period to batch the data. | `1D` | - | -| `sampling_rate_s` | float | Sampling rate in seconds. | `1.0` | - | -| `start_time` | string | Start time of the time range to process. | `2021-01-01T00:00:00Z` | - | +| `end_time` | string | End time of the time range to process. | "2022-01-01T00:00:00Z" | `-` | +| `iso_date_regex_pattern` | string | ISO date regex pattern. | "\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z" | `-` | +| `parser_kwargs` | dictionary | Keyword arguments to pass to the parser. | {} | `-` | +| `period` | string | Time period to batch the data. | "1D" | `-` | +| `sampling_rate_s` | float | Sampling rate in seconds. | "1.0" | `-` | +| `start_time` | string | Start time of the time range to process. | "2021-01-01T00:00:00Z" | `-` | #### `user_splitting_options` | Parameter | Type | Description | Example Value | Default Value | |----------------------|---------|-------------------------------------------------------|------------------------|---------------| -| `fallback_username` | string | Fallback user to use if no model is found for a user. | `generic` | - | -| `include_generic` | boolean | Include generic models in the results. | `true` | - | -| `include_individual` | boolean | Include individual models in the results. | `true` | - | -| `only_users` | list | List of users to include in the results. | `["user_a", "user_b"]` | - | -| `skip_users` | list | List of users to exclude from the results. | `["user_c"]` | - | -| `userid_column_name` | string | Column name for the user ID. | `user_id` | - | +| `fallback_username` | string | Fallback user to use if no model is found for a user. | "generic" | `-` | +| `include_generic` | boolean | Include generic models in the results. | "true" | `-` | +| `include_individual` | boolean | Include individual models in the results. | "true" | `-` | +| `only_users` | list | List of users to include in the results. + ### Example JSON Configuration @@ -96,5 +95,5 @@ process into a single module. "userid_column_name": "user_id" }, "supported_loaders": {} -} -``` \ No newline at end of file +} +``` diff --git a/docs/source/modules/examples/digital_fingerprinting/dfp_rolling_window.md b/docs/source/modules/examples/digital_fingerprinting/dfp_rolling_window.md index 40d6dc6f74..1f521b4a94 100644 --- a/docs/source/modules/examples/digital_fingerprinting/dfp_rolling_window.md +++ b/docs/source/modules/examples/digital_fingerprinting/dfp_rolling_window.md @@ -23,13 +23,13 @@ This module function splits the data based on user IDs. | Parameter | Type | Description | Example Value | Default Value | |-----------------------|--------|-------------------------------------------------------------|---------------|---------------| -| cache_mode | string | The user ID to use if the user ID is not found | 'batch' | 'batch' | -| min_history | int | Minimum history to trigger a new training event | 1 | 1 | -| max_history | int | Maximum history to include in a new training event | 0 | 0 | -| timestamp_column_name | string | Name of the column containing timestamps | 'timestamp' | 'timestamp' | -| aggregation_span | string | Lookback timespan for training data in a new training event | '60d' | '60d' | -| cache_to_disk | bool | Whether or not to cache streaming data to disk | false | false | -| cache_dir | string | Directory to use for caching streaming data | './.cache' | './.cache' | +| cache_mode | string | The user ID to use if the user ID is not found | "batch" | `batch` | +| min_history | int | Minimum history to trigger a new training event | 1 | `1` | +| max_history | int | Maximum history to include in a new training event | 0 | `0` | +| timestamp_column_name | string | Name of the column containing timestamps | "timestamp" | `timestamp` | +| aggregation_span | string | Lookback timespan for training data in a new training event | "60d" | `60d` | +| cache_to_disk | bool | Whether or not to cache streaming data to disk | false | `false` | +| cache_dir | string | Directory to use for caching streaming data | "./.cache" | `./.cache` | ### Example JSON Configuration @@ -43,4 +43,4 @@ This module function splits the data based on user IDs. "cache_to_disk": false, "cache_dir": "./.cache" } -``` \ No newline at end of file +``` diff --git a/docs/source/modules/examples/digital_fingerprinting/dfp_split_users.md b/docs/source/modules/examples/digital_fingerprinting/dfp_split_users.md index badc264bba..7e63c9c704 100644 --- a/docs/source/modules/examples/digital_fingerprinting/dfp_split_users.md +++ b/docs/source/modules/examples/digital_fingerprinting/dfp_split_users.md @@ -23,13 +23,13 @@ This module function splits the data based on user IDs. | Key | Type | Description | Example Value | Default Value | |-----------------------|------|------------------------------------------------------|-----------------------------|----------------| -| fallback_username | str | The user ID to use if the user ID is not found | "generic_user" | 'generic_user' | -| include_generic | bool | Whether to include a generic user ID in the output | false | False | -| include_individual | bool | Whether to include individual user IDs in the output | true | False | -| only_users | list | List of user IDs to include; others will be excluded | ["user1", "user2", "user3"] | [] | -| skip_users | list | List of user IDs to exclude from the output | ["user4", "user5"] | [] | -| timestamp_column_name | str | Name of the column containing timestamps | "timestamp" | 'timestamp' | -| userid_column_name | str | Name of the column containing user IDs | "username" | 'username' | +| fallback_username | str | The user ID to use if the user ID is not found | "generic_user" | `generic_user` | +| include_generic | bool | Whether to include a generic user ID in the output | false | `false` | +| include_individual | bool | Whether to include individual user IDs in the output | true | `false` | +| only_users | list | List of user IDs to include; others will be excluded | ["user1", "user2", "user3"] | `[]` | +| skip_users | list | List of user IDs to exclude from the output | ["user4", "user5"] | `[]` | +| timestamp_column_name | str | Name of the column containing timestamps | "timestamp" | `timestamp` | +| userid_column_name | str | Name of the column containing user IDs | "username" | `username` | ### Example JSON Configuration @@ -51,16 +51,3 @@ This module function splits the data based on user IDs. "userid_column_name": "username" } ``` - -### Default Settings - -| Property | Value | -| -------- | ----- | -| fallback_username | generic_user | -| include_generic | False | -| include_individual | False | -| include_individual | False | -| only_users | [] | -| skip_users | [] | -| timestamp_column_name | timestamp | -| userid_column_name | username | diff --git a/docs/source/modules/examples/digital_fingerprinting/dfp_training.md b/docs/source/modules/examples/digital_fingerprinting/dfp_training.md index 362ee24dca..78afffebfd 100644 --- a/docs/source/modules/examples/digital_fingerprinting/dfp_training.md +++ b/docs/source/modules/examples/digital_fingerprinting/dfp_training.md @@ -23,10 +23,10 @@ This module function is responsible for training the model. | Parameter | Type | Description | Example Value | Default Value | |-----------------|-------|----------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------| -| feature_columns | list | List of feature columns to train on | ["column1", "column2", "column3"] | - | -| epochs | int | Number of epochs to train for | 50 | - | +| feature_columns | list | List of feature columns to train on | ["column1", "column2", "column3"] | `-` | +| epochs | int | Number of epochs to train for | 50 | `-` | | model_kwargs | dict | Keyword arguments to pass to the model | {"encoder_layers": [64, 32], "decoder_layers": [32, 64], "activation": "relu", "swap_p": 0.1, "lr": 0.001, "lr_decay": 0.9, "batch_size": 32, "verbose": 1, "optimizer": "adam", "scalar": "min_max", "min_cats": 10, "progress_bar": false, "device": "cpu"} | - | -| validation_size | float | Size of the validation set | 0.1 | - | +| validation_size | float | Size of the validation set | 0.1 | `-` | ## JSON Example @@ -62,11 +62,3 @@ This module function is responsible for training the model. "validation_size": 0.1 } ``` - -### Default Settings - -| Property | Value | -| -------- | ----- | -| epochs | 1 | -| model_kwargs | {} | -| validation_size | 0.0 | diff --git a/docs/source/modules/examples/digital_fingerprinting/dfp_training_pipe.md b/docs/source/modules/examples/digital_fingerprinting/dfp_training_pipe.md index 4371b210b7..635d98e1af 100644 --- a/docs/source/modules/examples/digital_fingerprinting/dfp_training_pipe.md +++ b/docs/source/modules/examples/digital_fingerprinting/dfp_training_pipe.md @@ -23,64 +23,65 @@ This module function consolidates multiple DFP pipeline modules relevant to the | Key | Type | Description | Example Value | Default Value | |------------------------------|------|-----------------------------------------------------------------------------------------|---------------|---------------| -| `timestamp_column_name` | str | Name of the timestamp column used in the data. | "timestamp" | - | -| `cache_dir` | str | Directory to cache the rolling window data. | "/tmp/cache" | - | -| `batching_options` | dict | Options for batching files. | See Below | - | -| `user_splitting_options` | dict | Options for splitting data by user. | See Below | - | -| `stream_aggregation_options` | dict | Options for aggregating data by stream. | See Below | - | -| `preprocessing_options` | dict | Options for preprocessing the data. | - | - | -| `dfencoder_options` | dict | Options for configuring the data frame encoder, used for training the model. | See Below | - | -| `mlflow_writer_options` | dict | Options for the MLflow model writer, which is responsible for saving the trained model. | See Below | - | +| `timestamp_column_name` | str | Name of the timestamp column used in the data. | "timestamp" | `-` | +| `cache_dir` | str | Directory to cache the rolling window data. | "/tmp/cache" | `-` | +| `batching_options` | dict | Options for batching files. | See Below | `-` | +| `user_splitting_options` | dict | Options for splitting data by user. | See Below | `-` | +| `stream_aggregation_options` | dict | Options for aggregating data by stream. | See Below | `-` | +| `preprocessing_options` | dict | Options for preprocessing the data. | `-` | `-` | +| `dfencoder_options` | dict | Options for configuring the data frame encoder, used for training the model. | See Below | `-` | +| `mlflow_writer_options` | dict | Options for the MLflow model writer, which is responsible for saving the trained model. | See Below | `-` | ### `batching_options` | Key | Type | Description | Example Value | Default Value | |--------------------------|-------|------------------------------------------|---------------------------------------------------------|---------------| -| `end_time` | str | End time of the time range to process. | "2023-03-01T00:00:00" | - | -| `iso_date_regex_pattern` | str | ISO date regex pattern. | "\\\\d{4}-\\\\d{2}-\\\\d{2}T\\\\d{2}:\\\\d{2}:\\\\d{2}" | - | -| `parser_kwargs` | dict | Keyword arguments to pass to the parser. | {} | - | -| `period` | str | Time period to batch the data. | "1min" | - | -| `sampling_rate_s` | float | Sampling rate in seconds. | 60 | - | -| `start_time` | str | Start time of the time range to process. | "2023-02-01T00:00:00" | - | +| `end_time` | str | End time of the time range to process. | "2023-03-01T00:00:00" | `-` | +| `iso_date_regex_pattern` | str | ISO date regex pattern. | "\\\\d{4}-\\\\d{2}-\\\\d{2}T\\\\d{2}:\\\\d{2}:\\\\d{2}" | `-` | +| `parser_kwargs` | dict | Keyword arguments to pass to the parser. | {} | `-` | +| `period` | str | Time period to batch the data. | "1min" | `-` | +| `sampling_rate_s` | float | Sampling rate in seconds. | 60 | `-` | +| `start_time` | str | Start time of the time range to process. | "2023-02-01T00:00:00" | `-` | ### `user_splitting_options` | Key | Type | Description | Example Value | Default Value | |----------------------|-----------|-------------------------------------------------------|---------------|---------------| -| `fallback_username` | str | Fallback user to use if no model is found for a user. | "generic" | - | -| `include_generic` | bool | Include generic models in the results. | true | - | -| `include_individual` | bool | Include individual models in the results. | true | - | -| `only_users` | List[str] | List of users to include in the results. | [] | - | -| `skip_users` | List[str] | List of users to exclude from the results. | [] | - | -| `userid_column_name` | str | Column name for the user ID. | "user_id" | - | +| `fallback_username` | str | Fallback user to use if no model is found for a user. | "generic" | `-` | +| `include_generic` | bool | Include generic models in the results. | true | `-` | +| `include_individual` | bool | Include individual models in the results. | true | `-` | +| `only_users` | List[str] | List of users to include in the results. | [] | `-` | +| `skip_users` | List[str] | List of users to exclude from the results. | [] | `-` | +| `userid_column_name` | str | Column name for the user ID. | "user_id" | `-` | + ### `stream_aggregation_options` | Key | Type | Description | Example Value | Default Value | |-------------------------|--------|-------------------------------------------------------------|---------------|---------------| -| `cache_mode` | string | The user ID to use if the user ID is not found | 'batch' | 'batch' | -| `min_history` | int | Minimum history to trigger a new training event | 1 | 1 | -| `max_history` | int | Maximum history to include in a new training event | 0 | 0 | -| `timestamp_column_name` | string | Name of the column containing timestamps | 'timestamp' | 'timestamp' | -| `aggregation_span` | string | Lookback timespan for training data in a new training event | '60d' | '60d' | -| `cache_to_disk` | bool | Whether or not to cache streaming data to disk | false | false | -| `cache_dir` | string | Directory to use for caching streaming data | './.cache' | './.cache' | +| `cache_mode` | string | The user ID to use if the user ID is not found | "batch" | `batch` | +| `min_history` | int | Minimum history to trigger a new training event | 1 | `1` | +| `max_history` | int | Maximum history to include in a new training event | 0 | `0` | +| `timestamp_column_name` | string | Name of the column containing timestamps | 'timestamp' | `timestamp` | +| `aggregation_span` | string | Lookback timespan for training data in a new training event | "60d" | `60d` | +| `cache_to_disk` | bool | Whether or not to cache streaming data to disk | false | `false` | +| `cache_dir` | string | Directory to use for caching streaming data | "./.cache" | `./.cache` | ### `dfencoder_options` | Parameter | Type | Description | Example Value | Default Value | |-------------------|-------|----------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------| -| `feature_columns` | list | List of feature columns to train on | ["column1", "column2", "column3"] | - | -| `epochs` | int | Number of epochs to train for | 50 | - | +| `feature_columns` | list | List of feature columns to train on | ["column1", "column2", "column3"] | `- ` | +| `epochs` | int | Number of epochs to train for | 50 | `-` | | `model_kwargs` | dict | Keyword arguments to pass to the model | {"encoder_layers": [64, 32], "decoder_layers": [32, 64], "activation": "relu", "swap_p": 0.1, "lr": 0.001, "lr_decay": 0.9, "batch_size": 32, "verbose": 1, "optimizer": "adam", "scalar": "min_max", "min_cats": 10, "progress_bar": false, "device": "cpu"} | - | -| `validation_size` | float | Size of the validation set | 0.1 | - | +| `validation_size` | float | Size of the validation set | 0.1 | `-` | ### `mlflow_writer_options` | Key | Type | Description | Example Value | Default Value | |-----------------------------|------------|-----------------------------------|-------------------------------|---------------| -| `conda_env` | string | Conda environment for the model | `path/to/conda_env.yml` | `[Required]` | -| `databricks_permissions` | dictionary | Permissions for the model | See Below | None | -| `experiment_name_formatter` | string | Formatter for the experiment name | `experiment_name_{timestamp}` | `[Required]` | -| `model_name_formatter` | string | Formatter for the model name | `model_name_{timestamp}` | `[Required]` | -| `timestamp_column_name` | string | Name of the timestamp column | `timestamp` | timestamp | +| `conda_env` | string | Conda environment for the model | "path/to/conda_env.yml" | `[Required]` | +| `databricks_permissions` | dictionary | Permissions for the model | See Below | `None` | +| `experiment_name_formatter` | string | Formatter for the experiment name | "experiment_name_{timestamp}" | `[Required]` | +| `model_name_formatter` | string | Formatter for the model name | "model_name_{timestamp}" | `[Required]` | +| `timestamp_column_name` | string | Name of the timestamp column | "timestamp" | `timestamp` | diff --git a/morpheus/modules/write_to_file.py b/morpheus/modules/write_to_file.py index 287de768bc..7775e92637 100644 --- a/morpheus/modules/write_to_file.py +++ b/morpheus/modules/write_to_file.py @@ -28,7 +28,6 @@ from morpheus.messages.message_meta import MessageMeta from morpheus.utils.module_ids import MORPHEUS_MODULE_NAMESPACE from morpheus.utils.module_ids import WRITE_TO_FILE -from morpheus.utils.module_utils import get_module_config from morpheus.utils.module_utils import register_module logger = logging.getLogger(__name__) From f947fb1dbd72b854711b7bbd4916a412522c2948 Mon Sep 17 00:00:00 2001 From: Bhargav Suryadevara Date: Tue, 28 Mar 2023 21:30:45 -0500 Subject: [PATCH 141/157] updated module schema docs --- morpheus/modules/write_to_file.py | 1 + 1 file changed, 1 insertion(+) diff --git a/morpheus/modules/write_to_file.py b/morpheus/modules/write_to_file.py index 5fb750b29c..b06107142b 100644 --- a/morpheus/modules/write_to_file.py +++ b/morpheus/modules/write_to_file.py @@ -101,6 +101,7 @@ def node_fn(obs: mrc.Observable, sub: mrc.Subscriber): # Open up the file handle with open(output_file, "a") as out_file: + def write_to_file(x: MessageMeta): lines = convert_to_strings(x.df) From 1b19c8b566cb6b9db52f5dee82f56465b082f342 Mon Sep 17 00:00:00 2001 From: Bhargav Suryadevara Date: Wed, 29 Mar 2023 10:38:02 -0500 Subject: [PATCH 142/157] module doc updates --- docs/source/loaders/morpheus_loaders.md | 2 +- .../modules/examples/digital_fingerprinting/dfp_preproc.md | 5 +++-- .../modules/examples/digital_fingerprinting/dfp_training.md | 2 +- .../examples/digital_fingerprinting/dfp_training_pipe.md | 2 +- 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/source/loaders/morpheus_loaders.md b/docs/source/loaders/morpheus_loaders.md index e5af305082..5665770dfe 100644 --- a/docs/source/loaders/morpheus_loaders.md +++ b/docs/source/loaders/morpheus_loaders.md @@ -19,7 +19,7 @@ limitations under the License. Custom functions called "Loaders" can be utilized by the DataLoader Module to load data into the pipeline. The user can choose to register their own customized loader function and add it to a dataloader registry, which will then become accessible to the DataLoader module during module loading. -**Note** : Loaders receive configuration from the `load` task via [control message](./../../source/control_message_guide.md) during runtime. +**Note** : Loaders receive configuration from the `load` task via [control message](../../source/control_message_guide.md) during runtime. ## Core Loaders diff --git a/docs/source/modules/examples/digital_fingerprinting/dfp_preproc.md b/docs/source/modules/examples/digital_fingerprinting/dfp_preproc.md index 23d399fe88..cd0355e78f 100644 --- a/docs/source/modules/examples/digital_fingerprinting/dfp_preproc.md +++ b/docs/source/modules/examples/digital_fingerprinting/dfp_preproc.md @@ -58,8 +58,9 @@ process into a single module. | `fallback_username` | string | Fallback user to use if no model is found for a user. | "generic" | `-` | | `include_generic` | boolean | Include generic models in the results. | "true" | `-` | | `include_individual` | boolean | Include individual models in the results. | "true" | `-` | -| `only_users` | list | List of users to include in the results. - +| `only_users` | list | List of users to include in the results. | ["user_a", "user_b"] | `-` | +| `skip_users` | list | List of users to exclude from the results. | ["user_c"] | `-` | +| `userid_column_name` | string | Column name for the user ID. | "user_id" | `-` | ### Example JSON Configuration diff --git a/docs/source/modules/examples/digital_fingerprinting/dfp_training.md b/docs/source/modules/examples/digital_fingerprinting/dfp_training.md index 78afffebfd..3dce760535 100644 --- a/docs/source/modules/examples/digital_fingerprinting/dfp_training.md +++ b/docs/source/modules/examples/digital_fingerprinting/dfp_training.md @@ -25,7 +25,7 @@ This module function is responsible for training the model. |-----------------|-------|----------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------| | feature_columns | list | List of feature columns to train on | ["column1", "column2", "column3"] | `-` | | epochs | int | Number of epochs to train for | 50 | `-` | -| model_kwargs | dict | Keyword arguments to pass to the model | {"encoder_layers": [64, 32], "decoder_layers": [32, 64], "activation": "relu", "swap_p": 0.1, "lr": 0.001, "lr_decay": 0.9, "batch_size": 32, "verbose": 1, "optimizer": "adam", "scalar": "min_max", "min_cats": 10, "progress_bar": false, "device": "cpu"} | - | +| model_kwargs | dict | Keyword arguments to pass to the model | {"encoder_layers": [64, 32], "decoder_layers": [32, 64], "activation": "relu", "swap_p": 0.1, "lr": 0.001, "lr_decay": 0.9, "batch_size": 32, "verbose": 1, "optimizer": "adam", "scalar": "min_max", "min_cats": 10, "progress_bar": false, "device": "cpu"} | `-` | | validation_size | float | Size of the validation set | 0.1 | `-` | ## JSON Example diff --git a/docs/source/modules/examples/digital_fingerprinting/dfp_training_pipe.md b/docs/source/modules/examples/digital_fingerprinting/dfp_training_pipe.md index 635d98e1af..ad719e4e4a 100644 --- a/docs/source/modules/examples/digital_fingerprinting/dfp_training_pipe.md +++ b/docs/source/modules/examples/digital_fingerprinting/dfp_training_pipe.md @@ -73,7 +73,7 @@ This module function consolidates multiple DFP pipeline modules relevant to the |-------------------|-------|----------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------| | `feature_columns` | list | List of feature columns to train on | ["column1", "column2", "column3"] | `- ` | | `epochs` | int | Number of epochs to train for | 50 | `-` | -| `model_kwargs` | dict | Keyword arguments to pass to the model | {"encoder_layers": [64, 32], "decoder_layers": [32, 64], "activation": "relu", "swap_p": 0.1, "lr": 0.001, "lr_decay": 0.9, "batch_size": 32, "verbose": 1, "optimizer": "adam", "scalar": "min_max", "min_cats": 10, "progress_bar": false, "device": "cpu"} | - | +| `model_kwargs` | dict | Keyword arguments to pass to the model | {"encoder_layers": [64, 32], "decoder_layers": [32, 64], "activation": "relu", "swap_p": 0.1, "lr": 0.001, "lr_decay": 0.9, "batch_size": 32, "verbose": 1, "optimizer": "adam", "scalar": "min_max", "min_cats": 10, "progress_bar": false, "device": "cpu"} | `-` | | `validation_size` | float | Size of the validation set | 0.1 | `-` | ### `mlflow_writer_options` From 64635e87583eaf43aadd929ff942fbd6dfb3b08d Mon Sep 17 00:00:00 2001 From: Bhargav Suryadevara Date: Wed, 29 Mar 2023 10:43:46 -0500 Subject: [PATCH 143/157] module doc updates --- docs/source/loaders/core/file_to_df_loader.md | 6 +++--- docs/source/loaders/core/fsspec_loader.md | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/source/loaders/core/file_to_df_loader.md b/docs/source/loaders/core/file_to_df_loader.md index 119a3b7343..eca6c049e7 100644 --- a/docs/source/loaders/core/file_to_df_loader.md +++ b/docs/source/loaders/core/file_to_df_loader.md @@ -39,11 +39,11 @@ Using below configuration while loading DataLoader module, specifies that the Da The parameters that can be configured for this specific loader at load task level: -| Parameter | Type | Description | Example Value | Default Value | +| Parameter | Type | Description | Example Value | Default Value | | ------------------ | ---------- | -------------------------------- | ------------------------ | -------------- | -| `batcher_config ` | dictionary | Options for batching | See below | `[Required]` | +| `batcher_config ` | dictionary | Options for batching | See below | `[Required]` | | `files` | array | List of files to load | ["/path/to/input/files"] | `[]` | -| `loader_id` | string | Unique identifier for the loader | "file_to_df" | `[Required]` | +| `loader_id` | string | Unique identifier for the loader | "file_to_df" | `[Required]` | ### `batcher_config` diff --git a/docs/source/loaders/core/fsspec_loader.md b/docs/source/loaders/core/fsspec_loader.md index 069e1f0e50..958822139e 100644 --- a/docs/source/loaders/core/fsspec_loader.md +++ b/docs/source/loaders/core/fsspec_loader.md @@ -39,7 +39,7 @@ The parameters that can be configured for this specific loader at load task leve | Parameter | Type | Description | Example Value | Default Value | | ------------------ | ---------- | -------------------------------- | --------------------------------- | -------------- | | `files` | array | List of files to load | ["/your/input/filepath"] | `[]` | -| `loader_id` | string | Unique identifier for the loader | "file_to_df" | `[Required]` | +| `loader_id` | string | Unique identifier for the loader | "file_to_df" | `[Required]` | From dabccbb6cb76c1566bbda06b28cc8e41890967df Mon Sep 17 00:00:00 2001 From: Bhargav Suryadevara Date: Wed, 29 Mar 2023 10:52:40 -0500 Subject: [PATCH 144/157] module doc updates --- docs/source/stages/morpheus_stages.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/source/stages/morpheus_stages.md b/docs/source/stages/morpheus_stages.md index 90e753eccd..d9e3ff4215 100644 --- a/docs/source/stages/morpheus_stages.md +++ b/docs/source/stages/morpheus_stages.md @@ -23,7 +23,6 @@ limitations under the License. ## General -- [Broadcast Stage](./general/broadcast_stage.md) - [Buffer Stage](./general/buffer_stage.md) - [Delay Stage](./general/delay_stage.md) - [Linear Modules Stage](./general/linear_modules_stage.md) @@ -79,4 +78,4 @@ limitations under the License. ## Training -- [Training Stage](./training/training_stage.md) \ No newline at end of file +- [Training Stage](./training/training_stage.md) From c2713f4949b504cf042e220004b2f2b22957b178 Mon Sep 17 00:00:00 2001 From: Devin Robison Date: Wed, 29 Mar 2023 17:49:45 -0600 Subject: [PATCH 145/157] IWYU fixes --- .../_lib/include/morpheus/io/data_loader.hpp | 3 ++- .../morpheus/io/data_loader_registry.hpp | 5 ++--- .../_lib/include/morpheus/io/loaders/file.hpp | 4 +++- .../_lib/include/morpheus/io/loaders/grpc.hpp | 3 +++ .../_lib/include/morpheus/io/loaders/lambda.hpp | 4 ++++ .../include/morpheus/io/loaders/payload.hpp | 3 +++ .../_lib/include/morpheus/io/loaders/rest.hpp | 3 +++ .../_lib/include/morpheus/messages/control.hpp | 5 ++++- .../morpheus/modules/data_loader_module.hpp | 2 ++ .../include/morpheus/stages/deserialize.hpp | 1 + .../include/morpheus/stages/preprocess_fil.hpp | 1 + .../include/morpheus/stages/preprocess_nlp.hpp | 1 + .../_lib/include/morpheus/stages/serialize.hpp | 1 + .../morpheus/stages/triton_inference.hpp | 1 + morpheus/_lib/src/io/data_loader.cpp | 3 ++- morpheus/_lib/src/io/data_loader_registry.cpp | 8 ++++++++ morpheus/_lib/src/io/loaders/file.cpp | 9 +++++++++ morpheus/_lib/src/io/loaders/grpc.cpp | 2 ++ morpheus/_lib/src/io/loaders/lambda.cpp | 4 +++- morpheus/_lib/src/io/loaders/payload.cpp | 2 ++ morpheus/_lib/src/io/loaders/rest.cpp | 2 ++ morpheus/_lib/src/messages/control.cpp | 4 +++- .../_lib/src/modules/data_loader_module.cpp | 14 ++++++++++++++ morpheus/_lib/src/objects/python_data_table.cpp | 2 +- morpheus/_lib/src/python_modules/common.cpp | 15 ++++++++------- morpheus/_lib/src/python_modules/messages.cpp | 11 ++++++----- morpheus/_lib/src/python_modules/modules.cpp | 4 ++++ morpheus/_lib/src/python_modules/stages.cpp | 12 ++++++++++-- morpheus/_lib/src/stages/add_classification.cpp | 3 +++ morpheus/_lib/src/stages/add_scores.cpp | 3 +++ .../_lib/src/stages/add_scores_stage_base.cpp | 10 +++++++++- morpheus/_lib/src/stages/deserialize.cpp | 7 +++++++ morpheus/_lib/src/stages/file_source.cpp | 7 +++++++ morpheus/_lib/src/stages/filter_detection.cpp | 11 ++++++++++- morpheus/_lib/src/stages/kafka_source.cpp | 5 +++++ morpheus/_lib/src/stages/preprocess_fil.cpp | 11 +++++++++-- morpheus/_lib/src/stages/preprocess_nlp.cpp | 9 ++++++++- morpheus/_lib/src/stages/serialize.cpp | 9 +++++++++ morpheus/_lib/src/stages/triton_inference.cpp | 10 +++++++++- morpheus/_lib/src/stages/write_to_file.cpp | 11 ++++++++++- morpheus/_lib/tests/io/test_data_loader.cpp | 12 ++++++++++-- .../_lib/tests/io/test_data_loader_registry.cpp | 8 +++++++- morpheus/_lib/tests/io/test_loaders.cpp | 16 ++++++++++++++-- .../tests/messages/test_control_message.cpp | 7 ++++++- .../tests/modules/test_data_loader_module.cpp | 17 ++++++++++++++--- morpheus/_lib/tests/modules/test_modules.hpp | 2 +- morpheus/_lib/tests/test_matx_util.cpp | 3 +-- morpheus/_lib/tests/test_morpheus.cpp | 14 ++++++++++++-- morpheus/_lib/tests/test_morpheus.hpp | 5 ++++- morpheus/_lib/tests/test_multi_slices.cpp | 1 - morpheus/modules/mlflow_model_writer.py | 2 +- tests/modules/test_morpheus_modules.py | 3 +++ 52 files changed, 267 insertions(+), 48 deletions(-) diff --git a/morpheus/_lib/include/morpheus/io/data_loader.hpp b/morpheus/_lib/include/morpheus/io/data_loader.hpp index a8ba303b4f..02f68864fb 100644 --- a/morpheus/_lib/include/morpheus/io/data_loader.hpp +++ b/morpheus/_lib/include/morpheus/io/data_loader.hpp @@ -20,10 +20,11 @@ #include "morpheus/messages/control.hpp" #include "morpheus/messages/meta.hpp" -#include +#include #include #include +#include namespace morpheus { diff --git a/morpheus/_lib/include/morpheus/io/data_loader_registry.hpp b/morpheus/_lib/include/morpheus/io/data_loader_registry.hpp index 1e40b0418f..2b06425256 100644 --- a/morpheus/_lib/include/morpheus/io/data_loader_registry.hpp +++ b/morpheus/_lib/include/morpheus/io/data_loader_registry.hpp @@ -18,13 +18,12 @@ #pragma once #include "morpheus/io/data_loader.hpp" +#include "morpheus/messages/control.hpp" #include "morpheus/objects/factory_registry.hpp" -#include -#include +#include #include -#include #include #include #include diff --git a/morpheus/_lib/include/morpheus/io/loaders/file.hpp b/morpheus/_lib/include/morpheus/io/loaders/file.hpp index 91d247d0c3..390064175d 100644 --- a/morpheus/_lib/include/morpheus/io/loaders/file.hpp +++ b/morpheus/_lib/include/morpheus/io/loaders/file.hpp @@ -18,10 +18,12 @@ #pragma once #include "morpheus/io/data_loader.hpp" -#include "morpheus/messages/meta.hpp" +#include "morpheus/messages/control.hpp" #include +#include + namespace morpheus { #pragma GCC visibility push(default) /** diff --git a/morpheus/_lib/include/morpheus/io/loaders/grpc.hpp b/morpheus/_lib/include/morpheus/io/loaders/grpc.hpp index c799856e46..80c2c1e5c1 100644 --- a/morpheus/_lib/include/morpheus/io/loaders/grpc.hpp +++ b/morpheus/_lib/include/morpheus/io/loaders/grpc.hpp @@ -18,9 +18,12 @@ #pragma once #include "morpheus/io/data_loader.hpp" +#include "morpheus/messages/control.hpp" #include +#include + namespace morpheus { #pragma GCC visibility push(default) /** diff --git a/morpheus/_lib/include/morpheus/io/loaders/lambda.hpp b/morpheus/_lib/include/morpheus/io/loaders/lambda.hpp index 90c499254d..d17b87d1ae 100644 --- a/morpheus/_lib/include/morpheus/io/loaders/lambda.hpp +++ b/morpheus/_lib/include/morpheus/io/loaders/lambda.hpp @@ -18,9 +18,13 @@ #pragma once #include "morpheus/io/data_loader.hpp" +#include "morpheus/messages/control.hpp" #include +#include +#include + namespace morpheus { #pragma GCC visibility push(default) /** diff --git a/morpheus/_lib/include/morpheus/io/loaders/payload.hpp b/morpheus/_lib/include/morpheus/io/loaders/payload.hpp index 53646edd14..87847adccc 100644 --- a/morpheus/_lib/include/morpheus/io/loaders/payload.hpp +++ b/morpheus/_lib/include/morpheus/io/loaders/payload.hpp @@ -18,6 +18,9 @@ #pragma once #include "morpheus/io/data_loader.hpp" +#include "morpheus/messages/control.hpp" + +#include #include diff --git a/morpheus/_lib/include/morpheus/io/loaders/rest.hpp b/morpheus/_lib/include/morpheus/io/loaders/rest.hpp index 36f212ff22..d011218de6 100644 --- a/morpheus/_lib/include/morpheus/io/loaders/rest.hpp +++ b/morpheus/_lib/include/morpheus/io/loaders/rest.hpp @@ -18,9 +18,12 @@ #pragma once #include "morpheus/io/data_loader.hpp" +#include "morpheus/messages/control.hpp" #include +#include + namespace morpheus { #pragma GCC visibility push(default) /** diff --git a/morpheus/_lib/include/morpheus/messages/control.hpp b/morpheus/_lib/include/morpheus/messages/control.hpp index 34ca982743..ebec332879 100644 --- a/morpheus/_lib/include/morpheus/messages/control.hpp +++ b/morpheus/_lib/include/morpheus/messages/control.hpp @@ -18,12 +18,15 @@ #pragma once #include -#include +#include +#include #include +#include namespace morpheus { class MessageMeta; + #pragma GCC visibility push(default) enum class ControlMessageType { diff --git a/morpheus/_lib/include/morpheus/modules/data_loader_module.hpp b/morpheus/_lib/include/morpheus/modules/data_loader_module.hpp index ae92d5f963..5777d803a2 100644 --- a/morpheus/_lib/include/morpheus/modules/data_loader_module.hpp +++ b/morpheus/_lib/include/morpheus/modules/data_loader_module.hpp @@ -23,6 +23,8 @@ #include #include +#include + namespace morpheus { #pragma GCC visibility push(default) class DataLoaderModule : public mrc::modules::SegmentModule, public mrc::modules::PersistentModule diff --git a/morpheus/_lib/include/morpheus/stages/deserialize.hpp b/morpheus/_lib/include/morpheus/stages/deserialize.hpp index 314a746d1a..eb9ccbc799 100644 --- a/morpheus/_lib/include/morpheus/stages/deserialize.hpp +++ b/morpheus/_lib/include/morpheus/stages/deserialize.hpp @@ -31,6 +31,7 @@ #include #include #include +// IWYU pragma: no_include "rxcpp/sources/rx-iterate.hpp" #include #include diff --git a/morpheus/_lib/include/morpheus/stages/preprocess_fil.hpp b/morpheus/_lib/include/morpheus/stages/preprocess_fil.hpp index 41d64f9786..4e10122510 100644 --- a/morpheus/_lib/include/morpheus/stages/preprocess_fil.hpp +++ b/morpheus/_lib/include/morpheus/stages/preprocess_fil.hpp @@ -31,6 +31,7 @@ #include #include #include // for apply, make_subscriber, observable_member, is_on_error<>::not_void, is_on_next_of<>::not_void, from +// IWYU pragma: no_include "rxcpp/sources/rx-iterate.hpp" #include #include diff --git a/morpheus/_lib/include/morpheus/stages/preprocess_nlp.hpp b/morpheus/_lib/include/morpheus/stages/preprocess_nlp.hpp index 82b42779a0..2598670eac 100644 --- a/morpheus/_lib/include/morpheus/stages/preprocess_nlp.hpp +++ b/morpheus/_lib/include/morpheus/stages/preprocess_nlp.hpp @@ -30,6 +30,7 @@ #include #include #include // for apply, make_subscriber, observable_member, is_on_error<>::not_void, is_on_next_of<>::not_void, from +// IWYU pragma: no_include "rxcpp/sources/rx-iterate.hpp" #include // for uint32_t #include diff --git a/morpheus/_lib/include/morpheus/stages/serialize.hpp b/morpheus/_lib/include/morpheus/stages/serialize.hpp index edadc14cd1..7a5c2de4da 100644 --- a/morpheus/_lib/include/morpheus/stages/serialize.hpp +++ b/morpheus/_lib/include/morpheus/stages/serialize.hpp @@ -30,6 +30,7 @@ #include #include #include // for apply, make_subscriber, observable_member, is_on_error<>::not_void, is_on_next_of<>::not_void, from +// IWYU pragma: no_include "rxcpp/sources/rx-iterate.hpp" #include #include diff --git a/morpheus/_lib/include/morpheus/stages/triton_inference.hpp b/morpheus/_lib/include/morpheus/stages/triton_inference.hpp index 0f042a473d..91f8bec91e 100644 --- a/morpheus/_lib/include/morpheus/stages/triton_inference.hpp +++ b/morpheus/_lib/include/morpheus/stages/triton_inference.hpp @@ -33,6 +33,7 @@ #include #include #include // for apply, make_subscriber, observable_member, is_on_error<>::not_void, is_on_next_of<>::not_void, from +// IWYU pragma: no_include "rxcpp/sources/rx-iterate.hpp" #include #include diff --git a/morpheus/_lib/src/io/data_loader.cpp b/morpheus/_lib/src/io/data_loader.cpp index 55bb5db2fc..f6d7502289 100644 --- a/morpheus/_lib/src/io/data_loader.cpp +++ b/morpheus/_lib/src/io/data_loader.cpp @@ -21,8 +21,9 @@ #include -#include #include +#include +#include namespace morpheus { diff --git a/morpheus/_lib/src/io/data_loader_registry.cpp b/morpheus/_lib/src/io/data_loader_registry.cpp index 79db180e2b..62a3e5536c 100644 --- a/morpheus/_lib/src/io/data_loader_registry.cpp +++ b/morpheus/_lib/src/io/data_loader_registry.cpp @@ -22,9 +22,17 @@ #include "morpheus/messages/control.hpp" #include "morpheus/objects/factory_registry.hpp" +#include #include +#include +#include +#include #include +#include +#include +#include + namespace morpheus { template class FactoryRegistry; diff --git a/morpheus/_lib/src/io/loaders/file.cpp b/morpheus/_lib/src/io/loaders/file.cpp index 3802ebe0ef..fc7710ebf6 100644 --- a/morpheus/_lib/src/io/loaders/file.cpp +++ b/morpheus/_lib/src/io/loaders/file.cpp @@ -22,11 +22,20 @@ #include #include +#include +#include #include +#include #include +#include +#include +#include #include #include +#include +#include +#include namespace py = pybind11; diff --git a/morpheus/_lib/src/io/loaders/grpc.cpp b/morpheus/_lib/src/io/loaders/grpc.cpp index e96033e0a1..2040d5a0db 100644 --- a/morpheus/_lib/src/io/loaders/grpc.cpp +++ b/morpheus/_lib/src/io/loaders/grpc.cpp @@ -21,6 +21,8 @@ #include #include +#include +#include namespace morpheus { GRPCDataLoader::GRPCDataLoader(nlohmann::json config) : Loader(config) {} diff --git a/morpheus/_lib/src/io/loaders/lambda.cpp b/morpheus/_lib/src/io/loaders/lambda.cpp index d1f8f2c8c5..f26c7ed7f4 100644 --- a/morpheus/_lib/src/io/loaders/lambda.cpp +++ b/morpheus/_lib/src/io/loaders/lambda.cpp @@ -21,13 +21,15 @@ #include #include +#include +#include namespace morpheus { LambdaLoader::LambdaLoader( std::function(std::shared_ptr, nlohmann::json)> lambda_load, nlohmann::json config) : Loader(config), - m_lambda_load(lambda_load) + m_lambda_load(std::move(lambda_load)) {} std::shared_ptr LambdaLoader::load(std::shared_ptr message, nlohmann::json task) diff --git a/morpheus/_lib/src/io/loaders/payload.cpp b/morpheus/_lib/src/io/loaders/payload.cpp index 2f6c0f2cc9..08019d242d 100644 --- a/morpheus/_lib/src/io/loaders/payload.cpp +++ b/morpheus/_lib/src/io/loaders/payload.cpp @@ -21,6 +21,8 @@ #include #include +#include +#include namespace morpheus { PayloadDataLoader::PayloadDataLoader(nlohmann::json config) : Loader(config) {} diff --git a/morpheus/_lib/src/io/loaders/rest.cpp b/morpheus/_lib/src/io/loaders/rest.cpp index 8c7f4e7f28..fba206d23b 100644 --- a/morpheus/_lib/src/io/loaders/rest.cpp +++ b/morpheus/_lib/src/io/loaders/rest.cpp @@ -21,6 +21,8 @@ #include #include +#include +#include namespace morpheus { RESTDataLoader::RESTDataLoader(nlohmann::json config) : Loader(config) {} diff --git a/morpheus/_lib/src/messages/control.cpp b/morpheus/_lib/src/messages/control.cpp index 70ba791f8e..763b95f39c 100644 --- a/morpheus/_lib/src/messages/control.cpp +++ b/morpheus/_lib/src/messages/control.cpp @@ -18,9 +18,11 @@ #include "morpheus/messages/control.hpp" #include -#include #include +#include +#include + namespace py = pybind11; namespace morpheus { diff --git a/morpheus/_lib/src/modules/data_loader_module.cpp b/morpheus/_lib/src/modules/data_loader_module.cpp index 031cab12c3..69f8e2cdfa 100644 --- a/morpheus/_lib/src/modules/data_loader_module.cpp +++ b/morpheus/_lib/src/modules/data_loader_module.cpp @@ -17,14 +17,28 @@ #include "morpheus/modules/data_loader_module.hpp" +#include "mrc/modules/properties/persistent.hpp" +#include "mrc/node/rx_node.hpp" +#include "rxcpp/operators/rx-map.hpp" +#include "rxcpp/sources/rx-iterate.hpp" + #include "morpheus/io/data_loader_registry.hpp" +#include "morpheus/messages/control.hpp" +#include #include #include #include #include +#include +#include +#include +#include +#include #include +#include +#include using namespace mrc::modules; using nlohmann::json; diff --git a/morpheus/_lib/src/objects/python_data_table.cpp b/morpheus/_lib/src/objects/python_data_table.cpp index 3f9fa7bbd8..fd3787ea0c 100644 --- a/morpheus/_lib/src/objects/python_data_table.cpp +++ b/morpheus/_lib/src/objects/python_data_table.cpp @@ -23,8 +23,8 @@ #include // for object::cast #include #include -#include +#include #include namespace morpheus { diff --git a/morpheus/_lib/src/python_modules/common.cpp b/morpheus/_lib/src/python_modules/common.cpp index 4b1dc141f8..831d337670 100644 --- a/morpheus/_lib/src/python_modules/common.cpp +++ b/morpheus/_lib/src/python_modules/common.cpp @@ -17,12 +17,14 @@ #include "morpheus/io/data_loader_registry.hpp" #include "morpheus/io/deserializers.hpp" // for read_file_to_df -#include "morpheus/io/loaders/all.hpp" +#include "morpheus/io/loaders/file.hpp" +#include "morpheus/io/loaders/grpc.hpp" +#include "morpheus/io/loaders/payload.hpp" +#include "morpheus/io/loaders/rest.hpp" #include "morpheus/io/serializers.hpp" -#include "morpheus/messages/control.hpp" -#include "morpheus/objects/dtype.hpp" // for TypeId +#include "morpheus/objects/dtype.hpp" // for TypeId #include "morpheus/objects/fiber_queue.hpp" -#include "morpheus/objects/file_types.hpp" // for FileTypes, determine_file_type +#include "morpheus/objects/file_types.hpp" // for FileTypes, determine_file_type #include "morpheus/objects/filter_source.hpp" #include "morpheus/objects/tensor_object.hpp" // for TensorObject #include "morpheus/objects/wrapped_tensor.hpp" @@ -30,13 +32,12 @@ #include "morpheus/version.hpp" #include +#include #include -#include #include -#include -#include // for pymrc::import #include +#include #include namespace morpheus { diff --git a/morpheus/_lib/src/python_modules/messages.cpp b/morpheus/_lib/src/python_modules/messages.cpp index 14c5c087de..80cbf5b2fb 100644 --- a/morpheus/_lib/src/python_modules/messages.cpp +++ b/morpheus/_lib/src/python_modules/messages.cpp @@ -15,6 +15,8 @@ * limitations under the License. */ +#include "pymrc/utilities/object_wrappers.hpp" + #include "morpheus/io/data_loader_registry.hpp" #include "morpheus/messages/control.hpp" #include "morpheus/messages/memory/inference_memory.hpp" @@ -41,26 +43,25 @@ #include #include // for Status #include -#include #include #include #include +#include #include -#include #include // IWYU pragma: keep #include #include #include // IWYU pragma: keep -#include -#include +#include // IWYU pragma: keep #include #include // for pymrc::import #include -#include +#include #include #include #include +#include #include #include diff --git a/morpheus/_lib/src/python_modules/modules.cpp b/morpheus/_lib/src/python_modules/modules.cpp index 7748783a12..01343d4e15 100644 --- a/morpheus/_lib/src/python_modules/modules.cpp +++ b/morpheus/_lib/src/python_modules/modules.cpp @@ -21,8 +21,12 @@ #include #include +#include #include // for arg, init, class_, module_, str_attr_accessor, PYBIND11_MODULE, pybind11 +#include +#include + namespace morpheus { namespace py = pybind11; diff --git a/morpheus/_lib/src/python_modules/stages.cpp b/morpheus/_lib/src/python_modules/stages.cpp index 6d08a2d191..8e21968614 100644 --- a/morpheus/_lib/src/python_modules/stages.cpp +++ b/morpheus/_lib/src/python_modules/stages.cpp @@ -15,6 +15,11 @@ * limitations under the License. */ +#include "mrc/channel/status.hpp" +#include "mrc/node/rx_sink_base.hpp" +#include "mrc/node/rx_source_base.hpp" +#include "mrc/types.hpp" + #include "morpheus/messages/meta.hpp" #include "morpheus/messages/multi.hpp" #include "morpheus/objects/file_types.hpp" // for FileTypes @@ -33,16 +38,19 @@ #include "morpheus/utilities/cudf_util.hpp" #include "morpheus/version.hpp" -#include +#include #include #include -#include #include // for multiple_inheritance #include // for arg, init, class_, module_, str_attr_accessor, PYBIND11_MODULE, pybind11 #include // for dict, sequence #include // for pymrc::import +#include +#include #include +#include +#include namespace morpheus { namespace py = pybind11; diff --git a/morpheus/_lib/src/stages/add_classification.cpp b/morpheus/_lib/src/stages/add_classification.cpp index 3fe6609fd8..a384e2aa7d 100644 --- a/morpheus/_lib/src/stages/add_classification.cpp +++ b/morpheus/_lib/src/stages/add_classification.cpp @@ -17,6 +17,9 @@ #include "morpheus/stages/add_classification.hpp" +#include "mrc/segment/builder.hpp" +#include "mrc/segment/object.hpp" + #include #include #include diff --git a/morpheus/_lib/src/stages/add_scores.cpp b/morpheus/_lib/src/stages/add_scores.cpp index ccba4e631e..84f6a20d53 100644 --- a/morpheus/_lib/src/stages/add_scores.cpp +++ b/morpheus/_lib/src/stages/add_scores.cpp @@ -17,6 +17,9 @@ #include "morpheus/stages/add_scores.hpp" +#include "mrc/segment/builder.hpp" +#include "mrc/segment/object.hpp" + #include "morpheus/stages/add_scores_stage_base.hpp" #include // for size_t diff --git a/morpheus/_lib/src/stages/add_scores_stage_base.cpp b/morpheus/_lib/src/stages/add_scores_stage_base.cpp index 97bd3ae6eb..b9a498dcdd 100644 --- a/morpheus/_lib/src/stages/add_scores_stage_base.cpp +++ b/morpheus/_lib/src/stages/add_scores_stage_base.cpp @@ -17,7 +17,15 @@ #include "morpheus/stages/add_scores_stage_base.hpp" -#include "morpheus/objects/dtype.hpp" // for DType +#include "mrc/node/rx_sink_base.hpp" +#include "mrc/node/rx_source_base.hpp" +#include "mrc/node/sink_properties.hpp" +#include "mrc/node/source_properties.hpp" +#include "mrc/types.hpp" +#include "pymrc/node.hpp" +#include "rxcpp/operators/rx-map.hpp" + +#include "morpheus/objects/dtype.hpp" // for DType #include "morpheus/objects/tensor.hpp" #include "morpheus/objects/tensor_object.hpp" // for TensorObject #include "morpheus/types.hpp" // for TensorIndex diff --git a/morpheus/_lib/src/stages/deserialize.cpp b/morpheus/_lib/src/stages/deserialize.cpp index 1b5c1ad6bd..5896770107 100644 --- a/morpheus/_lib/src/stages/deserialize.cpp +++ b/morpheus/_lib/src/stages/deserialize.cpp @@ -17,6 +17,13 @@ #include "morpheus/stages/deserialize.hpp" +#include "mrc/node/rx_sink_base.hpp" +#include "mrc/node/rx_source_base.hpp" +#include "mrc/node/sink_properties.hpp" +#include "mrc/node/source_properties.hpp" +#include "mrc/segment/object.hpp" +#include "mrc/types.hpp" + #include "morpheus/types.hpp" #include "morpheus/utilities/python_util.hpp" #include "morpheus/utilities/string_util.hpp" diff --git a/morpheus/_lib/src/stages/file_source.cpp b/morpheus/_lib/src/stages/file_source.cpp index a14bd27e02..7d4f2cfe44 100644 --- a/morpheus/_lib/src/stages/file_source.cpp +++ b/morpheus/_lib/src/stages/file_source.cpp @@ -17,6 +17,13 @@ #include "morpheus/stages/file_source.hpp" +#include "mrc/node/rx_sink_base.hpp" +#include "mrc/node/rx_source_base.hpp" +#include "mrc/node/source_properties.hpp" +#include "mrc/segment/object.hpp" +#include "mrc/types.hpp" +#include "pymrc/node.hpp" + #include "morpheus/io/deserializers.hpp" #include "morpheus/objects/table_info.hpp" #include "morpheus/utilities/cudf_util.hpp" diff --git a/morpheus/_lib/src/stages/filter_detection.cpp b/morpheus/_lib/src/stages/filter_detection.cpp index 0036265b7e..7e5d3b7e55 100644 --- a/morpheus/_lib/src/stages/filter_detection.cpp +++ b/morpheus/_lib/src/stages/filter_detection.cpp @@ -17,6 +17,15 @@ #include "morpheus/stages/filter_detection.hpp" // IWYU pragma: accosiated +#include "mrc/node/rx_sink_base.hpp" +#include "mrc/node/rx_source_base.hpp" +#include "mrc/node/sink_properties.hpp" +#include "mrc/node/source_properties.hpp" +#include "mrc/segment/builder.hpp" +#include "mrc/segment/object.hpp" +#include "mrc/types.hpp" +#include "pymrc/node.hpp" + #include "morpheus/messages/multi_tensor.hpp" #include "morpheus/objects/dev_mem_info.hpp" // for DevMemInfo #include "morpheus/objects/dtype.hpp" // for DataType @@ -27,7 +36,7 @@ #include "morpheus/utilities/matx_util.hpp" #include "morpheus/utilities/tensor_util.hpp" // for TensorUtils::get_element_stride -#include // for cudaMemcpy, cudaMemcpyDeviceToDevice, cudaMemcpyDeviceToHost +#include // for cudaMemcpy, cudaMemcpyDeviceToDevice, cudaMemcpyDeviceToHost #include #include #include // for CHECK, CHECK_NE diff --git a/morpheus/_lib/src/stages/kafka_source.cpp b/morpheus/_lib/src/stages/kafka_source.cpp index b697b6bf0c..086b4c7716 100644 --- a/morpheus/_lib/src/stages/kafka_source.cpp +++ b/morpheus/_lib/src/stages/kafka_source.cpp @@ -17,6 +17,11 @@ #include "morpheus/stages/kafka_source.hpp" +#include "mrc/node/rx_sink_base.hpp" +#include "mrc/node/rx_source_base.hpp" +#include "mrc/node/source_properties.hpp" +#include "mrc/segment/object.hpp" + #include "morpheus/io/deserializers.hpp" #include "morpheus/messages/meta.hpp" #include "morpheus/utilities/stage_util.hpp" diff --git a/morpheus/_lib/src/stages/preprocess_fil.cpp b/morpheus/_lib/src/stages/preprocess_fil.cpp index 687307bb7c..f2d9a76083 100644 --- a/morpheus/_lib/src/stages/preprocess_fil.cpp +++ b/morpheus/_lib/src/stages/preprocess_fil.cpp @@ -17,11 +17,18 @@ #include "morpheus/stages/preprocess_fil.hpp" +#include "mrc/node/rx_sink_base.hpp" +#include "mrc/node/rx_source_base.hpp" +#include "mrc/node/sink_properties.hpp" +#include "mrc/node/source_properties.hpp" +#include "mrc/segment/object.hpp" +#include "mrc/types.hpp" + #include "morpheus/messages/memory/inference_memory_fil.hpp" #include "morpheus/messages/meta.hpp" // for MessageMeta #include "morpheus/objects/dev_mem_info.hpp" // for DevMemInfo #include "morpheus/objects/dtype.hpp" -#include "morpheus/objects/table_info.hpp" // for TableInfo +#include "morpheus/objects/table_info.hpp" // for TableInfo #include "morpheus/objects/tensor.hpp" #include "morpheus/objects/tensor_object.hpp" // for TensorObject #include "morpheus/types.hpp" // for TensorIndex @@ -34,7 +41,7 @@ #include #include // for MRC_CHECK_CUDA #include -#include // for object_api::operator(), operator""_a +#include // for object_api::operator(), operator""_a #include #include // for str_attr_accessor, arg #include diff --git a/morpheus/_lib/src/stages/preprocess_nlp.cpp b/morpheus/_lib/src/stages/preprocess_nlp.cpp index f785c7fe59..61f128dccd 100644 --- a/morpheus/_lib/src/stages/preprocess_nlp.cpp +++ b/morpheus/_lib/src/stages/preprocess_nlp.cpp @@ -17,13 +17,20 @@ #include "morpheus/stages/preprocess_nlp.hpp" +#include "mrc/node/rx_sink_base.hpp" +#include "mrc/node/rx_source_base.hpp" +#include "mrc/node/sink_properties.hpp" +#include "mrc/node/source_properties.hpp" +#include "mrc/segment/object.hpp" +#include "mrc/types.hpp" + #include "morpheus/messages/memory/inference_memory.hpp" // for InferenceMemory #include "morpheus/messages/multi_inference.hpp" #include "morpheus/objects/dev_mem_info.hpp" #include "morpheus/objects/dtype.hpp" #include "morpheus/objects/table_info.hpp" // for TableInfo #include "morpheus/objects/tensor.hpp" -#include "morpheus/types.hpp" // for TensorIndex, TensorMap +#include "morpheus/types.hpp" // for TensorIndex, TensorMap #include "morpheus/utilities/matx_util.hpp" #include // for column, column::contents diff --git a/morpheus/_lib/src/stages/serialize.cpp b/morpheus/_lib/src/stages/serialize.cpp index 384abb6b36..671cde5d2b 100644 --- a/morpheus/_lib/src/stages/serialize.cpp +++ b/morpheus/_lib/src/stages/serialize.cpp @@ -17,6 +17,15 @@ #include "morpheus/stages/serialize.hpp" +#include "mrc/node/rx_sink_base.hpp" +#include "mrc/node/rx_source_base.hpp" +#include "mrc/node/sink_properties.hpp" +#include "mrc/node/source_properties.hpp" +#include "mrc/segment/builder.hpp" +#include "mrc/segment/object.hpp" +#include "mrc/types.hpp" +#include "pymrc/node.hpp" + #include "morpheus/messages/meta.hpp" #include "morpheus/objects/table_info.hpp" diff --git a/morpheus/_lib/src/stages/triton_inference.cpp b/morpheus/_lib/src/stages/triton_inference.cpp index 5faea798ae..e055fb7192 100644 --- a/morpheus/_lib/src/stages/triton_inference.cpp +++ b/morpheus/_lib/src/stages/triton_inference.cpp @@ -17,6 +17,14 @@ #include "morpheus/stages/triton_inference.hpp" +#include "mrc/node/rx_sink_base.hpp" +#include "mrc/node/rx_source_base.hpp" +#include "mrc/node/sink_properties.hpp" +#include "mrc/node/source_properties.hpp" +#include "mrc/segment/builder.hpp" +#include "mrc/segment/object.hpp" +#include "mrc/types.hpp" + #include "morpheus/messages/memory/response_memory.hpp" #include "morpheus/messages/memory/tensor_memory.hpp" // for TensorMemory #include "morpheus/objects/dev_mem_info.hpp" // for DevMemInfo @@ -39,7 +47,7 @@ #include // for cuda_stream_per_thread #include // for device_buffer -#include // for min +#include // for min #include #include #include diff --git a/morpheus/_lib/src/stages/write_to_file.cpp b/morpheus/_lib/src/stages/write_to_file.cpp index ff891bb3f1..7d84694af8 100644 --- a/morpheus/_lib/src/stages/write_to_file.cpp +++ b/morpheus/_lib/src/stages/write_to_file.cpp @@ -17,6 +17,15 @@ #include "morpheus/stages/write_to_file.hpp" // IWYU pragma: accosiated +#include "mrc/node/rx_sink_base.hpp" +#include "mrc/node/rx_source_base.hpp" +#include "mrc/node/sink_properties.hpp" +#include "mrc/node/source_properties.hpp" +#include "mrc/segment/builder.hpp" +#include "mrc/segment/object.hpp" +#include "mrc/types.hpp" +#include "pymrc/node.hpp" + #include "morpheus/io/serializers.hpp" #include "morpheus/utilities/string_util.hpp" @@ -25,7 +34,7 @@ #include #include // for invalid_argument, runtime_error #include -#include // for forward, move, addressof +#include // for forward, move, addressof namespace morpheus { diff --git a/morpheus/_lib/tests/io/test_data_loader.cpp b/morpheus/_lib/tests/io/test_data_loader.cpp index 48fb052974..85f7b38b4e 100644 --- a/morpheus/_lib/tests/io/test_data_loader.cpp +++ b/morpheus/_lib/tests/io/test_data_loader.cpp @@ -15,17 +15,25 @@ * limitations under the License. */ +#include "../test_morpheus.hpp" // IWYU pragma: associated #include "test_io.hpp" #include "morpheus/io/data_loader.hpp" -#include "morpheus/io/loaders/all.hpp" +#include "morpheus/io/loaders/file.hpp" +#include "morpheus/io/loaders/payload.hpp" #include "morpheus/messages/control.hpp" +#include #include +#include +#include +#include #include -#include #include +#include +#include +#include namespace py = pybind11; using namespace morpheus; diff --git a/morpheus/_lib/tests/io/test_data_loader_registry.cpp b/morpheus/_lib/tests/io/test_data_loader_registry.cpp index 493513e70c..4021305776 100644 --- a/morpheus/_lib/tests/io/test_data_loader_registry.cpp +++ b/morpheus/_lib/tests/io/test_data_loader_registry.cpp @@ -15,12 +15,18 @@ * limitations under the License. */ +#include "../test_morpheus.hpp" // IWYU pragma: associated #include "test_io.hpp" #include "morpheus/io/data_loader_registry.hpp" -#include "morpheus/io/loaders/all.hpp" +#include "morpheus/io/loaders/payload.hpp" + +#include +#include +#include #include +#include namespace py = pybind11; using namespace morpheus; diff --git a/morpheus/_lib/tests/io/test_loaders.cpp b/morpheus/_lib/tests/io/test_loaders.cpp index a13eee9adc..32c9890652 100644 --- a/morpheus/_lib/tests/io/test_loaders.cpp +++ b/morpheus/_lib/tests/io/test_loaders.cpp @@ -15,13 +15,25 @@ * limitations under the License. */ +#include "../test_morpheus.hpp" // IWYU pragma: associated #include "test_io.hpp" -#include "morpheus/io/loaders/all.hpp" +#include "morpheus/io/loaders/file.hpp" +#include "morpheus/io/loaders/grpc.hpp" +#include "morpheus/io/loaders/payload.hpp" +#include "morpheus/io/loaders/rest.hpp" #include "morpheus/messages/control.hpp" +#include +#include +#include +#include + +#include #include -#include +#include +#include +#include namespace py = pybind11; using namespace morpheus; diff --git a/morpheus/_lib/tests/messages/test_control_message.cpp b/morpheus/_lib/tests/messages/test_control_message.cpp index 467219e16a..5d225615a7 100644 --- a/morpheus/_lib/tests/messages/test_control_message.cpp +++ b/morpheus/_lib/tests/messages/test_control_message.cpp @@ -15,13 +15,18 @@ * limitations under the License. */ -#include "nlohmann/json.hpp" +#include "../test_morpheus.hpp" // IWYU pragma: associated #include "test_messages.hpp" #include "morpheus/messages/control.hpp" #include "morpheus/messages/meta.hpp" +#include +#include + #include +#include +#include using namespace morpheus; using namespace morpheus::test; diff --git a/morpheus/_lib/tests/modules/test_data_loader_module.cpp b/morpheus/_lib/tests/modules/test_data_loader_module.cpp index 1a03e67141..d0f19f8dbf 100644 --- a/morpheus/_lib/tests/modules/test_data_loader_module.cpp +++ b/morpheus/_lib/tests/modules/test_data_loader_module.cpp @@ -15,16 +15,21 @@ * limitations under the License. */ +#include "../test_morpheus.hpp" // IWYU pragma: associated +#include "mrc/channel/status.hpp" +#include "mrc/modules/properties/persistent.hpp" +#include "mrc/options/engine_groups.hpp" +#include "mrc/runnable/types.hpp" +#include "mrc/segment/object.hpp" #include "test_modules.hpp" #include "morpheus/messages/control.hpp" -#include "morpheus/messages/meta.hpp" #include "morpheus/modules/data_loader_module.hpp" +#include +#include #include #include -#include -#include #include #include #include @@ -32,8 +37,14 @@ #include #include #include +#include +#include #include +#include +#include +#include +#include using namespace morpheus; using namespace morpheus::test; diff --git a/morpheus/_lib/tests/modules/test_modules.hpp b/morpheus/_lib/tests/modules/test_modules.hpp index d7c1eaed39..a542f6e43c 100644 --- a/morpheus/_lib/tests/modules/test_modules.hpp +++ b/morpheus/_lib/tests/modules/test_modules.hpp @@ -21,5 +21,5 @@ namespace morpheus::test { -using TestDataLoaderModule = TestWithPythonInterpreter; +using TestDataLoaderModule = TestWithPythonInterpreter; // NOLINT } // namespace morpheus::test \ No newline at end of file diff --git a/morpheus/_lib/tests/test_matx_util.cpp b/morpheus/_lib/tests/test_matx_util.cpp index 8557533e79..3a10f8d0d0 100644 --- a/morpheus/_lib/tests/test_matx_util.cpp +++ b/morpheus/_lib/tests/test_matx_util.cpp @@ -28,14 +28,13 @@ #include // for column_view #include #include -#include // for data_type, size_type +#include // for data_type, size_type #include #include // for MRC_CHECK_CUDA #include // for cuda_stream_per_thread #include #include // for int64_t, uint8_t -#include // for std::getenv #include // for shared_ptr, make_shared, unique_ptr #include diff --git a/morpheus/_lib/tests/test_morpheus.cpp b/morpheus/_lib/tests/test_morpheus.cpp index 3d1d756348..5d3e55d30a 100644 --- a/morpheus/_lib/tests/test_morpheus.cpp +++ b/morpheus/_lib/tests/test_morpheus.cpp @@ -18,18 +18,28 @@ #include "test_morpheus.hpp" #include "morpheus/io/data_loader_registry.hpp" -#include "morpheus/io/loaders/all.hpp" +#include "morpheus/io/loaders/file.hpp" +#include "morpheus/io/loaders/grpc.hpp" +#include "morpheus/io/loaders/payload.hpp" +#include "morpheus/io/loaders/rest.hpp" #include "morpheus/messages/meta.hpp" #include "morpheus/utilities/string_util.hpp" +#include +#include #include +#include #include +#include +#include +#include #include +#include #include -#include #include #include +#include namespace morpheus::test { diff --git a/morpheus/_lib/tests/test_morpheus.hpp b/morpheus/_lib/tests/test_morpheus.hpp index 4df63bb9c1..e015fd0b79 100644 --- a/morpheus/_lib/tests/test_morpheus.hpp +++ b/morpheus/_lib/tests/test_morpheus.hpp @@ -19,9 +19,12 @@ #include // IWYU pragma: keep #include // IWYU pragma: keep -#include +#include #include +#include +#include +#include #define TEST_CLASS(name) \ class __attribute__((visibility("default"))) Test##name : public ::testing::Test \ diff --git a/morpheus/_lib/tests/test_multi_slices.cpp b/morpheus/_lib/tests/test_multi_slices.cpp index 1fdfbd08d6..98e97d8303 100644 --- a/morpheus/_lib/tests/test_multi_slices.cpp +++ b/morpheus/_lib/tests/test_multi_slices.cpp @@ -28,7 +28,6 @@ #include #include -#include #include #include // for unique_ptr #include diff --git a/morpheus/modules/mlflow_model_writer.py b/morpheus/modules/mlflow_model_writer.py index db0c4c3e26..798b0bd8c0 100644 --- a/morpheus/modules/mlflow_model_writer.py +++ b/morpheus/modules/mlflow_model_writer.py @@ -141,7 +141,7 @@ def apply_model_permissions(reg_model_name: str): "access_control_list": [{ "group_name": group, "permission_level": permission } for group, - permission in databricks_permissions.items()] + permission in databricks_permissions.items()] } requests.patch(url=patch_registered_model_permissions_url, diff --git a/tests/modules/test_morpheus_modules.py b/tests/modules/test_morpheus_modules.py index 27d77c2374..02e8a5cc52 100644 --- a/tests/modules/test_morpheus_modules.py +++ b/tests/modules/test_morpheus_modules.py @@ -68,6 +68,7 @@ def test_get_module(): def test_get_module_with_bad_config_no_loaders(): + def init_wrapper(builder: mrc.Builder): def gen_data(): @@ -107,6 +108,7 @@ def gen_data(): def test_get_module_with_bad_loader_type(): + def init_wrapper(builder: mrc.Builder): def gen_data(): @@ -141,6 +143,7 @@ def gen_data(): def test_get_module_with_bad_control_message(): + def init_wrapper(builder: mrc.Builder): def gen_data(): From a26b60862900441171aa77ba560762189b1762bb Mon Sep 17 00:00:00 2001 From: Devin Robison Date: Wed, 29 Mar 2023 18:47:58 -0600 Subject: [PATCH 146/157] Formatting tweaks -- local iwyu is a bit different from CI. Fix python_modules so they use the autogen path --- morpheus/_lib/cmake/python_modules/common.cmake | 2 ++ morpheus/_lib/cmake/python_modules/messages.cmake | 2 ++ morpheus/_lib/cmake/python_modules/modules.cmake | 2 ++ morpheus/_lib/cmake/python_modules/stages.cmake | 2 ++ morpheus/_lib/src/modules/data_loader_module.cpp | 2 +- morpheus/_lib/src/python_modules/common.cpp | 4 ++-- morpheus/_lib/src/stages/add_scores_stage_base.cpp | 2 +- morpheus/_lib/src/stages/filter_detection.cpp | 2 +- morpheus/_lib/src/stages/preprocess_fil.cpp | 8 ++++---- morpheus/_lib/src/stages/preprocess_nlp.cpp | 2 +- morpheus/_lib/src/stages/triton_inference.cpp | 2 +- morpheus/_lib/src/stages/write_to_file.cpp | 2 +- morpheus/_lib/tests/io/test_data_loader.cpp | 2 +- morpheus/_lib/tests/io/test_loaders.cpp | 2 +- morpheus/_lib/tests/test_matx_util.cpp | 2 +- 15 files changed, 23 insertions(+), 15 deletions(-) diff --git a/morpheus/_lib/cmake/python_modules/common.cmake b/morpheus/_lib/cmake/python_modules/common.cmake index 15354e5fa4..5253bc61ed 100644 --- a/morpheus/_lib/cmake/python_modules/common.cmake +++ b/morpheus/_lib/cmake/python_modules/common.cmake @@ -14,6 +14,8 @@ morpheus_add_pybind11_module( common + INCLUDE_DIRS + "${CMAKE_BINARY_DIR}/autogenerated/include" SOURCE_FILES "${MORPHEUS_LIB_ROOT}/src/python_modules/common.cpp" ) diff --git a/morpheus/_lib/cmake/python_modules/messages.cmake b/morpheus/_lib/cmake/python_modules/messages.cmake index 9f15e1ff9f..11f293a3b4 100644 --- a/morpheus/_lib/cmake/python_modules/messages.cmake +++ b/morpheus/_lib/cmake/python_modules/messages.cmake @@ -14,6 +14,8 @@ morpheus_add_pybind11_module( messages + INCLUDE_DIRS + "${CMAKE_BINARY_DIR}/autogenerated/include" SOURCE_FILES "${MORPHEUS_LIB_ROOT}/src/python_modules/messages.cpp" ) diff --git a/morpheus/_lib/cmake/python_modules/modules.cmake b/morpheus/_lib/cmake/python_modules/modules.cmake index d8a244410e..4e2fbf8a43 100644 --- a/morpheus/_lib/cmake/python_modules/modules.cmake +++ b/morpheus/_lib/cmake/python_modules/modules.cmake @@ -14,6 +14,8 @@ morpheus_utils_add_pybind11_module( modules + INCLUDE_DIRS + "${CMAKE_BINARY_DIR}/autogenerated/include" SOURCE_FILES "${MORPHEUS_LIB_ROOT}/src/python_modules/modules.cpp" ) \ No newline at end of file diff --git a/morpheus/_lib/cmake/python_modules/stages.cmake b/morpheus/_lib/cmake/python_modules/stages.cmake index d0db380f75..882e575618 100644 --- a/morpheus/_lib/cmake/python_modules/stages.cmake +++ b/morpheus/_lib/cmake/python_modules/stages.cmake @@ -14,6 +14,8 @@ morpheus_add_pybind11_module( stages + INCLUDE_DIRS + "${CMAKE_BINARY_DIR}/autogenerated/include" SOURCE_FILES "${MORPHEUS_LIB_ROOT}/src/python_modules/stages.cpp" ) diff --git a/morpheus/_lib/src/modules/data_loader_module.cpp b/morpheus/_lib/src/modules/data_loader_module.cpp index 69f8e2cdfa..6ab3b82e0e 100644 --- a/morpheus/_lib/src/modules/data_loader_module.cpp +++ b/morpheus/_lib/src/modules/data_loader_module.cpp @@ -20,7 +20,6 @@ #include "mrc/modules/properties/persistent.hpp" #include "mrc/node/rx_node.hpp" #include "rxcpp/operators/rx-map.hpp" -#include "rxcpp/sources/rx-iterate.hpp" #include "morpheus/io/data_loader_registry.hpp" #include "morpheus/messages/control.hpp" @@ -31,6 +30,7 @@ #include #include #include +// IWYU pragma: no_include "rxcpp/sources/rx-iterate.hpp" #include #include diff --git a/morpheus/_lib/src/python_modules/common.cpp b/morpheus/_lib/src/python_modules/common.cpp index 831d337670..14e0610492 100644 --- a/morpheus/_lib/src/python_modules/common.cpp +++ b/morpheus/_lib/src/python_modules/common.cpp @@ -22,9 +22,9 @@ #include "morpheus/io/loaders/payload.hpp" #include "morpheus/io/loaders/rest.hpp" #include "morpheus/io/serializers.hpp" -#include "morpheus/objects/dtype.hpp" // for TypeId +#include "morpheus/objects/dtype.hpp" // for TypeId #include "morpheus/objects/fiber_queue.hpp" -#include "morpheus/objects/file_types.hpp" // for FileTypes, determine_file_type +#include "morpheus/objects/file_types.hpp" // for FileTypes, determine_file_type #include "morpheus/objects/filter_source.hpp" #include "morpheus/objects/tensor_object.hpp" // for TensorObject #include "morpheus/objects/wrapped_tensor.hpp" diff --git a/morpheus/_lib/src/stages/add_scores_stage_base.cpp b/morpheus/_lib/src/stages/add_scores_stage_base.cpp index b9a498dcdd..cc75c8f8bb 100644 --- a/morpheus/_lib/src/stages/add_scores_stage_base.cpp +++ b/morpheus/_lib/src/stages/add_scores_stage_base.cpp @@ -25,7 +25,7 @@ #include "pymrc/node.hpp" #include "rxcpp/operators/rx-map.hpp" -#include "morpheus/objects/dtype.hpp" // for DType +#include "morpheus/objects/dtype.hpp" // for DType #include "morpheus/objects/tensor.hpp" #include "morpheus/objects/tensor_object.hpp" // for TensorObject #include "morpheus/types.hpp" // for TensorIndex diff --git a/morpheus/_lib/src/stages/filter_detection.cpp b/morpheus/_lib/src/stages/filter_detection.cpp index 7e5d3b7e55..357b798e9d 100644 --- a/morpheus/_lib/src/stages/filter_detection.cpp +++ b/morpheus/_lib/src/stages/filter_detection.cpp @@ -36,7 +36,7 @@ #include "morpheus/utilities/matx_util.hpp" #include "morpheus/utilities/tensor_util.hpp" // for TensorUtils::get_element_stride -#include // for cudaMemcpy, cudaMemcpyDeviceToDevice, cudaMemcpyDeviceToHost +#include // for cudaMemcpy, cudaMemcpyDeviceToDevice, cudaMemcpyDeviceToHost #include #include #include // for CHECK, CHECK_NE diff --git a/morpheus/_lib/src/stages/preprocess_fil.cpp b/morpheus/_lib/src/stages/preprocess_fil.cpp index f2d9a76083..a51aef29a7 100644 --- a/morpheus/_lib/src/stages/preprocess_fil.cpp +++ b/morpheus/_lib/src/stages/preprocess_fil.cpp @@ -28,7 +28,7 @@ #include "morpheus/messages/meta.hpp" // for MessageMeta #include "morpheus/objects/dev_mem_info.hpp" // for DevMemInfo #include "morpheus/objects/dtype.hpp" -#include "morpheus/objects/table_info.hpp" // for TableInfo +#include "morpheus/objects/table_info.hpp" // for TableInfo #include "morpheus/objects/tensor.hpp" #include "morpheus/objects/tensor_object.hpp" // for TensorObject #include "morpheus/types.hpp" // for TensorIndex @@ -41,7 +41,7 @@ #include #include // for MRC_CHECK_CUDA #include -#include // for object_api::operator(), operator""_a +#include // for object_api::operator(), operator""_a #include #include // for str_attr_accessor, arg #include @@ -121,8 +121,8 @@ PreprocessFILStage::subscribe_fn_t PreprocessFILStage::build_operator() input__0.get_memory(), x->mess_offset), seq_id_dtype, - {x->mess_count, 3}, - {}, + {x->mess_count, 3}, + {}, 0); // Build the results diff --git a/morpheus/_lib/src/stages/preprocess_nlp.cpp b/morpheus/_lib/src/stages/preprocess_nlp.cpp index 61f128dccd..2e19f68a95 100644 --- a/morpheus/_lib/src/stages/preprocess_nlp.cpp +++ b/morpheus/_lib/src/stages/preprocess_nlp.cpp @@ -30,7 +30,7 @@ #include "morpheus/objects/dtype.hpp" #include "morpheus/objects/table_info.hpp" // for TableInfo #include "morpheus/objects/tensor.hpp" -#include "morpheus/types.hpp" // for TensorIndex, TensorMap +#include "morpheus/types.hpp" // for TensorIndex, TensorMap #include "morpheus/utilities/matx_util.hpp" #include // for column, column::contents diff --git a/morpheus/_lib/src/stages/triton_inference.cpp b/morpheus/_lib/src/stages/triton_inference.cpp index e055fb7192..25024a7786 100644 --- a/morpheus/_lib/src/stages/triton_inference.cpp +++ b/morpheus/_lib/src/stages/triton_inference.cpp @@ -47,7 +47,7 @@ #include // for cuda_stream_per_thread #include // for device_buffer -#include // for min +#include // for min #include #include #include diff --git a/morpheus/_lib/src/stages/write_to_file.cpp b/morpheus/_lib/src/stages/write_to_file.cpp index 7d84694af8..9bb8e961d3 100644 --- a/morpheus/_lib/src/stages/write_to_file.cpp +++ b/morpheus/_lib/src/stages/write_to_file.cpp @@ -34,7 +34,7 @@ #include #include // for invalid_argument, runtime_error #include -#include // for forward, move, addressof +#include // for forward, move, addressof namespace morpheus { diff --git a/morpheus/_lib/tests/io/test_data_loader.cpp b/morpheus/_lib/tests/io/test_data_loader.cpp index 85f7b38b4e..24fc00df15 100644 --- a/morpheus/_lib/tests/io/test_data_loader.cpp +++ b/morpheus/_lib/tests/io/test_data_loader.cpp @@ -132,7 +132,7 @@ TEST_F(TestDataLoader, FileLoaderTest) auto string_df = create_mock_csv_file({"col1", "col2", "col3"}, {"int32", "float32", "string"}, 5); - char temp_file[] = "/tmp/morpheus_test_XXXXXXXX"; + char temp_file[] = "/tmp/morpheus_test_XXXXXXXX"; // NOLINT int fd = mkstemp(temp_file); if (fd == -1) { diff --git a/morpheus/_lib/tests/io/test_loaders.cpp b/morpheus/_lib/tests/io/test_loaders.cpp index 32c9890652..b4fa67ff67 100644 --- a/morpheus/_lib/tests/io/test_loaders.cpp +++ b/morpheus/_lib/tests/io/test_loaders.cpp @@ -51,7 +51,7 @@ TEST_F(TestLoader, LoaderFileTest) { auto string_df = create_mock_csv_file({"col1", "col2", "col3"}, {"int32", "float32", "string"}, 5); - char temp_file[] = "/tmp/morpheus_test_XXXXXXXX"; + char temp_file[] = "/tmp/morpheus_test_XXXXXXXX"; // NOLINT int fd = mkstemp(temp_file); if (fd == -1) { diff --git a/morpheus/_lib/tests/test_matx_util.cpp b/morpheus/_lib/tests/test_matx_util.cpp index 3a10f8d0d0..d2b9c6d9a7 100644 --- a/morpheus/_lib/tests/test_matx_util.cpp +++ b/morpheus/_lib/tests/test_matx_util.cpp @@ -28,7 +28,7 @@ #include // for column_view #include #include -#include // for data_type, size_type +#include // for data_type, size_type #include #include // for MRC_CHECK_CUDA #include // for cuda_stream_per_thread From 15efb8b9043906e3a870b1574ac92102a1acb05d Mon Sep 17 00:00:00 2001 From: Devin Robison Date: Wed, 29 Mar 2023 18:57:25 -0600 Subject: [PATCH 147/157] Switch modules version to use morphues version.hpp --- morpheus/_lib/src/python_modules/modules.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/morpheus/_lib/src/python_modules/modules.cpp b/morpheus/_lib/src/python_modules/modules.cpp index 01343d4e15..673c2df289 100644 --- a/morpheus/_lib/src/python_modules/modules.cpp +++ b/morpheus/_lib/src/python_modules/modules.cpp @@ -20,7 +20,6 @@ #include "morpheus/version.hpp" #include -#include #include #include // for arg, init, class_, module_, str_attr_accessor, PYBIND11_MODULE, pybind11 @@ -40,10 +39,11 @@ PYBIND11_MODULE(modules, _module) )pbdoc"; - const std::vector MRCModuleVersion{mrc_VERSION_MAJOR, mrc_VERSION_MINOR, mrc_VERSION_PATCH}; + const std::vector MorpheusModuleVersion{ + morpheus_VERSION_MAJOR, morpheus_VERSION_MINOR, morpheus_VERSION_PATCH}; mrc::modules::ModelRegistryUtil::create_registered_module( - "DataLoader", "morpheus", MRCModuleVersion); + "DataLoader", "morpheus", MorpheusModuleVersion); _module.attr("__version__") = MORPHEUS_CONCAT_STR(morpheus_VERSION_MAJOR << "." << morpheus_VERSION_MINOR << "." << morpheus_VERSION_PATCH); From efeaeeb1bc8314f3c31c588dff6d8dae70dcb917 Mon Sep 17 00:00:00 2001 From: Devin Robison Date: Wed, 29 Mar 2023 19:13:49 -0600 Subject: [PATCH 148/157] Formatting fix --- morpheus/_lib/src/stages/preprocess_fil.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/morpheus/_lib/src/stages/preprocess_fil.cpp b/morpheus/_lib/src/stages/preprocess_fil.cpp index a51aef29a7..0a27afc402 100644 --- a/morpheus/_lib/src/stages/preprocess_fil.cpp +++ b/morpheus/_lib/src/stages/preprocess_fil.cpp @@ -121,8 +121,8 @@ PreprocessFILStage::subscribe_fn_t PreprocessFILStage::build_operator() input__0.get_memory(), x->mess_offset), seq_id_dtype, - {x->mess_count, 3}, - {}, + {x->mess_count, 3}, + {}, 0); // Build the results From 49a2f6250e0141cc4c5da251052668247a7403ff Mon Sep 17 00:00:00 2001 From: Devin Robison Date: Wed, 29 Mar 2023 19:52:35 -0600 Subject: [PATCH 149/157] Be a bit more specific about importing morpheus.common in loader tests, CI doesn't seem to find it --- morpheus/messages/__init__.py | 2 ++ morpheus/messages/control_message.py | 29 ---------------------------- tests/io/test_loader_registry.py | 6 +++--- 3 files changed, 5 insertions(+), 32 deletions(-) delete mode 100644 morpheus/messages/control_message.py diff --git a/morpheus/messages/__init__.py b/morpheus/messages/__init__.py index 243f596fd4..173f0c50b8 100644 --- a/morpheus/messages/__init__.py +++ b/morpheus/messages/__init__.py @@ -19,6 +19,7 @@ # isort: off from morpheus._lib.messages import ControlMessage +from morpheus._lib.messages import DataLoaderRegistry from morpheus.messages.memory.tensor_memory import TensorMemory from morpheus.messages.memory.inference_memory import InferenceMemory from morpheus.messages.memory.inference_memory import InferenceMemoryAE @@ -42,6 +43,7 @@ __all__ = [ "ControlMessage", + "DataLoaderRegistry", "InferenceMemory", "InferenceMemoryAE", "InferenceMemoryFIL", diff --git a/morpheus/messages/control_message.py b/morpheus/messages/control_message.py deleted file mode 100644 index 7c998c32c6..0000000000 --- a/morpheus/messages/control_message.py +++ /dev/null @@ -1,29 +0,0 @@ -# Copyright (c) 2021-2023, NVIDIA CORPORATION. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import morpheus._lib.messages as _messages -from morpheus.messages.message_base import MessageBase - - -class ControlMessage(MessageBase, cpp_class=_messages.ControlMessage): - """ - ControlMessage is an object that serves as a specification of the tasks to be executed in a pipeline workflow. - The ControlMessage is passed between stages of the pipeline, with each stage executing the tasks specified in - the ControlMessage configuration. - - ControlMessage is capable of carrying payload of the MessageMeta type. - """ - - def __init__(self, *arg, **kwargs): - super().__init__(*arg, **kwargs) diff --git a/tests/io/test_loader_registry.py b/tests/io/test_loader_registry.py index fd9a7c238f..198f4f7193 100644 --- a/tests/io/test_loader_registry.py +++ b/tests/io/test_loader_registry.py @@ -16,8 +16,10 @@ import cudf +# Morpheus.common is required to register pre-made loaders +import morpheus.common # noqa: F401 import morpheus.messages as messages -from morpheus._lib.messages import DataLoaderRegistry +from morpheus.messages import DataLoaderRegistry def test_loader_registry_contains(): @@ -38,7 +40,6 @@ def test_loader_registry_contains(): def test_loader_registry_register_loader(): - def test_loader(control_message: messages.ControlMessage, task: dict): task_properties = task['properties'] if ('files' not in task_properties): @@ -74,7 +75,6 @@ def test_loader(control_message: messages.ControlMessage, task: dict): def test_loader_registry_unregister_loader(): - def test_loader(control_message: messages.ControlMessage, task: dict): task_properties = task['properties'] if ('files' not in task_properties): From 1396c72ae58c9b58895c6f42925541e8ed9b1e9a Mon Sep 17 00:00:00 2001 From: Devin Robison Date: Wed, 29 Mar 2023 20:30:35 -0600 Subject: [PATCH 150/157] Add xfails to test_loader_registry tests to see if we get ci passing --- tests/io/test_loader_registry.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/io/test_loader_registry.py b/tests/io/test_loader_registry.py index 198f4f7193..c8a97ea0ef 100644 --- a/tests/io/test_loader_registry.py +++ b/tests/io/test_loader_registry.py @@ -15,6 +15,7 @@ # limitations under the License. import cudf +import pytest # Morpheus.common is required to register pre-made loaders import morpheus.common # noqa: F401 @@ -22,6 +23,7 @@ from morpheus.messages import DataLoaderRegistry +@pytest.xfail(reason="Currently failing in CI, but nowhere else") def test_loader_registry_contains(): assert (not DataLoaderRegistry.contains("not_a_loader")) @@ -39,6 +41,7 @@ def test_loader_registry_contains(): assert (DataLoaderRegistry.contains(loader)) +@pytest.xfail(reason="Currently failing in CI, but nowhere else") def test_loader_registry_register_loader(): def test_loader(control_message: messages.ControlMessage, task: dict): task_properties = task['properties'] @@ -74,6 +77,7 @@ def test_loader(control_message: messages.ControlMessage, task: dict): assert (True) +@pytest.xfail(reason="Currently failing in CI, but nowhere else") def test_loader_registry_unregister_loader(): def test_loader(control_message: messages.ControlMessage, task: dict): task_properties = task['properties'] From f0b0dbe04dd76dfa40e515458835f861e760205a Mon Sep 17 00:00:00 2001 From: David Gardner Date: Wed, 29 Mar 2023 21:14:11 -0700 Subject: [PATCH 151/157] Fix in-place python builds --- morpheus/_lib/cmake/python_modules/modules.cmake | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/morpheus/_lib/cmake/python_modules/modules.cmake b/morpheus/_lib/cmake/python_modules/modules.cmake index 4e2fbf8a43..cc5e931159 100644 --- a/morpheus/_lib/cmake/python_modules/modules.cmake +++ b/morpheus/_lib/cmake/python_modules/modules.cmake @@ -12,10 +12,10 @@ # the License. # ============================================================================= -morpheus_utils_add_pybind11_module( +morpheus_add_pybind11_module( modules INCLUDE_DIRS "${CMAKE_BINARY_DIR}/autogenerated/include" SOURCE_FILES "${MORPHEUS_LIB_ROOT}/src/python_modules/modules.cpp" -) \ No newline at end of file +) From 2f2c8f3df6a682a7599350b2ea36c09c8e980d92 Mon Sep 17 00:00:00 2001 From: David Gardner Date: Wed, 29 Mar 2023 21:15:25 -0700 Subject: [PATCH 152/157] Remove xfails --- tests/io/test_loader_registry.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/io/test_loader_registry.py b/tests/io/test_loader_registry.py index c8a97ea0ef..d15d3191c4 100644 --- a/tests/io/test_loader_registry.py +++ b/tests/io/test_loader_registry.py @@ -14,16 +14,16 @@ # See the License for the specific language governing permissions and # limitations under the License. -import cudf import pytest +import cudf + # Morpheus.common is required to register pre-made loaders import morpheus.common # noqa: F401 import morpheus.messages as messages from morpheus.messages import DataLoaderRegistry -@pytest.xfail(reason="Currently failing in CI, but nowhere else") def test_loader_registry_contains(): assert (not DataLoaderRegistry.contains("not_a_loader")) @@ -41,8 +41,8 @@ def test_loader_registry_contains(): assert (DataLoaderRegistry.contains(loader)) -@pytest.xfail(reason="Currently failing in CI, but nowhere else") def test_loader_registry_register_loader(): + def test_loader(control_message: messages.ControlMessage, task: dict): task_properties = task['properties'] if ('files' not in task_properties): @@ -77,8 +77,8 @@ def test_loader(control_message: messages.ControlMessage, task: dict): assert (True) -@pytest.xfail(reason="Currently failing in CI, but nowhere else") def test_loader_registry_unregister_loader(): + def test_loader(control_message: messages.ControlMessage, task: dict): task_properties = task['properties'] if ('files' not in task_properties): From bca5a9506f7a675d64af81ad565e8a152a112f3d Mon Sep 17 00:00:00 2001 From: David Gardner Date: Wed, 29 Mar 2023 21:25:49 -0700 Subject: [PATCH 153/157] Remove unused import --- tests/io/test_loader_registry.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/io/test_loader_registry.py b/tests/io/test_loader_registry.py index d15d3191c4..c964215252 100644 --- a/tests/io/test_loader_registry.py +++ b/tests/io/test_loader_registry.py @@ -14,8 +14,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -import pytest - import cudf # Morpheus.common is required to register pre-made loaders From d1657188f007bffc6213ae166e7eb61626017916 Mon Sep 17 00:00:00 2001 From: David Gardner Date: Wed, 29 Mar 2023 22:58:45 -0700 Subject: [PATCH 154/157] Skip known failing tests --- tests/io/test_loader_registry.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/io/test_loader_registry.py b/tests/io/test_loader_registry.py index c964215252..adedfe4546 100644 --- a/tests/io/test_loader_registry.py +++ b/tests/io/test_loader_registry.py @@ -14,6 +14,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +import pytest + import cudf # Morpheus.common is required to register pre-made loaders @@ -22,6 +24,7 @@ from morpheus.messages import DataLoaderRegistry +@pytest.mark.skip def test_loader_registry_contains(): assert (not DataLoaderRegistry.contains("not_a_loader")) @@ -39,6 +42,7 @@ def test_loader_registry_contains(): assert (DataLoaderRegistry.contains(loader)) +@pytest.mark.skip def test_loader_registry_register_loader(): def test_loader(control_message: messages.ControlMessage, task: dict): @@ -75,6 +79,7 @@ def test_loader(control_message: messages.ControlMessage, task: dict): assert (True) +@pytest.mark.skip def test_loader_registry_unregister_loader(): def test_loader(control_message: messages.ControlMessage, task: dict): From 455286ca556a94d410e7e93ab60b75e575b3fb4b Mon Sep 17 00:00:00 2001 From: Devin Robison Date: Thu, 30 Mar 2023 00:01:41 -0600 Subject: [PATCH 155/157] Xfail fix --- tests/io/test_loader_registry.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/tests/io/test_loader_registry.py b/tests/io/test_loader_registry.py index adedfe4546..f899a6124f 100644 --- a/tests/io/test_loader_registry.py +++ b/tests/io/test_loader_registry.py @@ -14,9 +14,8 @@ # See the License for the specific language governing permissions and # limitations under the License. -import pytest - import cudf +import pytest # noqa: F401 # Morpheus.common is required to register pre-made loaders import morpheus.common # noqa: F401 @@ -24,7 +23,7 @@ from morpheus.messages import DataLoaderRegistry -@pytest.mark.skip +@pytest.mark.xfail(reason="Currently failing in CI, but nowhere else") def test_loader_registry_contains(): assert (not DataLoaderRegistry.contains("not_a_loader")) @@ -42,9 +41,8 @@ def test_loader_registry_contains(): assert (DataLoaderRegistry.contains(loader)) -@pytest.mark.skip +@pytest.mark.xfail(reason="Currently failing in CI, but nowhere else") def test_loader_registry_register_loader(): - def test_loader(control_message: messages.ControlMessage, task: dict): task_properties = task['properties'] if ('files' not in task_properties): @@ -79,9 +77,8 @@ def test_loader(control_message: messages.ControlMessage, task: dict): assert (True) -@pytest.mark.skip +@pytest.mark.xfail(reason="Currently failing in CI, but nowhere else") def test_loader_registry_unregister_loader(): - def test_loader(control_message: messages.ControlMessage, task: dict): task_properties = task['properties'] if ('files' not in task_properties): From 47e40288ce2984df8040344d9d57e2731884b944 Mon Sep 17 00:00:00 2001 From: Devin Robison Date: Thu, 30 Mar 2023 02:37:04 -0600 Subject: [PATCH 156/157] isort fix --- tests/io/test_loader_registry.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/io/test_loader_registry.py b/tests/io/test_loader_registry.py index f899a6124f..4aec7cd7ff 100644 --- a/tests/io/test_loader_registry.py +++ b/tests/io/test_loader_registry.py @@ -14,9 +14,10 @@ # See the License for the specific language governing permissions and # limitations under the License. -import cudf import pytest # noqa: F401 +import cudf + # Morpheus.common is required to register pre-made loaders import morpheus.common # noqa: F401 import morpheus.messages as messages From 613530b7bd43bb168cd820d20b3101ffc7812871 Mon Sep 17 00:00:00 2001 From: Devin Robison Date: Thu, 30 Mar 2023 10:45:49 -0600 Subject: [PATCH 157/157] Fix missing symbols in release build --- morpheus/_lib/include/morpheus/io/data_loader_registry.hpp | 4 ++++ morpheus/_lib/include/morpheus/objects/factory_registry.hpp | 1 - morpheus/_lib/src/io/data_loader_registry.cpp | 2 +- tests/io/test_loader_registry.py | 5 ----- 4 files changed, 5 insertions(+), 7 deletions(-) diff --git a/morpheus/_lib/include/morpheus/io/data_loader_registry.hpp b/morpheus/_lib/include/morpheus/io/data_loader_registry.hpp index 2b06425256..57df5e3a81 100644 --- a/morpheus/_lib/include/morpheus/io/data_loader_registry.hpp +++ b/morpheus/_lib/include/morpheus/io/data_loader_registry.hpp @@ -31,6 +31,8 @@ namespace morpheus { #pragma GCC visibility push(default) +extern template class FactoryRegistry; + using LoaderRegistry = FactoryRegistry; // NOLINT struct LoaderRegistryProxy @@ -43,4 +45,6 @@ struct LoaderRegistryProxy static void register_factory_cleanup_fn(const std::string& name); }; + +#pragma GCC visibility pop } // namespace morpheus diff --git a/morpheus/_lib/include/morpheus/objects/factory_registry.hpp b/morpheus/_lib/include/morpheus/objects/factory_registry.hpp index 4f5b36c92e..b591237412 100644 --- a/morpheus/_lib/include/morpheus/objects/factory_registry.hpp +++ b/morpheus/_lib/include/morpheus/objects/factory_registry.hpp @@ -53,7 +53,6 @@ class FactoryRegistry return names; } - // TODO(Devin): Rename -- this isn't a constructor, its creating an instance static std::shared_ptr create_object_from_factory(const std::string& name, nlohmann::json config = {}) { diff --git a/morpheus/_lib/src/io/data_loader_registry.cpp b/morpheus/_lib/src/io/data_loader_registry.cpp index 62a3e5536c..3277404b6e 100644 --- a/morpheus/_lib/src/io/data_loader_registry.cpp +++ b/morpheus/_lib/src/io/data_loader_registry.cpp @@ -42,7 +42,7 @@ void LoaderRegistryProxy::register_proxy_factory_fn( proxy_constructor, bool throw_if_exists) { - FactoryRegistry::register_factory_fn( + LoaderRegistry::register_factory_fn( name, [proxy_constructor](nlohmann::json config) { return std::make_shared( diff --git a/tests/io/test_loader_registry.py b/tests/io/test_loader_registry.py index 4aec7cd7ff..198f4f7193 100644 --- a/tests/io/test_loader_registry.py +++ b/tests/io/test_loader_registry.py @@ -14,8 +14,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -import pytest # noqa: F401 - import cudf # Morpheus.common is required to register pre-made loaders @@ -24,7 +22,6 @@ from morpheus.messages import DataLoaderRegistry -@pytest.mark.xfail(reason="Currently failing in CI, but nowhere else") def test_loader_registry_contains(): assert (not DataLoaderRegistry.contains("not_a_loader")) @@ -42,7 +39,6 @@ def test_loader_registry_contains(): assert (DataLoaderRegistry.contains(loader)) -@pytest.mark.xfail(reason="Currently failing in CI, but nowhere else") def test_loader_registry_register_loader(): def test_loader(control_message: messages.ControlMessage, task: dict): task_properties = task['properties'] @@ -78,7 +74,6 @@ def test_loader(control_message: messages.ControlMessage, task: dict): assert (True) -@pytest.mark.xfail(reason="Currently failing in CI, but nowhere else") def test_loader_registry_unregister_loader(): def test_loader(control_message: messages.ControlMessage, task: dict): task_properties = task['properties']