From 6099d3e563aab75ca0aa7a4ba3f6c7a5b1919134 Mon Sep 17 00:00:00 2001 From: valmikGit Date: Wed, 1 Apr 2026 00:13:57 +0530 Subject: [PATCH 01/21] Error in regression_inference. --- rust_ml/regression_inference/Cargo.toml | 18 +++++++ rust_ml/regression_inference/src/inference.rs | 52 +++++++++++++++++++ rust_ml/regression_inference/src/main.rs | 49 +++++++++++++++++ rust_ml/regression_inference/src/state.rs | 33 ++++++++++++ 4 files changed, 152 insertions(+) create mode 100644 rust_ml/regression_inference/Cargo.toml create mode 100644 rust_ml/regression_inference/src/inference.rs create mode 100644 rust_ml/regression_inference/src/main.rs create mode 100644 rust_ml/regression_inference/src/state.rs diff --git a/rust_ml/regression_inference/Cargo.toml b/rust_ml/regression_inference/Cargo.toml new file mode 100644 index 0000000..0c229aa --- /dev/null +++ b/rust_ml/regression_inference/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "regression_inference" +version = "0.1.0" +edition = "2024" + +[dependencies] +regression = { path = "../regression" } +burn = { version = "~0.20", features = ["std", "ndarray"], default-features = false } +axum = "0.8" +tokio = { version = "1", features = ["full"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +tower-http = { version = "0.6", features = ["cors", "trace"] } + +[lints] +workspace = true diff --git a/rust_ml/regression_inference/src/inference.rs b/rust_ml/regression_inference/src/inference.rs new file mode 100644 index 0000000..4421427 --- /dev/null +++ b/rust_ml/regression_inference/src/inference.rs @@ -0,0 +1,52 @@ +use axum::{ + extract::State, + http::StatusCode, + Json, +}; +use burn::data::dataloader::batcher::Batcher; +use burn::tensor::Data; +use regression::dataset::{HousingBatcher, HousingDistrictItem}; +use serde::{Deserialize, Serialize}; + +use crate::state::AppState; + +#[derive(Serialize)] +pub struct PredictResponse { + pub predicted_median_house_value: f32, +} + +#[derive(Serialize)] +pub struct ErrorResponse { + pub error: String, +} + +pub async fn predict_handler( + State(state): State, + Json(payload): Json, +) -> Result, (StatusCode, Json)> { + let device = Default::default(); + + // Create batcher + let batcher = HousingBatcher::new(device.clone()); + + // Process item + // Note: HousingBatcher::batch transforms a Vec into a HousingBatch + let batch = batcher.batch(vec![payload], &device); + + // Perform forward pass inference + let output = state.model.lock().unwrap().forward(batch.inputs); + + // Extract single result + let predicted_tensors = output.squeeze_dim::<1>(1).into_data(); + + // Assuming `into_data()` gives us Burn's generic `Data`, we extract f32 value. + // Since we batched a single item, it should be the first entry + let predicted_value = predicted_tensors + .iter::() + .next() + .unwrap_or(0.0); + + Ok(Json(PredictResponse { + predicted_median_house_value: predicted_value, + })) +} diff --git a/rust_ml/regression_inference/src/main.rs b/rust_ml/regression_inference/src/main.rs new file mode 100644 index 0000000..df39a1f --- /dev/null +++ b/rust_ml/regression_inference/src/main.rs @@ -0,0 +1,49 @@ +mod inference; +mod state; + +use axum::{ + routing::{get, post}, + Router, +}; +use std::net::SocketAddr; +use tower_http::cors::CorsLayer; +use tower_http::trace::TraceLayer; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; + +use crate::inference::predict_handler; +use crate::state::AppState; + +#[tokio::main] +async fn main() { + // Initialize tracing + tracing_subscriber::registry() + .with(tracing_subscriber::EnvFilter::new( + std::env::var("RUST_LOG").unwrap_or_else(|_| "info".into()), + )) + .with(tracing_subscriber::fmt::layer()) + .init(); + + // Load Model State + // In Docker, it'll mount to /app/model. mpk is the default extension from burn's NoStdTrainingRecorder + let model_path = std::env::var("MODEL_PATH") + .unwrap_or_else(|_| -> String { "./model".to_string() }); + + // Let the AppState construct the pre-loaded memory model + let state = AppState::new(&model_path); + + // Build Axum Router + let app = Router::new() + .route("/health", get(|| async { "OK" })) + .route("/predict", post(predict_handler)) + .layer(CorsLayer::permissive()) + .layer(TraceLayer::new_for_http()) + .with_state(state); + + // Run Server + let port = 9060; + let addr = SocketAddr::from(([0, 0, 0, 0], port)); + tracing::info!("Server listening on http://{}", addr); + + let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); + axum::serve(listener, app).await.unwrap(); +} diff --git a/rust_ml/regression_inference/src/state.rs b/rust_ml/regression_inference/src/state.rs new file mode 100644 index 0000000..142b779 --- /dev/null +++ b/rust_ml/regression_inference/src/state.rs @@ -0,0 +1,33 @@ +use burn::{ + backend::NdArray, + module::Module, + record::{NoStdTrainingRecorder, Recorder}, +}; +use regression::model::{RegressionModel, RegressionModelConfig, RegressionModelRecord}; +use std::sync::{Arc, Mutex}; + +pub type Backend = NdArray; + +#[derive(Clone)] +pub struct AppState { + pub model: Arc>>, +} + +impl AppState { + pub fn new(model_path: &str) -> Self { + let device = Default::default(); + + // Load model configuration + let record: RegressionModelRecord = NoStdTrainingRecorder::new() + .load(model_path.into(), &device) + .expect("Failed to load model record. Ensure the model is trained."); + + let model = RegressionModelConfig::new() + .init(&device) + .load_record(record); + + Self { + model: Arc::new(Mutex::new(model)), + } + } +} From 62814fdae74074558ac6512d32cc90c77865e028 Mon Sep 17 00:00:00 2001 From: valmikGit Date: Wed, 1 Apr 2026 00:15:39 +0530 Subject: [PATCH 02/21] Error in regression_inference. --- rust_ml/Cargo.toml | 2 +- rust_ml/Dockerfile.regression_inf | 32 +++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 rust_ml/Dockerfile.regression_inf diff --git a/rust_ml/Cargo.toml b/rust_ml/Cargo.toml index a24c637..173e579 100644 --- a/rust_ml/Cargo.toml +++ b/rust_ml/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "3" -members = ["lstm_train","mnist_infer","mnist_ml", "regression", "text_classification_infer", "text_classification_news", "text_gen_train"] +members = ["lstm_train","mnist_infer","mnist_ml", "regression", "regression_inference", "text_classification_infer", "text_classification_news", "text_gen_train"] [workspace.lints.clippy] all = "warn" diff --git a/rust_ml/Dockerfile.regression_inf b/rust_ml/Dockerfile.regression_inf new file mode 100644 index 0000000..edc8353 --- /dev/null +++ b/rust_ml/Dockerfile.regression_inf @@ -0,0 +1,32 @@ +# Build Stage +FROM rust:1.92-alpine as builder + +WORKDIR /app +WORKDIR /app/rust_ml + +# Copy workspace +# This assumes the build context is the `rust_ml` root directory. +COPY . /app/rust_ml/ + +# Build the release binary for regression_inference +RUN cargo build --release -p regression_inference + +# Runtime Stage +FROM alpine:3.23 + +# Install ca-certificates and openssl in case axum needs them +RUN apk add --no-cache openssl-dev ca-certificates + +WORKDIR /app + +# Copy compiled binary from builder +COPY --from=builder /app/rust_ml/target/release/regression_inference /app/regression_inference + +# Environment variables +ENV RUST_LOG=info +# Setup the default mounted model path +ENV MODEL_PATH=/app/model/regression_rust/model + +EXPOSE 9060 + +CMD ["./regression_inference"] From 8868302ca4b1e4429c352bd0f0f301d4bfcc42a1 Mon Sep 17 00:00:00 2001 From: valmikGit Date: Wed, 1 Apr 2026 00:47:05 +0530 Subject: [PATCH 03/21] Error resolved in regression inference. --- rust_ml/regression_inference/src/inference.rs | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/rust_ml/regression_inference/src/inference.rs b/rust_ml/regression_inference/src/inference.rs index 4421427..37f985a 100644 --- a/rust_ml/regression_inference/src/inference.rs +++ b/rust_ml/regression_inference/src/inference.rs @@ -4,11 +4,10 @@ use axum::{ Json, }; use burn::data::dataloader::batcher::Batcher; -use burn::tensor::Data; use regression::dataset::{HousingBatcher, HousingDistrictItem}; -use serde::{Deserialize, Serialize}; +use serde::Serialize; -use crate::state::AppState; +use crate::state::{AppState, Backend}; #[derive(Serialize)] pub struct PredictResponse { @@ -24,10 +23,10 @@ pub async fn predict_handler( State(state): State, Json(payload): Json, ) -> Result, (StatusCode, Json)> { - let device = Default::default(); + let device: ::Device = Default::default(); - // Create batcher - let batcher = HousingBatcher::new(device.clone()); + // Create batcher mapped to backend + let batcher = HousingBatcher::::new(device.clone()); // Process item // Note: HousingBatcher::batch transforms a Vec into a HousingBatch From e4612d6792036d82312bc120e4c399ae05f36d67 Mon Sep 17 00:00:00 2001 From: valmikGit Date: Wed, 1 Apr 2026 01:05:17 +0530 Subject: [PATCH 04/21] Errors resolved for lstm_inference. --- rust_ml/Cargo.toml | 2 +- rust_ml/Dockerfile.lstm_inf | 31 +++++++++++++++ rust_ml/lstm_inference/Cargo.toml | 18 +++++++++ rust_ml/lstm_inference/src/inference.rs | 50 +++++++++++++++++++++++++ rust_ml/lstm_inference/src/main.rs | 48 ++++++++++++++++++++++++ rust_ml/lstm_inference/src/state.rs | 42 +++++++++++++++++++++ 6 files changed, 190 insertions(+), 1 deletion(-) create mode 100644 rust_ml/Dockerfile.lstm_inf create mode 100644 rust_ml/lstm_inference/Cargo.toml create mode 100644 rust_ml/lstm_inference/src/inference.rs create mode 100644 rust_ml/lstm_inference/src/main.rs create mode 100644 rust_ml/lstm_inference/src/state.rs diff --git a/rust_ml/Cargo.toml b/rust_ml/Cargo.toml index 173e579..ea509eb 100644 --- a/rust_ml/Cargo.toml +++ b/rust_ml/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "3" -members = ["lstm_train","mnist_infer","mnist_ml", "regression", "regression_inference", "text_classification_infer", "text_classification_news", "text_gen_train"] +members = ["lstm_inference","lstm_train","mnist_infer","mnist_ml", "regression", "regression_inference", "text_classification_infer", "text_classification_news", "text_gen_train"] [workspace.lints.clippy] all = "warn" diff --git a/rust_ml/Dockerfile.lstm_inf b/rust_ml/Dockerfile.lstm_inf new file mode 100644 index 0000000..a70786b --- /dev/null +++ b/rust_ml/Dockerfile.lstm_inf @@ -0,0 +1,31 @@ +# Build Stage +FROM rust:1.92-alpine as builder + +WORKDIR /app +WORKDIR /app/rust_ml + +# Copy workspace +COPY . /app/rust_ml/ + +# Build the release binary for lstm_inference +RUN cargo build --release -p lstm_inference + +# Runtime Stage +FROM alpine:3.23 + +# Install ca-certificates and openssl +RUN apk add --no-cache openssl-dev ca-certificates + +WORKDIR /app + +# Copy compiled binary from builder +COPY --from=builder /app/rust_ml/target/release/lstm_inference /app/lstm_inference + +# Environment variables +ENV RUST_LOG=info +# Setup the default mounted model path +ENV MODEL_DIR=/app/model/lstm_train_rust + +EXPOSE 9070 + +CMD ["./lstm_inference"] diff --git a/rust_ml/lstm_inference/Cargo.toml b/rust_ml/lstm_inference/Cargo.toml new file mode 100644 index 0000000..b3f5e73 --- /dev/null +++ b/rust_ml/lstm_inference/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "lstm_inference" +version = "0.1.0" +edition = "2024" + +[dependencies] +lstm_train = { path = "../lstm_train" } +burn = { version = "~0.20", features = ["std", "ndarray"], default-features = false } +axum = "0.8" +tokio = { version = "1", features = ["full"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +tower-http = { version = "0.6", features = ["cors", "trace"] } + +[lints] +workspace = true diff --git a/rust_ml/lstm_inference/src/inference.rs b/rust_ml/lstm_inference/src/inference.rs new file mode 100644 index 0000000..fdd857d --- /dev/null +++ b/rust_ml/lstm_inference/src/inference.rs @@ -0,0 +1,50 @@ +use axum::{ + extract::State, + http::StatusCode, + Json, +}; +use burn::data::dataloader::batcher::Batcher; +use lstm_train::dataset::{SequenceBatcher, SequenceDatasetItem}; +use serde::Serialize; + +use crate::state::{AppState, Backend}; + +#[derive(Serialize)] +pub struct PredictResponse { + pub predicted_next_value: f32, +} + +#[derive(Serialize)] +pub struct ErrorResponse { + pub error: String, +} + +pub async fn predict_handler( + State(state): State, + Json(payload): Json, +) -> Result, (StatusCode, Json)> { + let device: ::Device = Default::default(); + + // Create batcher mapped to backend + let batcher = SequenceBatcher::default(); + + // Process item into batched tensors + let batch = batcher.batch(vec![payload], &device); + + // Perform forward pass inference + let output = state.model.lock().unwrap().forward(batch.sequences, None); + + // Extract single result + let predicted_tensors = output.squeeze_dim::<1>(1).into_data(); + + let predicted_value = predicted_tensors + .as_slice::() + .unwrap_or(&[]) + .first() + .copied() + .unwrap_or(0.0); + + Ok(Json(PredictResponse { + predicted_next_value: predicted_value, + })) +} diff --git a/rust_ml/lstm_inference/src/main.rs b/rust_ml/lstm_inference/src/main.rs new file mode 100644 index 0000000..1850600 --- /dev/null +++ b/rust_ml/lstm_inference/src/main.rs @@ -0,0 +1,48 @@ +mod inference; +mod state; + +use axum::{ + routing::{get, post}, + Router, +}; +use std::net::SocketAddr; +use tower_http::cors::CorsLayer; +use tower_http::trace::TraceLayer; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; + +use crate::inference::predict_handler; +use crate::state::AppState; + +#[tokio::main] +async fn main() { + // Initialize tracing + tracing_subscriber::registry() + .with(tracing_subscriber::EnvFilter::new( + std::env::var("RUST_LOG").unwrap_or_else(|_| "info".into()), + )) + .with(tracing_subscriber::fmt::layer()) + .init(); + + // Load Model State + let model_dir = std::env::var("MODEL_DIR") + .unwrap_or_else(|_| -> String { "./model".to_string() }); + + // Let the AppState construct the pre-loaded memory model + let state = AppState::new(&model_dir); + + // Build Axum Router + let app = Router::new() + .route("/health", get(|| async { "OK" })) + .route("/predict", post(predict_handler)) + .layer(CorsLayer::permissive()) + .layer(TraceLayer::new_for_http()) + .with_state(state); + + // Run Server + let port = 9070; + let addr = SocketAddr::from(([0, 0, 0, 0], port)); + tracing::info!("Server listening on http://{}", addr); + + let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); + axum::serve(listener, app).await.unwrap(); +} diff --git a/rust_ml/lstm_inference/src/state.rs b/rust_ml/lstm_inference/src/state.rs new file mode 100644 index 0000000..15d9fca --- /dev/null +++ b/rust_ml/lstm_inference/src/state.rs @@ -0,0 +1,42 @@ +use burn::{ + backend::NdArray, + module::Module, + prelude::Config, + record::{CompactRecorder, Recorder}, +}; +use lstm_train::{ + model::LstmNetwork, + training::TrainingConfig, +}; +use std::sync::{Arc, Mutex}; + +pub type Backend = NdArray; + +#[derive(Clone)] +pub struct AppState { + pub model: Arc>>, +} + +impl AppState { + pub fn new(model_dir: &str) -> Self { + let device = Default::default(); + + let config_path = format!("{}/config.json", model_dir); + let model_path = format!("{}/model", model_dir); + + // Load training configuration + let config = TrainingConfig::load(&config_path) + .expect("Config should exist for the model; run train first"); + + // Load model configuration and initialized layers + let record = CompactRecorder::new() + .load(model_path.into(), &device) + .expect("Trained model should exist; run train first"); + + let model: LstmNetwork = config.model.init(&device).load_record(record); + + Self { + model: Arc::new(Mutex::new(model)), + } + } +} From fd6a10a1ec27de0b405edb2ce8fcd28509b58ff9 Mon Sep 17 00:00:00 2001 From: valmikGit Date: Wed, 1 Apr 2026 12:04:35 +0530 Subject: [PATCH 05/21] Added all latex reports. --- latex_reports/lstm.tex | 104 +++++++ latex_reports/main.tex | 227 ++++++++++++++ latex_reports/mnist.tex | 292 +++++++++++++++++ latex_reports/regression.tex | 104 +++++++ latex_reports/text_classification_news.tex | 346 +++++++++++++++++++++ 5 files changed, 1073 insertions(+) create mode 100644 latex_reports/lstm.tex create mode 100644 latex_reports/main.tex create mode 100644 latex_reports/mnist.tex create mode 100644 latex_reports/regression.tex create mode 100644 latex_reports/text_classification_news.tex diff --git a/latex_reports/lstm.tex b/latex_reports/lstm.tex new file mode 100644 index 0000000..029ce51 --- /dev/null +++ b/latex_reports/lstm.tex @@ -0,0 +1,104 @@ +\documentclass[12pt]{article} +\usepackage{amsmath} +\usepackage{amssymb} +\usepackage{graphicx} +\usepackage{hyperref} +\usepackage{minted} +\usepackage{geometry} +\geometry{a4paper, margin=1in} + +\title{\textbf{Comparative Analysis of LSTM Implementation: Rust (Burn) vs. PyTorch}} +\author{Technical Report} +\date{\today} + +\begin{document} + +\maketitle +\tableofcontents +\newpage + +\section{Introduction} +This document outlines the detailed architectural, mathematical, and translational specifics of implementing a Long Short-Term Memory (LSTM) model across two prominent machine learning environments: Rust (using the Burn framework) and Python (using PyTorch). It covers the model architecture, training pipelines, specialized deployment techniques using network filesystems (NFS) with Docker, and language-specific design implications. + +\section{Model Architecture and Mathematical Formulation} + +\subsection{Mathematical Foundation of the LSTM Cell} +The core of the model revolves around a custom, manually-implemented LSTM cell. Instead of relying on the standard un-inspectable black-box LSTM implementations provided by typical ML libraries, both codebases explicitly define the cell-level math. + +For a given timestep $t$, the input tensor $x_t$ and the previous hidden state $h_{t-1}$ are used to compute the various gates. The mathematical formulation utilized is: +\begin{align} + f_t &= \sigma(W_f \cdot [h_{t-1}, x_t] + b_f) \quad &\text{(Forget Gate)} \\ + i_t &= \sigma(W_i \cdot [h_{t-1}, x_t] + b_i) \quad &\text{(Input Gate)} \\ + g_t &= \tanh(W_g \cdot [h_{t-1}, x_t] + b_g) \quad &\text{(Candidate State)} \\ + o_t &= \sigma(W_o \cdot [h_{t-1}, x_t] + b_o) \quad &\text{(Output Gate)} +\end{align} +\begin{align} + c_t &= f_t \odot c_{t-1} + i_t \odot g_t \quad &\text{(New Cell State)} \\ + h_t &= o_t \odot \tanh(c_t) \quad &\text{(New Hidden State)} +\end{align} + +Where: +\begin{itemize} + \item $\sigma$ represents the Sigmoid activation function. + \item $\tanh$ represents the Hyperbolic Tangent activation function. + \item $\odot$ denotes element-wise multiplication (Hadamard product). + \item $[h_{t-1}, x_t]$ symbolizes the concatenation of the previous hidden state and the current input. +\end{itemize} + +\subsection{Architectural Details} +Both implementations adhere strictly to the following architectural design: +\begin{enumerate} + \item \textbf{Layer Normalization:} Pre-activation gates, the cell state ($c_t$), and the hidden state ($h_t$) pass through separate \texttt{LayerNorm} layers. This design choice stabilizes training dynamics since the feature distributions inside the LSTM evolve at every sequence step (making standard Batch Normalization ineffective). + \item \textbf{Optimized Gate Compute:} Instead of computing 4 separate linear transformations per timestep for the features, the model employs a single combined projection that outputs a $4 \times \text{hidden\_size}$ tensor. This tensor is subsequently split into four chunks corresponding to the $i, f, g$, and $o$ gates. + \item \textbf{Bidirectional Support:} An encapsulated \texttt{StackedLstm} module stacks multiple manual LSTM layers (applying dropout between layers except on the final one). The main \texttt{LstmNetwork} integrates a forward processing stack and an optional backward processing stack (which flips the temporal dimension of the input sequence). Their respective output hidden states are concatenated along the feature dimension before passing through a fully-connected projection head. + \item \textbf{Initialization bias:} The forget-gate bias parameters are explicitly initialized to $1.0$ (via Xavier Normal parameter slicing) to prevent fatal early-training gradient decay. +\end{enumerate} + + +\section{Training Pipeline} +The training behavior is intentionally synchronized to ensure parity between the languages: +\begin{itemize} + \item \textbf{Data Loading:} Operates synchronously on synthetically generated noisy sequential datasets. The validation set is scaled symmetrically relative to the training set ($20\%$ of training size). + \item \textbf{Optimization Algorithm:} Utilizes the Adam Optimizer. + \item \textbf{Loss Function:} Mean Squared Error (MSE), with reduction set to \textit{mean}. Both explicitly weigh loss accumulation during epoch passes by scaling local batch losses by the discrete batch size, averaging properly at the conclusion of the epoch. + \item \textbf{Gradient Clipping:} Ensures numerical stability on longer sequence inputs. The gradient norm is strictly clipped to $\max = 1.0$ right before the optimizer steps. + \item \textbf{Artifacts Output:} Training scripts generate an \texttt{artifact\_dir} where they store a \texttt{config.json} representation of hyperparameters, and the full state dictionary (\texttt{model.pt} in PyTorch; CompactRecorder files in Rust Burn). +\end{itemize} + +\section{Inference Pipeline and Docker NFS Integration} +\subsection{PyTorch Inference Architecture} +A critical requirement for modern PyTorch inference deployments is resolving the massive disk footprint of CUDA-enabled PyTorch backend libraries. The PyTorch pipeline employs a sophisticated Network File System (NFS) logic to achieve a highly optimized, lightweight Dockerized inference deployment: +\begin{enumerate} + \item \textbf{External Library Mounting:} A host-level script (\texttt{mount\_libs.sh}) maps an external NAS/NFS storage partition (from \texttt{172.16.203.14}) loaded with Python environments targeting \texttt{/mnt/LSTM-libs}. + \item \textbf{Optimized Dockerfile:} The image leverages the \texttt{nvidia/cuda:12.1.1-cudnn8-runtime-ubuntu22.04} base image and installs basic \texttt{python3.11} runtime headers without calling \texttt{pip install torch}. Thus, the final image size is structurally negligible compared to standard ml-images. + \item \textbf{Runtime Binding:} The inference container bootloader scripts (\texttt{run\_container.sh}) bind these volume mounts (\texttt{-v \$NFS\_MOUNT\_POINT:/external-libs}) and crucially overrides the \texttt{PYTHONPATH} env-variable: + \begin{verbatim} + -e PYTHONPATH="$CONTAINER_LIB_MOUNT/LSTM_env/lib/.../site-packages" + \end{verbatim} + \item \textbf{Inference Execution:} \texttt{app.py} loads the model weights off an abstracted configuration path, builds a zero-gradient loader, runs inference iteratively over a single collapsed batch, and yields predictions natively. +\end{enumerate} + +\subsection{Rust Inference Architecture} +Rust's inference pipeline diverges significantly regarding deployment complexity due to compilation structures: +\begin{itemize} + \item \textbf{Stateless Binaries:} No containerized runtime libraries are mandated because Burn compiles statically down to heavily optimized binaries, pulling model states directly via the \texttt{CompactRecorder}. + \item \textbf{Visualization:} Results are mapped into native Polars \texttt{DataFrame} objects (\texttt{df![]}) rendering lightweight native tables detailing \textit{expected targets} versus \textit{computed predictions}. +\end{itemize} + +\section{Implementation Specifics} +\subsection{PyTorch Specific Constraints} +\begin{itemize} + \item \textbf{Dynamic computation graphing:} The \texttt{model.py} cleanly slices and chunks gates natively on tensors (e.g., \texttt{gates.chunk(4, dim=1)}). + \item \textbf{Sequence Reversals:} Done programmatically via continuous \texttt{Tensor.flip(dims=[1])} which mandates that tensors must remain contiguously stored within PyTorch internals to avoid memory reallocation overhead. + \item \textbf{Seed Setting API:} Requires deterministic locking across four sub-systems (\texttt{random, numpy, torch, torch.cuda}) to match Rust's reproducibility parameters. +\end{itemize} + +\subsection{Rust (Burn) Specific Constraints} +\begin{itemize} + \item \textbf{Compile-Time Dimension Types:} Rust explicitly binds Tensor dimensionality at compile time (\texttt{Tensor} vs \texttt{Tensor}). This offers un-matched safety by forbidding invalid dimension injections that PyTorch would crash on dynamically. + \item \textbf{Trait Encapsulation:} Leverages explicit trait architectures (\texttt{\#[derive(Module, Config)]}) that automate saving hyperparameters and generating gradient backends. Burn models must be mapped cleanly from standard states to \texttt{autodiff} states. + \item \textbf{No-Mutation Logic:} State mutations generated sequentially in LSTMs are represented safely utilizing explicit tuple destructuring via \texttt{LstmState\{hidden, cell\}}, bypassing complex internal pointer tracking. + \item \textbf{Explicit Initialization Handling:} Since Burn limits orthogonal initializes out-of-the-box, Xavier Normalization was invoked explicitly, paired with \texttt{slice\_assign} tensor mappings to safely load the 1.0 uniform fill into the forget-gate components. +\end{itemize} + +\end{document} diff --git a/latex_reports/main.tex b/latex_reports/main.tex new file mode 100644 index 0000000..fc3179c --- /dev/null +++ b/latex_reports/main.tex @@ -0,0 +1,227 @@ +\documentclass[11pt,a4paper]{article} + +\usepackage{geometry} +\geometry{margin=1in} + +\usepackage{amsmath,amssymb} +\usepackage{graphicx} +\usepackage{booktabs} +\usepackage{hyperref} +\usepackage{enumitem} +\usepackage{array} +\usepackage{float} + +\title{\textbf{Progress Report: System-Level Evaluation of Rust and Python for Machine Learning}} +\author{Project Elective} +\date{\today} + +\begin{document} + +\maketitle + +\section{Overview of the Project} + +This project studies the use of \textbf{Rust} as an alternative systems language for machine learning workflows traditionally implemented in \textbf{Python}. +Rather than focusing on state-of-the-art model performance, the emphasis is on: + +\begin{itemize} + \item feasibility of end-to-end ML workflows, + \item system stability and reproducibility, + \item developer experience and DevOps complexity, + \item deployment and operational characteristics. +\end{itemize} + +To ensure clarity and rigor, the work is organized into \textbf{two clearly separated experimental tracks}. + +--- + +\section{Project Structure: Two-Track Evaluation} + +The project consists of the following two tracks: + +\subsection*{Track 1: Training-Based Systems Evaluation} +This track compares \textbf{machine learning training pipelines} implemented in: +\begin{itemize} + \item PyTorch (Python), and + \item Burn (Rust). +\end{itemize} + +The goal is to evaluate training feasibility, stability, compile-time guarantees, and DevOps impact, rather than raw training speed. + +\subsection*{Track 2: Inference-Based DevOps Evaluation} +This track compares \textbf{production-style inference services} implemented in: +\begin{itemize} + \item Python-based ONNX inference, and + \item Rust-based ONNX inference. +\end{itemize} + +The focus is on deployment, security, containerization, CI/CD behavior, and runtime efficiency. + +Each track is designed to answer a distinct research question while remaining complementary. + +--- + +\section{Machine Learning Tasks Considered} + +To ensure coverage of diverse ML workloads, the following tasks are identified: + +\begin{itemize} + \item \textbf{Text Classification}: Dataset to be finalized. + \item \textbf{Image Classification}: MNIST dataset. + \item \textbf{Credit Score Assignment}: Supervised classification task. + \item \textbf{Multi-Objective Machine Learning}: Brain Tumor dataset with a MOML formulation. + \item \textbf{Fine-Tuning Task}: BERT-based classification (ANLP Assignment 1), with optional LoRA / QLoRA. + \item \textbf{Autoregressive Decoding}: Experiments using the Burn framework. +\end{itemize} + +At the current stage, the \textbf{MNIST image classification task has been fully implemented}. +The corresponding training code is available in the project GitHub repository. + +--- + +\section{Related Work} + +The following research papers are being used to guide experimental design and evaluation: + +\begin{itemize} + \item \url{https://ieeexplore.ieee.org/document/11126113} + \item \url{https://ieeexplore.ieee.org/document/11261485} + \item \url{https://ieeexplore.ieee.org/document/11212348} + \item \url{https://www.ijsred.com/volume8/issue2/IJSRED-V8I2P143.pdf} +\end{itemize} + +--- + +\section{Code Repository and Current Status} + +Project repository: +\begin{center} +\url{https://github.com/Abhinav-Kumar012/Rust_Python_ML_PE.git} +\end{center} + +Current progress includes: +\begin{itemize} + \item MNIST training pipeline implemented + \item PyTorch baseline established + \item Initial Rust (Burn) training setup completed +\end{itemize} + +--- + +\section{Track 1: Training-Based Systems Evaluation} + +\subsection{Objective} + +The objective of this track is to answer the following research question: + +\begin{quote} +\textit{Can Rust realistically support end-to-end machine learning training pipelines, and what system-level trade-offs does this introduce compared to PyTorch?} +\end{quote} + +This track explicitly avoids speed-centric benchmarking and instead focuses on system behavior. + +--- + +\subsection{Frameworks Compared} + +\subsubsection{PyTorch (Baseline)} +\begin{itemize} + \item Language: Python + \item Training maturity: Very high + \item Ecosystem: Extensive +\end{itemize} + +\subsubsection{Rust (Burn)} +\begin{itemize} + \item Language: Rust + \item Training maturity: Emerging + \item Design: Idiomatic Rust, native training support +\end{itemize} + +--- + +\subsection{Experimental Controls} + +\textbf{Fixed Across Both Implementations} +\begin{itemize} + \item Dataset splits + \item Number of epochs + \item Batch size + \item Optimizer type + \item Learning rate + \item Hardware +\end{itemize} + +\textbf{Allowed Differences} +\begin{itemize} + \item Internal kernel implementations + \item Graph execution model + \item Memory management +\end{itemize} + +--- + +\subsection{Metrics Collected} + +\begin{itemize} + \item Training time per epoch (reported cautiously) + \item Loss curves and convergence behavior + \item Runtime failures and numerical stability + \item Reproducibility across runs + \item Environment setup and build complexity + \item Dependency footprint and artifact size +\end{itemize} + +--- + +\section{Track 2: Inference-Based DevOps Evaluation} + +\subsection{Objective} + +The objective of this track is to compare \textbf{deployment, security, and operational characteristics} of Python-based and Rust-based ML inference services executing the same ONNX model. + +--- + +\subsection{Inference Services Compared} + +\textbf{Python Service} +\begin{itemize} + \item FastAPI + Uvicorn + \item ONNX Runtime (Python) +\end{itemize} + +\textbf{Rust Service} +\begin{itemize} + \item Axum / Actix + \item ONNX Runtime (Rust bindings) +\end{itemize} + +Both services expose identical inference endpoints and return identical outputs. + +--- + +\subsection{Evaluation Dimensions} + +\begin{itemize} + \item CI/CD build behavior + \item Container image size and layering + \item Cold-start latency + \item Inference latency and throughput + \item Resource utilization + \item Security and supply-chain surface +\end{itemize} + +--- + +\section{Upcoming Work} + +The following tasks are planned for the next phase of the project: +s +\begin{itemize} + \item Develop production-style inference services for both Python and Rust. + \item Write Dockerfiles for Python and Rust inference services. + \item Set up Jenkins-based CI pipelines for inference, including build, test, containerization, and security scanning. +\end{itemize} + + +\end{document} diff --git a/latex_reports/mnist.tex b/latex_reports/mnist.tex new file mode 100644 index 0000000..678a68d --- /dev/null +++ b/latex_reports/mnist.tex @@ -0,0 +1,292 @@ +\documentclass[11pt,a4paper]{article} + +\usepackage{geometry} +\geometry{margin=1in} + +\usepackage{amsmath,amssymb} +\usepackage{graphicx} +\usepackage{booktabs} +\usepackage{hyperref} +\usepackage{enumitem} +\usepackage{array} +\usepackage{float} +\usepackage{listings} +\usepackage{xcolor} + +\lstdefinelanguage{Dockerfile}{ + keywords={FROM, RUN, CMD, LABEL, MAINTAINER, EXPOSE, ENV, ADD, COPY, ENTRYPOINT, VOLUME, USER, WORKDIR, ARG, ONBUILD, STOPSIGNAL, HEALTHCHECK, SHELL}, + sensitive=true, + comment=[l]{\#}, + morestring=[b]", +} + +\title{\textbf{MNIST}} +\date{\today} + +\begin{document} + +\maketitle + +\section{Architecture Details} + +\begin{table}[h!] +\centering +\renewcommand{\arraystretch}{1.3} +\begin{tabular}{|c|l|l|l|l|} +\hline +\textbf{Step} & \textbf{Layer} & \textbf{Configuration} & \textbf{Input Shape} & \textbf{Output Shape} \\ +\hline + +1 & Input & Grayscale Images & +$[B, H, W]$ & +$[B, H, W]$ \\ + +\hline +2 & Reshape & Add channel dimension & +$[B, H, W]$ & +$[B, 1, H, W]$ \\ + +\hline +3 & Conv2D (conv1) & +$1 \rightarrow 8$, kernel $3 \times 3$ & +$[B, 1, H, W]$ & +$[B, 8, H-2, W-2]$ \\ + +\hline +4 & Dropout & +$p = 0.5$ & +$[B, 8, H-2, W-2]$ & +$[B, 8, H-2, W-2]$ \\ + +\hline +5 & Conv2D (conv2) & +$8 \rightarrow 16$, kernel $3 \times 3$ & +$[B, 8, H-2, W-2]$ & +$[B, 16, H-4, W-4]$ \\ + +\hline +6 & Dropout & +$p = 0.5$ & +$[B, 16, H-4, W-4]$ & +$[B, 16, H-4, W-4]$ \\ + +\hline +7 & ReLU & +Activation & +$[B, 16, H-4, W-4]$ & +$[B, 16, H-4, W-4]$ \\ + +\hline +8 & Adaptive Avg Pool & +Output size $8 \times 8$ & +$[B, 16, H-4, W-4]$ & +$[B, 16, 8, 8]$ \\ + +\hline +9 & Flatten & +$16 \times 8 \times 8$ & +$[B, 16, 8, 8]$ & +$[B, 1024]$ \\ + +\hline +10 & Linear (fc1) & +$1024 \rightarrow \texttt{hidden\_size}$ & +$[B, 1024]$ & +$[B, \texttt{hidden\_size}]$ \\ + +\hline +11 & Dropout & +$p = 0.5$ & +$[B, \texttt{hidden\_size}]$ & +$[B, \texttt{hidden\_size}]$ \\ + +\hline +12 & ReLU & +Activation & +$[B, \texttt{hidden\_size}]$ & +$[B, \texttt{hidden\_size}]$ \\ + +\hline +13 & Linear (fc2) & +$\texttt{hidden\_size} \rightarrow \texttt{num\_classes}$ & +$[B, \texttt{hidden\_size}]$ & +$[B, \texttt{num\_classes}]$ \\ + +\hline +\end{tabular} +\caption{Detailed architecture of the convolutional neural network implemented in Burn. +$B$ denotes batch size, $H$ and $W$ denote input image height and width respectively.} +\label{tab:burn-cnn-architecture} +\end{table} + +\noindent\textbf{Notes:} +\begin{itemize} + \item All convolution layers use default stride = 1 and no padding. + \item Dropout probability is configurable via \texttt{ModelConfig.dropout}. + \item Adaptive average pooling ensures a fixed spatial resolution regardless of input size. + \item The model is fully differentiable and backend-agnostic via Burn's \texttt{Backend} trait. +\end{itemize} + +\section{Rust Dockerfile Analysis: \texttt{Dockerfile.mnist\_inf}} + +The \texttt{Dockerfile.mnist\_inf} file defines the containerization process for the Rust-based MNIST inference service. It utilizes a \textbf{multi-stage build strategy} to optimize the final image size and security by separating the build environment from the runtime environment. + +\subsection{Multi-Stage Build Optimization} +The Dockerfile is divided into two distinct stages: +\begin{enumerate} + \item \textbf{Builder Stage:} A heavier image containing the full Rust toolchain (compiler, cargo, libraries) used to compile the source code. + \item \textbf{Runtime Stage:} A minimal Alpine Linux image containing only the compiled binary and necessary system libraries. +\end{enumerate} + +\textbf{Benefit:} This approach ensures that the final production image does not contain unnecessary build artifacts (like the \texttt{target/} directory, source code, or the Rust compiler itself), resulting in a significantly smaller and more secure container. + +\subsection{Step-by-Step Explanation} + +\subsubsection{Stage 1: The Builder} + +\begin{itemize} + \item \texttt{FROM rust:1.92-alpine as builder} \\ + Initializes the build stage using the official Rust 1.92 image based on Alpine Linux. The \texttt{as builder} alias allows us to refer to this stage later. Alpine is chosen for its lightweight nature. + + \item \texttt{WORKDIR /app/rust\_ml} \\ + Sets the working directory inside the container to \texttt{/app/rust\_ml}, ensuring all subsequent commands execute in this context. + + \item \texttt{COPY . /app/rust\_ml/} \\ + Copies the entire build context (source code) from the host machine into the container's working directory. This includes the \texttt{rust\_ml} workspace and likely the root context required for build resolution. + + \item \texttt{RUN cargo build --release -p mnist\_infer} \\ + Executes the compilation command. + \begin{itemize} + \item \texttt{--release}: Compiles with optimizations enabled for maximum performance. + \item \texttt{-p mnist\_infer}: specifically targets the \texttt{mnist\_infer} package within the workspace. + \end{itemize} +\end{itemize} + +\subsubsection{Stage 2: The Runtime} + +\begin{itemize} + \item \texttt{FROM alpine:3.23} \\ + Starts a fresh, clean layer using Alpine Linux 3.23. This is a minimal distribution (approx. 5MB) ideal for production containers. + + \item \texttt{RUN apk add --no-cache openssl-dev ca-certificates} \\ + Installs necessary runtime dependencies: + \begin{itemize} + \item \texttt{openssl-dev}: Required for cryptographic operations (often needed by web frameworks like Axum or Tokio). + \item \texttt{ca-certificates}: Ensures the container can verify SSL/TLS certificates when making outbound HTTPS requests. + \item \texttt{--no-cache}: Prevents caching the package index locally, keeping the image size small. + \end{itemize} + + \item \texttt{WORKDIR /app} \\ + Sets the working directory for the runtime container to \texttt{/app}. + + \item \texttt{COPY --from=builder /app/rust\_ml/target/release/mnist\_infer /app/mnist\_infer} \\ + \textbf{Crucial Step:} Copies \textit{only} the compiled executable from the \texttt{builder} stage into the runtime image. The bulky source code and build tools are left behind. + + \item \texttt{COPY ./model/mnist\_rust/model.mpk /app/model/mnist\_rust/model.mpk} \\ + Copies the pre-trained model file (\texttt{model.mpk}) from the host's file system into the container. This ensures the inference service has the model artifact available locally. + + \item \texttt{ENV RUST\_LOG=info} \\ + Sets the logging level to \texttt{info}, controlling the verbosity of the application's output. + + \item \texttt{ENV MODEL\_PATH=/app/model/mnist\_rust/model.mpk} \\ + Defines an environment variable pointing to the location of the model file, which the application reads at startup. + + \item \texttt{EXPOSE 9050} \\ + Documents that the container listens on port 9050, allowing traffic to reach the inference API. + + \item \texttt{CMD ["./mnist\_infer"]} \\ + Specifies the command to run when the container starts: executing the compiled binary. +\end{itemize} + +\section{Python (PyTorch) Dockerfile} + +This section details the image optimization strategy implemented for the MNIST inference container. The core approach minimizes the Docker image size by decoupling the heavy machine learning dependencies (PyTorch, etc.) from the application container. Instead of baking these libraries into the image, they are stored on an external volume (NFS share) and mounted at runtime. + +\subsection{Dockerfile Analysis} + +The \texttt{Dockerfile} is kept intentionally lightweight. By excluding large dependencies like \texttt{torch} from the \texttt{pip install} command, the image size remains very small (only containing the base Python runtime and lightweight web frameworks). + +\begin{lstlisting}[language=Dockerfile, caption={Optimized Inference Dockerfile}, label={lst:dockerfile_inference}] +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 +ENV OMP_NUM_THREADS=1 +ENV MKL_NUM_THREADS=1 + +# Critical: Point Python to the external volume +ENV PYTHONPATH=/external-libs/ml_env/lib/python3.12/site-packages + +WORKDIR /app + +# Only install lightweight app dependencies +RUN pip install --no-cache-dir --upgrade pip && \ + pip install fastapi==0.110.0 uvicorn==0.29.0 python-multipart==0.0.9 + +COPY app.py model.py model.pt ./ + +EXPOSE 8000 + +CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"] +\end{lstlisting} + +\begin{itemize} + \item \textbf{Base Image:} Uses \texttt{python:3.12-slim} to minimise the OS footprint. + \item \textbf{Environment Configuration:} + \begin{itemize} + \item \texttt{PYTHONDONTWRITEBYTECODE=1}: Prevents Python from writing \texttt{.pyc} files to disk. + \item \textbf{\texttt{PYTHONPATH}}: Crucially set to \texttt{/external-libs/ml\_env/lib/python3.12/site-packages}. This instructs the Python interpreter to look for libraries in the mounted volume directory, not just the default system paths. + \end{itemize} + \item \textbf{Minimal Dependencies:} The \texttt{pip install} command only installs \texttt{fastapi}, \texttt{uvicorn}, and \texttt{python-multipart}. Heavy ML libraries are assumed to be present in the mounted volume. +\end{itemize} + +\subsection{Volume Mounting Strategy} + +The strategy relies on two shell scripts to set up the environment on the host machine and run the container with the correct volume mappings. + +\subsubsection{Library Setup (\texttt{mount\_libs.sh})} +This script runs on the host machine (or a VM node) to prepare the shared library volume. +\begin{enumerate} + \item \textbf{NFS Client Installation:} It installs \texttt{nfs-common} to enable Network File System capabilities. + \item \textbf{Mounting:} It connects to a remote NFS server (\texttt{172.16.203.14}) where the pre-installed ML libraries reside. + \item \textbf{Local Path:} The remote libraries are mounted to \texttt{/mnt/ml-libs} on the host. This directory acts as the bridge between the NFS server and the Docker container. +\end{enumerate} + +\subsubsection{Runtime Execution (\texttt{run\_container.sh})} +This script launches the Docker container with the necessary runtime configurations to access the external libraries. + +\begin{lstlisting}[language=Bash, caption={Container Execution Command}] +docker run -d \ + -v /mnt/ml-libs:/external-libs \ + -e PYTHONPATH=/external-libs/ml_env/lib/python3.12/site-packages \ + -p 8000:8000 \ + fastapi-ml-app +\end{lstlisting} + +\begin{itemize} + \item \textbf{\texttt{-v /mnt/ml-libs:/external-libs}}: This bind mount maps the host's \texttt{/mnt/ml-libs} (which contains the NFS data) to \texttt{/external-libs} inside the container. + \item \textbf{\texttt{-e PYTHONPATH=...}}: explicit environment variable override ensures the container's Python runtime finds the packages in \texttt{/external-libs}. +\end{itemize} + +\subsection{Benefits and Optimization} + +\begin{table}[h!] +\centering +\caption{Optimization Benefits} +\label{tab:docker_optimization} +\begin{tabular}{|l|p{6cm}|p{6cm}|} +\hline +\textbf{Feature} & \textbf{Standard Approach} & \textbf{Volume Mount Approach} \\ \hline +\textbf{Image Size} & \textbf{Huge} (>2GB). Includes PyTorch, CUDA binaries, and all dependencies. & \textbf{Tiny} (~100MB). Only contains app code and minimal HTTP libs. \\ \hline +\textbf{Build Time} & \textbf{Slow}. Downloading and installing PyTorch takes minutes. & \textbf{Fast}. setup only installs \texttt{fastapi}. \\ \hline +\textbf{Updates} & requires rebuilding and pushing large layers for every code change. & Code changes only require rebuilding the tiny app layer. Library updates are handled externally. \\ \hline +\end{tabular} +\end{table} + +This architecture allows for rapid deployment and updating of the application logic without the overhead of moving gigabytes of container layers for unchanged machine learning dependencies. + +\section{Rust CI/CD} + +\section{Python CI/CD} + +\end{document} diff --git a/latex_reports/regression.tex b/latex_reports/regression.tex new file mode 100644 index 0000000..d20fa15 --- /dev/null +++ b/latex_reports/regression.tex @@ -0,0 +1,104 @@ +\documentclass[12pt]{article} +\usepackage{amsmath} +\usepackage{amssymb} +\usepackage{graphicx} +\usepackage{hyperref} +\usepackage{geometry} +\geometry{a4paper, margin=1in} + +\title{\textbf{Comparative Analysis of Regression Implementations: Rust (Burn) vs. PyTorch}} +\author{Technical Report} +\date{\today} + +\begin{document} + +\maketitle +\tableofcontents +\newpage + +\section{Introduction} +This document outlines the architectural, mathematical, and deployment specifics of implementing a Neural Network-based Regression model across two disparate machine learning environments: Rust (utilizing the Burn framework) and Python (utilizing PyTorch). It covers the distinct model architecture decisions, dataset handling strategies, and specialized pipeline deployment techniques leveraging Network File Systems (NFS) mapping via Docker bounds. + +\section{Model Architecture and Mathematical Formulation} + +\subsection{Mathematical Foundation} +The core mathematical foundation deployed across both frameworks is a classical Feed-Forward Neural Network consisting of a single hidden dimension mapping inputs directly onto a continuous single-variable regression output. + +For a given input feature vector $X \in \mathbb{R}^N$ (where $N$ dictates the feature size depending on the target dataset), the network's forward transformation can be represented sequentially as: +\begin{align} + Z_1 &= X \cdot W_1^T + b_1 \quad &\text{(Input Projection)} \\ + A_1 &= \max(0, Z_1) \quad &\text{(ReLU Activation)} \\ + \hat{Y} &= A_1 \cdot W_2^T + b_2 \quad &\text{(Output Projection)} +\end{align} + +Where: +\begin{itemize} + \item $W_1 \in \mathbb{R}^{H \times N}$ and $b_1 \in \mathbb{R}^H$ map the inputs onto the hidden vector space $H$. + \item $\max(0, \cdot)$ denotes the Non-Linear Rectified Linear Unit (ReLU) mapping algorithm. + \item $W_2 \in \mathbb{R}^{1 \times H}$ and $b_2 \in \mathbb{R}$ collapse the hidden abstraction onto the finalized regression scalar prediction $\hat{Y}$. +\end{itemize} + +\subsection{Architectural Configurations} +While the mathematical foundations are identical, implementations slightly differ based on dataset selections within the modules: +\begin{itemize} + \item \textbf{PyTorch Architecture:} Configures $N=13$ input features mapping to $H=64$ hidden parameters. + \item \textbf{Rust (Burn) Architecture:} Configures $N=8$ input features concurrently mapping to $H=64$ hidden parameters. +\end{itemize} +In both configurations, standard parameter biases (`bias=True`) are included and automatically initialized. + +\section{Training Pipelines} + +Both codebases train the model iteratively tracking gradients via the Adam optimizer scaled against Mean Squared Error (MSE) loss logic: +\[ \text{MSE} = \frac{1}{B} \sum_{i=1}^{B} (Y_i - \hat{Y}_i)^2 \] + +\subsection{PyTorch Context} +\begin{itemize} + \item \textbf{Data Loading:} Automatically pulls the \textbf{Boston Housing} dataset array (.npz file) from an external Google API via `urllib` and manually partitions it down into an explicit $80/20$ split. + \item \textbf{Telemetry Metrics:} Generates explicit hardware tracking loops inside the main epoch runner. Uses the `psutil` library to compute and stream epoch `iteration\_speed`, raw RAM consumption, and `cpu\_temp` hardware sensors parallel to the loss parameters. +\end{itemize} + +\subsection{Rust (Burn) Context} +\begin{itemize} + \item \textbf{Data Loading:} Links into Huggingface's dataset registry asynchronously targeting the \textbf{California Housing} SQLized splits mapping onto memory arrays via localized `HousingDistrictItem` structs. + \item \textbf{Normalization Mapping:} Computes spatial min-max normalizations programmatically over inputs during training: + \[ X_{norm} = \frac{X - \text{min}}{\text{max} - \text{min}} \] + This logic restricts features within standard boundaries precluding exploding gradient derivations. +\end{itemize} + +\section{Inference Pipeline and Docker NFS Integration} + +Deploying these isolated pipelines necessitates radically different execution strategies, highlighting Python's heavyweight runtime dependency bottlenecks versus Rust's compile-time optimizations. + +\subsection{PyTorch Inference Architecture} +Standard PyTorch Docker environments routinely eclipse several gigabytes due to CUDA bindings and generic scientific computation loops. To circumvent this inside microservices, the PyTorch inference pipeline mandates a hybrid Network File System (NFS) mapping architecture: +\begin{enumerate} + \item \textbf{NFS Mounting (\texttt{mount\_libs.sh}):} Installs an external `nfs-common` client locally and binds the extensive python library volume from an external dedicated storage server (`172.16.203.14`) into the host machine's `/mnt/LSTM-libs` map. + \item \textbf{Lightweight Container Image:} The backend \texttt{Dockerfile} avoids `pip install` commands completely, simply initializing a barebone `nvidia/cuda:12.1.1` image mapping Python $3.11$ system links. + \item \textbf{Volume Inject (\texttt{run\_container.sh}):} The script initializes the container enforcing `-v` flags that sync the NFS `/mnt/LSTM-libs` directory seamlessly onto the Docker's `/external-libs`. Crucially, it overrides the system \texttt{PYTHONPATH} to target those external `site-packages` at runtime. + \item \textbf{Execution:} The `FastAPI` instance loads, bypasses massive disk pulls, links the models iteratively, and fields inbound `HousingFeatures` lists continuously. +\end{enumerate} + +\subsection{Rust (Burn) Inference Architecture} +Rust handles Docker microservices inherently via statically linked deployments: +\begin{itemize} + \item \textbf{Multi-Stage Compiling:} Executes a build phase operating within an oversized `rust:1.92-alpine` chain, ejecting the resulting binary onto an isolated stripped `alpine:3.23` environment structure. + \item \textbf{Native Routing:} Utilizes \texttt{Axum} servers to establish the HTTP logic endpoints securely routing JSON payloads mapping to specific feature names (e.g. \texttt{median\_income}, \texttt{house\_age}). +\end{itemize} + +\section{Language Specific Implementation Details} + +\subsection{PyTorch-Specific Paradigms} +\begin{itemize} + \item \textbf{Thread Clamping:} Due to inference optimization restrictions (especially running CPU variations alongside container structures), the `app.py` enforces explicit core binding calls via `torch.set\_num\_threads(1)` and `torch.set\_num\_interop\_threads(1)` securing computational resources and restricting OS context-switching overheads. + \item \textbf{Matrix Array Verifications:} Manually inspects raw matrix vector mappings validating dimensions dynamically against numeric constraints: \texttt{len(x) != NUM\_FEATURES} triggering runtime panics before pipeline evaluations fail. + \item \textbf{Manual Hardware Moving:} The framework is heavily littered with required `.to(device)` mapping configurations switching inputs, datasets, targets, and models manually between the host and external components. +\end{itemize} + +\subsection{Rust (Burn)-Specific Paradigms} +\begin{itemize} + \item \textbf{Generic Compile-Time Shapes:} Dimension mappings and tensor validations are fundamentally enforced inside the Rust compiler boundaries via `` arrays indicating batches of distinct input structures mapping to `targets: Tensor`. Invalid sizes fail compilation, voiding the requirement for manual PyTorch matrix validations. + \item \textbf{Struct Batching Protocols:} Inference doesn't evaluate primitive float arrays. Intead, the API relies on executing an overarching `HousingBatcher` which transforms specific struct domains (\texttt{HousingDistrictItem}) safely into tensor primitives while executing implicit `self.normalizer.to\_device(device)` logic silently against constants behind boundaries. + \item \textbf{Record Deserialization:} States are strictly detached from models via standard `.mpk` maps. They invoke explicit \texttt{NoStdTrainingRecorder::new().load()} tracking traits unbinding memory limits inherent to standard dict serialization configurations natively loaded via `RegressionModelConfig`. +\end{itemize} + +\end{document} diff --git a/latex_reports/text_classification_news.tex b/latex_reports/text_classification_news.tex new file mode 100644 index 0000000..29062b4 --- /dev/null +++ b/latex_reports/text_classification_news.tex @@ -0,0 +1,346 @@ +\documentclass[11pt,a4paper]{article} + +\usepackage{geometry} +\geometry{margin=1in} + +\usepackage{amsmath,amssymb} +\usepackage{graphicx} +\usepackage{booktabs} +\usepackage{hyperref} +\usepackage{enumitem} +\usepackage{array} +\usepackage{float} +\usepackage{listings} + +\lstdefinelanguage{Dockerfile}{ + keywords={FROM, RUN, CMD, LABEL, MAINTAINER, EXPOSE, ENV, ADD, COPY, ENTRYPOINT, VOLUME, USER, WORKDIR, ARG, ONBUILD, STOPSIGNAL, HEALTHCHECK, SHELL}, + sensitive=true, + comment=[l]{\#}, + morestring=[b]", +} + +\title{\textbf{Text Classification (News)}} +\date{\today} + +\begin{document} + +\maketitle + +\section{Model Architecture and Training Strategy} + +The text classification system is built using the \texttt{Burn} framework in Rust, leveraging a Transformer-based architecture for feature extraction and a linear classification head. This section details the mathematical formulation of the model and the strategy employed for training. + +\subsection{Model Architecture} +The core of the model is a Transformer Encoder, which processes a sequence of token embeddings to capture contextual relationships. The architecture consists of three primary stages: embedding, encoding, and classification. + +\subsubsection{Embedding Layer} +Input text is tokenized and converted into a sequence of indices $X \in \mathbb{N}^{B \times L}$, where $B$ is the batch size and $L$ is the sequence length. The model utilizes two parallel embedding layers: +\begin{enumerate} + \item \textbf{Token Embedding ($E_{tok}$)}: Maps token indices to dense vectors of dimension $d_{model}$. + \item \textbf{Positional Embedding ($E_{pos}$)}: Maps position indices $[0, \dots, L-1]$ to dense vectors of dimension $d_{model}$ to inject sequence order information. +\end{enumerate} + +The final embedding representation $E$ is obtained by averaging the token and positional embeddings: +\begin{equation} + E = \frac{E_{tok}(X) + E_{pos}(\text{positions})}{2} +\end{equation} + +\subsubsection{Transformer Encoder} +The embedding tensor $E$ is passed through a multi-layer Transformer Encoder. Each layer consists of a Multi-Head Self-Attention (MHSA) mechanism followed by a Position-wise Feed-Forward Network (FFN), with residual connections and layer normalization. + +The configuration used in this implementation is as follows: +\begin{itemize} + \item \textbf{Model Dimension ($d_{model}$)}: 256 + \item \textbf{Feed-Forward Dimension ($d_{ff}$)}: 1024 + \item \textbf{Number of Heads ($N_{heads}$)}: 8 + \item \textbf{Number of Layers ($N_{layers}$)}: 4 + \item \textbf{Normalization}: Layer norm applied before sub-layers (Pre-Norm). +\end{itemize} + +Let $H = \text{TransformerEncoder}(E)$, where $H \in \mathbb{R}^{B \times L \times d_{model}}$ represents the contextualized representations of the input sequence. + +\subsubsection{Classification Head} +For classification, the model utilizes the representation of the first token (typically acting as the [CLS] token) from the encoded sequence. This vector is passed through a linear layer to project it into the class space: +\begin{equation} + Y = \text{Linear}(H_{[:, 0, :]}) +\end{equation} +where $Y \in \mathbb{R}^{B \times N_{classes}}$ represents the logits. For inference, a Softmax function is applied to obtain probabilities: +\begin{equation} + \hat{P} = \text{Softmax}(Y) +\end{equation} + +\begin{table}[h] +\centering +\caption{Model Architecture Summary} +\label{tab:model_arch} +\begin{tabular}{|l|l|c|c|} +\hline +\textbf{Component} & \textbf{Configuration / Details} & \textbf{Input Shape} & \textbf{Output Shape} \\ \hline +Token Embedding & $V \to d_{model}$ ($V$: Vocab Size) & $(B, L)$ & $(B, L, 256)$ \\ \hline +Pos Embedding & $L_{max} \to d_{model}$ & $(B, L)$ & $(B, L, 256)$ \\ \hline +Embedding Merge & Average ($E_{tok} + E_{pos}$) & - & $(B, L, 256)$ \\ \hline +Transformer Block & 4 Layers, 8 Heads, $d_{ff}=1024$ & $(B, L, 256)$ & $(B, L, 256)$ \\ \hline +Feature Extract & Slice First Token (Index 0) & $(B, L, 256)$ & $(B, 256)$ \\ \hline +Classifier Head & Linear ($256 \to N_{classes}$) & $(B, 256)$ & $(B, N_{classes})$ \\ \hline +\end{tabular} +\end{table} + +\subsection{Training Strategy} +The model is trained using a supervised learning approach with the following configuration: + +\subsubsection{Loss Function} +The training objective is to minimize the Cross-Entropy Loss between the predicted logits $Y$ and the ground truth class labels $C$: +\begin{equation} + \mathcal{L} = \text{CrossEntropy}(Y, C) = -\sum_{c=1}^{N_{classes}} \mathbb{1}_{c=C} \log\left(\frac{e^{Y_c}}{\sum_{j} e^{Y_j}}\right) +\end{equation} + +\subsubsection{Optimization} +We employ the **Adam** optimizer with the following parameters: +\begin{itemize} + \item \textbf{Weight Decay}: $5 \times 10^{-5}$ + \item \textbf{Beta Coefficients}: Standard defaults (typically $\beta_1=0.9, \beta_2=0.999$) +\end{itemize} + +\subsubsection{Learning Rate Scheduling} +A **Noam Learning Rate Scheduler** is used to stabilize training. The learning rate increases linearly during a warmup phase and then decays proportionally to the inverse square root of the step number. +\begin{equation} + LR = d_{model}^{-0.5} \cdot \min(step\_num^{-0.5}, step\_num \cdot warmup\_steps^{-1.5}) +\end{equation} +\begin{itemize} + \item \textbf{Warmup Steps}: 1000 + \item \textbf{Base Learning Rate}: 0.01 +\end{itemize} + +\subsubsection{Metrics} +During training and validation, the following metrics are tracked to monitor performance: +\begin{itemize} + \item \textbf{Loss}: Cross-Entropy Loss. + \item \textbf{Accuracy}: Percentage of correct predictions. + \item \textbf{F1-Score, Precision, Recall}: Macro-averaged metrics to account for class balance. +\end{itemize} + +\section{Burn Code Specifications} + +This section outlines the significant implementation details of the text classification system, focusing on the architectural choices in \texttt{model.rs} and the robust training pipeline defined in \texttt{training.rs}. +\subsection{Model Implementation (\texttt{model.rs})} +The \texttt{TextClassificationModel} leverages the \textbf{Burn} framework's modular design to implement a Transformer-based classifier. Key features of this implementation include: +\begin{itemize} + \item \textbf{Dual Embedding Strategy:} The model employs two distinct embedding layers: \texttt{embedding\_token} for semantic content and \texttt{embedding\_pos} for positional information. A unique characteristic of this implementation is the fusion strategy, where these embeddings are combined via averaging: + \[ + E_{final} = \frac{E_{pos} + E_{token}}{2} + \] + This differs from the standard summation approach often found in BERT implementations, potentially stabilizing the initial magnitude of the embedding vectors. + + \item \textbf{Configurable Architecture:} The system uses a \texttt{TextClassificationModelConfig} struct derived with the \texttt{Config} macro. This allows for type-safe and serializable hyperparameter management, ensuring the model architecture (hidden size, vocabulary size, sequence length) can be easily saved, loaded, and reproducible. + + \item \textbf{Masked Attention:} The forward pass actively utilizes padding masks (\texttt{mask\_pad}). These masks are passed into the \texttt{TransformerEncoderInput}, ensuring that the self-attention mechanism strictly ignores padding tokens, which is critical for handling variable-length text sequences correctly. + + \item \textbf{Separation of Train and Inference Logic:} The model explicitly implements the \texttt{TrainStep} and \texttt{InferenceStep} traits. + \begin{itemize} + \item \textbf{Training:} Returns a \texttt{ClassificationOutput} struct containing the calculated Cross-Entropy loss for backpropagation. + \item \textbf{Inference:} Returns raw probabilities by applying a softmax activation on the output logits, facilitating direct class prediction. + \end{itemize} +\end{itemize} +\subsection{Training Pipeline (\texttt{training.rs})} +The training module is designed for reliability and comprehensive observability. It integrates advanced optimization techniques and hardware-aware monitoring. +\begin{itemize} + \item \textbf{Noam Scheduler:} Transformer models are notoriously sensitive to learning rates. The code implements the \textbf{Noam Learning Rate Scheduler} (popularized by "Attention Is All You Need"), which features a linear warmup phase (1000 steps) followed by an inverse square root decay based on the model dimension ($d_{model}$). This prevents gradient explosions during early training stages. + + \item \textbf{Distributed Training Support:} The implementation explicitly handles distributed computing scenarios. It utilizes Rust's feature flags (\texttt{cfg[feature = "ddp"]}) to switch between single-device training and \textbf{Distributed Data Parallel (DDP)} strategies. When enabled, it employs a tree-based \texttt{AllReduceStrategy} for synchronizing gradients across multiple GPUs or nodes. + + \item \textbf{Comprehensive Telemetry:} The training loop is instrumented with an extensive suite of metrics beyond simple accuracy. It tracks: + \begin{itemize} + \item \textbf{Classification Metrics:} Macro-averaged F1-Score, Precision, and Recall, providing a holistic view of model performance on imbalanced datasets. + \item \textbf{Hardware Diagnostics:} CPU temperature, memory usage, and utilization are logged alongside training progress, aiding in the detection of thermal throttling or memory leaks during long training runs. + \end{itemize} + + \item \textbf{Efficient Data Sampling:} To manage large datasets efficiently, the loader utilizes a \texttt{SamplerDataset}. This limits the effective epoch size to 50,000 training samples and 5,000 validation samples, allowing for rapid iteration and feedback loops without needing to process the entire corpus in every epoch. +\end{itemize} + +\subsection{Conditional Compilation} +I think we should document a bit about this. + +\section{Rust Docker image} + +\section{Rust Inference Code} + +\section{PyTorch Training Pipeline} +This section details the Python implementation of the text classification training pipeline. The code mimics the architecture and logic of the Rust version to ensuring comparable performance and behavior. +\subsection{Code Highlights} +\begin{itemize} + \item \textbf{Custom Transformer Model:} + The \texttt{TextClassificationModel} is a custom \texttt{nn.Module} containing: + \begin{itemize} + \item Dual embedding layers (\texttt{embedding\_token} and \texttt{embedding\_pos}). + \item A unique fusion strategy averaging the two embeddings: $E = (E_{pos} + E_{tok}) / 2$. + \item A standard \texttt{TransformerEncoder} stack. + \item A classification head that projects the encoded features to the 4 output classes of the AG News dataset. + \end{itemize} + \item \textbf{Noam Learning Rate Scheduler:} + A custom \texttt{NoamLR} scheduler is implemented to replicate the specific warmup and decay behavior used in the Rust implementation (and the original "Attention Is All You Need" paper). + \[ + lr = \text{factor} \cdot (d_{model}^{-0.5}) \cdot \min(step^{-0.5}, step \cdot warmup^{-1.5}) + \] + This ensures stable training dynamics for the Transformer architecture. + \item \textbf{Dataset Handling:} + The code utilizes the Hugging Face \texttt{datasets} library to load the "ag\_news" dataset. It explicitly shuffles and subsets the data (50,000 train, 5,000 test) to match the constraints applied in the Rust implementation, ensuring a fair apples-to-apples comparison between the two languages. + \item \textbf{Collate Function with Padding Masks:} + A custom \texttt{collate\_fn} handles dynamic batching. It tokenizes text using the \texttt{bert-base-cased} tokenizer and generates a boolean padding mask. Note the inversion logic: PyTorch's \texttt{TransformerEncoder} expects \texttt{True} for padded positions (unlike some other implementations where 1 implies validity), requiring careful mask generation: + \begin{verbatim} + mask_pad = (encoding['attention_mask'] == 0) + \end{verbatim} + \item \textbf{Training Loop:} + The training loop is a standard PyTorch implementation using \texttt{tqdm} for progress tracking. It uses \texttt{CrossEntropyLoss} as the criterion and the \texttt{Adam} optimizer. Crucially, the scheduler step is called after every batch (not every epoch), consistent with the Noam schedule requirements. +\end{itemize} + +\section{PyTorch Inference Pipeline Docker Image: Hybrid NFS and Docker Inference Architecture} + +This section details the hybrid deployment strategy designed to optimize Docker image size and leverage a centralized machine learning environment. The architecture splits the responsibilities between a \textbf{Library VM} (storage-heavy) and a \textbf{Docker VM} (compute-centric). + +\subsection{Architecture Overview} + +The system comprises two primary components: +\begin{enumerate} + \item \textbf{Library VM (NFS Server)}: Hosts the heavy Python environment, including PyTorch, Transformers, and CUDA libraries. This environment is exported via NFS. + \item \textbf{Docker VM (Inference Client)}: Runs a lightweight Docker container that mounts the external libraries at runtime. +\end{enumerate} + +\subsection{Implementation Details} + +\subsubsection{1. Library Sharing via NFS} +The Library VM exports the directory containing the Python site-packages. On the Docker VM, this directory is mounted using the \texttt{mount\_libs.sh} script. + +\begin{lstlisting}[language=bash, caption={Mounting the NFS Library Volume}] +# Configuration from mount_libs.sh +NFS_SERVER_IP="172.16.203.14" +NFS_EXPORT_PATH="/home/iiitb/Documents/textClassificationVolume" +LOCAL_MOUNT_POINT="/mnt/text-libs" + +# Mounting the remote volume +sudo mount -t nfs "$NFS_SERVER_IP:$NFS_EXPORT_PATH" "$LOCAL_MOUNT_POINT" +\end{lstlisting} + +\subsubsection{2. Lightweight Docker Image} +The Docker image is built using \texttt{Dockerfile.cpu} and excludes heavy ML libraries. It only contains the application code, the model weights, and minimal system dependencies. + +\begin{lstlisting}[language=Dockerfile, caption={Dockerfile.cpu Configuration}] +FROM python:3.12-slim + +# Point Python to the external NFS mount +ENV PYTHONPATH=/external-libs/text_env/lib/python3.12/site-packages + +# Copy only the app and model +COPY app.py ./ +COPY model_pytorch_text_classification/ag_news_model.pth ./model/ + +# No 'pip install torch' is performed here! +\end{lstlisting} + +\subsubsection{3. Runtime Execution} +The container is launched via \texttt{run\_inference.sh}, which mounts the NFS volume into the container at \texttt{/external-libs}. + +\begin{lstlisting}[language=bash, caption={Mounting the NFS Library Volume}] +docker run --gpus all \ + -v /mnt/text-libs:/external-libs \ + -v text_model_vol:/models \ + -e PYTHONPATH=/external-libs/text_env/lib/python3.12/site-packages \ + -p 8000:8000 \ + text_classification_image +\end{lstlisting} + +\subsection{Impact on Image Size} + +This architecture drastically reduces the storage footprint of the inference artifact. By decoupling the static libraries from the application logic, we achieve the following reduction: + +\begin{table}[h] +\centering +\begin{tabular}{|l|c|c|} +\hline +\textbf{Component} & \textbf{Traditional Approach} & \textbf{Hybrid NFS Approach} \\ \hline +Base Image (Python Slim) & $\sim$150 MB & $\sim$150 MB \\ \hline +PyTorch & $\sim$3.5 GB & \textbf{0 MB (Mounted)} \\ \hline +Transformers & $\sim$500 MB & \textbf{0 MB (Mounted)} \\ \hline +Application Code & $<1$ MB & $<1$ MB \\ \hline +Model Weights & $\sim$100 MB & $\sim$100 MB \\ \hline +\textbf{Total Image Size} & \textbf{8.93 GB} & \textbf{88.5 MB} \\ \hline +\end{tabular} +\caption{Comparison of Docker Image Sizes} +\end{table} + +This \textbf{99.03\% reduction} in image size results in: +\begin{itemize} + \item Faster deployment and rollback times. + \item Significantly lower network bandwidth usage. + \item Efficient storage utilization on the Docker VM. +\end{itemize} + +\section{Hybrid Inference Architecture with NFS and Docker} + +This section outlines the architectural design of our hybrid machine learning deployment strategy, detailing the distinct roles of the Library VM and the Docker VM, and how they interact to optimize resource usage. + +\subsection{Library Virtual Machine (NFS Server)} + +The \textbf{Library VM} serves as the centralized repository for the heavy components of the machine learning environment. Its primary function is to host large, static dependencies such as the Python runtime environment, deep learning frameworks (e.g., PyTorch, TensorFlow), and specialized libraries (e.g., Transformers, CUDA routines). + +By consolidating these resource-intensive libraries on a single machine, we avoid the redundancy of installing them on every inference node. This machine acts as a Network File System (NFS) server, exporting its directory structure to be accessed by other machines in the network. + +\subsubsection{What is an NFS Server?} + +A \textbf{Network File System (NFS)} server is a computer that allows other machines (clients) to access its files over a network as if they were stored locally. In our architecture, the NFS server "shares" the directory containing the Python libraries. The client machines can then read these files directly, eliminating the need to physically copy the heavy libraries to each client. + +\subsection{Docker Virtual Machine (Inference Node)} + +The \textbf{Docker VM} is the compute-centric node responsible for executing the inference workload. It hosts the Docker engine and runs the lightweight containerized application. + +This machine does not permanently store the heavy ML libraries. instead, it mounts the shared directory from the Library VM at runtime. reliable network connectivity to the Library VM ensures that the Docker container has immediate access to the necessary software dependencies. + +\subsection{Hybrid Deployment Strategy} + +The hybrid strategy combines the isolation and portability of Docker with the efficiency of centralized storage. + +\begin{enumerate} + \item \textbf{Decoupling Environment and Application}: We separate the rapidly changing application code (API logic, business rules) from the slowly changing environment (Python packages). The application code resides inside the Docker image, while the environment resides on the NFS share. + \item \textbf{Runtime Linking}: When the Docker container starts, it mounts the NFS share. The container's environment variables are configured to add this mounted path to its Python path. This allows the Python interpreter inside the container to import modules (like \texttt{torch} or \texttt{transformers}) from the network share as if they were installed locally. + \item \textbf{Drastic Image Reduction}: Since the Docker image only contains the application code and minimal system dependencies, its size is reduced from several gigabytes to a few hundred megabytes. This facilitates rapid deployments, faster scaling, and reduced storage costs. +\end{enumerate} + +This architecture essentially transforms the Docker container into a lightweight "shell" that borrows its heavy "engine" from the Library VM only when needed. + +\subsection{Identifying the Virtual Machine Roles} + +The architecture explicitly designates two separate machines for distinct purposes. Based on the configuration scripts, their roles are defined as follows: + +\subsubsection{1. The Library VM (Environment Host)} +This machine acts as the \textbf{storage backend} for the machine learning environment. +\begin{itemize} + \item \textbf{Role}: It hosts the actual Python environment (Torch, Transformers, etc.) on its local filesystem and exports it via NFS. + \item \textbf{Identifier}: In our configuration (see \texttt{mount\_libs.sh}), this machine is identified by the IP address \texttt{172.16.203.14}. + \item \textbf{Key Path}: The environment resides at \texttt{/home/iiitb/Documents/textClassificationVolume}. + \item \textbf{Action}: It does \textit{not} run the Docker container. Ideally, it simply stays online to serve files to other machines. +\end{itemize} + +\subsubsection{2. The Docker VM (Inference Runner)} +This machine acts as the \textbf{compute frontend} that serves the API. +\begin{itemize} + \item \textbf{Role}: It builds and runs the lightweight Docker container. It does not have the deep learning libraries installed on its own disk; it borrows them from the Library VM. + \item \textbf{Identifier}: This is the machine where you execute the \texttt{mount\_libs.sh} and \texttt{run\_inference.sh} scripts. + \item \textbf{Key Path}: It mounts the remote library to the local path \texttt{/mnt/text-libs}. + \item \textbf{Action}: It executes the \texttt{docker run} command, effectively "bringing the code to the data" (or in this case, bringing the library data to the code container). +\end{itemize} + +\begin{table}[h] +\centering +\begin{tabular}{|l|l|l|} +\hline +\textbf{Feature} & \textbf{Library VM} & \textbf{Docker VM} \\ \hline +\textbf{Primary Function} & Storage \& NFS Server & Model Inference \& API Hosting \\ \hline +\textbf{IP Address} & \texttt{172.16.203.14} & (Assigned by Network) \\ \hline +\textbf{Python Libs} & Stored Physically on Disk & Mounted via Network (NFS) \\ \hline +\textbf{Docker Image} & Not required & Builds \& Runs Lightweight Image \\ \hline +\end{tabular} +\caption{Distinction between Library VM and Docker VM} +\end{table} + +\section{Python Inference Code} + +\end{document} From fdd3c195fc0c1954ee6d18853bb85ad4c5f4554b Mon Sep 17 00:00:00 2001 From: valmikGit Date: Wed, 1 Apr 2026 16:12:03 +0530 Subject: [PATCH 06/21] Added model.rs in the LSTM Rust inference module. --- rust_ml/lstm_inference/Cargo.toml | 2 +- rust_ml/lstm_inference/src/inference.rs | 4 +- rust_ml/lstm_inference/src/main.rs | 2 + rust_ml/lstm_inference/src/model.rs | 380 ++++++++++++++++++++++++ rust_ml/lstm_inference/src/state.rs | 20 +- 5 files changed, 396 insertions(+), 12 deletions(-) create mode 100644 rust_ml/lstm_inference/src/model.rs diff --git a/rust_ml/lstm_inference/Cargo.toml b/rust_ml/lstm_inference/Cargo.toml index b3f5e73..816d3e7 100644 --- a/rust_ml/lstm_inference/Cargo.toml +++ b/rust_ml/lstm_inference/Cargo.toml @@ -5,7 +5,7 @@ edition = "2024" [dependencies] lstm_train = { path = "../lstm_train" } -burn = { version = "~0.20", features = ["std", "ndarray"], default-features = false } +burn = { version = "~0.20", features = ["std", "wgpu"], default-features = false } axum = "0.8" tokio = { version = "1", features = ["full"] } serde = { version = "1", features = ["derive"] } diff --git a/rust_ml/lstm_inference/src/inference.rs b/rust_ml/lstm_inference/src/inference.rs index fdd857d..21bb78c 100644 --- a/rust_ml/lstm_inference/src/inference.rs +++ b/rust_ml/lstm_inference/src/inference.rs @@ -7,7 +7,7 @@ use burn::data::dataloader::batcher::Batcher; use lstm_train::dataset::{SequenceBatcher, SequenceDatasetItem}; use serde::Serialize; -use crate::state::{AppState, Backend}; +use crate::state::{AppState, MyBackend}; #[derive(Serialize)] pub struct PredictResponse { @@ -23,7 +23,7 @@ pub async fn predict_handler( State(state): State, Json(payload): Json, ) -> Result, (StatusCode, Json)> { - let device: ::Device = Default::default(); + let device: ::Device = Default::default(); // Create batcher mapped to backend let batcher = SequenceBatcher::default(); diff --git a/rust_ml/lstm_inference/src/main.rs b/rust_ml/lstm_inference/src/main.rs index 1850600..8b4fca3 100644 --- a/rust_ml/lstm_inference/src/main.rs +++ b/rust_ml/lstm_inference/src/main.rs @@ -1,4 +1,6 @@ +#![recursion_limit = "256"] mod inference; +mod model; mod state; use axum::{ diff --git a/rust_ml/lstm_inference/src/model.rs b/rust_ml/lstm_inference/src/model.rs new file mode 100644 index 0000000..9537858 --- /dev/null +++ b/rust_ml/lstm_inference/src/model.rs @@ -0,0 +1,380 @@ +use burn::{ + nn::{ + Dropout, DropoutConfig, Initializer, LayerNorm, LayerNormConfig, Linear, LinearConfig, + LstmState, Sigmoid, Tanh, + }, + prelude::*, +}; + +/// LSTM Cell implementation with layer normalization. +/// +/// Mathematical formulation of LSTM: +/// f_t = σ(W_f · [h_{t-1}, x_t] + b_f) # Forget gate +/// i_t = σ(W_i · [h_{t-1}, x_t] + b_i] # Input gate +/// g_t = tanh(W_g · [h_{t-1}, x_t] + b_g] # Candidate cell state +/// o_t = σ(W_o · [h_{t-1}, x_t] + b_o) # Output gate +/// +/// c_t = f_t ⊙ c_{t-1} + i_t ⊙ g_t # New cell state +/// h_t = o_t ⊙ tanh(c_t) # New hidden state +/// +/// where: +/// - σ is the sigmoid function +/// - ⊙ is the element-wise multiplication +/// - [h_{t-1}, x_t] represents concatenation + +#[derive(Module, Debug)] +pub struct LstmCell { + pub hidden_size: usize, + // Combined weight matrices for efficiency + // weight_ih layer uses combined weights for [i_t, f_t, g_t, o_t] for input x_t + // weight_hh layer uses combined weights for [i_t, f_t, g_t, o_t] for hidden state h_{t-1} + pub weight_ih: Linear, + pub weight_hh: Linear, + // Layer Normalization for better training stability. Don't use BatchNorm because the input distribution is always changing for LSTM. + pub norm_x: LayerNorm, // Normalize gate pre-activations + pub norm_h: LayerNorm, // Normalize hidden state + pub norm_c: LayerNorm, // Normalize cell state + pub dropout: Dropout, +} + +/// Configuration to create a Lstm module using the init function. +#[derive(Config, Debug)] +pub struct LstmCellConfig { + // The size of the input features + pub input_size: usize, + // The size of the hidden state + pub hidden_size: usize, + // The number of hidden layers + pub dropout: f64, +} + +impl LstmCellConfig { + // Initialize parameters using best practices: + // 1. Orthogonal initialization for better gradient flow (here we use Xavier because of the lack of Orthogonal in burn) + // 2. Initialize forget gate bias to 1.0 to prevent forgetting at start of training + #[allow(clippy::single_range_in_vec_init)] + pub fn init( + &self, + device: &B::Device, + ) -> LstmCell { + let initializer = Initializer::XavierNormal { gain: 1.0 }; + let init_bias = Tensor::::ones([self.hidden_size], device); + + let mut weight_ih = LinearConfig::new(self.input_size, 4 * self.hidden_size) + .with_initializer(initializer.clone()) + .init(device); + // Set forget gate bias to 1.0 (helps with learning long sequences) + let bias = weight_ih + .bias + .clone() + .unwrap() + .val() + .slice_assign([self.hidden_size..2 * self.hidden_size], init_bias.clone()); + weight_ih.bias = weight_ih.bias.map(|p| p.map(|_t| bias)); + + let mut weight_hh = LinearConfig::new(self.hidden_size, 4 * self.hidden_size) + .with_initializer(initializer) + .init(device); + let bias = weight_hh + .bias + .clone() + .unwrap() + .val() + .slice_assign([self.hidden_size..2 * self.hidden_size], init_bias); + weight_hh.bias = weight_hh.bias.map(|p| p.map(|_t| bias)); + + LstmCell { + hidden_size: self.hidden_size, + weight_ih, + weight_hh, + norm_x: LayerNormConfig::new(4 * self.hidden_size).init(device), + norm_h: LayerNormConfig::new(self.hidden_size).init(device), + norm_c: LayerNormConfig::new(self.hidden_size).init(device), + dropout: DropoutConfig::new(self.dropout).init(), + } + } +} + +impl LstmCell { + /// Forward pass of LSTM cell. + /// Args: + /// x: Input tensor of shape (batch_size, input_size) + /// state: Tuple of (h_{t-1}, c_{t-1}) each of shape (batch_size, hidden_size) + /// Returns: + /// Tuple of (h_t, c_t) representing new hidden and cell states + pub fn forward( + &self, + x: Tensor, + state: LstmState, + ) -> LstmState { + let (h_prev, c_prev) = (state.hidden, state.cell); + + // Combined matrix multiplication for all gates + // Shape: (batch_size, 4 * hidden_size) + let gates_x = self.weight_ih.forward(x); // Transform input + let gates_h = self.weight_hh.forward(h_prev); // Transform previous hidden state + + // Apply layer normalization + let gates_x = self.norm_x.forward(gates_x); + // Combined gate pre-activations + let gates = gates_x + gates_h; + + // Split into individual gates + // Each gate shape: (batch_size, hidden_size) + let gates = gates.chunk(4, 1); + let i_gate = gates[0].clone(); + let f_gate = gates[1].clone(); + let g_gate = gates[2].clone(); + let o_gate = gates[3].clone(); + + // Apply gate non-linearities + let i_t = Sigmoid::new().forward(i_gate); + let f_t = Sigmoid::new().forward(f_gate); + let g_t = Tanh::new().forward(g_gate); + let o_t = Sigmoid::new().forward(o_gate); + + // Update cell state: c_t = f_t ⊙ c_{t-1} + i_t ⊙ g_t + let c_t = f_t * c_prev + i_t * g_t; + let c_t = self.norm_c.forward(c_t); + + // Update cell state: h_t = o_t ⊙ tanh(c_t) + let h_t = o_t * Tanh::new().forward(c_t.clone()); + let h_t = self.norm_h.forward(h_t); + + let h_t = self.dropout.forward(h_t); + + LstmState::new(h_t, c_t) + } + + // Initialize cell state and hidden state if provided or with zeros + pub fn init_state( + &self, + batch_size: usize, + device: &B::Device, + ) -> LstmState { + let cell = Tensor::zeros([batch_size, self.hidden_size], device); + let hidden = Tensor::zeros([batch_size, self.hidden_size], device); + + LstmState::new(cell, hidden) + } +} + +/// Stacked LSTM implementation supporting multiple layers +/// Each layer processes the output of the previous layer +#[derive(Module, Debug)] +pub struct StackedLstm { + pub layers: Vec>, +} + +#[derive(Config, Debug)] +pub struct StackedLstmConfig { + pub input_size: usize, + pub hidden_size: usize, + pub num_layers: usize, + pub dropout: f64, +} + +impl StackedLstmConfig { + pub fn init( + &self, + device: &B::Device, + ) -> StackedLstm { + let mut layers: Vec> = vec![]; + // Create list of LSTM cells, one for each layer + for i in 0..self.num_layers { + if i == 0 { + if i < self.num_layers - 1 { + layers.push( + LstmCellConfig::new(self.input_size, self.hidden_size, self.dropout) + .init(device), + ); + } else { + // No dropout on last layer + layers.push( + LstmCellConfig::new(self.input_size, self.hidden_size, 0.0).init(device), + ); + } + } else if i < self.num_layers - 1 { + layers.push( + LstmCellConfig::new(self.hidden_size, self.hidden_size, self.dropout) + .init(device), + ); + } else { + // No dropout on last layer + layers.push( + LstmCellConfig::new(self.hidden_size, self.hidden_size, 0.0).init(device), + ); + } + } + StackedLstm { layers } + } +} + +impl StackedLstm { + /// Process input sequence through stacked LSTM layers. + /// + /// Args: + /// x: Input tensor of shape (batch_size, seq_length, input_size) + /// states: Optional initial states for each layer + /// + /// Returns: + /// Tuple of (output, states) where output has shape (batch_size, seq_length, hidden_size) + /// and states is a vector of length num_layers, both cell and hidden state in each element have shape (batch_size, hidden_size) + pub fn forward( + &self, + x: Tensor, + states: Option>>, + ) -> (Tensor, Vec>) { + let [batch_size, seq_length, _] = x.dims(); + let device = x.device(); + + let mut states = match states { + None => { + let mut temp: Vec> = vec![]; + for layer in self.layers.iter() { + temp.push(layer.init_state(batch_size, &device)); + } + temp + } + _ => states.unwrap(), + }; + + let mut layer_outputs = vec![]; + for t in 0..seq_length { + let mut input_t = x.clone().slice(s![.., t..t + 1, ..]).squeeze_dim::<2>(1); + for (i, lstm_cell) in self.layers.iter().enumerate() { + let mut state: LstmState = + LstmState::new(states[i].cell.clone(), states[i].hidden.clone()); + state = lstm_cell.forward(input_t, state); + input_t = state.hidden.clone(); + states[i] = state; + } + layer_outputs.push(input_t); + } + + // Stack output along sequence dimension + let output = Tensor::stack(layer_outputs, 1); + + (output, states) + } +} + +/// Complete LSTM network with bidirectional support. +/// +/// In bidirectional mode: +/// - Forward LSTM processes sequence from left to right +/// - Backward LSTM processes sequence from right to left +/// - Outputs are concatenated for final prediction +#[derive(Module, Debug)] +pub struct LstmNetwork { + // Forward direction LSTM + pub stacked_lstm: StackedLstm, + // Optional backward direction LSTM for bidirectional processing + pub reverse_lstm: Option>, + pub dropout: Dropout, + pub fc: Linear, +} + +#[derive(Config, Debug)] +pub struct LstmNetworkConfig { + #[config(default = 1)] + pub input_size: usize, // Single feature (number sequence) + #[config(default = 32)] + pub hidden_size: usize, // Size of LSTM hidden state + #[config(default = 2)] + pub num_layers: usize, // Number of LSTM layers + #[config(default = 1)] + pub output_size: usize, // Predict one number + #[config(default = 0.1)] + pub dropout: f64, + #[config(default = true)] + pub bidirectional: bool, // Use bidirectional LSTM +} + +impl LstmNetworkConfig { + pub fn init( + &self, + device: &B::Device, + ) -> LstmNetwork { + // Forward direction LSTM + let stacked_lstm = StackedLstmConfig::new( + self.input_size, + self.hidden_size, + self.num_layers, + self.dropout, + ) + .init(device); + + // Optional backward direction LSTM for bidirectional processing + let (reverse_lstm, hidden_size) = if self.bidirectional { + let lstm = StackedLstmConfig::new( + self.input_size, + self.hidden_size, + self.num_layers, + self.dropout, + ) + .init(device); + (Some(lstm), 2 * self.hidden_size) + } else { + (None, self.hidden_size) + }; + + let fc = LinearConfig::new(hidden_size, self.output_size).init(device); + let dropout = DropoutConfig::new(self.dropout).init(); + + LstmNetwork { + stacked_lstm, + reverse_lstm, + dropout, + fc, + } + } +} + +impl LstmNetwork { + /// Forward pass of the network. + /// + /// For bidirectional processing: + /// 1. Process sequence normally with forward LSTM + /// 2. Process reversed sequence with backward LSTM + /// 3. Concatenate both outputs + /// 4. Apply final linear transformation + /// + /// Args: + /// x: Input tensor of shape (batch_size, seq_length, input_size) + /// states: Optional initial states + /// + /// Returns: + /// Output tensor of shape (batch_size, output_size) + pub fn forward( + &self, + x: Tensor, + states: Option>>, + ) -> Tensor { + let seq_length = x.dims()[1]; + // Forward direction + let (mut output, _states) = self.stacked_lstm.forward(x.clone(), states); + + output = match &self.reverse_lstm { + Some(reverse_lstm) => { + //Process sequence in reverse direction + let (mut reverse_output, _states) = reverse_lstm.forward(x.flip([1]), None); + // Flip back to align with forward sequence + reverse_output = reverse_output.flip([1]); + // Concatenate forward and backward outputs along the feature dimension + output = Tensor::cat(vec![output, reverse_output], 2); + output + } + None => output, + }; + + // Apply dropout before final layer + output = self.dropout.forward(output); + // Use final timestep output for prediction + self.fc.forward( + output + .slice(s![.., seq_length - 1..seq_length, ..]) + .squeeze_dim::<2>(1), + ) + } +} diff --git a/rust_ml/lstm_inference/src/state.rs b/rust_ml/lstm_inference/src/state.rs index 15d9fca..ec06bff 100644 --- a/rust_ml/lstm_inference/src/state.rs +++ b/rust_ml/lstm_inference/src/state.rs @@ -1,20 +1,22 @@ use burn::{ - backend::NdArray, + backend::Wgpu, module::Module, prelude::Config, record::{CompactRecorder, Recorder}, }; -use lstm_train::{ - model::LstmNetwork, - training::TrainingConfig, -}; +use crate::model::{LstmNetwork, LstmNetworkConfig}; use std::sync::{Arc, Mutex}; -pub type Backend = NdArray; +pub type MyBackend = Wgpu; + +#[derive(Config,Debug)] +pub struct InferenceConfig { + pub model: LstmNetworkConfig, +} #[derive(Clone)] pub struct AppState { - pub model: Arc>>, + pub model: Arc>>, } impl AppState { @@ -25,7 +27,7 @@ impl AppState { let model_path = format!("{}/model", model_dir); // Load training configuration - let config = TrainingConfig::load(&config_path) + let config = InferenceConfig::load(&config_path) .expect("Config should exist for the model; run train first"); // Load model configuration and initialized layers @@ -33,7 +35,7 @@ impl AppState { .load(model_path.into(), &device) .expect("Trained model should exist; run train first"); - let model: LstmNetwork = config.model.init(&device).load_record(record); + let model: LstmNetwork = config.model.init(&device).load_record(record); Self { model: Arc::new(Mutex::new(model)), From 9e3dde869393b52573aae2211a00b7f7dd6ad901 Mon Sep 17 00:00:00 2001 From: valmikGit Date: Wed, 1 Apr 2026 16:22:42 +0530 Subject: [PATCH 07/21] Added GPU usage in the inference code and the Dockerfile of the LSTM inference. --- rust_ml/Dockerfile.lstm_inf | 26 +++++++++++++++++-------- rust_ml/lstm_inference/src/inference.rs | 10 ++++++++-- 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/rust_ml/Dockerfile.lstm_inf b/rust_ml/Dockerfile.lstm_inf index a70786b..4c533cc 100644 --- a/rust_ml/Dockerfile.lstm_inf +++ b/rust_ml/Dockerfile.lstm_inf @@ -1,20 +1,30 @@ +# ----------------------- # Build Stage -FROM rust:1.92-alpine as builder +# ----------------------- +FROM ubuntu:16.04 AS builder -WORKDIR /app WORKDIR /app/rust_ml -# Copy workspace -COPY . /app/rust_ml/ +RUN apt-get update && apt-get install -y \ + curl \ + build-essential \ + pkg-config \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# Install Rust +RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y +ENV PATH="/root/.cargo/bin:${PATH}" + +COPY . . # Build the release binary for lstm_inference RUN cargo build --release -p lstm_inference +# ----------------------- # Runtime Stage -FROM alpine:3.23 - -# Install ca-certificates and openssl -RUN apk add --no-cache openssl-dev ca-certificates +# ----------------------- +FROM nvidia/vulkan:1.3-470 WORKDIR /app diff --git a/rust_ml/lstm_inference/src/inference.rs b/rust_ml/lstm_inference/src/inference.rs index 21bb78c..7f618fe 100644 --- a/rust_ml/lstm_inference/src/inference.rs +++ b/rust_ml/lstm_inference/src/inference.rs @@ -21,15 +21,21 @@ pub struct ErrorResponse { pub async fn predict_handler( State(state): State, - Json(payload): Json, + Json(payload): Json>, ) -> Result, (StatusCode, Json)> { let device: ::Device = Default::default(); + // Explicitly construct the dataset mapping bypassing manual target generation for clients + let item = SequenceDatasetItem { + sequence: payload, + target: 0.0, + }; + // Create batcher mapped to backend let batcher = SequenceBatcher::default(); // Process item into batched tensors - let batch = batcher.batch(vec![payload], &device); + let batch = batcher.batch(vec![item], &device); // Perform forward pass inference let output = state.model.lock().unwrap().forward(batch.sequences, None); From 37a6a9de2c8eb72aecfac10afc6bb89a323583d7 Mon Sep 17 00:00:00 2001 From: valmikGit Date: Wed, 1 Apr 2026 16:39:29 +0530 Subject: [PATCH 08/21] Added wgpu usage in the regression inference pipeline, its Dockerfile and added model.rs in the regression_inference model. --- rust_ml/Dockerfile.regression_inf | 25 ++++++++---- rust_ml/regression_inference/Cargo.toml | 2 +- rust_ml/regression_inference/src/main.rs | 1 + rust_ml/regression_inference/src/model.rs | 49 +++++++++++++++++++++++ rust_ml/regression_inference/src/state.rs | 6 +-- 5 files changed, 72 insertions(+), 11 deletions(-) create mode 100644 rust_ml/regression_inference/src/model.rs diff --git a/rust_ml/Dockerfile.regression_inf b/rust_ml/Dockerfile.regression_inf index edc8353..dacaf4f 100644 --- a/rust_ml/Dockerfile.regression_inf +++ b/rust_ml/Dockerfile.regression_inf @@ -1,21 +1,32 @@ +# ----------------------- # Build Stage -FROM rust:1.92-alpine as builder +# ----------------------- +FROM ubuntu:16.04 AS builder -WORKDIR /app WORKDIR /app/rust_ml +RUN apt-get update && apt-get install -y \ + curl \ + build-essential \ + pkg-config \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# Install Rust +RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y +ENV PATH="/root/.cargo/bin:${PATH}" + # Copy workspace # This assumes the build context is the `rust_ml` root directory. -COPY . /app/rust_ml/ +COPY . . # Build the release binary for regression_inference RUN cargo build --release -p regression_inference +# ----------------------- # Runtime Stage -FROM alpine:3.23 - -# Install ca-certificates and openssl in case axum needs them -RUN apk add --no-cache openssl-dev ca-certificates +# ----------------------- +FROM nvidia/vulkan:1.3-470 WORKDIR /app diff --git a/rust_ml/regression_inference/Cargo.toml b/rust_ml/regression_inference/Cargo.toml index 0c229aa..afea4d1 100644 --- a/rust_ml/regression_inference/Cargo.toml +++ b/rust_ml/regression_inference/Cargo.toml @@ -5,7 +5,7 @@ edition = "2024" [dependencies] regression = { path = "../regression" } -burn = { version = "~0.20", features = ["std", "ndarray"], default-features = false } +burn = { version = "~0.20", features = ["std", "wgpu"], default-features = false } axum = "0.8" tokio = { version = "1", features = ["full"] } serde = { version = "1", features = ["derive"] } diff --git a/rust_ml/regression_inference/src/main.rs b/rust_ml/regression_inference/src/main.rs index df39a1f..2e5cc7e 100644 --- a/rust_ml/regression_inference/src/main.rs +++ b/rust_ml/regression_inference/src/main.rs @@ -1,4 +1,5 @@ mod inference; +mod model; mod state; use axum::{ diff --git a/rust_ml/regression_inference/src/model.rs b/rust_ml/regression_inference/src/model.rs new file mode 100644 index 0000000..29ffed0 --- /dev/null +++ b/rust_ml/regression_inference/src/model.rs @@ -0,0 +1,49 @@ +use burn::{ + nn::{Linear, LinearConfig, Relu}, + prelude::*, +}; +use regression::dataset::NUM_FEATURES; + +#[derive(Module, Debug)] +pub struct RegressionModel { + input_layer: Linear, + output_layer: Linear, + activation: Relu, +} + +#[derive(Config, Debug)] +pub struct RegressionModelConfig { + #[config(default = 64)] + pub hidden_size: usize, +} + +impl RegressionModelConfig { + pub fn init( + &self, + device: &B::Device, + ) -> RegressionModel { + let input_layer = LinearConfig::new(NUM_FEATURES, self.hidden_size) + .with_bias(true) + .init(device); + let output_layer = LinearConfig::new(self.hidden_size, 1) + .with_bias(true) + .init(device); + + RegressionModel { + input_layer, + output_layer, + activation: Relu::new(), + } + } +} + +impl RegressionModel { + pub fn forward( + &self, + input: Tensor, + ) -> Tensor { + let x = self.input_layer.forward(input); + let x = self.activation.forward(x); + self.output_layer.forward(x) + } +} diff --git a/rust_ml/regression_inference/src/state.rs b/rust_ml/regression_inference/src/state.rs index 142b779..c5a8776 100644 --- a/rust_ml/regression_inference/src/state.rs +++ b/rust_ml/regression_inference/src/state.rs @@ -1,12 +1,12 @@ use burn::{ - backend::NdArray, + backend::Wgpu, module::Module, record::{NoStdTrainingRecorder, Recorder}, }; -use regression::model::{RegressionModel, RegressionModelConfig, RegressionModelRecord}; +use crate::model::{RegressionModel, RegressionModelConfig, RegressionModelRecord}; use std::sync::{Arc, Mutex}; -pub type Backend = NdArray; +pub type Backend = Wgpu; #[derive(Clone)] pub struct AppState { From 432fb790512566ea354164db6eb9243e6a759566 Mon Sep 17 00:00:00 2001 From: Abhinav Kumar Date: Wed, 1 Apr 2026 22:46:43 +0530 Subject: [PATCH 09/21] benchmarking files done and changed all port to 9050 --- python_ml/benchmark/bench_lstm.py | 15 ++++++++ python_ml/benchmark/bench_regression.py | 44 ++++++++++++++++++++++++ rust_ml/lstm_inference/src/main.rs | 4 +-- rust_ml/regression_inference/src/main.rs | 4 +-- 4 files changed, 63 insertions(+), 4 deletions(-) create mode 100644 python_ml/benchmark/bench_lstm.py create mode 100644 python_ml/benchmark/bench_regression.py diff --git a/python_ml/benchmark/bench_lstm.py b/python_ml/benchmark/bench_lstm.py new file mode 100644 index 0000000..1ec8631 --- /dev/null +++ b/python_ml/benchmark/bench_lstm.py @@ -0,0 +1,15 @@ +from locust import HttpUser, task, between +import numpy as np + +IP = "127.0.0.1" +PORT = "9050" +SEQ_LEN = 4 + +class LoadTestprofile(HttpUser): + wait_time = between(0.1,0.2) + host = f"http://{IP}:{PORT}" + rng = rng = np.random.default_rng() + @task + def load_task(self): + random_list = self.rng.uniform(0.0, 10.0, SEQ_LEN).tolist() + _ = self.client.post("/predict",json=random_list) \ No newline at end of file diff --git a/python_ml/benchmark/bench_regression.py b/python_ml/benchmark/bench_regression.py new file mode 100644 index 0000000..8f0e019 --- /dev/null +++ b/python_ml/benchmark/bench_regression.py @@ -0,0 +1,44 @@ +from locust import HttpUser, task, between +import pandas as pd +import numpy as np + +IP = "127.0.0.1" +PORT = "9050" +PATH_TO_DSET = "test_data/regression/cali_housing.parquet" + +''' +# download outside the script + +import pandas as pd + +df = pd.read_parquet( + "hf://datasets/gvlassis/california_housing/data/test-00000-of-00001.parquet" +) +df.to_parquet("cali_housing.parquet") +''' + +class LoadTestprofile(HttpUser): + wait_time = between(0.1,0.2) + host = f"http://{IP}:{PORT}" + + @task + def load_task(self): + random_row = self.df.iloc[np.random.randint(0,len(self.df))] + text_to_send = { + 'MedInc' : random_row['MedInc'], + 'HouseAge' : random_row['HouseAge'], + 'AveRooms' : random_row['AveRooms'], + 'AveBedrms' : random_row['AveBedrms'], + 'Population' : random_row['Population'], + 'AveOccup' : random_row['AveOccup'], + 'Latitude' : random_row['Latitude'], + 'Longitude' : random_row['Longitude'], + 'MedHouseVal' : random_row['MedHouseVal'] + } + _ = self.client.post("/predict",json=text_to_send) + # is_correct = (response.json()['prediction'] == self.dict_class[random_row['label']]) + + + + def on_start(self): + self.df = pd.read_parquet(PATH_TO_DSET) \ No newline at end of file diff --git a/rust_ml/lstm_inference/src/main.rs b/rust_ml/lstm_inference/src/main.rs index 8b4fca3..c71e0da 100644 --- a/rust_ml/lstm_inference/src/main.rs +++ b/rust_ml/lstm_inference/src/main.rs @@ -27,7 +27,7 @@ async fn main() { // Load Model State let model_dir = std::env::var("MODEL_DIR") - .unwrap_or_else(|_| -> String { "./model".to_string() }); + .unwrap_or_else(|_| -> String { "model/lstm_train".to_string() }); // Let the AppState construct the pre-loaded memory model let state = AppState::new(&model_dir); @@ -41,7 +41,7 @@ async fn main() { .with_state(state); // Run Server - let port = 9070; + let port = 9050; let addr = SocketAddr::from(([0, 0, 0, 0], port)); tracing::info!("Server listening on http://{}", addr); diff --git a/rust_ml/regression_inference/src/main.rs b/rust_ml/regression_inference/src/main.rs index 2e5cc7e..d410b9e 100644 --- a/rust_ml/regression_inference/src/main.rs +++ b/rust_ml/regression_inference/src/main.rs @@ -27,7 +27,7 @@ async fn main() { // Load Model State // In Docker, it'll mount to /app/model. mpk is the default extension from burn's NoStdTrainingRecorder let model_path = std::env::var("MODEL_PATH") - .unwrap_or_else(|_| -> String { "./model".to_string() }); + .unwrap_or_else(|_| -> String { "model/regression_train/model.bin".to_string() }); // Let the AppState construct the pre-loaded memory model let state = AppState::new(&model_path); @@ -41,7 +41,7 @@ async fn main() { .with_state(state); // Run Server - let port = 9060; + let port = 9050; let addr = SocketAddr::from(([0, 0, 0, 0], port)); tracing::info!("Server listening on http://{}", addr); From 46d70801c8d3ada7574cabedb43f541d906e1ed2 Mon Sep 17 00:00:00 2001 From: Abhinav Kumar Date: Wed, 1 Apr 2026 22:57:26 +0530 Subject: [PATCH 10/21] docker file to be tested - 2 --- rust_ml/Dockerfile.lstm_inf | 11 +++++++---- rust_ml/Dockerfile.regression_inf | 9 +++++---- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/rust_ml/Dockerfile.lstm_inf b/rust_ml/Dockerfile.lstm_inf index 4c533cc..fbeb16a 100644 --- a/rust_ml/Dockerfile.lstm_inf +++ b/rust_ml/Dockerfile.lstm_inf @@ -29,13 +29,16 @@ FROM nvidia/vulkan:1.3-470 WORKDIR /app # Copy compiled binary from builder -COPY --from=builder /app/rust_ml/target/release/lstm_inference /app/lstm_inference +COPY --from=builder /app/rust_ml/target/release/lstm_inference /app/binary + +COPY ./model/lstm_train/config.json /app//model/lstm_train/config.json +COPY ./model/lstm_train/model.mpk /app//model/lstm_train/model.mpk # Environment variables ENV RUST_LOG=info # Setup the default mounted model path -ENV MODEL_DIR=/app/model/lstm_train_rust +ENV MODEL_DIR=/app/model/lstm_train -EXPOSE 9070 +EXPOSE 9050 -CMD ["./lstm_inference"] +CMD ["./binary"] diff --git a/rust_ml/Dockerfile.regression_inf b/rust_ml/Dockerfile.regression_inf index dacaf4f..2d7c027 100644 --- a/rust_ml/Dockerfile.regression_inf +++ b/rust_ml/Dockerfile.regression_inf @@ -31,13 +31,14 @@ FROM nvidia/vulkan:1.3-470 WORKDIR /app # Copy compiled binary from builder -COPY --from=builder /app/rust_ml/target/release/regression_inference /app/regression_inference +COPY --from=builder /app/rust_ml/target/release/regression_inference /app/binary +COPY ./model/regression_train/model.bin /app/model/regression_train/model.bin # Environment variables ENV RUST_LOG=info # Setup the default mounted model path -ENV MODEL_PATH=/app/model/regression_rust/model +ENV MODEL_PATH=/app/model/regression_train/model.bin -EXPOSE 9060 +EXPOSE 9050 -CMD ["./regression_inference"] +CMD ["./binary"] From 1111a1987985603f996fb30e4a33e346c839d7c8 Mon Sep 17 00:00:00 2001 From: valmikGit Date: Thu, 2 Apr 2026 14:26:52 +0530 Subject: [PATCH 11/21] Added Jenkinsfile.modified to capture parameters. --- Jenkinsfile.modified | 192 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 192 insertions(+) create mode 100644 Jenkinsfile.modified diff --git a/Jenkinsfile.modified b/Jenkinsfile.modified new file mode 100644 index 0000000..3201c28 --- /dev/null +++ b/Jenkinsfile.modified @@ -0,0 +1,192 @@ +/* + * LSTM — Inference Evaluation Pipeline + */ +pipeline { + agent any + + environment { + BUILD_CTX = "${WORKSPACE}" + METRICS_DIR = "${WORKSPACE}/eval_metrics/lstm" + + MODEL_NAME = "lstm" + IMAGE_GPU = "py-lstm-gpu" + + CONTAINER_NAME = "eval-lstm" + EVAL_PORT = "8002" + + PATH = "/var/lib/jenkins/.local/bin:/home/ajitesh/.local/bin:${env.PATH}" + } + + stages { + stage('Setup') { + steps { + sh "mkdir -p ${METRICS_DIR}" + } + } + + stage('Build Image') { + steps { + script { + buildImg(IMAGE_GPU, 'python_ml/pytorch/LSTM/Inference/Dockerfile') + } + } + } + + stage('Image Metrics') { + steps { + script { + measureImg(IMAGE_GPU) + } + } + } + + stage('Evaluate') { + steps { + script { + evaluate(IMAGE_GPU, EVAL_PORT) + } + } + } + } + + post { + always { + sh "docker rm -f ${CONTAINER_NAME} 2>/dev/null || true" + + archiveArtifacts artifacts: 'eval_metrics/lstm/**/*', allowEmptyArchive: true + } + } +} + +def buildImg(String image, String dockerfile) { + def t0 = System.currentTimeMillis() + def status = 'SUCCESS' + + try { + sh "docker build -t ${image} -f ${dockerfile} ${BUILD_CTX}" + } catch (Exception e) { + status = 'FAILURE' + throw e + } finally { + def dur = System.currentTimeMillis() - t0 + def ts = new Date().format("yyyy-MM-dd'T'HH:mm:ss'Z'") + + sh """ + cat > ${METRICS_DIR}/${image}_build.json </dev/null || echo 0) + LC=\$(docker history -q ${image} 2>/dev/null | wc -l) + SM=\$(echo "scale=2; \$SB / 1048576" | bc) + + cat > ${METRICS_DIR}/${image}_image.json </dev/null || true" + + sh """ + T0=\$(date +%s%3N) + + docker run -d \ + --name ${CONTAINER_NAME} \ + -p ${port}:8000 \ + -v ${METRICS_DIR}:/app/devops_metrics \ + ${image} + + T1=\$(date +%s%3N) + + echo "Container launched, waiting for readiness..." + + READY=0 + + for i in \$(seq 1 300); do + HEALTH_STATUS=\$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' ${CONTAINER_NAME} 2>/dev/null || echo "missing") + + if [ "\$HEALTH_STATUS" = "healthy" ]; then + READY=1 + break + fi + + if curl -sf http://localhost:${port}/health > /dev/null 2>&1; then + READY=1 + break + fi + + sleep 0.1 + done + + T2=\$(date +%s%3N) + + CONTAINER_START_MS=\$((T1 - T0)) + APP_READY_MS=\$((T2 - T1)) + TOTAL_COLD_START_MS=\$((T2 - T0)) + + if [ "\$READY" -eq 1 ]; then + HEALTH_PAYLOAD=\$(curl -sf http://localhost:${port}/health 2>/dev/null || echo '{}') + + cat > ${METRICS_DIR}/cold_start.json < ${METRICS_DIR}/cold_start.json < ${METRICS_DIR}/app_metrics.json || echo '{}' > ${METRICS_DIR}/app_metrics.json + + docker stats --no-stream --format '{"dimension":"5_resources","cpu":"{{.CPUPerc}}","mem":"{{.MemUsage}}"}' ${CONTAINER_NAME} > ${METRICS_DIR}/docker_stats.json || true + + docker inspect ${CONTAINER_NAME} > ${METRICS_DIR}/container_inspect.json || true + """ + + sh "docker rm -f ${CONTAINER_NAME} 2>/dev/null || true" +} \ No newline at end of file From 2b2ee451f1c2ee4b26b114c654dc286204cbc98d Mon Sep 17 00:00:00 2001 From: Abhinav Kumar Date: Thu, 2 Apr 2026 16:27:13 +0530 Subject: [PATCH 12/21] correction in lstm docker image --- rust_ml/Dockerfile.lstm_inf | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rust_ml/Dockerfile.lstm_inf b/rust_ml/Dockerfile.lstm_inf index fbeb16a..da26e46 100644 --- a/rust_ml/Dockerfile.lstm_inf +++ b/rust_ml/Dockerfile.lstm_inf @@ -31,8 +31,8 @@ WORKDIR /app # Copy compiled binary from builder COPY --from=builder /app/rust_ml/target/release/lstm_inference /app/binary -COPY ./model/lstm_train/config.json /app//model/lstm_train/config.json -COPY ./model/lstm_train/model.mpk /app//model/lstm_train/model.mpk +COPY ./model/lstm_train/config.json /app/model/lstm_train/config.json +COPY ./model/lstm_train/model.mpk /app/model/lstm_train/model.mpk # Environment variables ENV RUST_LOG=info From f037ae432ac42058a1ee0d8afb263c875d06d129 Mon Sep 17 00:00:00 2001 From: Abhinav Kumar Date: Mon, 6 Apr 2026 21:51:14 +0530 Subject: [PATCH 13/21] mnist correction incresed recursion limit --- rust_ml/mnist_ml/src/main.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/rust_ml/mnist_ml/src/main.rs b/rust_ml/mnist_ml/src/main.rs index 03c994e..ac10cc6 100644 --- a/rust_ml/mnist_ml/src/main.rs +++ b/rust_ml/mnist_ml/src/main.rs @@ -1,3 +1,4 @@ +#![recursion_limit = "256"] mod data; mod model; mod training; From 02617a9fdc8dd7c153e0d7e69782396c48105572 Mon Sep 17 00:00:00 2001 From: Abhinav Kumar Date: Thu, 9 Apr 2026 00:43:06 +0530 Subject: [PATCH 14/21] removed benchmark code to check correctness in text classification --- python_ml/benchmark/bench_text_class.py | 34 ++++++++++++------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/python_ml/benchmark/bench_text_class.py b/python_ml/benchmark/bench_text_class.py index 4dc1347..fb7cfbb 100644 --- a/python_ml/benchmark/bench_text_class.py +++ b/python_ml/benchmark/bench_text_class.py @@ -27,22 +27,22 @@ def load_task(self): text_to_send = { 'text' : random_row['text'] } - response = self.client.post("/predict",json=text_to_send) - is_correct = (response.json()['prediction'] == self.dict_class[random_row['label']]) - events.request.fire( - request_type="ML", - name="accuracy", - response_time=0, - response_length=1, - exception=None if is_correct else Exception("wrong"), - ) + _ = self.client.post("/predict",json=text_to_send) + # is_correct = (response.json()['prediction'] == self.dict_class[random_row['label']]) + # events.request.fire( + # request_type="ML", + # name="accuracy", + # response_time=0, + # response_length=1, + # exception=None if is_correct else Exception("wrong"), + # ) - def on_start(self): - self.df = pd.read_parquet(PATH_TO_DSET) - self.dict_class = { - 0 : "World", - 1 : "Sports", - 2 : "Business", - 3 : "Technology" - } \ No newline at end of file + # def on_start(self): + # self.df = pd.read_parquet(PATH_TO_DSET) + # self.dict_class = { + # 0 : "World", + # 1 : "Sports", + # 2 : "Business", + # 3 : "Technology" + # } \ No newline at end of file From abc2775c29059f7c4599360397c13abe32e2d2fd Mon Sep 17 00:00:00 2001 From: Abhinav Kumar Date: Thu, 9 Apr 2026 01:05:06 +0530 Subject: [PATCH 15/21] correction in text class benchmark file --- python_ml/benchmark/bench_text_class.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/python_ml/benchmark/bench_text_class.py b/python_ml/benchmark/bench_text_class.py index fb7cfbb..3423e7c 100644 --- a/python_ml/benchmark/bench_text_class.py +++ b/python_ml/benchmark/bench_text_class.py @@ -38,11 +38,11 @@ def load_task(self): # ) - # def on_start(self): - # self.df = pd.read_parquet(PATH_TO_DSET) - # self.dict_class = { - # 0 : "World", - # 1 : "Sports", - # 2 : "Business", - # 3 : "Technology" - # } \ No newline at end of file + def on_start(self): + self.df = pd.read_parquet(PATH_TO_DSET) + # self.dict_class = { + # 0 : "World", + # 1 : "Sports", + # 2 : "Business", + # 3 : "Technology" + # } \ No newline at end of file From afe9a236290ced7d3f3f1b7af38329ff57b4dc16 Mon Sep 17 00:00:00 2001 From: Abhinav Kumar Date: Thu, 9 Apr 2026 02:02:29 +0530 Subject: [PATCH 16/21] rust inference reports - 1 --- reports/rust/lstm.html | 124 +++++++++++++++++++++++++++++++++++ reports/rust/mnist.html | 124 +++++++++++++++++++++++++++++++++++ reports/rust/regression.html | 124 +++++++++++++++++++++++++++++++++++ reports/rust/text_class.html | 124 +++++++++++++++++++++++++++++++++++ 4 files changed, 496 insertions(+) create mode 100644 reports/rust/lstm.html create mode 100644 reports/rust/mnist.html create mode 100644 reports/rust/regression.html create mode 100644 reports/rust/text_class.html diff --git a/reports/rust/lstm.html b/reports/rust/lstm.html new file mode 100644 index 0000000..43ae5e7 --- /dev/null +++ b/reports/rust/lstm.html @@ -0,0 +1,124 @@ + + + + + + + + + + + Locust + + + + +
+ + + + + \ No newline at end of file diff --git a/reports/rust/mnist.html b/reports/rust/mnist.html new file mode 100644 index 0000000..6f73a2f --- /dev/null +++ b/reports/rust/mnist.html @@ -0,0 +1,124 @@ + + + + + + + + + + + Locust + + + + +
+ + + + + \ No newline at end of file diff --git a/reports/rust/regression.html b/reports/rust/regression.html new file mode 100644 index 0000000..0c554d7 --- /dev/null +++ b/reports/rust/regression.html @@ -0,0 +1,124 @@ + + + + + + + + + + + Locust + + + + +
+ + + + + \ No newline at end of file diff --git a/reports/rust/text_class.html b/reports/rust/text_class.html new file mode 100644 index 0000000..e9417ab --- /dev/null +++ b/reports/rust/text_class.html @@ -0,0 +1,124 @@ + + + + + + + + + + + Locust + + + + +
+ + + + + \ No newline at end of file From 339bd7d3cf2a67d1708e6a7cc0304c66ff0d389b Mon Sep 17 00:00:00 2001 From: valmikGit Date: Wed, 15 Apr 2026 23:01:06 +0530 Subject: [PATCH 17/21] Tried to create .tex combined file. --- latex_reports/combined_research_paper.tex | 2139 ++++++++++++++++++++ latex_reports/lstm.tex | 224 +- latex_reports/main.tex | 2 +- latex_reports/merge_script.py | 132 ++ latex_reports/mnist.tex | 515 ++++- latex_reports/regression.tex | 231 ++- latex_reports/text_classification_news.tex | 356 +++- 7 files changed, 3510 insertions(+), 89 deletions(-) create mode 100644 latex_reports/combined_research_paper.tex create mode 100644 latex_reports/merge_script.py diff --git a/latex_reports/combined_research_paper.tex b/latex_reports/combined_research_paper.tex new file mode 100644 index 0000000..25e0987 --- /dev/null +++ b/latex_reports/combined_research_paper.tex @@ -0,0 +1,2139 @@ +\documentclass[10pt,a4paper,twocolumn]{article} + +\usepackage{geometry} +\geometry{margin=0.75in, columnsep=0.25in} + +\usepackage{amsmath,amssymb} +\usepackage{graphicx} +\usepackage{booktabs} +\usepackage{hyperref} +\usepackage{enumitem} +\usepackage{array} +\usepackage{float} +\usepackage{listings} +\usepackage{xcolor} +\usepackage{pdfpages} +\usepackage{minted} + +\lstdefinelanguage{Dockerfile}{ + keywords={FROM, RUN, CMD, LABEL, MAINTAINER, EXPOSE, ENV, ADD, COPY, ENTRYPOINT, VOLUME, USER, WORKDIR, ARG, ONBUILD, STOPSIGNAL, HEALTHCHECK, SHELL}, + sensitive=true, + comment=[l]{\#}, + morestring=[b]", +} + +\title{\textbf{System-Level Evaluation of Rust and Python for Machine Learning}} +\author{Project Elective} +\date{\today} + +\begin{document} + +\maketitle +\section{Overview of the Project} + +This project studies the use of \textbf{Rust} as an alternative systems language for machine learning workflows traditionally implemented in \textbf{Python}. +Rather than focusing on state-of-the-art model performance, the emphasis is on: + +\begin{itemize} + \item feasibility of end-to-end ML workflows, + \item system stability and reproducibility, + \item developer experience and DevOps complexity, + \item deployment and operational characteristics. +\end{itemize} + +To ensure clarity and rigor, the work is organized into \textbf{two clearly separated experimental tracks}. + +--- + +\section{Project Structure: Two-Track Evaluation} + +The project consists of the following two tracks: + +\subsection*{Track 1: Training-Based Systems Evaluation} +This track compares \textbf{machine learning training pipelines} implemented in: +\begin{itemize} + \item PyTorch (Python), and + \item Burn (Rust). +\end{itemize} + +The goal is to evaluate training feasibility, stability, compile-time guarantees, and DevOps impact, rather than raw training speed. + +\subsection*{Track 2: Inference-Based DevOps Evaluation} +This track compares \textbf{production-style inference services} implemented in: +\begin{itemize} + \item Python-based ONNX inference, and + \item Rust-based ONNX inference. +\end{itemize} + +The focus is on deployment, security, containerization, CI/CD behavior, and runtime efficiency. + +Each track is designed to answer a distinct research question while remaining complementary. + +--- + +\section{Machine Learning Tasks Considered} + +To ensure coverage of diverse ML workloads, the following tasks are identified: + +\begin{itemize} + \item \textbf{Text Classification}: Dataset to be finalized. + \item \textbf{Image Classification}: MNIST dataset. + \item \textbf{Credit Score Assignment}: Supervised classification task. + \item \textbf{Multi-Objective Machine Learning}: Brain Tumor dataset with a MOML formulation. + \item \textbf{Fine-Tuning Task}: BERT-based classification (ANLP Assignment 1), with optional LoRA / QLoRA. + \item \textbf{Autoregressive Decoding}: Experiments using the Burn framework. +\end{itemize} + +At the current stage, the \textbf{MNIST image classification task has been fully implemented}. +The corresponding training code is available in the project GitHub repository. + +--- + +\section{Related Work} + +The following research papers are being used to guide experimental design and evaluation: + +\begin{itemize} + \item \url{https://ieeexplore.ieee.org/document/11126113} + \item \url{https://ieeexplore.ieee.org/document/11261485} + \item \url{https://ieeexplore.ieee.org/document/11212348} + \item \url{https://www.ijsred.com/volume8/issue2/IJSRED-V8I2P143.pdf} +\end{itemize} + +--- + +\section{Code Repository and Current Status} + +Project repository: +\begin{center} +\url{https://github.com/Abhinav-Kumar012/Rust_Python_ML_PE.git} +\end{center} + +Current progress includes: +\begin{itemize} + \item MNIST training pipeline implemented + \item PyTorch baseline established + \item Initial Rust (Burn) training setup completed +\end{itemize} + +--- + +\section{Track 1: Training-Based Systems Evaluation} + +\subsection{Objective} + +The objective of this track is to answer the following research question: + +\begin{quote} +\textit{Can Rust realistically support end-to-end machine learning training pipelines, and what system-level trade-offs does this introduce compared to PyTorch?} +\end{quote} + +This track explicitly avoids speed-centric benchmarking and instead focuses on system behavior. + +--- + +\subsection{Frameworks Compared} + +\subsubsection{PyTorch (Baseline)} +\begin{itemize} + \item Language: Python + \item Training maturity: Very high + \item Ecosystem: Extensive +\end{itemize} + +\subsubsection{Rust (Burn)} +\begin{itemize} + \item Language: Rust + \item Training maturity: Emerging + \item Design: Idiomatic Rust, native training support +\end{itemize} + +--- + +\subsection{Experimental Controls} + +\textbf{Fixed Across Both Implementations} +\begin{itemize} + \item Dataset splits + \item Number of epochs + \item Batch size + \item Optimizer type + \item Learning rate + \item Hardware +\end{itemize} + +\textbf{Allowed Differences} +\begin{itemize} + \item Internal kernel implementations + \item Graph execution model + \item Memory management +\end{itemize} + +--- + +\subsection{Metrics Collected} + +\begin{itemize} + \item Training time per epoch (reported cautiously) + \item Loss curves and convergence behavior + \item Runtime failures and numerical stability + \item Reproducibility across runs + \item Environment setup and build complexity + \item Dependency footprint and artifact size +\end{itemize} + +--- + +\section{Track 2: Inference-Based DevOps Evaluation} + +\subsection{Objective} + +The objective of this track is to compare \textbf{deployment, security, and operational characteristics} of Python-based and Rust-based ML inference services executing the same ONNX model. + +--- + +\subsection{Inference Services Compared} + +\textbf{Python Service} +\begin{itemize} + \item FastAPI + Uvicorn + \item ONNX Runtime (Python) +\end{itemize} + +\textbf{Rust Service} +\begin{itemize} + \item Axum / Actix + \item burn-rs +\end{itemize} + +Both services expose identical inference endpoints and return identical outputs. + +--- + +\subsection{Evaluation Dimensions} + +\begin{itemize} + \item CI/CD build behavior + \item Container image size and layering + \item Cold-start latency + \item Inference latency and throughput + \item Resource utilization + \item Security and supply-chain surface +\end{itemize} + +--- + +\section{Upcoming Work} + +The following tasks are planned for the next phase of the project: +s +\begin{itemize} + \item Develop production-style inference services for both Python and Rust. + \item Write Dockerfiles for Python and Rust inference services. + \item Set up Jenkins-based CI pipelines for inference, including build, test, containerization, and security scanning. +\end{itemize} + +\clearpage + +\section{Task: MNIST Image Classification} +\subsection{Architecture Details} + +\begin{table*}[t!] +\centering +\renewcommand{\arraystretch}{1.3} +\begin{tabular}{|c|l|l|l|l|} +\hline +\textbf{Step} & \textbf{Layer} & \textbf{Configuration} & \textbf{Input Shape} & \textbf{Output Shape} \\ +\hline + +1 & Input & Grayscale Images & +$[B, H, W]$ & +$[B, H, W]$ \\ + +\hline +2 & Reshape & Add channel dimension & +$[B, H, W]$ & +$[B, 1, H, W]$ \\ + +\hline +3 & Conv2D (conv1) & +$1 \rightarrow 8$, kernel $3 \times 3$ & +$[B, 1, H, W]$ & +$[B, 8, H-2, W-2]$ \\ + +\hline +4 & Dropout & +$p = 0.5$ & +$[B, 8, H-2, W-2]$ & +$[B, 8, H-2, W-2]$ \\ + +\hline +5 & Conv2D (conv2) & +$8 \rightarrow 16$, kernel $3 \times 3$ & +$[B, 8, H-2, W-2]$ & +$[B, 16, H-4, W-4]$ \\ + +\hline +6 & Dropout & +$p = 0.5$ & +$[B, 16, H-4, W-4]$ & +$[B, 16, H-4, W-4]$ \\ + +\hline +7 & ReLU & +Activation & +$[B, 16, H-4, W-4]$ & +$[B, 16, H-4, W-4]$ \\ + +\hline +8 & Adaptive Avg Pool & +Output size $8 \times 8$ & +$[B, 16, H-4, W-4]$ & +$[B, 16, 8, 8]$ \\ + +\hline +9 & Flatten & +$16 \times 8 \times 8$ & +$[B, 16, 8, 8]$ & +$[B, 1024]$ \\ + +\hline +10 & Linear (fc1) & +$1024 \rightarrow \texttt{hidden\_size}$ & +$[B, 1024]$ & +$[B, \texttt{hidden\_size}]$ \\ + +\hline +11 & Dropout & +$p = 0.5$ & +$[B, \texttt{hidden\_size}]$ & +$[B, \texttt{hidden\_size}]$ \\ + +\hline +12 & ReLU & +Activation & +$[B, \texttt{hidden\_size}]$ & +$[B, \texttt{hidden\_size}]$ \\ + +\hline +13 & Linear (fc2) & +$\texttt{hidden\_size} \rightarrow \texttt{num\_classes}$ & +$[B, \texttt{hidden\_size}]$ & +$[B, \texttt{num\_classes}]$ \\ + +\hline +\end{tabular} +\caption{Detailed architecture of the convolutional neural network implemented in Burn. +$B$ denotes batch size, $H$ and $W$ denote input image height and width respectively.} +\label{tab:burn-cnn-architecture} +\end{table*} + +\noindent\textbf{Notes:} +\begin{itemize} + \item All convolution layers use default stride = 1 and no padding. + \item Dropout probability is configurable via \texttt{ModelConfig.dropout}. + \item Adaptive average pooling ensures a fixed spatial resolution regardless of input size. + \item The model is fully differentiable and backend-agnostic via Burn's \texttt{Backend} trait. +\end{itemize} + + +\subsection{Rust Backend: Load Testing with Locust} + +The performance of the Rust-based backend was evaluated using the Locust load testing framework. The objective was to analyze system behavior under concurrent user load and measure key performance characteristics such as throughput and latency. + +\textbf{Testing Setup:} +\textbf{Testing Setup:} +\begin{itemize} + \item Tool: Locust + \item Backend: Rust (HTTP service) + \item Test Type: Concurrent user load simulation + \item Environment: Linux system +\end{itemize} + +\textbf{Dashboard Visualization:} +\textbf{Full Report:} + +The complete load testing dashboard has been exported as a PDF and is included below for detailed inspection. + +\includepdf[pages=-]{RustLocust/MNIST.pdf} + + +\subsection{Python Backend: Load Testing with Locust} + +The performance of the Python-based backend was evaluated using the Locust load testing framework. The goal was to assess system behavior under concurrent user load and analyze key performance characteristics such as throughput and response latency. + +\textbf{Testing Setup:} +\begin{itemize} + \item Tool: Locust + \item Backend: Python (HTTP service) + \item Test Type: Concurrent user load simulation + \item Environment: Linux system +\end{itemize} + +\textbf{Dashboard Visualization:} + +\textbf{Full Report:} + +The complete load testing dashboard has been exported as a PDF and is included below for detailed inspection. + +\includepdf[pages=-]{PythonLocust/MNIST.pdf} + + +\subsection{Rust: Dockerfile Design and Containerization Strategy} + +The Dockerfile used for the Rust-based MNIST inference application follows a multi-stage build strategy. Multi-stage builds are commonly used to reduce the size of the final container image by separating the compilation environment from the runtime environment. + +\subsubsection{Overview of Multi-Stage Build} + +The Dockerfile is divided into two major stages: + +\begin{enumerate} + \item Builder Stage + \item Runtime Stage +\end{enumerate} + +The builder stage is responsible for compiling the Rust application, while the runtime stage contains only the compiled binary and the required runtime dependencies. + +\subsubsection{Builder Stage} + +The first stage begins with: + +\begin{verbatim} +FROM ubuntu:16.04 AS builder +\end{verbatim} + +This instruction uses Ubuntu 16.04 as the base image for building the Rust application. The alias \texttt{builder} is assigned to this stage so that its outputs can later be referenced in the runtime stage. + +\paragraph{Working Directory} + +\begin{verbatim} +WORKDIR /app/rust_ml +\end{verbatim} + +The \texttt{WORKDIR} instruction sets the default working directory inside the container to: + +\begin{verbatim} +/app/rust_ml +\end{verbatim} + +All subsequent commands in the builder stage are executed relative to this directory. + +\paragraph{Installing Build Dependencies} + +The following command installs the required packages for compiling the Rust project: + +\begin{verbatim} +RUN apt-get update && apt-get install -y \ + curl \ + build-essential \ + pkg-config \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* +\end{verbatim} + +Each package serves a specific purpose: + +\begin{itemize} + \item \texttt{curl}: Used to download external files, including the Rust installation script. + \item \texttt{build-essential}: Provides common compilation tools such as \texttt{gcc}, \texttt{g++}, and \texttt{make}. + \item \texttt{pkg-config}: Helps discover system libraries during the build process. + \item \texttt{ca-certificates}: Ensures secure HTTPS communication when downloading dependencies. +\end{itemize} + +The final cleanup command: + +\begin{verbatim} +rm -rf /var/lib/apt/lists/* +\end{verbatim} + +removes cached package lists to reduce image size. + +\paragraph{Installing Rust} + +Rust is installed using the official Rust installer: + +\begin{verbatim} +RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y +\end{verbatim} + +This command downloads and executes the \texttt{rustup} installer. + +The flags used have the following meanings: + +\begin{itemize} + \item \texttt{--proto '=https'}: Restricts downloads to HTTPS only. + \item \texttt{--tlsv1.2}: Forces the use of TLS version 1.2 for secure transport. + \item \texttt{-sSf}: Makes \texttt{curl} silent while still showing errors if the download fails. + \item \texttt{-y}: Automatically accepts all installation prompts. +\end{itemize} + +After Rust is installed, the PATH environment variable is updated: + +\begin{verbatim} +ENV PATH="/root/.cargo/bin:${PATH}" +\end{verbatim} + +This ensures that Rust tools such as \texttt{cargo} and \texttt{rustc} are available in subsequent commands. + +\paragraph{Copying Source Code} + +\begin{verbatim} +COPY . . +\end{verbatim} + +This instruction copies the entire project directory from the host system into the current working directory inside the container. + +\paragraph{Building the Application} + +\begin{verbatim} +RUN cargo build --release -p mnist_infer +\end{verbatim} + +This command compiles the Rust project in release mode. + +The options used are: + +\begin{itemize} + \item \texttt{--release}: Builds the application with compiler optimizations enabled. + \item \texttt{-p mnist\_infer}: Specifies that only the \texttt{mnist\_infer} package should be compiled. +\end{itemize} + +The generated executable is stored in: + +\begin{verbatim} +/app/rust_ml/target/release/mnist_infer +\end{verbatim} + +\subsubsection{Runtime Stage} + +The second stage begins with: + +\begin{verbatim} +FROM nvidia/vulkan:1.3-470 +\end{verbatim} + +This stage uses an NVIDIA Vulkan runtime image as the base image. The purpose of using this image is to provide Vulkan-related runtime libraries and GPU compatibility for applications that may rely on Vulkan acceleration. + +Compared to the builder image, this runtime image is significantly smaller because it does not contain compilation tools, Rust compilers, or source code. + + +\paragraph{Runtime Working Directory} + +\begin{verbatim} +WORKDIR /app +\end{verbatim} + +This sets the runtime working directory to: + +\begin{verbatim} +/app +\end{verbatim} + +All runtime files are placed relative to this location. + +\paragraph{Copying the Compiled Binary} + +\begin{verbatim} +COPY --from=builder /app/rust_ml/target/release/mnist_infer /app/binary +\end{verbatim} + +This instruction copies the compiled executable from the builder stage into the runtime image. + +The \texttt{--from=builder} option tells Docker to retrieve the file from the stage named \texttt{builder}. + +The binary is renamed from: + +\begin{verbatim} +mnist_infer +\end{verbatim} + +to: + +\begin{verbatim} +/app/binary +\end{verbatim} + +inside the runtime container. + +\paragraph{Copying the Model File} + +\begin{verbatim} +COPY ./model/mnist_rust/model.mpk /app/model/mnist_rust/model.mpk +\end{verbatim} + +This instruction copies the trained model file into the runtime container. + +The model file is stored at: + +\begin{verbatim} +/app/model/mnist_rust/model.mpk +\end{verbatim} + +The application can later load this file during inference. + +\paragraph{Environment Variables} + +Two environment variables are defined: + +\begin{verbatim} +ENV RUST_LOG=info +ENV MODEL_PATH=/app/model/mnist_rust/model.mpk +\end{verbatim} + +Their purposes are: + +\begin{itemize} + \item \texttt{RUST\_LOG=info}: Enables logging at the info level. + \item \texttt{MODEL\_PATH}: Stores the path to the trained model file. +\end{itemize} + +Using environment variables makes the application more flexible because configuration values can be changed without modifying the source code. + +\paragraph{Exposing the Application Port} + +\begin{verbatim} +EXPOSE 9050 +\end{verbatim} + +This instruction documents that the containerized application listens on port 9050. + +Although \texttt{EXPOSE} does not automatically publish the port to the host system, it informs users and orchestration tools such as Docker Compose or Kubernetes which port should be mapped. + +\paragraph{Container Startup Command} + +\begin{verbatim} +CMD ["./binary"] +\end{verbatim} + +This instruction defines the default command executed when the container starts. + +The compiled Rust binary is launched directly from the runtime working directory. + +\subsubsection{Advantages of the Dockerfile Design} + +This Dockerfile provides several important advantages: + +\begin{itemize} + \item Reduced final image size through multi-stage builds. + \item Separation of build dependencies and runtime dependencies. + \item Improved security because the runtime image does not contain compilers or source code. + \item Faster deployment due to a lightweight runtime container. + \item Better portability because the same container can run consistently across different environments. + \item Easier maintenance through the use of environment variables and explicit working directories. +\end{itemize} + +Overall, this Dockerfile is designed to efficiently package the Rust-based MNIST inference application for deployment while minimizing runtime overhead and maintaining reproducibility. + +\subsection{Python (PyTorch) Dockerfile} + +This section details the image optimization strategy implemented for the MNIST inference container. The core approach minimizes the Docker image size by decoupling the heavy machine learning dependencies (PyTorch, etc.) from the application container. Instead of baking these libraries into the image, they are stored on an external volume (NFS share) and mounted at runtime. + +\subsubsection{Dockerfile Analysis} + +The \texttt{Dockerfile} is kept intentionally lightweight. By excluding large dependencies like \texttt{torch} from the \texttt{pip install} command, the image size remains very small (only containing the base Python runtime and lightweight web frameworks). + +\begin{lstlisting}[language=Dockerfile, caption={Optimized Inference Dockerfile}, label={lst:dockerfile_inference}] +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 +ENV OMP_NUM_THREADS=1 +ENV MKL_NUM_THREADS=1 + +# Critical: Point Python to the external volume +ENV PYTHONPATH=/external-libs/ml_env/lib/python3.12/site-packages + +WORKDIR /app + +# Only install lightweight app dependencies +RUN pip install --no-cache-dir --upgrade pip && \ + pip install fastapi==0.110.0 uvicorn==0.29.0 python-multipart==0.0.9 + +COPY app.py model.py model.pt ./ + +EXPOSE 8000 + +CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"] +\end{lstlisting} + +\begin{itemize} + \item \textbf{Base Image:} Uses \texttt{python:3.12-slim} to minimise the OS footprint. + \item \textbf{Environment Configuration:} + \begin{itemize} + \item \texttt{PYTHONDONTWRITEBYTECODE=1}: Prevents Python from writing \texttt{.pyc} files to disk. + \item \textbf{\texttt{PYTHONPATH}}: Crucially set to \texttt{/external-libs/ml\_env/lib/python3.12/site-packages}. This instructs the Python interpreter to look for libraries in the mounted volume directory, not just the default system paths. + \end{itemize} + \item \textbf{Minimal Dependencies:} The \texttt{pip install} command only installs \texttt{fastapi}, \texttt{uvicorn}, and \texttt{python-multipart}. Heavy ML libraries are assumed to be present in the mounted volume. +\end{itemize} + +\subsubsection{Volume Mounting Strategy} + +The strategy relies on two shell scripts to set up the environment on the host machine and run the container with the correct volume mappings. + +\paragraph{Library Setup (\texttt{mount\_libs.sh})} +This script runs on the host machine (or a VM node) to prepare the shared library volume. +\begin{enumerate} + \item \textbf{NFS Client Installation:} It installs \texttt{nfs-common} to enable Network File System capabilities. + \item \textbf{Mounting:} It connects to a remote NFS server (\texttt{172.16.203.14}) where the pre-installed ML libraries reside. + \item \textbf{Local Path:} The remote libraries are mounted to \texttt{/mnt/ml-libs} on the host. This directory acts as the bridge between the NFS server and the Docker container. +\end{enumerate} + +\paragraph{Runtime Execution (\texttt{run\_container.sh})} +This script launches the Docker container with the necessary runtime configurations to access the external libraries. + +\begin{lstlisting}[language=Bash, caption={Container Execution Command}] +docker run -d \ + -v /mnt/ml-libs:/external-libs \ + -e PYTHONPATH=/external-libs/ml_env/lib/python3.12/site-packages \ + -p 8000:8000 \ + fastapi-ml-app +\end{lstlisting} + +\begin{itemize} + \item \textbf{\texttt{-v /mnt/ml-libs:/external-libs}}: This bind mount maps the host's \texttt{/mnt/ml-libs} (which contains the NFS data) to \texttt{/external-libs} inside the container. + \item \textbf{\texttt{-e PYTHONPATH=...}}: explicit environment variable override ensures the container's Python runtime finds the packages in \texttt{/external-libs}. +\end{itemize} + +\subsubsection{Benefits and Optimization} + +\begin{table*}[t!] +\centering +\caption{Optimization Benefits} +\label{tab:docker_optimization} +\begin{tabular}{|l|p{6cm}|p{6cm}|} +\hline +\textbf{Feature} & \textbf{Standard Approach} & \textbf{Volume Mount Approach} \\ \hline +\textbf{Image Size} & \textbf{Huge} ($>2GB$). Includes PyTorch, CUDA binaries, and all dependencies. & \textbf{Tiny} (~100MB). Only contains app code and minimal HTTP libs. \\ \hline +\textbf{Build Time} & \textbf{Slow}. Downloading and installing PyTorch takes minutes. & \textbf{Fast}. setup only installs \texttt{fastapi}. \\ \hline +\textbf{Updates} & requires rebuilding and pushing large layers for every code change. & Code changes only require rebuilding the tiny app layer. Library updates are handled externally. \\ \hline +\end{tabular} +\end{table*} + +This architecture allows for rapid deployment and updating of the application logic without the overhead of moving gigabytes of container layers for unchanged machine learning dependencies. + +\subsection{Rust: CNN Model Architecture and Training Performance} + +The convolutional neural network (CNN) model used for the experiment consisted of two convolutional layers followed by adaptive average pooling, dropout, and two fully connected layers. The complete architecture is shown below: + +\begin{verbatim} +Model { + conv1: Conv2d {ch_in: 1, ch_out: 8, stride: [1, 1], kernel_size: [3, 3], dilation: [1, 1], groups: 1, padding: Valid, params: 80} + conv2: Conv2d {ch_in: 8, ch_out: 16, stride: [1, 1], kernel_size: [3, 3], dilation: [1, 1], groups: 1, padding: Valid, params: 1168} + pool: AdaptiveAvgPool2d {output_size: [8, 8]} + dropout: Dropout {prob: 0.5} + linear1: Linear {d_input: 1024, d_output: 512, bias: true, params: 524800} + linear2: Linear {d_input: 512, d_output: 10, bias: true, params: 5130} + activation: Relu + params: 531178 +} +\end{verbatim} + +The model was trained for 10 epochs. Over the course of training, both the training and validation performance improved consistently. Training accuracy increased from 81.575\% in the first epoch to 97.300\% in the final epoch, while validation accuracy improved from 92.133\% to 98.517\%. + +Similarly, the training loss decreased significantly from 0.656 to 0.087, and the validation loss reduced from 0.258 to 0.054 by the end of training. The macro F1-score also improved substantially, reaching 96.974\% for training and 98.321\% for validation. + +\begin{table*}[t!] +\centering +\caption{Training and Validation Metrics Summary} +\begin{tabular}{|l|l|c|c|c|c|} +\hline +\textbf{Split} & \textbf{Metric} & \textbf{Min.} & \textbf{Epoch} & \textbf{Max.} & \textbf{Epoch} \\ +\hline +Train & Accuracy & 81.575 & 1 & 97.300 & 10 \\ +Train & Loss & 0.087 & 10 & 0.656 & 1 \\ +Train & Precision@Top1 [Macro] & 82.126 & 1 & 97.304 & 10 \\ +Train & Recall@Top1 [Macro] & 81.286 & 1 & 97.232 & 10 \\ +Train & F1-Score@Top1 [Macro] & 79.715 & 1 & 96.974 & 10 \\ +Train & Top-5 Accuracy & 97.696 & 1 & 99.969 & 10 \\ +Train & CPU Memory (GB) & 2.514 & 2 & 2.927 & 10 \\ +Train & CPU Usage (\%) & 20.753 & 5 & 30.394 & 10 \\ +\hline +Valid & Accuracy & 92.133 & 1 & 98.517 & 10 \\ +Valid & Loss & 0.054 & 10 & 0.258 & 1 \\ +Valid & Precision@Top1 [Macro] & 92.154 & 1 & 98.527 & 10 \\ +Valid & Recall@Top1 [Macro] & 91.978 & 1 & 98.425 & 10 \\ +Valid & F1-Score@Top1 [Macro] & 91.176 & 1 & 98.321 & 10 \\ +Valid & Top-5 Accuracy & 99.583 & 1 & 99.967 & 10 \\ +Valid & CPU Memory (GB) & 2.514 & 2 & 3.085 & 10 \\ +Valid & CPU Usage (\%) & 20.539 & 2 & 39.652 & 10 \\ +\hline +\end{tabular} +\label{tab:cnn_metrics_summary} +\end{table*} + +The results indicate that the model achieved strong generalization performance with minimal overfitting, as the validation accuracy remained slightly higher than the training accuracy throughout the experiment. The consistently high Top-5 accuracy values further demonstrate that the model was able to correctly identify the correct class within its top predictions. + +It should also be noted that the execution terminated with a segmentation fault after training completion. However, since the fault occurred after all epochs had been completed and metrics had already been recorded, it did not affect the validity of the training results. \\ + +The time taken to train the model is 229.916s (3min 49.916s). + + + +\subsection{Python: CNN Model Architecture and Training Performance} + +The convolutional neural network (CNN) model implemented in Python mirrors the Rust architecture, consisting of two convolutional layers followed by adaptive average pooling, dropout regularization, and two fully connected layers. + +\textbf{Model Architecture:} + +\begin{verbatim} +Model { + conv1: Conv2d {ch_in: 1, ch_out: 8, kernel_size: [3, 3], stride: [1, 1]} + conv2: Conv2d {ch_in: 8, ch_out: 16, kernel_size: [3, 3], stride: [1, 1]} + pool: AdaptiveAvgPool2d {output_size: [8, 8]} + dropout: Dropout {prob: 0.5} + linear1: Linear {d_input: 1024, d_output: 512, params: 524800} + linear2: Linear {d_input: 512, d_output: 10, params: 5130} + activation: ReLU + total params: 531178 +} +\end{verbatim} + +The model was trained for 10 epochs. Training and validation performance improved consistently over time. + +Training accuracy increased from 82.01\% to 97.29\%, while validation accuracy improved from 92.87\% to 98.20\%. +Training loss decreased significantly from 0.5947 to 0.0867, and validation loss reduced from 0.2475 to 0.0579. + +The macro F1-score reached 0.9814, demonstrating strong classification performance. Additionally, the Top-5 accuracy achieved 99.98\%, indicating highly reliable predictions. + +\begin{table*}[t!] +\centering +\begin{tabular}{|l|l|c|c|c|c|} +\hline +\textbf{Split} & \textbf{Metric} & \textbf{Min} & \textbf{Epoch} & \textbf{Max} & \textbf{Epoch} \\ +\hline + +Train & Accuracy & 82.01 & 1 & 97.29 & 10 \\ +Train & Loss & 0.0867 & 10 & 0.5947 & 1 \\ +Train & Precision@Top1 [Macro] & -- & -- & -- & -- \\ +Train & Recall@Top1 [Macro] & -- & -- & -- & -- \\ +Train & F1-Score@Top1 [Macro] & -- & -- & -- & -- \\ +Train & Top-5 Accuracy & -- & -- & -- & -- \\ +Train & CPU Memory (GB) & 0.84 & -- & 0.98 & -- \\ +Train & CPU Usage (\%) & 72.1 & -- & 92.0 & -- \\ +\hline + +Valid & Accuracy & 92.87 & 1 & 98.20 & 10 \\ +Valid & Loss & 0.0579 & 10 & 0.2475 & 1 \\ +Valid & Precision@Top1 [Macro] & 0.9816 & -- & 0.9816 & -- \\ +Valid & Recall@Top1 [Macro] & 0.9812 & -- & 0.9812 & -- \\ +Valid & F1-Score@Top1 [Macro] & 0.9814 & -- & 0.9814 & -- \\ +Valid & Top-5 Accuracy & 99.98 & -- & 99.98 & -- \\ +Valid & CPU Memory (GB) & 0.84 & -- & 0.98 & -- \\ +Valid & CPU Usage (\%) & 72.1 & -- & 92.0 & -- \\ +\hline + +\end{tabular} +\caption{Python Training and Validation Metrics Summary} +\end{table*} + +The results indicate strong generalization performance with no signs of overfitting. Validation accuracy remained consistently high and closely followed training accuracy. + +Training was stable with zero NaN events observed. The total training time was 182.23 seconds, with an average epoch time of 15.05 seconds and an average iteration speed of 50.58 iterations per second. + + + +\newpage + +\subsection{Container Comparison: Python vs Rust} + +\begin{table*}[t!] +\centering +\begin{tabular}{|l|c|c|} +\hline +\textbf{Feature} & \textbf{Python (GPU)} & \textbf{Rust (WGPU)} \\ +\hline +Image Name & text\_classification\_image & text\_class\_rs \\ +\hline +Size & 3.98GB & 1.02GB \\ +\hline +Backend & PyTorch + CUDA & Native Rust (WGPU) \\ +\hline +Startup Time & Slower & Faster \\ +\hline +Dependencies & Heavy (Torch, CUDA, Python) & Minimal (compiled binary) \\ +\hline +Deployment Complexity & Higher & Lower \\ +\hline +Flexibility & High (research-friendly) & Moderate \\ +\hline +Runtime Stability & Medium & High \\ +\hline +\end{tabular} +\caption{Comparison of Python GPU-based and Rust-based inference containers} +\end{table*} + +\noindent +The Python-based container provides flexibility and rapid experimentation using the PyTorch ecosystem, but at the cost of larger image size and dependency complexity. In contrast, the Rust-based container offers a lightweight, production-ready solution with faster startup time and minimal runtime dependencies, making it more suitable for deployment scenarios. + +\subsection{Model Size Comparison} + +\begin{table*}[t!] +\centering +\begin{tabular}{|l|c|c|} +\hline +\textbf{Model} & \textbf{Rust (.mpk/.bin)} & \textbf{Python (.pt/.pth)} \\ +\hline +MNIST & 1.1 MB & 2 MB \\ +\hline +Text Classification (AG News) & 21 MB & 40.6 MB \\ +\hline +Regression & 4 KB & 6 MB \\ +\hline +LSTM & 60 KB & 120 KB \\ +\hline +\end{tabular} +\caption{Comparison of model sizes between Rust and Python implementations} +\end{table*} + +\noindent +Rust-based serialized models are consistently smaller than their Python counterparts. This reduction is most significant in simpler models such as regression, and remains substantial for larger models like text classification. The smaller footprint of Rust models makes them more suitable for lightweight deployment and resource-constrained environments. + +\clearpage + +\section{Task: Regression} +\newpage + +\subsection{Introduction} +This document outlines the architectural, mathematical, and deployment specifics of implementing a Neural Network-based Regression model across two disparate machine learning environments: Rust (utilizing the Burn framework) and Python (utilizing PyTorch). It covers the distinct model architecture decisions, dataset handling strategies, and specialized pipeline deployment techniques leveraging Network File Systems (NFS) mapping via Docker bounds. + +\subsection{Model Architecture and Mathematical Formulation} + +\subsubsection{Mathematical Foundation} +The core mathematical foundation deployed across both frameworks is a classical Feed-Forward Neural Network consisting of a single hidden dimension mapping inputs directly onto a continuous single-variable regression output. + +For a given input feature vector $X \in \mathbb{R}^N$ (where $N$ dictates the feature size depending on the target dataset), the network's forward transformation can be represented sequentially as: +\begin{align} + Z_1 &= X \cdot W_1^T + b_1 \quad &\text{(Input Projection)} \\ + A_1 &= \max(0, Z_1) \quad &\text{(ReLU Activation)} \\ + \hat{Y} &= A_1 \cdot W_2^T + b_2 \quad &\text{(Output Projection)} +\end{align} + +Where: +\begin{itemize} + \item $W_1 \in \mathbb{R}^{H \times N}$ and $b_1 \in \mathbb{R}^H$ map the inputs onto the hidden vector space $H$. + \item $\max(0, \cdot)$ denotes the Non-Linear Rectified Linear Unit (ReLU) mapping algorithm. + \item $W_2 \in \mathbb{R}^{1 \times H}$ and $b_2 \in \mathbb{R}$ collapse the hidden abstraction onto the finalized regression scalar prediction $\hat{Y}$. +\end{itemize} + +\subsubsection{Architectural Configurations} +While the mathematical foundations are identical, implementations slightly differ based on dataset selections within the modules: +\begin{itemize} + \item \textbf{PyTorch Architecture:} Configures $N=13$ input features mapping to $H=64$ hidden parameters. + \item \textbf{Rust (Burn) Architecture:} Configures $N=8$ input features concurrently mapping to $H=64$ hidden parameters. +\end{itemize} +In both configurations, standard parameter biases (`bias=True`) are included and automatically initialized. + +\subsection{Training Pipelines} + +Both codebases train the model iteratively tracking gradients via the Adam optimizer scaled against Mean Squared Error (MSE) loss logic: +\[ \text{MSE} = \frac{1}{B} \sum_{i=1}^{B} (Y_i - \hat{Y}_i)^2 \] + +\subsubsection{PyTorch Context} +\begin{itemize} + \item \textbf{Data Loading:} Automatically pulls the \textbf{Boston Housing} dataset array (.npz file) from an external Google API via `urllib` and manually partitions it down into an explicit $80/20$ split. + \item \textbf{Telemetry Metrics:} Generates explicit hardware tracking loops inside the main epoch runner. Uses the `psutil` library to compute and stream epoch `iteration\_speed`, raw RAM consumption, and `cpu\_temp` hardware sensors parallel to the loss parameters. +\end{itemize} + +\subsubsection{Rust (Burn) Context} +\begin{itemize} + \item \textbf{Data Loading:} Links into Huggingface's dataset registry asynchronously targeting the \textbf{California Housing} SQLized splits mapping onto memory arrays via localized `HousingDistrictItem` structs. + \item \textbf{Normalization Mapping:} Computes spatial min-max normalizations programmatically over inputs during training: + \[ X_{norm} = \frac{X - \text{min}}{\text{max} - \text{min}} \] + This logic restricts features within standard boundaries precluding exploding gradient derivations. +\end{itemize} + +\subsection{Inference Pipeline and Docker NFS Integration} + +Deploying these isolated pipelines necessitates radically different execution strategies, highlighting Python's heavyweight runtime dependency bottlenecks versus Rust's compile-time optimizations. + +\subsubsection{PyTorch Inference Architecture} +Standard PyTorch Docker environments routinely eclipse several gigabytes due to CUDA bindings and generic scientific computation loops. To circumvent this inside microservices, the PyTorch inference pipeline mandates a hybrid Network File System (NFS) mapping architecture: +\begin{enumerate} + \item \textbf{NFS Mounting (\texttt{mount\_libs.sh}):} Installs an external `nfs-common` client locally and binds the extensive python library volume from an external dedicated storage server (`172.16.203.14`) into the host machine's `/mnt/LSTM-libs` map. + \item \textbf{Lightweight Container Image:} The backend \texttt{Dockerfile} avoids `pip install` commands completely, simply initializing a barebone `nvidia/cuda:12.1.1` image mapping Python $3.11$ system links. + \item \textbf{Volume Inject (\texttt{run\_container.sh}):} The script initializes the container enforcing `-v` flags that sync the NFS `/mnt/LSTM-libs` directory seamlessly onto the Docker's `/external-libs`. Crucially, it overrides the system \texttt{PYTHONPATH} to target those external `site-packages` at runtime. + \item \textbf{Execution:} The `FastAPI` instance loads, bypasses massive disk pulls, links the models iteratively, and fields inbound `HousingFeatures` lists continuously. +\end{enumerate} + +\subsubsection{Rust (Burn) Inference Architecture} +Rust handles Docker microservices inherently via statically linked deployments: +\begin{itemize} + \item \textbf{Multi-Stage Compiling:} Executes a build phase operating within an oversized `rust:1.92-alpine` chain, ejecting the resulting binary onto an isolated stripped `alpine:3.23` environment structure. + \item \textbf{Native Routing:} Utilizes \texttt{Axum} servers to establish the HTTP logic endpoints securely routing JSON payloads mapping to specific feature names (e.g. \texttt{median\_income}, \texttt{house\_age}). +\end{itemize} + +\subsection{Rust: Regression Model Performance Analysis} + +The regression model used in this experiment was a simple feed-forward neural network consisting of one hidden layer followed by an output layer. The model was designed to predict the median house value based on eight input features. + +\subsubsection{Model Architecture} + +The architecture of the regression model is shown below: + +\begin{verbatim} +RegressionModel { + input_layer: Linear {d_input: 8, d_output: 64, bias: true, params: 576} + output_layer: Linear {d_input: 64, d_output: 1, bias: true, params: 65} + activation: Relu + params: 641 +} +\end{verbatim} + +The model contains: + +\begin{itemize} + \item An input layer that maps 8 input features to 64 hidden units + \item A ReLU activation function applied after the hidden layer + \item An output layer that maps the 64 hidden units to a single scalar value +\end{itemize} + +The total number of trainable parameters in the model was only 641, making it a lightweight model suitable for fast training and inference. + +\subsubsection{Training Configuration} + +The model was trained for 100 epochs. A constant learning rate of: + +\[ +1.0 \times 10^{-3} +\] + +was used throughout the entire training process. + +\subsubsection{Training Performance} + +The training loss decreased substantially over the 100 epochs. Initially, the model started with a training loss of 3.086 during the first epoch. By the final epoch, the loss had reduced to 0.414. + +This significant reduction in loss indicates that the model successfully learned the underlying relationship between the input features and the target variable. + +\subsubsection{Validation Performance} + +Validation loss also showed a considerable improvement during training. The validation loss decreased from 4.132 in the first epoch to a minimum of 0.635 at epoch 51. + +The difference between the final training loss and the minimum validation loss suggests that the model achieved good generalization performance without severe overfitting. + +\begin{table*}[t!] +\centering +\caption{Training and Validation Metrics Summary for the Regression Model} +\begin{tabular}{|l|l|c|c|c|c|} +\hline +\textbf{Split} & \textbf{Metric} & \textbf{Min.} & \textbf{Epoch} & \textbf{Max.} & \textbf{Epoch} \\ +\hline +Train & Loss & 0.414 & 100 & 3.086 & 1 \\ +Train & Learning Rate & $1.0 \times 10^{-3}$ & 1 & $1.0 \times 10^{-3}$ & 100 \\ +Train & CPU Memory (GB) & 2.125 & 4 & 2.325 & 56 \\ +Train & CPU Usage (\%) & 19.539 & 54 & 37.989 & 11 \\ +\hline +Valid & Loss & 0.635 & 51 & 4.132 & 1 \\ +Valid & CPU Memory (GB) & 2.124 & 3 & 2.325 & 55 \\ +Valid & CPU Usage (\%) & 19.550 & 54 & 37.960 & 11 \\ +\hline +\end{tabular} +\label{tab:regression_metrics_summary} +\end{table*} + +\subsubsection{Prediction Example} + +A sample prediction generated by the model is shown below: + +\begin{verbatim} +Predicted 2.021734 Expected 2.158 +\end{verbatim} + +The predicted value is reasonably close to the expected value, indicating that the model was able to approximate the target variable with acceptable accuracy. + +Since the median house value was measured in units of 100,000 dollars, the prediction corresponds to: + +\begin{itemize} + \item Predicted value: approximately 202,173 dollars + \item Expected value: approximately 215,800 dollars +\end{itemize} + +\subsubsection{Predicted vs. Expected Distribution} + +The predicted-versus-expected plot suggests that the model captures the general trend in the target values, although some prediction errors remain for certain samples. + +Most of the predicted values appear concentrated around the central region of the distribution, indicating that the model performs better on common house value ranges than on extreme values. + +\subsubsection{Resource Utilization} + +The model required relatively little memory during execution. Training memory usage ranged from 2.125 GB to 2.325 GB, while validation memory usage ranged from 2.124 GB to 2.325 GB. + +CPU utilization remained moderate throughout training. Training CPU usage ranged from 19.539\% to 37.989\%, while validation CPU usage ranged from 19.550\% to 37.960\%. + +CPU temperature values were unavailable and therefore recorded as NaN. + +\subsubsection{Execution Time and Failure} + +The complete training and evaluation process required: + +\begin{itemize} + \item Real time: 3 minutes and 18.257 seconds + \item User CPU time: 4 minutes and 13.554 seconds + \item System CPU time: 50.340 seconds +\end{itemize} + +\subsection{Language Specific Implementation Details} + +\subsubsection{PyTorch-Specific Paradigms} +\begin{itemize} + \item \textbf{Thread Clamping:} Due to inference optimization restrictions (especially running CPU variations alongside container structures), the `app.py` enforces explicit core binding calls via `torch.set\_num\_threads(1)` and `torch.set\_num\_interop\_threads(1)` securing computational resources and restricting OS context-switching overheads. + \item \textbf{Matrix Array Verifications:} Manually inspects raw matrix vector mappings validating dimensions dynamically against numeric constraints: \texttt{len(x) != NUM\_FEATURES} triggering runtime panics before pipeline evaluations fail. + \item \textbf{Manual Hardware Moving:} The framework is heavily littered with required `.to(device)` mapping configurations switching inputs, datasets, targets, and models manually between the host and external components. +\end{itemize} + +\subsubsection{Rust (Burn)-Specific Paradigms} +\begin{itemize} + \item \textbf{Generic Compile-Time Shapes:} Dimension mappings and tensor validations are fundamentally enforced inside the Rust compiler boundaries via `` arrays indicating batches of distinct input structures mapping to `targets: Tensor`. Invalid sizes fail compilation, voiding the requirement for manual PyTorch matrix validations. + \item \textbf{Struct Batching Protocols:} Inference doesn't evaluate primitive float arrays. Intead, the API relies on executing an overarching `HousingBatcher` which transforms specific struct domains (\texttt{HousingDistrictItem}) safely into tensor primitives while executing implicit `self.normalizer.to\_device(device)` logic silently against constants behind boundaries. + \item \textbf{Record Deserialization:} States are strictly detached from models via standard `.mpk` maps. They invoke explicit \texttt{NoStdTrainingRecorder::new().load()} tracking traits unbinding memory limits inherent to standard dict serialization configurations natively loaded via `RegressionModelConfig`. +\end{itemize} + +\newpage +\subsection{Python: Regression Model Architecture and Training Performance} + +The regression model used in this experiment is a lightweight fully connected neural network with a small number of parameters (961 total). The model is optimized using mean squared error loss. + + + +The model was trained for 100 epochs. Training loss decreased significantly from 8265.55 to 69.86 (99.15\% reduction), while validation loss decreased from 9045.34 to 55.70. + +Despite strong loss reduction, the model struggled to achieve good generalization. The validation $R^2$ score remained negative (-1.07), indicating that the model performs worse than a simple baseline predictor. + +\begin{table*}[t!] +\centering +\begin{tabular}{|l|l|c|c|c|c|} +\hline +\textbf{Split} & \textbf{Metric} & \textbf{Min} & \textbf{Epoch} & \textbf{Max} & \textbf{Epoch} \\ +\hline + +Train & Loss & 68.50 & 100 & 8265.55 & 1 \\ +Train & RMSE & 8.39 & 100 & 91.53 & 1 \\ +Train & MAE & 6.29 & 100 & 90.33 & 1 \\ +Train & R$^2$ & -96.93 & 1 & 0.1767 & 100 \\ +Train & Grad Norm (Total) & 118.80 & -- & 112876.03 & 1 \\ +Train & Iteration Speed (it/s) & 0.69 & 1 & 14.39 & 54 \\ +Train & CPU Memory (GB) & 0.87 & -- & 1.02 & -- \\ +Train & CPU Usage (\%) & 45.7 & -- & 97.6 & -- \\ +\hline + +Valid & Loss & 50.93 & 65 & 9045.34 & 1 \\ +Valid & RMSE & 7.14 & 65 & 95.11 & 1 \\ +Valid & MAE & 6.00 & 65 & 93.52 & 1 \\ +Valid & R$^2$ & -335.40 & 1 & -0.8940 & 65 \\ +Valid & Iteration Speed (it/s) & 0.69 & 1 & 14.39 & 54 \\ +Valid & CPU Memory (GB) & 0.87 & -- & 1.02 & -- \\ +Valid & CPU Usage (\%) & 45.7 & -- & 97.6 & -- \\ +\hline + +\end{tabular} +\caption{Regression Model Training and Validation Metrics Summary} +\end{table*} + +\textbf{Training Efficiency and Stability:} +\begin{itemize} + \item Total Training Time: 47.39 seconds + \item Average Epoch Time: 0.225 seconds + \item Iteration Speed (Mean): 10.36 it/s + \item Gradient Norm (Mean): 7981.31 + \item NaN Events: 0 + \item Convergence: Non-monotonic loss decrease + \item Overfitting Detected: No +\end{itemize} + +The results indicate that while optimization was successful in reducing loss, the model lacks sufficient capacity or feature representation to generalize well. The persistently negative validation $R^2$ suggests underfitting or a mismatch between model complexity and data characteristics. + + + + +\newpage + +\subsection{Container Comparison: Python vs Rust} + +\begin{table*}[t!] +\centering +\begin{tabular}{|l|c|c|} +\hline +\textbf{Feature} & \textbf{Python (GPU)} & \textbf{Rust (WGPU)} \\ +\hline +Image Name & text\_classification\_image & text\_class\_rs \\ +\hline +Size & 3.98GB & 973MB \\ +\hline +Backend & PyTorch + CUDA & Native Rust (WGPU) \\ +\hline +Startup Time & Slower & Faster \\ +\hline +Dependencies & Heavy (Torch, CUDA, Python) & Minimal (compiled binary) \\ +\hline +Deployment Complexity & Higher & Lower \\ +\hline +Flexibility & High (research-friendly) & Moderate \\ +\hline +Runtime Stability & Medium & High \\ +\hline +\end{tabular} +\caption{Comparison of Python GPU-based and Rust-based inference containers} +\end{table*} + +\noindent +The Python-based container provides flexibility and rapid experimentation using the PyTorch ecosystem, but at the cost of larger image size and dependency complexity. In contrast, the Rust-based container offers a lightweight, production-ready solution with faster startup time and minimal runtime dependencies, making it more suitable for deployment scenarios. + + +\subsection{Model Size Comparison} + +\begin{table*}[t!] +\centering +\begin{tabular}{|l|c|c|} +\hline +\textbf{Model} & \textbf{Rust (.mpk/.bin)} & \textbf{Python (.pt/.pth)} \\ +\hline +MNIST & 1.1 MB & 2 MB \\ +\hline +Text Classification (AG News) & 21 MB & 40.6 MB \\ +\hline +Regression & 4 KB & 6 MB \\ +\hline +LSTM & 60 KB & 120 KB \\ +\hline +\end{tabular} +\caption{Comparison of model sizes between Rust and Python implementations} +\end{table*} + +\noindent +Rust-based serialized models are consistently smaller than their Python counterparts. This reduction is most significant in simpler models such as regression, and remains substantial for larger models like text classification. The smaller footprint of Rust models makes them more suitable for lightweight deployment and resource-constrained environments. + +\clearpage + +\section{Task: Text Classification (AG News)} +\subsection{Model Architecture and Training Strategy} + +The text classification system is built using the \texttt{Burn} framework in Rust, leveraging a Transformer-based architecture for feature extraction and a linear classification head. This section details the mathematical formulation of the model and the strategy employed for training. + +\subsubsection{Model Architecture} +The core of the model is a Transformer Encoder, which processes a sequence of token embeddings to capture contextual relationships. The architecture consists of three primary stages: embedding, encoding, and classification. + +\paragraph{Embedding Layer} +Input text is tokenized and converted into a sequence of indices $X \in \mathbb{N}^{B \times L}$, where $B$ is the batch size and $L$ is the sequence length. The model utilizes two parallel embedding layers: +\begin{enumerate} + \item \textbf{Token Embedding ($E_{tok}$)}: Maps token indices to dense vectors of dimension $d_{model}$. + \item \textbf{Positional Embedding ($E_{pos}$)}: Maps position indices $[0, \dots, L-1]$ to dense vectors of dimension $d_{model}$ to inject sequence order information. +\end{enumerate} + +The final embedding representation $E$ is obtained by averaging the token and positional embeddings: +\begin{equation} + E = \frac{E_{tok}(X) + E_{pos}(\text{positions})}{2} +\end{equation} + +\paragraph{Transformer Encoder} +The embedding tensor $E$ is passed through a multi-layer Transformer Encoder. Each layer consists of a Multi-Head Self-Attention (MHSA) mechanism followed by a Position-wise Feed-Forward Network (FFN), with residual connections and layer normalization. + +The configuration used in this implementation is as follows: +\begin{itemize} + \item \textbf{Model Dimension ($d_{model}$)}: 256 + \item \textbf{Feed-Forward Dimension ($d_{ff}$)}: 1024 + \item \textbf{Number of Heads ($N_{heads}$)}: 8 + \item \textbf{Number of Layers ($N_{layers}$)}: 4 + \item \textbf{Normalization}: Layer norm applied before sub-layers (Pre-Norm). +\end{itemize} + +Let $H = \text{TransformerEncoder}(E)$, where $H \in \mathbb{R}^{B \times L \times d_{model}}$ represents the contextualized representations of the input sequence. + +\paragraph{Classification Head} +For classification, the model utilizes the representation of the first token (typically acting as the [CLS] token) from the encoded sequence. This vector is passed through a linear layer to project it into the class space: +\begin{equation} + Y = \text{Linear}(H_{[:, 0, :]}) +\end{equation} +where $Y \in \mathbb{R}^{B \times N_{classes}}$ represents the logits. For inference, a Softmax function is applied to obtain probabilities: +\begin{equation} + \hat{P} = \text{Softmax}(Y) +\end{equation} + +\begin{table*}[t!] +\centering +\caption{Model Architecture Summary} +\label{tab:model_arch} +\begin{tabular}{|l|l|c|c|} +\hline +\textbf{Component} & \textbf{Configuration / Details} & \textbf{Input Shape} & \textbf{Output Shape} \\ \hline +Token Embedding & $V \to d_{model}$ ($V$: Vocab Size) & $(B, L)$ & $(B, L, 256)$ \\ \hline +Pos Embedding & $L_{max} \to d_{model}$ & $(B, L)$ & $(B, L, 256)$ \\ \hline +Embedding Merge & Average ($E_{tok} + E_{pos}$) & - & $(B, L, 256)$ \\ \hline +Transformer Block & 4 Layers, 8 Heads, $d_{ff}=1024$ & $(B, L, 256)$ & $(B, L, 256)$ \\ \hline +Feature Extract & Slice First Token (Index 0) & $(B, L, 256)$ & $(B, 256)$ \\ \hline +Classifier Head & Linear ($256 \to N_{classes}$) & $(B, 256)$ & $(B, N_{classes})$ \\ \hline +\end{tabular} +\end{table*} + + + +\subsection{Rust Backend: Load Testing with Locust} + +The performance of the Rust-based backend was evaluated using the Locust load testing framework. The objective was to analyze system behavior under concurrent user load and measure key performance characteristics such as throughput and latency. + +\textbf{Testing Setup:} +\textbf{Testing Setup:} +\begin{itemize} + \item Tool: Locust + \item Backend: Rust (HTTP service) + \item Test Type: Concurrent user load simulation + \item Environment: Linux system +\end{itemize} + +\textbf{Dashboard Visualization:} +\textbf{Full Report:} + +The complete load testing dashboard has been exported as a PDF and is included below for detailed inspection. + +\includepdf[pages=-]{RustLocust/text.pdf} + + +\subsection{Python Backend: Load Testing with Locust} + +The performance of the Python-based backend was evaluated using the Locust load testing framework. The goal was to assess system behavior under concurrent user load and analyze key performance characteristics such as throughput and response latency. + +\textbf{Testing Setup:} +\begin{itemize} + \item Tool: Locust + \item Backend: Python (HTTP service) + \item Test Type: Concurrent user load simulation + \item Environment: Linux system +\end{itemize} + +\textbf{Dashboard Visualization:} + +\textbf{Full Report:} + +The complete load testing dashboard has been exported as a PDF and is included below for detailed inspection. + +\includepdf[pages=-]{PythonLocust/text.pdf} + + +\subsubsection{Training Strategy} +The model is trained using a supervised learning approach with the following configuration: + +\paragraph{Loss Function} +The training objective is to minimize the Cross-Entropy Loss between the predicted logits $Y$ and the ground truth class labels $C$: +\begin{equation} + \mathcal{L} = \text{CrossEntropy}(Y, C) = -\sum_{c=1}^{N_{classes}} \mathbb{1}_{c=C} \log\left(\frac{e^{Y_c}}{\sum_{j} e^{Y_j}}\right) +\end{equation} + +\paragraph{Optimization} +We employ the **Adam** optimizer with the following parameters: +\begin{itemize} + \item \textbf{Weight Decay}: $5 \times 10^{-5}$ + \item \textbf{Beta Coefficients}: Standard defaults (typically $\beta_1=0.9, \beta_2=0.999$) +\end{itemize} + +\paragraph{Learning Rate Scheduling} +A **Noam Learning Rate Scheduler** is used to stabilize training. The learning rate increases linearly during a warmup phase and then decays proportionally to the inverse square root of the step number. +\begin{equation} + LR = d_{model}^{-0.5} \cdot \min(step\_num^{-0.5}, step\_num \cdot warmup\_steps^{-1.5}) +\end{equation} +\begin{itemize} + \item \textbf{Warmup Steps}: 1000 + \item \textbf{Base Learning Rate}: 0.01 +\end{itemize} + +\paragraph{Metrics} +During training and validation, the following metrics are tracked to monitor performance: +\begin{itemize} + \item \textbf{Loss}: Cross-Entropy Loss. + \item \textbf{Accuracy}: Percentage of correct predictions. + \item \textbf{F1-Score, Precision, Recall}: Macro-averaged metrics to account for class balance. +\end{itemize} + +\subsection{Burn Code Specifications} + +This section outlines the significant implementation details of the text classification system, focusing on the architectural choices in \texttt{model.rs} and the robust training pipeline defined in \texttt{training.rs}. +\subsubsection{Model Implementation (\texttt{model.rs})} +The \texttt{TextClassificationModel} leverages the \textbf{Burn} framework's modular design to implement a Transformer-based classifier. Key features of this implementation include: +\begin{itemize} + \item \textbf{Dual Embedding Strategy:} The model employs two distinct embedding layers: \texttt{embedding\_token} for semantic content and \texttt{embedding\_pos} for positional information. A unique characteristic of this implementation is the fusion strategy, where these embeddings are combined via averaging: + \[ + E_{final} = \frac{E_{pos} + E_{token}}{2} + \] + This differs from the standard summation approach often found in BERT implementations, potentially stabilizing the initial magnitude of the embedding vectors. + + \item \textbf{Configurable Architecture:} The system uses a \texttt{TextClassificationModelConfig} struct derived with the \texttt{Config} macro. This allows for type-safe and serializable hyperparameter management, ensuring the model architecture (hidden size, vocabulary size, sequence length) can be easily saved, loaded, and reproducible. + + \item \textbf{Masked Attention:} The forward pass actively utilizes padding masks (\texttt{mask\_pad}). These masks are passed into the \texttt{TransformerEncoderInput}, ensuring that the self-attention mechanism strictly ignores padding tokens, which is critical for handling variable-length text sequences correctly. + + \item \textbf{Separation of Train and Inference Logic:} The model explicitly implements the \texttt{TrainStep} and \texttt{InferenceStep} traits. + \begin{itemize} + \item \textbf{Training:} Returns a \texttt{ClassificationOutput} struct containing the calculated Cross-Entropy loss for backpropagation. + \item \textbf{Inference:} Returns raw probabilities by applying a softmax activation on the output logits, facilitating direct class prediction. + \end{itemize} +\end{itemize} +\subsubsection{Training Pipeline (\texttt{training.rs})} +The training module is designed for reliability and comprehensive observability. It integrates advanced optimization techniques and hardware-aware monitoring. +\begin{itemize} + \item \textbf{Noam Scheduler:} Transformer models are notoriously sensitive to learning rates. The code implements the \textbf{Noam Learning Rate Scheduler} (popularized by "Attention Is All You Need"), which features a linear warmup phase (1000 steps) followed by an inverse square root decay based on the model dimension ($d_{model}$). This prevents gradient explosions during early training stages. + + \item \textbf{Distributed Training Support:} The implementation explicitly handles distributed computing scenarios. It utilizes Rust's feature flags (\texttt{cfg[feature = "ddp"]}) to switch between single-device training and \textbf{Distributed Data Parallel (DDP)} strategies. When enabled, it employs a tree-based \texttt{AllReduceStrategy} for synchronizing gradients across multiple GPUs or nodes. + + \item \textbf{Comprehensive Telemetry:} The training loop is instrumented with an extensive suite of metrics beyond simple accuracy. It tracks: + \begin{itemize} + \item \textbf{Classification Metrics:} Macro-averaged F1-Score, Precision, and Recall, providing a holistic view of model performance on imbalanced datasets. + \item \textbf{Hardware Diagnostics:} CPU temperature, memory usage, and utilization are logged alongside training progress, aiding in the detection of thermal throttling or memory leaks during long training runs. + \end{itemize} + + \item \textbf{Efficient Data Sampling:} To manage large datasets efficiently, the loader utilizes a \texttt{SamplerDataset}. This limits the effective epoch size to 50,000 training samples and 5,000 validation samples, allowing for rapid iteration and feedback loops without needing to process the entire corpus in every epoch. +\end{itemize} + +\subsubsection{Conditional Compilation} +I think we should document a bit about this. + +\subsection{Rust Docker image} + +\subsection{Rust Inference Code} + +\subsection{Rust: Transformer-Based Text Classification Model Performance} + +The text classification model used in this experiment was based on a Transformer encoder architecture. The model consisted of token embeddings, positional embeddings, a multi-layer Transformer encoder, and a final linear classification layer. + +\subsubsection{Model Architecture} + +The architecture of the model is shown below: + +\begin{verbatim} +TextClassificationModel { + transformer: TransformerEncoder { + d_model: 256, + d_ff: 1024, + n_heads: 8, + n_layers: 4, + dropout: 0.1, + norm_first: true, + quiet_softmax: true, + params: 3159040 + } + embedding_token: Embedding { + n_embedding: 28996, + d_model: 256, + params: 7422976 + } + embedding_pos: Embedding { + n_embedding: 256, + d_model: 256, + params: 65536 + } + output: Linear { + d_input: 256, + d_output: 4, + bias: true, + params: 1028 + } + n_classes: 4 + params: 10648580 +} +\end{verbatim} + +The Transformer encoder used four encoder layers with eight attention heads per layer. Each layer had a model dimension of 256 and a feed-forward dimension of 1024. A dropout rate of 0.1 was used to reduce overfitting. + +The token embedding layer mapped a vocabulary of 28,996 tokens into 256-dimensional vectors. Positional embeddings of length 256 were also used so that the Transformer could capture token order information. + +The final output layer mapped the Transformer representation into four output classes. + +The total number of trainable parameters in the model was 10,648,580. + +\subsubsection{Training Configuration} + +The model was trained for a total of 5 epochs. During training, the learning rate decayed from $1.107 \times 10^{-5}$ in the first epoch to $3.733 \times 10^{-6}$ in the final epoch. + +\subsubsection{Training Performance} + +Training accuracy improved steadily from 57.968\% in the first epoch to 81.474\% in the fifth epoch. Similarly, the training loss decreased from 0.981 to 0.507. + +The macro precision, recall, and F1-score also improved significantly during training: + +\begin{itemize} + \item Precision increased from 58.893\% to 81.606\% + \item Recall increased from 57.741\% to 81.334\% + \item F1-score increased from 50.201\% to 76.001\% +\end{itemize} + +These results indicate that the model learned meaningful semantic patterns in the text data over successive epochs. + +\subsubsection{Validation Performance} + +Validation performance also improved consistently. Validation accuracy increased from 72.280\% in the first epoch to a maximum of 81.640\% in the fourth epoch. + +The validation loss decreased from 0.731 to 0.507, showing that the model generalized reasonably well to unseen data. + +The validation precision, recall, and F1-score also showed strong improvement: + +\begin{itemize} + \item Precision increased from 72.230\% to 81.739\% + \item Recall increased from 72.258\% to 82.039\% + \item F1-score increased from 65.796\% to 76.509\% +\end{itemize} + +The relatively close values between training and validation accuracy suggest that the model did not suffer from severe overfitting. + +\begin{table*}[t!] +\centering +\caption{Training and Validation Metrics Summary for the Transformer-Based Text Classification Model} +% @kumar please layout sahi kar sakta hai? love you +\begin{tabular}{|l|l|c|c|c|c|} +\hline +\textbf{Split} & \textbf{Metric} & \textbf{Min.} & \textbf{Epoch} & \textbf{Max.} & \textbf{Epoch} \\ +\hline +Train & Accuracy & 57.968 & 1 & 81.474 & 5 \\ +Train & Loss & 0.507 & 5 & 0.981 & 1 \\ +Train & Precision@Top1 [Macro] & 58.893 & 1 & 81.606 & 5 \\ +Train & Recall@Top1 [Macro] & 57.741 & 1 & 81.334 & 5 \\ +Train & F1-Score@Top1 [Macro] & 50.201 & 1 & 76.001 & 5 \\ +Train & Learning Rate & $3.733 \times 10^{-6}$ & 5 & $1.107 \times 10^{-5}$ & 1 \\ +Train & CPU Memory (GB) & 2.401 & 2 & 2.736 & 5 \\ +Train & CPU Usage (\%) & 16.529 & 1 & 17.160 & 5 \\ +\hline +Valid & Accuracy & 72.280 & 1 & 81.640 & 4 \\ +Valid & Loss & 0.507 & 5 & 0.731 & 1 \\ +Valid & Precision@Top1 [Macro] & 72.230 & 1 & 81.739 & 5 \\ +Valid & Recall@Top1 [Macro] & 72.258 & 1 & 82.039 & 5 \\ +Valid & F1-Score@Top1 [Macro] & 65.796 & 1 & 76.509 & 5 \\ +Valid & CPU Memory (GB) & 2.263 & 1 & 2.747 & 5 \\ +Valid & CPU Usage (\%) & 20.331 & 1 & 22.190 & 3 \\ +\hline +\end{tabular} +\label{tab:transformer_text_classification_metrics} +\end{table*} + +\subsubsection{Resource Utilization} + +The CPU memory usage remained relatively stable throughout training. Training memory usage ranged from 2.401 GB to 2.736 GB, while validation memory usage ranged from 2.263 GB to 2.747 GB. + +CPU utilization also remained moderate, with training CPU usage ranging from 16.529\% to 17.160\% and validation CPU usage ranging from 20.331\% to 22.190\%. + +CPU temperature values were unavailable during the experiment and therefore recorded as NaN. + +\subsubsection{Execution Time and Failure} + +The complete training run required: + +\begin{itemize} + \item Real time: 32 minutes and 37.872 seconds + \item User CPU time: 36 minutes and 52.313 seconds + \item System CPU time: 1 minute and 41.258 seconds +\end{itemize} + +\subsection{PyTorch Training Pipeline} +This section details the Python implementation of the text classification training pipeline. The code mimics the architecture and logic of the Rust version to ensuring comparable performance and behavior. +\subsubsection{Code Highlights} +\begin{itemize} + \item \textbf{Custom Transformer Model:} + The \texttt{TextClassificationModel} is a custom \texttt{nn.Module} containing: + \begin{itemize} + \item Dual embedding layers (\texttt{embedding\_token} and \texttt{embedding\_pos}). + \item A unique fusion strategy averaging the two embeddings: $E = (E_{pos} + E_{tok}) / 2$. + \item A standard \texttt{TransformerEncoder} stack. + \item A classification head that projects the encoded features to the 4 output classes of the AG News dataset. + \end{itemize} + \item \textbf{Noam Learning Rate Scheduler:} + A custom \texttt{NoamLR} scheduler is implemented to replicate the specific warmup and decay behavior used in the Rust implementation (and the original "Attention Is All You Need" paper). + \[ + lr = \text{factor} \cdot (d_{model}^{-0.5}) \cdot \min(step^{-0.5}, step \cdot warmup^{-1.5}) + \] + This ensures stable training dynamics for the Transformer architecture. + \item \textbf{Dataset Handling:} + The code utilizes the Hugging Face \texttt{datasets} library to load the "ag\_news" dataset. It explicitly shuffles and subsets the data (50,000 train, 5,000 test) to match the constraints applied in the Rust implementation, ensuring a fair apples-to-apples comparison between the two languages. + \item \textbf{Collate Function with Padding Masks:} + A custom \texttt{collate\_fn} handles dynamic batching. It tokenizes text using the \texttt{bert-base-cased} tokenizer and generates a boolean padding mask. Note the inversion logic: PyTorch's \texttt{TransformerEncoder} expects \texttt{True} for padded positions (unlike some other implementations where 1 implies validity), requiring careful mask generation: + \begin{verbatim} + mask_pad = (encoding['attention_mask'] == 0) + \end{verbatim} + \item \textbf{Training Loop:} + The training loop is a standard PyTorch implementation using \texttt{tqdm} for progress tracking. It uses \texttt{CrossEntropyLoss} as the criterion and the \texttt{Adam} optimizer. Crucially, the scheduler step is called after every batch (not every epoch), consistent with the Noam schedule requirements. +\end{itemize} + + +\subsection{Python: Text Classification (News) Transformer Model} + +The model is a Transformer-based architecture designed for multi-class news classification. It consists of a multi-layer encoder with multi-head self-attention and feedforward networks. + +\textbf{Model Architecture:} + +\begin{verbatim} +Model { + transformer_encoder: { + d_model: 256, + nhead: 8, + num_layers: 4, + dim_feedforward: 1024 + } + max_seq_len: 256 + num_classes: 4 + total params: 10649092 +} +\end{verbatim} + +The model was trained for 5 epochs and showed steady convergence across all evaluation metrics. + +Training accuracy improved from 56.19\% to 79.70\%, while validation accuracy increased from 68.14\% to 79.00\%. +Training loss decreased from 1.0145 to 0.5483, and validation loss reduced from 0.8137 to 0.5628. + +The model achieved a macro F1-score of 0.7903 on the validation/test set, indicating reasonably strong classification performance for a Transformer trained over a small number of epochs. + +\begin{table*}[t!] +\centering +\begin{tabular}{|l|l|c|c|c|c|} +\hline +\textbf{Split} & \textbf{Metric} & \textbf{Min} & \textbf{Epoch} & \textbf{Max} & \textbf{Epoch} \\ +\hline + +Train & Accuracy & 56.19 & 1 & 79.70 & 5 \\ +Train & Loss & 0.5483 & 5 & 1.0145 & 1 \\ +Train & Grad Norm (Total) & 10.39 & 4 & 22.95 & 3 \\ +Train & Iteration Speed (it/s) & 44.33 & 2 & 45.05 & 1 \\ +Train & CPU Memory (GB) & 1.12 & -- & 1.12 & -- \\ +Train & CPU Usage (\%) & 22.2 & -- & 23.5 & -- \\ +\hline + +Valid & Accuracy & 68.14 & 1 & 79.00 & 5 \\ +Valid & Loss & 0.5628 & 5 & 0.8137 & 1 \\ +Valid & Precision@Top1 [Macro] & 0.7187 & 1 & 0.7942 & 5 \\ +Valid & Recall@Top1 [Macro] & 0.6815 & 1 & 0.7899 & 5 \\ +Valid & F1-Score@Top1 [Macro] & 0.6773 & 1 & 0.7903 & 5 \\ +Valid & Iteration Speed (it/s) & 44.33 & 2 & 45.05 & 1 \\ +Valid & CPU Memory (GB) & 1.12 & -- & 1.12 & -- \\ +Valid & CPU Usage (\%) & 22.2 & -- & 23.5 & -- \\ +\hline + +\end{tabular} +\caption{Transformer Text Classification Training and Validation Metrics} +\end{table*} + +\textbf{Training Efficiency and Stability:} +\begin{itemize} + \item Total Training Time: 706.99 seconds + \item Average Epoch Time: 139.83 seconds + \item Iteration Speed (Mean): 44.70 it/s + \item Gradient Norm (Mean): 15.75 + \item GPU Memory Usage: 178.79 MB + \item NaN Events: 0 + \item Convergence: Monotonic loss decrease + \item Overfitting Detected: No +\end{itemize} + +The results indicate stable training and consistent improvement across epochs. While performance is lower than simpler CNN-based tasks, this is expected due to the increased complexity of natural language understanding tasks. + +\subsection{PyTorch Inference Pipeline Docker Image: Hybrid NFS and Docker Inference Architecture} + +This section details the hybrid deployment strategy designed to optimize Docker image size and leverage a centralized machine learning environment. The architecture splits the responsibilities between a \textbf{Library VM} (storage-heavy) and a \textbf{Docker VM} (compute-centric). + +\subsubsection{Architecture Overview} + +The system comprises two primary components: +\begin{enumerate} + \item \textbf{Library VM (NFS Server)}: Hosts the heavy Python environment, including PyTorch, Transformers, and CUDA libraries. This environment is exported via NFS. + \item \textbf{Docker VM (Inference Client)}: Runs a lightweight Docker container that mounts the external libraries at runtime. +\end{enumerate} + +\subsubsection{Implementation Details} + +\paragraph{1. Library Sharing via NFS} +The Library VM exports the directory containing the Python site-packages. On the Docker VM, this directory is mounted using the \texttt{mount\_libs.sh} script. + +\begin{lstlisting}[language=bash, caption={Mounting the NFS Library Volume}] +# Configuration from mount_libs.sh +NFS_SERVER_IP="172.16.203.14" +NFS_EXPORT_PATH="/home/iiitb/Documents/textClassificationVolume" +LOCAL_MOUNT_POINT="/mnt/text-libs" + +# Mounting the remote volume +sudo mount -t nfs "$NFS_SERVER_IP:$NFS_EXPORT_PATH" "$LOCAL_MOUNT_POINT" +\end{lstlisting} + +\paragraph{2. Lightweight Docker Image} +The Docker image is built using \texttt{Dockerfile.cpu} and excludes heavy ML libraries. It only contains the application code, the model weights, and minimal system dependencies. + +\begin{lstlisting}[language=Dockerfile, caption={Dockerfile.cpu Configuration}] +FROM python:3.12-slim + +# Point Python to the external NFS mount +ENV PYTHONPATH=/external-libs/text_env/lib/python3.12/site-packages + +# Copy only the app and model +COPY app.py ./ +COPY model_pytorch_text_classification/ag_news_model.pth ./model/ + +# No 'pip install torch' is performed here! +\end{lstlisting} + +\paragraph{3. Runtime Execution} +The container is launched via \texttt{run\_inference.sh}, which mounts the NFS volume into the container at \texttt{/external-libs}. + +\begin{lstlisting}[language=bash, caption={Mounting the NFS Library Volume}] +docker run --gpus all \ + -v /mnt/text-libs:/external-libs \ + -v text_model_vol:/models \ + -e PYTHONPATH=/external-libs/text_env/lib/python3.12/site-packages \ + -p 8000:8000 \ + text_classification_image +\end{lstlisting} + +\subsubsection{Impact on Image Size} + +This architecture drastically reduces the storage footprint of the inference artifact. By decoupling the static libraries from the application logic, we achieve the following reduction: + +% \begin{table*}[t!] +% \centering +% \begin{tabular}{|l|c|c|} +% \hline +% \textbf{Component} & \textbf{Traditional Approach} & \textbf{Hybrid NFS Approach} \\ \hline +% Base Image (Python Slim) & $\sim$150 MB & $\sim$150 MB \\ \hline +% PyTorch & $\sim$3.5 GB & \textbf{0 MB (Mounted)} \\ \hline +% Transformers & $\sim$500 MB & \textbf{0 MB (Mounted)} \\ \hline +% Application Code & $<1$ MB & $<1$ MB \\ \hline +% Model Weights & $\sim$100 MB & $\sim$100 MB \\ \hline +% \textbf{Total Image Size} & \textbf{8.93 GB} & \textbf{$~250$ MB} \\ \hline +% \end{tabular} +% \caption{Comparison of Docker Image Sizes} +% \end{table*} + +% This \textbf{99.03\% reduction} in image size results in: +% \begin{itemize} +% \item Faster deployment and rollback times. +% \item Significantly lower network bandwidth usage. +% \item Efficient storage utilization on the Docker VM. +% \end{itemize} + +\subsection{Hybrid Inference Architecture with NFS and Docker} + +This section outlines the architectural design of our hybrid machine learning deployment strategy, detailing the distinct roles of the Library VM and the Docker VM, and how they interact to optimize resource usage. + +\subsubsection{Library Virtual Machine (NFS Server)} + +The \textbf{Library VM} serves as the centralized repository for the heavy components of the machine learning environment. Its primary function is to host large, static dependencies such as the Python runtime environment, deep learning frameworks (e.g., PyTorch, TensorFlow), and specialized libraries (e.g., Transformers, CUDA routines). + +By consolidating these resource-intensive libraries on a single machine, we avoid the redundancy of installing them on every inference node. This machine acts as a Network File System (NFS) server, exporting its directory structure to be accessed by other machines in the network. + +\paragraph{What is an NFS Server?} + +A \textbf{Network File System (NFS)} server is a computer that allows other machines (clients) to access its files over a network as if they were stored locally. In our architecture, the NFS server "shares" the directory containing the Python libraries. The client machines can then read these files directly, eliminating the need to physically copy the heavy libraries to each client. + +\subsubsection{Docker Virtual Machine (Inference Node)} + +The \textbf{Docker VM} is the compute-centric node responsible for executing the inference workload. It hosts the Docker engine and runs the lightweight containerized application. + +This machine does not permanently store the heavy ML libraries. instead, it mounts the shared directory from the Library VM at runtime. reliable network connectivity to the Library VM ensures that the Docker container has immediate access to the necessary software dependencies. + +\subsubsection{Hybrid Deployment Strategy} + +The hybrid strategy combines the isolation and portability of Docker with the efficiency of centralized storage. + +\begin{enumerate} + \item \textbf{Decoupling Environment and Application}: We separate the rapidly changing application code (API logic, business rules) from the slowly changing environment (Python packages). The application code resides inside the Docker image, while the environment resides on the NFS share. + \item \textbf{Runtime Linking}: When the Docker container starts, it mounts the NFS share. The container's environment variables are configured to add this mounted path to its Python path. This allows the Python interpreter inside the container to import modules (like \texttt{torch} or \texttt{transformers}) from the network share as if they were installed locally. + \item \textbf{Drastic Image Reduction}: Since the Docker image only contains the application code and minimal system dependencies, its size is reduced from several gigabytes to a few hundred megabytes. This facilitates rapid deployments, faster scaling, and reduced storage costs. +\end{enumerate} + +This architecture essentially transforms the Docker container into a lightweight "shell" that borrows its heavy "engine" from the Library VM only when needed. + +\subsubsection{Identifying the Virtual Machine Roles} + +The architecture explicitly designates two separate machines for distinct purposes. Based on the configuration scripts, their roles are defined as follows: + +\paragraph{1. The Library VM (Environment Host)} +This machine acts as the \textbf{storage backend} for the machine learning environment. +\begin{itemize} + \item \textbf{Role}: It hosts the actual Python environment (Torch, Transformers, etc.) on its local filesystem and exports it via NFS. + \item \textbf{Identifier}: In our configuration (see \texttt{mount\_libs.sh}), this machine is identified by the IP address \texttt{172.16.203.14}. + \item \textbf{Key Path}: The environment resides at \texttt{/home/iiitb/Documents/textClassificationVolume}. + \item \textbf{Action}: It does \textit{not} run the Docker container. Ideally, it simply stays online to serve files to other machines. +\end{itemize} + +\paragraph{2. The Docker VM (Inference Runner)} +This machine acts as the \textbf{compute frontend} that serves the API. +\begin{itemize} + \item \textbf{Role}: It builds and runs the lightweight Docker container. It does not have the deep learning libraries installed on its own disk; it borrows them from the Library VM. + \item \textbf{Identifier}: This is the machine where you execute the \texttt{mount\_libs.sh} and \texttt{run\_inference.sh} scripts. + \item \textbf{Key Path}: It mounts the remote library to the local path \texttt{/mnt/text-libs}. + \item \textbf{Action}: It executes the \texttt{docker run} command, effectively "bringing the code to the data" (or in this case, bringing the library data to the code container). +\end{itemize} + +\begin{table*}[t!] +\centering +\begin{tabular}{|l|l|l|} +\hline +\textbf{Feature} & \textbf{Library VM} & \textbf{Docker VM} \\ \hline +\textbf{Primary Function} & Storage \& NFS Server & Model Inference \& API Hosting \\ \hline +\textbf{IP Address} & \texttt{172.16.203.14} & (Assigned by Network) \\ \hline +\textbf{Python Libs} & Stored Physically on Disk & Mounted via Network (NFS) \\ \hline +\textbf{Docker Image} & Not required & Builds \& Runs Lightweight Image \\ \hline +\end{tabular} +\caption{Distinction between Library VM and Docker VM} +\end{table*} + + + + +\newpage + +\subsection{Container Comparison: Python vs Rust} + +\begin{table*}[t!] +\centering +\begin{tabular}{|l|c|c|} +\hline +\textbf{Feature} & \textbf{Python (GPU)} & \textbf{Rust (WGPU)} \\ +\hline +Image Name & text\_classification\_image & text\_class\_rs \\ +\hline +Size & 4.02 GB & $\sim$1 GB \\ +\hline +Backend & PyTorch + CUDA & Native Rust (WGPU) \\ +\hline +Startup Time & Slower & Faster \\ +\hline +Dependencies & Heavy (Torch, CUDA, Python) & Minimal (compiled binary) \\ +\hline +Deployment Complexity & Higher & Lower \\ +\hline +Flexibility & High (research-friendly) & Moderate \\ +\hline +Runtime Stability & Medium & High \\ +\hline +\end{tabular} +\caption{Comparison of Python GPU-based and Rust-based inference containers} +\end{table*} + +\noindent +The Python-based container provides flexibility and rapid experimentation using the PyTorch ecosystem, but at the cost of larger image size and dependency complexity. In contrast, the Rust-based container offers a lightweight, production-ready solution with faster startup time and minimal runtime dependencies, making it more suitable for deployment scenarios. + + +\subsection{Model Size Comparison} + +\begin{table*}[t!] +\centering +\begin{tabular}{|l|c|c|} +\hline +\textbf{Model} & \textbf{Rust (.mpk/.bin)} & \textbf{Python (.pt/.pth)} \\ +\hline +MNIST & 1.1 MB & 2 MB \\ +\hline +Text Classification (AG News) & 21 MB & 40.6 MB \\ +\hline +Regression & 4 KB & 6 MB \\ +\hline +LSTM & 60 KB & 120 KB \\ +\hline +\end{tabular} +\caption{Comparison of model sizes between Rust and Python implementations} +\end{table*} + +\noindent +Rust-based serialized models are consistently smaller than their Python counterparts. This reduction is most significant in simpler models such as regression, and remains substantial for larger models like text classification. The smaller footprint of Rust models makes them more suitable for lightweight deployment and resource-constrained environments. + +\clearpage + +\section{Task: LSTM implementation} +\newpage + +\subsection{Introduction} +This document outlines the detailed architectural, mathematical, and translational specifics of implementing a Long Short-Term Memory (LSTM) model across two prominent machine learning environments: Rust (using the Burn framework) and Python (using PyTorch). It covers the model architecture, training pipelines, specialized deployment techniques using network filesystems (NFS) with Docker, and language-specific design implications. + +\subsection{Model Architecture and Mathematical Formulation} + +\subsubsection{Mathematical Foundation of the LSTM Cell} +The core of the model revolves around a custom, manually-implemented LSTM cell. Instead of relying on the standard un-inspectable black-box LSTM implementations provided by typical ML libraries, both codebases explicitly define the cell-level math. + +For a given timestep $t$, the input tensor $x_t$ and the previous hidden state $h_{t-1}$ are used to compute the various gates. The mathematical formulation utilized is: +\begin{align} + f_t &= \sigma(W_f \cdot [h_{t-1}, x_t] + b_f) \quad &\text{(Forget Gate)} \\ + i_t &= \sigma(W_i \cdot [h_{t-1}, x_t] + b_i) \quad &\text{(Input Gate)} \\ + g_t &= \tanh(W_g \cdot [h_{t-1}, x_t] + b_g) \quad &\text{(Candidate State)} \\ + o_t &= \sigma(W_o \cdot [h_{t-1}, x_t] + b_o) \quad &\text{(Output Gate)} +\end{align} +\begin{align} + c_t &= f_t \odot c_{t-1} + i_t \odot g_t \quad &\text{(New Cell State)} \\ + h_t &= o_t \odot \tanh(c_t) \quad &\text{(New Hidden State)} +\end{align} + +Where: +\begin{itemize} + \item $\sigma$ represents the Sigmoid activation function. + \item $\tanh$ represents the Hyperbolic Tangent activation function. + \item $\odot$ denotes element-wise multiplication (Hadamard product). + \item $[h_{t-1}, x_t]$ symbolizes the concatenation of the previous hidden state and the current input. +\end{itemize} + +\subsubsection{Architectural Details} +Both implementations adhere strictly to the following architectural design: +\begin{enumerate} + \item \textbf{Layer Normalization:} Pre-activation gates, the cell state ($c_t$), and the hidden state ($h_t$) pass through separate \texttt{LayerNorm} layers. This design choice stabilizes training dynamics since the feature distributions inside the LSTM evolve at every sequence step (making standard Batch Normalization ineffective). + \item \textbf{Optimized Gate Compute:} Instead of computing 4 separate linear transformations per timestep for the features, the model employs a single combined projection that outputs a $4 \times \text{hidden\_size}$ tensor. This tensor is subsequently split into four chunks corresponding to the $i, f, g$, and $o$ gates. + \item \textbf{Bidirectional Support:} An encapsulated \texttt{StackedLstm} module stacks multiple manual LSTM layers (applying dropout between layers except on the final one). The main \texttt{LstmNetwork} integrates a forward processing stack and an optional backward processing stack (which flips the temporal dimension of the input sequence). Their respective output hidden states are concatenated along the feature dimension before passing through a fully-connected projection head. + \item \textbf{Initialization bias:} The forget-gate bias parameters are explicitly initialized to $1.0$ (via Xavier Normal parameter slicing) to prevent fatal early-training gradient decay. +\end{enumerate} + + +\subsection{Rust Backend: Load Testing with Locust} + +The performance of the Rust-based backend was evaluated using the Locust load testing framework. The objective was to analyze system behavior under concurrent user load and measure key performance characteristics such as throughput and latency. + +\textbf{Testing Setup:} +\begin{itemize} + \item Tool: Locust + \item Backend: Rust (HTTP service) + \item Test Type: Concurrent user load simulation + \item Environment: Linux system +\end{itemize} + +\textbf{Dashboard Visualization:} +\textbf{Full Report:} + +The complete load testing dashboard has been exported as a PDF and is included below for detailed inspection. + +\includepdf[pages=-]{RustLocust/LSTM.pdf} + + + +\subsection{Python Backend: Load Testing with Locust} + +The performance of the Python-based backend was evaluated using the Locust load testing framework. The goal was to assess system behavior under concurrent user load and analyze key performance characteristics such as throughput and response latency. + +\textbf{Testing Setup:} +\begin{itemize} + \item Tool: Locust + \item Backend: Python (HTTP service) + \item Test Type: Concurrent user load simulation + \item Environment: Linux system +\end{itemize} + +\textbf{Dashboard Visualization:} + +\textbf{Full Report:} + +The complete load testing dashboard has been exported as a PDF and is included below for detailed inspection. + +\includepdf[pages=-]{PythonLocust/LSTM.pdf} + +\subsection{Training Pipeline} +The training behavior is intentionally synchronized to ensure parity between the languages: +\begin{itemize} + \item \textbf{Data Loading:} Operates synchronously on synthetically generated noisy sequential datasets. The validation set is scaled symmetrically relative to the training set ($20\%$ of training size). + \item \textbf{Optimization Algorithm:} Utilizes the Adam Optimizer. + \item \textbf{Loss Function:} Mean Squared Error (MSE), with reduction set to \textit{mean}. Both explicitly weigh loss accumulation during epoch passes by scaling local batch losses by the discrete batch size, averaging properly at the conclusion of the epoch. + \item \textbf{Gradient Clipping:} Ensures numerical stability on longer sequence inputs. The gradient norm is strictly clipped to $\max = 1.0$ right before the optimizer steps. + \item \textbf{Artifacts Output:} Training scripts generate an \texttt{artifact\_dir} where they store a \texttt{config.json} representation of hyperparameters, and the full state dictionary (\texttt{model.pt} in PyTorch; CompactRecorder files in Rust Burn). +\end{itemize} + +\subsection{Inference Pipeline and Docker NFS Integration} +\subsubsection{PyTorch Inference Architecture} +A critical requirement for modern PyTorch inference deployments is resolving the massive disk footprint of CUDA-enabled PyTorch backend libraries. The PyTorch pipeline employs a sophisticated Network File System (NFS) logic to achieve a highly optimized, lightweight Dockerized inference deployment: +\begin{enumerate} + \item \textbf{External Library Mounting:} A host-level script (\texttt{mount\_libs.sh}) maps an external NAS/NFS storage partition (from \texttt{172.16.203.14}) loaded with Python environments targeting \texttt{/mnt/LSTM-libs}. + \item \textbf{Optimized Dockerfile:} The image leverages the \texttt{nvidia/cuda:12.1.1-cudnn8-runtime-ubuntu22.04} base image and installs basic \texttt{python3.11} runtime headers without calling \texttt{pip install torch}. Thus, the final image size is structurally negligible compared to standard ml-images. + \item \textbf{Runtime Binding:} The inference container bootloader scripts (\texttt{run\_container.sh}) bind these volume mounts (\texttt{-v \$NFS\_MOUNT\_POINT:/external-libs}) and crucially overrides the \texttt{PYTHONPATH} env-variable: + \begin{verbatim} + -e PYTHONPATH="$CONTAINER_LIB_MOUNT/LSTM_env/lib/.../site-packages" + \end{verbatim} + \item \textbf{Inference Execution:} \texttt{app.py} loads the model weights off an abstracted configuration path, builds a zero-gradient loader, runs inference iteratively over a single collapsed batch, and yields predictions natively. +\end{enumerate} + +\subsubsection{Rust Inference Architecture} +Rust's inference pipeline diverges significantly regarding deployment complexity due to compilation structures: +\begin{itemize} + \item \textbf{Stateless Binaries:} No containerized runtime libraries are mandated because Burn compiles statically down to heavily optimized binaries, pulling model states directly via the \texttt{CompactRecorder}. + \item \textbf{Visualization:} Results are mapped into native Polars \texttt{DataFrame} objects (\texttt{df![]}) rendering lightweight native tables detailing \textit{expected targets} versus \textit{computed predictions}. +\end{itemize} + +\subsection{Implementation Specifics} +\subsubsection{PyTorch Specific Constraints} +\begin{itemize} + \item \textbf{Dynamic computation graphing:} The \texttt{model.py} cleanly slices and chunks gates natively on tensors (e.g., \texttt{gates.chunk(4, dim=1)}). + \item \textbf{Sequence Reversals:} Done programmatically via continuous \texttt{Tensor.flip(dims=[1])} which mandates that tensors must remain contiguously stored within PyTorch internals to avoid memory reallocation overhead. + \item \textbf{Seed Setting API:} Requires deterministic locking across four sub-systems (\texttt{random, numpy, torch, torch.cuda}) to match Rust's reproducibility parameters. +\end{itemize} + +\subsubsection{Rust (Burn) Specific Constraints} +\begin{itemize} + \item \textbf{Compile-Time Dimension Types:} Rust explicitly binds Tensor dimensionality at compile time (\texttt{Tensor} vs \texttt{Tensor}). This offers un-matched safety by forbidding invalid dimension injections that PyTorch would crash on dynamically. + \item \textbf{Trait Encapsulation:} Leverages explicit trait architectures (\texttt{\#[derive(Module, Config)]}) that automate saving hyperparameters and generating gradient backends. Burn models must be mapped cleanly from standard states to \texttt{autodiff} states. + \item \textbf{No-Mutation Logic:} State mutations generated sequentially in LSTMs are represented safely utilizing explicit tuple destructuring via \texttt{LstmState\{hidden, cell\}}, bypassing complex internal pointer tracking. + \item \textbf{Explicit Initialization Handling:} Since Burn limits orthogonal initializes out-of-the-box, Xavier Normalization was invoked explicitly, paired with \texttt{slice\_assign} tensor mappings to safely load the 1.0 uniform fill into the forget-gate components. +\end{itemize} + +\subsection{Rust: Training Loss Progression and Model Convergence} + +The model was trained for a total of 30 epochs. During training, both the training loss and validation loss decreased substantially, indicating that the model was able to learn meaningful patterns from the data. + +\subsubsection{Training Progress} + +The training process began with relatively high loss values. However, as training progressed, both the average training loss and average validation loss consistently decreased. + +The recorded loss values at different stages of training are shown below: + +\begin{table*}[t!] +\centering +\caption{Training and Validation Loss Progression} +\begin{tabular}{|c|c|c|} +\hline +\textbf{Epoch} & \textbf{Average Training Loss} & \textbf{Average Validation Loss} \\ +\hline +5 & 4456.9658 & 4473.4448 \\ +10 & 2510.1016 & 2438.3970 \\ +15 & 900.7457 & 801.6573 \\ +20 & 154.4127 & 164.8311 \\ +25 & 48.2149 & 20.1441 \\ +30 & 52.1122 & 17.5850 \\ +\hline +\end{tabular} +\label{tab:loss_progression} +\end{table*} + +\subsubsection{Loss Trend Analysis} + +The training loss decreased from 4456.9658 at epoch 5 to 52.1122 at epoch 30. Similarly, the validation loss decreased from 4473.4448 to 17.5850 over the same period. + +This large reduction in both training and validation loss suggests that the model successfully converged during training. + +Although the training loss slightly increased between epoch 25 and epoch 30, the validation loss continued to decrease. This indicates that the model continued to improve its ability to generalize to unseen data. + +The lowest validation loss achieved during the experiment was: + +\[ +17.5850 +\] + +at epoch 30. + +\subsubsection{Generalization Performance} + +The close alignment between the training loss and validation loss throughout training suggests that the model did not suffer from severe overfitting. + +In the earlier epochs, both losses were very high, which is expected because the model parameters were still being optimized. As training continued, the losses dropped rapidly, especially between epochs 10 and 25. + +This behavior indicates that the model learned most of its predictive capability during the middle phase of training. + +\subsubsection{Execution Time} + +The complete training process required: + +\begin{itemize} + \item Real time: 6 minutes and 42.185 seconds + \item User CPU time: 6 minutes and 42.414 seconds + \item System CPU time: 3.49 seconds +\end{itemize} + +The relatively low system CPU time compared to user CPU time suggests that most of the runtime was spent performing model computation rather than operating system overhead. +\subsection{Python: LSTM Model Architecture and Training Performance} + +The Long Short-Term Memory (LSTM) model consists of a 2-layer bidirectional LSTM followed by a fully connected output layer. Dropout is applied between LSTM layers to improve generalization. + +\textbf{Model Architecture:} + + +The model was trained for 30 epochs. Training and validation performance improved significantly and consistently. + +Training loss decreased from 5543.18 to 56.11 (98.99\% reduction), while validation loss decreased from 5699.26 to 48.92. +The model achieved strong regression performance, with validation RMSE reaching 6.99 and MAE reaching 4.33. + +The $R^2$ score improved from negative values to 0.9517, indicating strong predictive capability. + +\begin{table*}[t!] +\centering +\begin{tabular}{|l|l|c|c|c|c|} +\hline +\textbf{Split} & \textbf{Metric} & \textbf{Min} & \textbf{Epoch} & \textbf{Max} & \textbf{Epoch} \\ +\hline + +Train & Loss & 56.11 & 30 & 5543.18 & 1 \\ +Train & RMSE & 7.49 & 30 & 74.45 & 1 \\ +Train & MAE & 5.03 & 30 & 67.25 & 1 \\ +Train & R$^2$ & -4.41 & 1 & 0.9452 & 30 \\ +Train & Grad Norm (Total) & 524.61 & 1 & 4972.41 & 24 \\ +Train & Iteration Speed (it/s) & 5.02 & 29 & 6.94 & 4 \\ +Train & CPU Memory (GB) & 0.87 & -- & 1.13 & -- \\ +Train & CPU Usage (\%) & 72.4 & -- & 88.8 & -- \\ +\hline + +Valid & Loss & 47.96 & 29 & 5699.26 & 1 \\ +Valid & RMSE & 6.93 & 29 & 75.49 & 1 \\ +Valid & MAE & 3.54 & 28 & 68.51 & 1 \\ +Valid & R$^2$ & -4.63 & 1 & 0.9526 & 29 \\ +Valid & Iteration Speed (it/s) & 5.02 & 29 & 6.94 & 4 \\ +Valid & CPU Memory (GB) & 0.87 & -- & 1.13 & -- \\ +Valid & CPU Usage (\%) & 72.4 & -- & 88.8 & -- \\ +\hline + +\end{tabular} +\caption{Extended LSTM Training and Validation Metrics Summary} +\end{table*} + +\textbf{Training Efficiency and Stability:} +\begin{itemize} + \item Total Training Time: 158.63 seconds + \item Average Epoch Time: 5.18 seconds + \item Iteration Speed (Mean): 6.22 it/s + \item Gradient Norm (Mean): 1394.59 + \item NaN Events: 0 + \item Convergence: Monotonic loss decrease + \item Overfitting Detected: No +\end{itemize} + + + + +\newpage + +\subsection{Container Comparison: Python vs Rust} + +\begin{table*}[t!] +\centering +\begin{tabular}{|l|c|c|} +\hline +\textbf{Feature} & \textbf{Python (GPU)} & \textbf{Rust (WGPU)} \\ +\hline +Image Name & text\_classification\_image & text\_class\_rs \\ +\hline +Size & 3.98GB & 974MB \\ +\hline +Backend & PyTorch + CUDA & Native Rust (WGPU) \\ +\hline +Startup Time & Slower & Faster \\ +\hline +Dependencies & Heavy (Torch, CUDA, Python) & Minimal (compiled binary) \\ +\hline +Deployment Complexity & Higher & Lower \\ +\hline +Flexibility & High (research-friendly) & Moderate \\ +\hline +Runtime Stability & Medium & High \\ +\hline +\end{tabular} +\caption{Comparison of Python GPU-based and Rust-based inference containers} +\end{table*} + +\noindent +The Python-based container provides flexibility and rapid experimentation using the PyTorch ecosystem, but at the cost of larger image size and dependency complexity. In contrast, the Rust-based container offers a lightweight, production-ready solution with faster startup time and minimal runtime dependencies, making it more suitable for deployment scenarios. + + +\subsection{Model Size Comparison} + +\begin{table*}[t!] +\centering +\begin{tabular}{|l|c|c|} +\hline +\textbf{Model} & \textbf{Rust (.mpk/.bin)} & \textbf{Python (.pt/.pth)} \\ +\hline +MNIST & 1.1 MB & 2 MB \\ +\hline +Text Classification (AG News) & 21 MB & 40.6 MB \\ +\hline +Regression & 4 KB & 6 MB \\ +\hline +LSTM & 60 KB & 120 KB \\ +\hline +\end{tabular} +\caption{Comparison of model sizes between Rust and Python implementations} +\end{table*} + +\noindent +Rust-based serialized models are consistently smaller than their Python counterparts. This reduction is most significant in simpler models such as regression, and remains substantial for larger models like text classification. The smaller footprint of Rust models makes them more suitable for lightweight deployment and resource-constrained environments. + +\clearpage + + +\end{document} diff --git a/latex_reports/lstm.tex b/latex_reports/lstm.tex index 029ce51..fcf3814 100644 --- a/latex_reports/lstm.tex +++ b/latex_reports/lstm.tex @@ -6,6 +6,8 @@ \usepackage{minted} \usepackage{geometry} \geometry{a4paper, margin=1in} +\usepackage{pdfpages} + \title{\textbf{Comparative Analysis of LSTM Implementation: Rust (Burn) vs. PyTorch}} \author{Technical Report} @@ -55,6 +57,47 @@ \subsection{Architectural Details} \end{enumerate} +\section{Rust Backend: Load Testing with Locust} + +The performance of the Rust-based backend was evaluated using the Locust load testing framework. The objective was to analyze system behavior under concurrent user load and measure key performance characteristics such as throughput and latency. + +\textbf{Testing Setup:} +\begin{itemize} + \item Tool: Locust + \item Backend: Rust (HTTP service) + \item Test Type: Concurrent user load simulation + \item Environment: Linux system +\end{itemize} + +\textbf{Dashboard Visualization:} +\textbf{Full Report:} + +The complete load testing dashboard has been exported as a PDF and is included below for detailed inspection. + +\includepdf[pages=-]{RustLocust/LSTM.pdf} + + + +\section{Python Backend: Load Testing with Locust} + +The performance of the Python-based backend was evaluated using the Locust load testing framework. The goal was to assess system behavior under concurrent user load and analyze key performance characteristics such as throughput and response latency. + +\textbf{Testing Setup:} +\begin{itemize} + \item Tool: Locust + \item Backend: Python (HTTP service) + \item Test Type: Concurrent user load simulation + \item Environment: Linux system +\end{itemize} + +\textbf{Dashboard Visualization:} + +\textbf{Full Report:} + +The complete load testing dashboard has been exported as a PDF and is included below for detailed inspection. + +\includepdf[pages=-]{PythonLocust/LSTM.pdf} + \section{Training Pipeline} The training behavior is intentionally synchronized to ensure parity between the languages: \begin{itemize} @@ -75,7 +118,7 @@ \subsection{PyTorch Inference Architecture} \begin{verbatim} -e PYTHONPATH="$CONTAINER_LIB_MOUNT/LSTM_env/lib/.../site-packages" \end{verbatim} - \item \textbf{Inference Execution:} \texttt{app.py} loads the model weights off an abstracted configuration path, builds a zero-gradient loader, runs inference iteratively over a single collapsed batch, and yields predictions natively. + \item \textbf{Inference Execution:} \texttt{app.py} loads the model weights off an abstracted configuration path, builds a zero-gradient loader, runs inference iteratively over a single collapsed batch, and yields predictions natively. \end{enumerate} \subsection{Rust Inference Architecture} @@ -101,4 +144,183 @@ \subsection{Rust (Burn) Specific Constraints} \item \textbf{Explicit Initialization Handling:} Since Burn limits orthogonal initializes out-of-the-box, Xavier Normalization was invoked explicitly, paired with \texttt{slice\_assign} tensor mappings to safely load the 1.0 uniform fill into the forget-gate components. \end{itemize} +\section{Rust: Training Loss Progression and Model Convergence} + +The model was trained for a total of 30 epochs. During training, both the training loss and validation loss decreased substantially, indicating that the model was able to learn meaningful patterns from the data. + +\subsection{Training Progress} + +The training process began with relatively high loss values. However, as training progressed, both the average training loss and average validation loss consistently decreased. + +The recorded loss values at different stages of training are shown below: + +\begin{table}[h] +\centering +\caption{Training and Validation Loss Progression} +\begin{tabular}{|c|c|c|} +\hline +\textbf{Epoch} & \textbf{Average Training Loss} & \textbf{Average Validation Loss} \\ +\hline +5 & 4456.9658 & 4473.4448 \\ +10 & 2510.1016 & 2438.3970 \\ +15 & 900.7457 & 801.6573 \\ +20 & 154.4127 & 164.8311 \\ +25 & 48.2149 & 20.1441 \\ +30 & 52.1122 & 17.5850 \\ +\hline +\end{tabular} +\label{tab:loss_progression} +\end{table} + +\subsection{Loss Trend Analysis} + +The training loss decreased from 4456.9658 at epoch 5 to 52.1122 at epoch 30. Similarly, the validation loss decreased from 4473.4448 to 17.5850 over the same period. + +This large reduction in both training and validation loss suggests that the model successfully converged during training. + +Although the training loss slightly increased between epoch 25 and epoch 30, the validation loss continued to decrease. This indicates that the model continued to improve its ability to generalize to unseen data. + +The lowest validation loss achieved during the experiment was: + +\[ +17.5850 +\] + +at epoch 30. + +\subsection{Generalization Performance} + +The close alignment between the training loss and validation loss throughout training suggests that the model did not suffer from severe overfitting. + +In the earlier epochs, both losses were very high, which is expected because the model parameters were still being optimized. As training continued, the losses dropped rapidly, especially between epochs 10 and 25. + +This behavior indicates that the model learned most of its predictive capability during the middle phase of training. + +\subsection{Execution Time} + +The complete training process required: + +\begin{itemize} + \item Real time: 6 minutes and 42.185 seconds + \item User CPU time: 6 minutes and 42.414 seconds + \item System CPU time: 3.49 seconds +\end{itemize} + +The relatively low system CPU time compared to user CPU time suggests that most of the runtime was spent performing model computation rather than operating system overhead. +\section{Python: LSTM Model Architecture and Training Performance} + +The Long Short-Term Memory (LSTM) model consists of a 2-layer bidirectional LSTM followed by a fully connected output layer. Dropout is applied between LSTM layers to improve generalization. + +\textbf{Model Architecture:} + + +The model was trained for 30 epochs. Training and validation performance improved significantly and consistently. + +Training loss decreased from 5543.18 to 56.11 (98.99\% reduction), while validation loss decreased from 5699.26 to 48.92. +The model achieved strong regression performance, with validation RMSE reaching 6.99 and MAE reaching 4.33. + +The $R^2$ score improved from negative values to 0.9517, indicating strong predictive capability. + +\begin{table}[h] +\centering +\begin{tabular}{|l|l|c|c|c|c|} +\hline +\textbf{Split} & \textbf{Metric} & \textbf{Min} & \textbf{Epoch} & \textbf{Max} & \textbf{Epoch} \\ +\hline + +Train & Loss & 56.11 & 30 & 5543.18 & 1 \\ +Train & RMSE & 7.49 & 30 & 74.45 & 1 \\ +Train & MAE & 5.03 & 30 & 67.25 & 1 \\ +Train & R$^2$ & -4.41 & 1 & 0.9452 & 30 \\ +Train & Grad Norm (Total) & 524.61 & 1 & 4972.41 & 24 \\ +Train & Iteration Speed (it/s) & 5.02 & 29 & 6.94 & 4 \\ +Train & CPU Memory (GB) & 0.87 & -- & 1.13 & -- \\ +Train & CPU Usage (\%) & 72.4 & -- & 88.8 & -- \\ +\hline + +Valid & Loss & 47.96 & 29 & 5699.26 & 1 \\ +Valid & RMSE & 6.93 & 29 & 75.49 & 1 \\ +Valid & MAE & 3.54 & 28 & 68.51 & 1 \\ +Valid & R$^2$ & -4.63 & 1 & 0.9526 & 29 \\ +Valid & Iteration Speed (it/s) & 5.02 & 29 & 6.94 & 4 \\ +Valid & CPU Memory (GB) & 0.87 & -- & 1.13 & -- \\ +Valid & CPU Usage (\%) & 72.4 & -- & 88.8 & -- \\ +\hline + +\end{tabular} +\caption{Extended LSTM Training and Validation Metrics Summary} +\end{table} + +\textbf{Training Efficiency and Stability:} +\begin{itemize} + \item Total Training Time: 158.63 seconds + \item Average Epoch Time: 5.18 seconds + \item Iteration Speed (Mean): 6.22 it/s + \item Gradient Norm (Mean): 1394.59 + \item NaN Events: 0 + \item Convergence: Monotonic loss decrease + \item Overfitting Detected: No +\end{itemize} + + + + +\newpage + +\section{Container Comparison: Python vs Rust} + +\begin{table}[h] +\centering +\begin{tabular}{|l|c|c|} +\hline +\textbf{Feature} & \textbf{Python (GPU)} & \textbf{Rust (WGPU)} \\ +\hline +Image Name & text\_classification\_image & text\_class\_rs \\ +\hline +Size & 3.98GB & 974MB \\ +\hline +Backend & PyTorch + CUDA & Native Rust (WGPU) \\ +\hline +Startup Time & Slower & Faster \\ +\hline +Dependencies & Heavy (Torch, CUDA, Python) & Minimal (compiled binary) \\ +\hline +Deployment Complexity & Higher & Lower \\ +\hline +Flexibility & High (research-friendly) & Moderate \\ +\hline +Runtime Stability & Medium & High \\ +\hline +\end{tabular} +\caption{Comparison of Python GPU-based and Rust-based inference containers} +\end{table} + +\noindent +The Python-based container provides flexibility and rapid experimentation using the PyTorch ecosystem, but at the cost of larger image size and dependency complexity. In contrast, the Rust-based container offers a lightweight, production-ready solution with faster startup time and minimal runtime dependencies, making it more suitable for deployment scenarios. + + +\section{Model Size Comparison} + +\begin{table}[h] +\centering +\begin{tabular}{|l|c|c|} +\hline +\textbf{Model} & \textbf{Rust (.mpk/.bin)} & \textbf{Python (.pt/.pth)} \\ +\hline +MNIST & 1.1 MB & 2 MB \\ +\hline +Text Classification (AG News) & 21 MB & 40.6 MB \\ +\hline +Regression & 4 KB & 6 MB \\ +\hline +LSTM & 60 KB & 120 KB \\ +\hline +\end{tabular} +\caption{Comparison of model sizes between Rust and Python implementations} +\end{table} + +\noindent +Rust-based serialized models are consistently smaller than their Python counterparts. This reduction is most significant in simpler models such as regression, and remains substantial for larger models like text classification. The smaller footprint of Rust models makes them more suitable for lightweight deployment and resource-constrained environments. + + \end{document} diff --git a/latex_reports/main.tex b/latex_reports/main.tex index fc3179c..1c10156 100644 --- a/latex_reports/main.tex +++ b/latex_reports/main.tex @@ -193,7 +193,7 @@ \subsection{Inference Services Compared} \textbf{Rust Service} \begin{itemize} \item Axum / Actix - \item ONNX Runtime (Rust bindings) + \item burn-rs \end{itemize} Both services expose identical inference endpoints and return identical outputs. diff --git a/latex_reports/merge_script.py b/latex_reports/merge_script.py new file mode 100644 index 0000000..eddcfac --- /dev/null +++ b/latex_reports/merge_script.py @@ -0,0 +1,132 @@ +import os +import re + +directory = r"c:\Users\Valmik Belgaonkar\OneDrive\Desktop\Rust_Python_ML_PE\latex_reports" + +files = { + 'main': os.path.join(directory, 'main.tex'), + 'mnist': os.path.join(directory, 'mnist.tex'), + 'regression': os.path.join(directory, 'regression.tex'), + 'text': os.path.join(directory, 'text_classification_news.tex'), + 'lstm': os.path.join(directory, 'lstm.tex') +} + +out_file = os.path.join(directory, 'combined_research_paper.tex') + +def extract_body(file_path): + if not os.path.exists(file_path): + print(f"Warning: {file_path} not found.") + return "" + + with open(file_path, 'r', encoding='utf-8') as f: + content = f.read() + + # Extract everything between \begin{document} and \end{document} + match = re.search(r'\\begin\{document\}(.*?)\\end\{document\}', content, re.DOTALL) + if match: + body = match.group(1) + # Remove \maketitle, \tableofcontents, \newpage as we will have a unified one + body = re.sub(r'\\maketitle', '', body) + body = re.sub(r'\\tableofcontents', '', body) + # Shift sections down since these will be nested under a main Task section, EXCEPT for main.tex + if 'main.tex' not in file_path: + body = body.replace('\\subsubsection{', '\\paragraph{') + body = body.replace('\\subsection{', '\\subsubsection{') + body = body.replace('\\section{', '\\subsection{') + + # Also catch the asterisk versions like \section*{...} + body = body.replace('\\subsubsection*{', '\\paragraph*{') + body = body.replace('\\subsection*{', '\\subsubsection*{') + body = body.replace('\\section*{', '\\subsection*{') + return body.strip() + return "" + +def process_tables_for_twocolumn(body): + # Change \begin{table}[*] to \begin{table*}[*] to span two columns + body = re.sub(r'\\begin\{table\}\[.*?\]', r'\\begin{table*}[t!]', body) + body = re.sub(r'\\begin\{table\}', r'\\begin{table*}[t!]', body) + body = re.sub(r'\\end\{table\}', r'\\end{table*}', body) + return body + +print("Extracting contents...") +main_body = extract_body(files['main']) +mnist_body = extract_body(files['mnist']) +regression_body = extract_body(files['regression']) +text_body = extract_body(files['text']) +lstm_body = extract_body(files['lstm']) + +# The user wants twocolumn, so we use table* to ensure wide tables don't break. +mnist_body = process_tables_for_twocolumn(mnist_body) +regression_body = process_tables_for_twocolumn(regression_body) +text_body = process_tables_for_twocolumn(text_body) +lstm_body = process_tables_for_twocolumn(lstm_body) +main_body = process_tables_for_twocolumn(main_body) + +preamble = r"""\documentclass[10pt,a4paper,twocolumn]{article} + +\usepackage{geometry} +\geometry{margin=0.75in, columnsep=0.25in} + +\usepackage{amsmath,amssymb} +\usepackage{graphicx} +\usepackage{booktabs} +\usepackage{hyperref} +\usepackage{enumitem} +\usepackage{array} +\usepackage{float} +\usepackage{listings} +\usepackage{xcolor} +\usepackage{pdfpages} +\usepackage{minted} + +\lstdefinelanguage{Dockerfile}{ + keywords={FROM, RUN, CMD, LABEL, MAINTAINER, EXPOSE, ENV, ADD, COPY, ENTRYPOINT, VOLUME, USER, WORKDIR, ARG, ONBUILD, STOPSIGNAL, HEALTHCHECK, SHELL}, + sensitive=true, + comment=[l]{\#}, + morestring=[b]", +} + +\title{\textbf{System-Level Evaluation of Rust and Python for Machine Learning}} +\author{Project Elective} +\date{\today} + +\begin{document} + +\maketitle +""" + +postamble = r""" +\end{document} +""" + +print(f"Writing combined file to {out_file}...") +with open(out_file, 'w', encoding='utf-8') as f: + f.write(preamble) + + # Write main.tex content + f.write(main_body) + f.write("\n\n\\clearpage\n\n") + + # Write MNIST content + f.write(r"\section{Task: MNIST Image Classification}" + "\n") + f.write(mnist_body) + f.write("\n\n\\clearpage\n\n") + + # Write Regression content + f.write(r"\section{Task: Regression}" + "\n") + f.write(regression_body) + f.write("\n\n\\clearpage\n\n") + + # Write Text Classification content + f.write(r"\section{Task: Text Classification (AG News)}" + "\n") + f.write(text_body) + f.write("\n\n\\clearpage\n\n") + + # Write LSTM content + f.write(r"\section{Task: LSTM implementation}" + "\n") + f.write(lstm_body) + f.write("\n\n\\clearpage\n\n") + + f.write(postamble) + +print("Combined file created successfully!") diff --git a/latex_reports/mnist.tex b/latex_reports/mnist.tex index 678a68d..79b9052 100644 --- a/latex_reports/mnist.tex +++ b/latex_reports/mnist.tex @@ -7,11 +7,13 @@ \usepackage{graphicx} \usepackage{booktabs} \usepackage{hyperref} -\usepackage{enumitem} +\usepackage{enumitem} \usepackage{array} \usepackage{float} \usepackage{listings} \usepackage{xcolor} +\usepackage{pdfpages} + \lstdefinelanguage{Dockerfile}{ keywords={FROM, RUN, CMD, LABEL, MAINTAINER, EXPOSE, ENV, ADD, COPY, ENTRYPOINT, VOLUME, USER, WORKDIR, ARG, ONBUILD, STOPSIGNAL, HEALTHCHECK, SHELL}, @@ -127,77 +129,294 @@ \section{Architecture Details} \item The model is fully differentiable and backend-agnostic via Burn's \texttt{Backend} trait. \end{itemize} -\section{Rust Dockerfile Analysis: \texttt{Dockerfile.mnist\_inf}} -The \texttt{Dockerfile.mnist\_inf} file defines the containerization process for the Rust-based MNIST inference service. It utilizes a \textbf{multi-stage build strategy} to optimize the final image size and security by separating the build environment from the runtime environment. +\section{Rust Backend: Load Testing with Locust} + +The performance of the Rust-based backend was evaluated using the Locust load testing framework. The objective was to analyze system behavior under concurrent user load and measure key performance characteristics such as throughput and latency. + +\textbf{Testing Setup:} +\textbf{Testing Setup:} +\begin{itemize} + \item Tool: Locust + \item Backend: Rust (HTTP service) + \item Test Type: Concurrent user load simulation + \item Environment: Linux system +\end{itemize} + +\textbf{Dashboard Visualization:} +\textbf{Full Report:} + +The complete load testing dashboard has been exported as a PDF and is included below for detailed inspection. + +\includepdf[pages=-]{RustLocust/MNIST.pdf} + + +\section{Python Backend: Load Testing with Locust} + +The performance of the Python-based backend was evaluated using the Locust load testing framework. The goal was to assess system behavior under concurrent user load and analyze key performance characteristics such as throughput and response latency. + +\textbf{Testing Setup:} +\begin{itemize} + \item Tool: Locust + \item Backend: Python (HTTP service) + \item Test Type: Concurrent user load simulation + \item Environment: Linux system +\end{itemize} + +\textbf{Dashboard Visualization:} + +\textbf{Full Report:} + +The complete load testing dashboard has been exported as a PDF and is included below for detailed inspection. + +\includepdf[pages=-]{PythonLocust/MNIST.pdf} + + +\section{Rust: Dockerfile Design and Containerization Strategy} + +The Dockerfile used for the Rust-based MNIST inference application follows a multi-stage build strategy. Multi-stage builds are commonly used to reduce the size of the final container image by separating the compilation environment from the runtime environment. + +\subsection{Overview of Multi-Stage Build} + +The Dockerfile is divided into two major stages: -\subsection{Multi-Stage Build Optimization} -The Dockerfile is divided into two distinct stages: \begin{enumerate} - \item \textbf{Builder Stage:} A heavier image containing the full Rust toolchain (compiler, cargo, libraries) used to compile the source code. - \item \textbf{Runtime Stage:} A minimal Alpine Linux image containing only the compiled binary and necessary system libraries. + \item Builder Stage + \item Runtime Stage \end{enumerate} -\textbf{Benefit:} This approach ensures that the final production image does not contain unnecessary build artifacts (like the \texttt{target/} directory, source code, or the Rust compiler itself), resulting in a significantly smaller and more secure container. +The builder stage is responsible for compiling the Rust application, while the runtime stage contains only the compiled binary and the required runtime dependencies. -\subsection{Step-by-Step Explanation} +\subsection{Builder Stage} -\subsubsection{Stage 1: The Builder} +The first stage begins with: + +\begin{verbatim} +FROM ubuntu:16.04 AS builder +\end{verbatim} + +This instruction uses Ubuntu 16.04 as the base image for building the Rust application. The alias \texttt{builder} is assigned to this stage so that its outputs can later be referenced in the runtime stage. + +\subsubsection{Working Directory} + +\begin{verbatim} +WORKDIR /app/rust_ml +\end{verbatim} + +The \texttt{WORKDIR} instruction sets the default working directory inside the container to: + +\begin{verbatim} +/app/rust_ml +\end{verbatim} + +All subsequent commands in the builder stage are executed relative to this directory. + +\subsubsection{Installing Build Dependencies} + +The following command installs the required packages for compiling the Rust project: + +\begin{verbatim} +RUN apt-get update && apt-get install -y \ + curl \ + build-essential \ + pkg-config \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* +\end{verbatim} + +Each package serves a specific purpose: \begin{itemize} - \item \texttt{FROM rust:1.92-alpine as builder} \\ - Initializes the build stage using the official Rust 1.92 image based on Alpine Linux. The \texttt{as builder} alias allows us to refer to this stage later. Alpine is chosen for its lightweight nature. - - \item \texttt{WORKDIR /app/rust\_ml} \\ - Sets the working directory inside the container to \texttt{/app/rust\_ml}, ensuring all subsequent commands execute in this context. - - \item \texttt{COPY . /app/rust\_ml/} \\ - Copies the entire build context (source code) from the host machine into the container's working directory. This includes the \texttt{rust\_ml} workspace and likely the root context required for build resolution. - - \item \texttt{RUN cargo build --release -p mnist\_infer} \\ - Executes the compilation command. - \begin{itemize} - \item \texttt{--release}: Compiles with optimizations enabled for maximum performance. - \item \texttt{-p mnist\_infer}: specifically targets the \texttt{mnist\_infer} package within the workspace. - \end{itemize} + \item \texttt{curl}: Used to download external files, including the Rust installation script. + \item \texttt{build-essential}: Provides common compilation tools such as \texttt{gcc}, \texttt{g++}, and \texttt{make}. + \item \texttt{pkg-config}: Helps discover system libraries during the build process. + \item \texttt{ca-certificates}: Ensures secure HTTPS communication when downloading dependencies. \end{itemize} -\subsubsection{Stage 2: The Runtime} +The final cleanup command: + +\begin{verbatim} +rm -rf /var/lib/apt/lists/* +\end{verbatim} + +removes cached package lists to reduce image size. + +\subsubsection{Installing Rust} + +Rust is installed using the official Rust installer: + +\begin{verbatim} +RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y +\end{verbatim} + +This command downloads and executes the \texttt{rustup} installer. + +The flags used have the following meanings: \begin{itemize} - \item \texttt{FROM alpine:3.23} \\ - Starts a fresh, clean layer using Alpine Linux 3.23. This is a minimal distribution (approx. 5MB) ideal for production containers. - - \item \texttt{RUN apk add --no-cache openssl-dev ca-certificates} \\ - Installs necessary runtime dependencies: - \begin{itemize} - \item \texttt{openssl-dev}: Required for cryptographic operations (often needed by web frameworks like Axum or Tokio). - \item \texttt{ca-certificates}: Ensures the container can verify SSL/TLS certificates when making outbound HTTPS requests. - \item \texttt{--no-cache}: Prevents caching the package index locally, keeping the image size small. - \end{itemize} - - \item \texttt{WORKDIR /app} \\ - Sets the working directory for the runtime container to \texttt{/app}. - - \item \texttt{COPY --from=builder /app/rust\_ml/target/release/mnist\_infer /app/mnist\_infer} \\ - \textbf{Crucial Step:} Copies \textit{only} the compiled executable from the \texttt{builder} stage into the runtime image. The bulky source code and build tools are left behind. - - \item \texttt{COPY ./model/mnist\_rust/model.mpk /app/model/mnist\_rust/model.mpk} \\ - Copies the pre-trained model file (\texttt{model.mpk}) from the host's file system into the container. This ensures the inference service has the model artifact available locally. - - \item \texttt{ENV RUST\_LOG=info} \\ - Sets the logging level to \texttt{info}, controlling the verbosity of the application's output. - - \item \texttt{ENV MODEL\_PATH=/app/model/mnist\_rust/model.mpk} \\ - Defines an environment variable pointing to the location of the model file, which the application reads at startup. - - \item \texttt{EXPOSE 9050} \\ - Documents that the container listens on port 9050, allowing traffic to reach the inference API. - - \item \texttt{CMD ["./mnist\_infer"]} \\ - Specifies the command to run when the container starts: executing the compiled binary. + \item \texttt{--proto '=https'}: Restricts downloads to HTTPS only. + \item \texttt{--tlsv1.2}: Forces the use of TLS version 1.2 for secure transport. + \item \texttt{-sSf}: Makes \texttt{curl} silent while still showing errors if the download fails. + \item \texttt{-y}: Automatically accepts all installation prompts. \end{itemize} +After Rust is installed, the PATH environment variable is updated: + +\begin{verbatim} +ENV PATH="/root/.cargo/bin:${PATH}" +\end{verbatim} + +This ensures that Rust tools such as \texttt{cargo} and \texttt{rustc} are available in subsequent commands. + +\subsubsection{Copying Source Code} + +\begin{verbatim} +COPY . . +\end{verbatim} + +This instruction copies the entire project directory from the host system into the current working directory inside the container. + +\subsubsection{Building the Application} + +\begin{verbatim} +RUN cargo build --release -p mnist_infer +\end{verbatim} + +This command compiles the Rust project in release mode. + +The options used are: + +\begin{itemize} + \item \texttt{--release}: Builds the application with compiler optimizations enabled. + \item \texttt{-p mnist\_infer}: Specifies that only the \texttt{mnist\_infer} package should be compiled. +\end{itemize} + +The generated executable is stored in: + +\begin{verbatim} +/app/rust_ml/target/release/mnist_infer +\end{verbatim} + +\subsection{Runtime Stage} + +The second stage begins with: + +\begin{verbatim} +FROM nvidia/vulkan:1.3-470 +\end{verbatim} + +This stage uses an NVIDIA Vulkan runtime image as the base image. The purpose of using this image is to provide Vulkan-related runtime libraries and GPU compatibility for applications that may rely on Vulkan acceleration. + +Compared to the builder image, this runtime image is significantly smaller because it does not contain compilation tools, Rust compilers, or source code. + + +\subsubsection{Runtime Working Directory} + +\begin{verbatim} +WORKDIR /app +\end{verbatim} + +This sets the runtime working directory to: + +\begin{verbatim} +/app +\end{verbatim} + +All runtime files are placed relative to this location. + +\subsubsection{Copying the Compiled Binary} + +\begin{verbatim} +COPY --from=builder /app/rust_ml/target/release/mnist_infer /app/binary +\end{verbatim} + +This instruction copies the compiled executable from the builder stage into the runtime image. + +The \texttt{--from=builder} option tells Docker to retrieve the file from the stage named \texttt{builder}. + +The binary is renamed from: + +\begin{verbatim} +mnist_infer +\end{verbatim} + +to: + +\begin{verbatim} +/app/binary +\end{verbatim} + +inside the runtime container. + +\subsubsection{Copying the Model File} + +\begin{verbatim} +COPY ./model/mnist_rust/model.mpk /app/model/mnist_rust/model.mpk +\end{verbatim} + +This instruction copies the trained model file into the runtime container. + +The model file is stored at: + +\begin{verbatim} +/app/model/mnist_rust/model.mpk +\end{verbatim} + +The application can later load this file during inference. + +\subsubsection{Environment Variables} + +Two environment variables are defined: + +\begin{verbatim} +ENV RUST_LOG=info +ENV MODEL_PATH=/app/model/mnist_rust/model.mpk +\end{verbatim} + +Their purposes are: + +\begin{itemize} + \item \texttt{RUST\_LOG=info}: Enables logging at the info level. + \item \texttt{MODEL\_PATH}: Stores the path to the trained model file. +\end{itemize} + +Using environment variables makes the application more flexible because configuration values can be changed without modifying the source code. + +\subsubsection{Exposing the Application Port} + +\begin{verbatim} +EXPOSE 9050 +\end{verbatim} + +This instruction documents that the containerized application listens on port 9050. + +Although \texttt{EXPOSE} does not automatically publish the port to the host system, it informs users and orchestration tools such as Docker Compose or Kubernetes which port should be mapped. + +\subsubsection{Container Startup Command} + +\begin{verbatim} +CMD ["./binary"] +\end{verbatim} + +This instruction defines the default command executed when the container starts. + +The compiled Rust binary is launched directly from the runtime working directory. + +\subsection{Advantages of the Dockerfile Design} + +This Dockerfile provides several important advantages: + +\begin{itemize} + \item Reduced final image size through multi-stage builds. + \item Separation of build dependencies and runtime dependencies. + \item Improved security because the runtime image does not contain compilers or source code. + \item Faster deployment due to a lightweight runtime container. + \item Better portability because the same container can run consistently across different environments. + \item Easier maintenance through the use of environment variables and explicit working directories. +\end{itemize} + +Overall, this Dockerfile is designed to efficiently package the Rust-based MNIST inference application for deployment while minimizing runtime overhead and maintaining reproducibility. + \section{Python (PyTorch) Dockerfile} This section details the image optimization strategy implemented for the MNIST inference container. The core approach minimizes the Docker image size by decoupling the heavy machine learning dependencies (PyTorch, etc.) from the application container. Instead of baking these libraries into the image, they are stored on an external volume (NFS share) and mounted at runtime. @@ -272,12 +491,12 @@ \subsection{Benefits and Optimization} \begin{table}[h!] \centering -\caption{Optimization Benefits} +\caption{Optimization Benefits} \label{tab:docker_optimization} \begin{tabular}{|l|p{6cm}|p{6cm}|} \hline \textbf{Feature} & \textbf{Standard Approach} & \textbf{Volume Mount Approach} \\ \hline -\textbf{Image Size} & \textbf{Huge} (>2GB). Includes PyTorch, CUDA binaries, and all dependencies. & \textbf{Tiny} (~100MB). Only contains app code and minimal HTTP libs. \\ \hline +\textbf{Image Size} & \textbf{Huge} ($>2GB$). Includes PyTorch, CUDA binaries, and all dependencies. & \textbf{Tiny} (~100MB). Only contains app code and minimal HTTP libs. \\ \hline \textbf{Build Time} & \textbf{Slow}. Downloading and installing PyTorch takes minutes. & \textbf{Fast}. setup only installs \texttt{fastapi}. \\ \hline \textbf{Updates} & requires rebuilding and pushing large layers for every code change. & Code changes only require rebuilding the tiny app layer. Library updates are handled externally. \\ \hline \end{tabular} @@ -285,8 +504,182 @@ \subsection{Benefits and Optimization} This architecture allows for rapid deployment and updating of the application logic without the overhead of moving gigabytes of container layers for unchanged machine learning dependencies. -\section{Rust CI/CD} +\section{Rust: CNN Model Architecture and Training Performance} + +The convolutional neural network (CNN) model used for the experiment consisted of two convolutional layers followed by adaptive average pooling, dropout, and two fully connected layers. The complete architecture is shown below: + +\begin{verbatim} +Model { + conv1: Conv2d {ch_in: 1, ch_out: 8, stride: [1, 1], kernel_size: [3, 3], dilation: [1, 1], groups: 1, padding: Valid, params: 80} + conv2: Conv2d {ch_in: 8, ch_out: 16, stride: [1, 1], kernel_size: [3, 3], dilation: [1, 1], groups: 1, padding: Valid, params: 1168} + pool: AdaptiveAvgPool2d {output_size: [8, 8]} + dropout: Dropout {prob: 0.5} + linear1: Linear {d_input: 1024, d_output: 512, bias: true, params: 524800} + linear2: Linear {d_input: 512, d_output: 10, bias: true, params: 5130} + activation: Relu + params: 531178 +} +\end{verbatim} + +The model was trained for 10 epochs. Over the course of training, both the training and validation performance improved consistently. Training accuracy increased from 81.575\% in the first epoch to 97.300\% in the final epoch, while validation accuracy improved from 92.133\% to 98.517\%. + +Similarly, the training loss decreased significantly from 0.656 to 0.087, and the validation loss reduced from 0.258 to 0.054 by the end of training. The macro F1-score also improved substantially, reaching 96.974\% for training and 98.321\% for validation. + +\begin{table}[h] +\centering +\caption{Training and Validation Metrics Summary} +\begin{tabular}{|l|l|c|c|c|c|} +\hline +\textbf{Split} & \textbf{Metric} & \textbf{Min.} & \textbf{Epoch} & \textbf{Max.} & \textbf{Epoch} \\ +\hline +Train & Accuracy & 81.575 & 1 & 97.300 & 10 \\ +Train & Loss & 0.087 & 10 & 0.656 & 1 \\ +Train & Precision@Top1 [Macro] & 82.126 & 1 & 97.304 & 10 \\ +Train & Recall@Top1 [Macro] & 81.286 & 1 & 97.232 & 10 \\ +Train & F1-Score@Top1 [Macro] & 79.715 & 1 & 96.974 & 10 \\ +Train & Top-5 Accuracy & 97.696 & 1 & 99.969 & 10 \\ +Train & CPU Memory (GB) & 2.514 & 2 & 2.927 & 10 \\ +Train & CPU Usage (\%) & 20.753 & 5 & 30.394 & 10 \\ +\hline +Valid & Accuracy & 92.133 & 1 & 98.517 & 10 \\ +Valid & Loss & 0.054 & 10 & 0.258 & 1 \\ +Valid & Precision@Top1 [Macro] & 92.154 & 1 & 98.527 & 10 \\ +Valid & Recall@Top1 [Macro] & 91.978 & 1 & 98.425 & 10 \\ +Valid & F1-Score@Top1 [Macro] & 91.176 & 1 & 98.321 & 10 \\ +Valid & Top-5 Accuracy & 99.583 & 1 & 99.967 & 10 \\ +Valid & CPU Memory (GB) & 2.514 & 2 & 3.085 & 10 \\ +Valid & CPU Usage (\%) & 20.539 & 2 & 39.652 & 10 \\ +\hline +\end{tabular} +\label{tab:cnn_metrics_summary} +\end{table} + +The results indicate that the model achieved strong generalization performance with minimal overfitting, as the validation accuracy remained slightly higher than the training accuracy throughout the experiment. The consistently high Top-5 accuracy values further demonstrate that the model was able to correctly identify the correct class within its top predictions. + +It should also be noted that the execution terminated with a segmentation fault after training completion. However, since the fault occurred after all epochs had been completed and metrics had already been recorded, it did not affect the validity of the training results. \\ + +The time taken to train the model is 229.916s (3min 49.916s). + + + +\section{Python: CNN Model Architecture and Training Performance} + +The convolutional neural network (CNN) model implemented in Python mirrors the Rust architecture, consisting of two convolutional layers followed by adaptive average pooling, dropout regularization, and two fully connected layers. + +\textbf{Model Architecture:} + +\begin{verbatim} +Model { + conv1: Conv2d {ch_in: 1, ch_out: 8, kernel_size: [3, 3], stride: [1, 1]} + conv2: Conv2d {ch_in: 8, ch_out: 16, kernel_size: [3, 3], stride: [1, 1]} + pool: AdaptiveAvgPool2d {output_size: [8, 8]} + dropout: Dropout {prob: 0.5} + linear1: Linear {d_input: 1024, d_output: 512, params: 524800} + linear2: Linear {d_input: 512, d_output: 10, params: 5130} + activation: ReLU + total params: 531178 +} +\end{verbatim} + +The model was trained for 10 epochs. Training and validation performance improved consistently over time. + +Training accuracy increased from 82.01\% to 97.29\%, while validation accuracy improved from 92.87\% to 98.20\%. +Training loss decreased significantly from 0.5947 to 0.0867, and validation loss reduced from 0.2475 to 0.0579. + +The macro F1-score reached 0.9814, demonstrating strong classification performance. Additionally, the Top-5 accuracy achieved 99.98\%, indicating highly reliable predictions. + +\begin{table}[h] +\centering +\begin{tabular}{|l|l|c|c|c|c|} +\hline +\textbf{Split} & \textbf{Metric} & \textbf{Min} & \textbf{Epoch} & \textbf{Max} & \textbf{Epoch} \\ +\hline + +Train & Accuracy & 82.01 & 1 & 97.29 & 10 \\ +Train & Loss & 0.0867 & 10 & 0.5947 & 1 \\ +Train & Precision@Top1 [Macro] & -- & -- & -- & -- \\ +Train & Recall@Top1 [Macro] & -- & -- & -- & -- \\ +Train & F1-Score@Top1 [Macro] & -- & -- & -- & -- \\ +Train & Top-5 Accuracy & -- & -- & -- & -- \\ +Train & CPU Memory (GB) & 0.84 & -- & 0.98 & -- \\ +Train & CPU Usage (\%) & 72.1 & -- & 92.0 & -- \\ +\hline + +Valid & Accuracy & 92.87 & 1 & 98.20 & 10 \\ +Valid & Loss & 0.0579 & 10 & 0.2475 & 1 \\ +Valid & Precision@Top1 [Macro] & 0.9816 & -- & 0.9816 & -- \\ +Valid & Recall@Top1 [Macro] & 0.9812 & -- & 0.9812 & -- \\ +Valid & F1-Score@Top1 [Macro] & 0.9814 & -- & 0.9814 & -- \\ +Valid & Top-5 Accuracy & 99.98 & -- & 99.98 & -- \\ +Valid & CPU Memory (GB) & 0.84 & -- & 0.98 & -- \\ +Valid & CPU Usage (\%) & 72.1 & -- & 92.0 & -- \\ +\hline + +\end{tabular} +\caption{Python Training and Validation Metrics Summary} +\end{table} + +The results indicate strong generalization performance with no signs of overfitting. Validation accuracy remained consistently high and closely followed training accuracy. + +Training was stable with zero NaN events observed. The total training time was 182.23 seconds, with an average epoch time of 15.05 seconds and an average iteration speed of 50.58 iterations per second. + + + +\newpage + +\section{Container Comparison: Python vs Rust} + +\begin{table}[h] +\centering +\begin{tabular}{|l|c|c|} +\hline +\textbf{Feature} & \textbf{Python (GPU)} & \textbf{Rust (WGPU)} \\ +\hline +Image Name & text\_classification\_image & text\_class\_rs \\ +\hline +Size & 3.98GB & 1.02GB \\ +\hline +Backend & PyTorch + CUDA & Native Rust (WGPU) \\ +\hline +Startup Time & Slower & Faster \\ +\hline +Dependencies & Heavy (Torch, CUDA, Python) & Minimal (compiled binary) \\ +\hline +Deployment Complexity & Higher & Lower \\ +\hline +Flexibility & High (research-friendly) & Moderate \\ +\hline +Runtime Stability & Medium & High \\ +\hline +\end{tabular} +\caption{Comparison of Python GPU-based and Rust-based inference containers} +\end{table} + +\noindent +The Python-based container provides flexibility and rapid experimentation using the PyTorch ecosystem, but at the cost of larger image size and dependency complexity. In contrast, the Rust-based container offers a lightweight, production-ready solution with faster startup time and minimal runtime dependencies, making it more suitable for deployment scenarios. + +\section{Model Size Comparison} + +\begin{table}[h] +\centering +\begin{tabular}{|l|c|c|} +\hline +\textbf{Model} & \textbf{Rust (.mpk/.bin)} & \textbf{Python (.pt/.pth)} \\ +\hline +MNIST & 1.1 MB & 2 MB \\ +\hline +Text Classification (AG News) & 21 MB & 40.6 MB \\ +\hline +Regression & 4 KB & 6 MB \\ +\hline +LSTM & 60 KB & 120 KB \\ +\hline +\end{tabular} +\caption{Comparison of model sizes between Rust and Python implementations} +\end{table} + +\noindent +Rust-based serialized models are consistently smaller than their Python counterparts. This reduction is most significant in simpler models such as regression, and remains substantial for larger models like text classification. The smaller footprint of Rust models makes them more suitable for lightweight deployment and resource-constrained environments. -\section{Python CI/CD} \end{document} diff --git a/latex_reports/regression.tex b/latex_reports/regression.tex index d20fa15..2b18f1b 100644 --- a/latex_reports/regression.tex +++ b/latex_reports/regression.tex @@ -5,12 +5,14 @@ \usepackage{hyperref} \usepackage{geometry} \geometry{a4paper, margin=1in} +\usepackage{pdfpages} + \title{\textbf{Comparative Analysis of Regression Implementations: Rust (Burn) vs. PyTorch}} \author{Technical Report} \date{\today} -\begin{document} +\begin{document} \maketitle \tableofcontents @@ -85,6 +87,116 @@ \subsection{Rust (Burn) Inference Architecture} \item \textbf{Native Routing:} Utilizes \texttt{Axum} servers to establish the HTTP logic endpoints securely routing JSON payloads mapping to specific feature names (e.g. \texttt{median\_income}, \texttt{house\_age}). \end{itemize} +\section{Rust: Regression Model Performance Analysis} + +The regression model used in this experiment was a simple feed-forward neural network consisting of one hidden layer followed by an output layer. The model was designed to predict the median house value based on eight input features. + +\subsection{Model Architecture} + +The architecture of the regression model is shown below: + +\begin{verbatim} +RegressionModel { + input_layer: Linear {d_input: 8, d_output: 64, bias: true, params: 576} + output_layer: Linear {d_input: 64, d_output: 1, bias: true, params: 65} + activation: Relu + params: 641 +} +\end{verbatim} + +The model contains: + +\begin{itemize} + \item An input layer that maps 8 input features to 64 hidden units + \item A ReLU activation function applied after the hidden layer + \item An output layer that maps the 64 hidden units to a single scalar value +\end{itemize} + +The total number of trainable parameters in the model was only 641, making it a lightweight model suitable for fast training and inference. + +\subsection{Training Configuration} + +The model was trained for 100 epochs. A constant learning rate of: + +\[ +1.0 \times 10^{-3} +\] + +was used throughout the entire training process. + +\subsection{Training Performance} + +The training loss decreased substantially over the 100 epochs. Initially, the model started with a training loss of 3.086 during the first epoch. By the final epoch, the loss had reduced to 0.414. + +This significant reduction in loss indicates that the model successfully learned the underlying relationship between the input features and the target variable. + +\subsection{Validation Performance} + +Validation loss also showed a considerable improvement during training. The validation loss decreased from 4.132 in the first epoch to a minimum of 0.635 at epoch 51. + +The difference between the final training loss and the minimum validation loss suggests that the model achieved good generalization performance without severe overfitting. + +\begin{table}[h] +\centering +\caption{Training and Validation Metrics Summary for the Regression Model} +\begin{tabular}{|l|l|c|c|c|c|} +\hline +\textbf{Split} & \textbf{Metric} & \textbf{Min.} & \textbf{Epoch} & \textbf{Max.} & \textbf{Epoch} \\ +\hline +Train & Loss & 0.414 & 100 & 3.086 & 1 \\ +Train & Learning Rate & $1.0 \times 10^{-3}$ & 1 & $1.0 \times 10^{-3}$ & 100 \\ +Train & CPU Memory (GB) & 2.125 & 4 & 2.325 & 56 \\ +Train & CPU Usage (\%) & 19.539 & 54 & 37.989 & 11 \\ +\hline +Valid & Loss & 0.635 & 51 & 4.132 & 1 \\ +Valid & CPU Memory (GB) & 2.124 & 3 & 2.325 & 55 \\ +Valid & CPU Usage (\%) & 19.550 & 54 & 37.960 & 11 \\ +\hline +\end{tabular} +\label{tab:regression_metrics_summary} +\end{table} + +\subsection{Prediction Example} + +A sample prediction generated by the model is shown below: + +\begin{verbatim} +Predicted 2.021734 Expected 2.158 +\end{verbatim} + +The predicted value is reasonably close to the expected value, indicating that the model was able to approximate the target variable with acceptable accuracy. + +Since the median house value was measured in units of 100,000 dollars, the prediction corresponds to: + +\begin{itemize} + \item Predicted value: approximately 202,173 dollars + \item Expected value: approximately 215,800 dollars +\end{itemize} + +\subsection{Predicted vs. Expected Distribution} + +The predicted-versus-expected plot suggests that the model captures the general trend in the target values, although some prediction errors remain for certain samples. + +Most of the predicted values appear concentrated around the central region of the distribution, indicating that the model performs better on common house value ranges than on extreme values. + +\subsection{Resource Utilization} + +The model required relatively little memory during execution. Training memory usage ranged from 2.125 GB to 2.325 GB, while validation memory usage ranged from 2.124 GB to 2.325 GB. + +CPU utilization remained moderate throughout training. Training CPU usage ranged from 19.539\% to 37.989\%, while validation CPU usage ranged from 19.550\% to 37.960\%. + +CPU temperature values were unavailable and therefore recorded as NaN. + +\subsection{Execution Time and Failure} + +The complete training and evaluation process required: + +\begin{itemize} + \item Real time: 3 minutes and 18.257 seconds + \item User CPU time: 4 minutes and 13.554 seconds + \item System CPU time: 50.340 seconds +\end{itemize} + \section{Language Specific Implementation Details} \subsection{PyTorch-Specific Paradigms} @@ -101,4 +213,121 @@ \subsection{Rust (Burn)-Specific Paradigms} \item \textbf{Record Deserialization:} States are strictly detached from models via standard `.mpk` maps. They invoke explicit \texttt{NoStdTrainingRecorder::new().load()} tracking traits unbinding memory limits inherent to standard dict serialization configurations natively loaded via `RegressionModelConfig`. \end{itemize} +\newpage +\section{Python: Regression Model Architecture and Training Performance} + +The regression model used in this experiment is a lightweight fully connected neural network with a small number of parameters (961 total). The model is optimized using mean squared error loss. + + + +The model was trained for 100 epochs. Training loss decreased significantly from 8265.55 to 69.86 (99.15\% reduction), while validation loss decreased from 9045.34 to 55.70. + +Despite strong loss reduction, the model struggled to achieve good generalization. The validation $R^2$ score remained negative (-1.07), indicating that the model performs worse than a simple baseline predictor. + +\begin{table}[h] +\centering +\begin{tabular}{|l|l|c|c|c|c|} +\hline +\textbf{Split} & \textbf{Metric} & \textbf{Min} & \textbf{Epoch} & \textbf{Max} & \textbf{Epoch} \\ +\hline + +Train & Loss & 68.50 & 100 & 8265.55 & 1 \\ +Train & RMSE & 8.39 & 100 & 91.53 & 1 \\ +Train & MAE & 6.29 & 100 & 90.33 & 1 \\ +Train & R$^2$ & -96.93 & 1 & 0.1767 & 100 \\ +Train & Grad Norm (Total) & 118.80 & -- & 112876.03 & 1 \\ +Train & Iteration Speed (it/s) & 0.69 & 1 & 14.39 & 54 \\ +Train & CPU Memory (GB) & 0.87 & -- & 1.02 & -- \\ +Train & CPU Usage (\%) & 45.7 & -- & 97.6 & -- \\ +\hline + +Valid & Loss & 50.93 & 65 & 9045.34 & 1 \\ +Valid & RMSE & 7.14 & 65 & 95.11 & 1 \\ +Valid & MAE & 6.00 & 65 & 93.52 & 1 \\ +Valid & R$^2$ & -335.40 & 1 & -0.8940 & 65 \\ +Valid & Iteration Speed (it/s) & 0.69 & 1 & 14.39 & 54 \\ +Valid & CPU Memory (GB) & 0.87 & -- & 1.02 & -- \\ +Valid & CPU Usage (\%) & 45.7 & -- & 97.6 & -- \\ +\hline + +\end{tabular} +\caption{Regression Model Training and Validation Metrics Summary} +\end{table} + +\textbf{Training Efficiency and Stability:} +\begin{itemize} + \item Total Training Time: 47.39 seconds + \item Average Epoch Time: 0.225 seconds + \item Iteration Speed (Mean): 10.36 it/s + \item Gradient Norm (Mean): 7981.31 + \item NaN Events: 0 + \item Convergence: Non-monotonic loss decrease + \item Overfitting Detected: No +\end{itemize} + +The results indicate that while optimization was successful in reducing loss, the model lacks sufficient capacity or feature representation to generalize well. The persistently negative validation $R^2$ suggests underfitting or a mismatch between model complexity and data characteristics. + + + + +\newpage + +\section{Container Comparison: Python vs Rust} + +\begin{table}[h] +\centering +\begin{tabular}{|l|c|c|} +\hline +\textbf{Feature} & \textbf{Python (GPU)} & \textbf{Rust (WGPU)} \\ +\hline +Image Name & text\_classification\_image & text\_class\_rs \\ +\hline +Size & 3.98GB & 973MB \\ +\hline +Backend & PyTorch + CUDA & Native Rust (WGPU) \\ +\hline +Startup Time & Slower & Faster \\ +\hline +Dependencies & Heavy (Torch, CUDA, Python) & Minimal (compiled binary) \\ +\hline +Deployment Complexity & Higher & Lower \\ +\hline +Flexibility & High (research-friendly) & Moderate \\ +\hline +Runtime Stability & Medium & High \\ +\hline +\end{tabular} +\caption{Comparison of Python GPU-based and Rust-based inference containers} +\end{table} + +\noindent +The Python-based container provides flexibility and rapid experimentation using the PyTorch ecosystem, but at the cost of larger image size and dependency complexity. In contrast, the Rust-based container offers a lightweight, production-ready solution with faster startup time and minimal runtime dependencies, making it more suitable for deployment scenarios. + + +\section{Model Size Comparison} + +\begin{table}[h] +\centering +\begin{tabular}{|l|c|c|} +\hline +\textbf{Model} & \textbf{Rust (.mpk/.bin)} & \textbf{Python (.pt/.pth)} \\ +\hline +MNIST & 1.1 MB & 2 MB \\ +\hline +Text Classification (AG News) & 21 MB & 40.6 MB \\ +\hline +Regression & 4 KB & 6 MB \\ +\hline +LSTM & 60 KB & 120 KB \\ +\hline +\end{tabular} +\caption{Comparison of model sizes between Rust and Python implementations} +\end{table} + +\noindent +Rust-based serialized models are consistently smaller than their Python counterparts. This reduction is most significant in simpler models such as regression, and remains substantial for larger models like text classification. The smaller footprint of Rust models makes them more suitable for lightweight deployment and resource-constrained environments. + + + + \end{document} diff --git a/latex_reports/text_classification_news.tex b/latex_reports/text_classification_news.tex index 29062b4..dafcd2c 100644 --- a/latex_reports/text_classification_news.tex +++ b/latex_reports/text_classification_news.tex @@ -6,11 +6,13 @@ \usepackage{amsmath,amssymb} \usepackage{graphicx} \usepackage{booktabs} -\usepackage{hyperref} -\usepackage{enumitem} +\usepackage{hyperref} +\usepackage{enumitem} \usepackage{array} \usepackage{float} \usepackage{listings} +\usepackage{pdfpages} + \lstdefinelanguage{Dockerfile}{ keywords={FROM, RUN, CMD, LABEL, MAINTAINER, EXPOSE, ENV, ADD, COPY, ENTRYPOINT, VOLUME, USER, WORKDIR, ARG, ONBUILD, STOPSIGNAL, HEALTHCHECK, SHELL}, @@ -19,7 +21,7 @@ morestring=[b]", } -\title{\textbf{Text Classification (News)}} +\title{\textbf{Text Classification}} \date{\today} \begin{document} @@ -85,6 +87,50 @@ \subsubsection{Classification Head} \end{tabular} \end{table} + + +\section{Rust Backend: Load Testing with Locust} + +The performance of the Rust-based backend was evaluated using the Locust load testing framework. The objective was to analyze system behavior under concurrent user load and measure key performance characteristics such as throughput and latency. + +\textbf{Testing Setup:} +\textbf{Testing Setup:} +\begin{itemize} + \item Tool: Locust + \item Backend: Rust (HTTP service) + \item Test Type: Concurrent user load simulation + \item Environment: Linux system +\end{itemize} + +\textbf{Dashboard Visualization:} +\textbf{Full Report:} + +The complete load testing dashboard has been exported as a PDF and is included below for detailed inspection. + +\includepdf[pages=-]{RustLocust/text.pdf} + + +\section{Python Backend: Load Testing with Locust} + +The performance of the Python-based backend was evaluated using the Locust load testing framework. The goal was to assess system behavior under concurrent user load and analyze key performance characteristics such as throughput and response latency. + +\textbf{Testing Setup:} +\begin{itemize} + \item Tool: Locust + \item Backend: Python (HTTP service) + \item Test Type: Concurrent user load simulation + \item Environment: Linux system +\end{itemize} + +\textbf{Dashboard Visualization:} + +\textbf{Full Report:} + +The complete load testing dashboard has been exported as a PDF and is included below for detailed inspection. + +\includepdf[pages=-]{PythonLocust/text.pdf} + + \subsection{Training Strategy} The model is trained using a supervised learning approach with the following configuration: @@ -164,6 +210,136 @@ \section{Rust Docker image} \section{Rust Inference Code} +\section{Rust: Transformer-Based Text Classification Model Performance} + +The text classification model used in this experiment was based on a Transformer encoder architecture. The model consisted of token embeddings, positional embeddings, a multi-layer Transformer encoder, and a final linear classification layer. + +\subsection{Model Architecture} + +The architecture of the model is shown below: + +\begin{verbatim} +TextClassificationModel { + transformer: TransformerEncoder { + d_model: 256, + d_ff: 1024, + n_heads: 8, + n_layers: 4, + dropout: 0.1, + norm_first: true, + quiet_softmax: true, + params: 3159040 + } + embedding_token: Embedding { + n_embedding: 28996, + d_model: 256, + params: 7422976 + } + embedding_pos: Embedding { + n_embedding: 256, + d_model: 256, + params: 65536 + } + output: Linear { + d_input: 256, + d_output: 4, + bias: true, + params: 1028 + } + n_classes: 4 + params: 10648580 +} +\end{verbatim} + +The Transformer encoder used four encoder layers with eight attention heads per layer. Each layer had a model dimension of 256 and a feed-forward dimension of 1024. A dropout rate of 0.1 was used to reduce overfitting. + +The token embedding layer mapped a vocabulary of 28,996 tokens into 256-dimensional vectors. Positional embeddings of length 256 were also used so that the Transformer could capture token order information. + +The final output layer mapped the Transformer representation into four output classes. + +The total number of trainable parameters in the model was 10,648,580. + +\subsection{Training Configuration} + +The model was trained for a total of 5 epochs. During training, the learning rate decayed from $1.107 \times 10^{-5}$ in the first epoch to $3.733 \times 10^{-6}$ in the final epoch. + +\subsection{Training Performance} + +Training accuracy improved steadily from 57.968\% in the first epoch to 81.474\% in the fifth epoch. Similarly, the training loss decreased from 0.981 to 0.507. + +The macro precision, recall, and F1-score also improved significantly during training: + +\begin{itemize} + \item Precision increased from 58.893\% to 81.606\% + \item Recall increased from 57.741\% to 81.334\% + \item F1-score increased from 50.201\% to 76.001\% +\end{itemize} + +These results indicate that the model learned meaningful semantic patterns in the text data over successive epochs. + +\subsection{Validation Performance} + +Validation performance also improved consistently. Validation accuracy increased from 72.280\% in the first epoch to a maximum of 81.640\% in the fourth epoch. + +The validation loss decreased from 0.731 to 0.507, showing that the model generalized reasonably well to unseen data. + +The validation precision, recall, and F1-score also showed strong improvement: + +\begin{itemize} + \item Precision increased from 72.230\% to 81.739\% + \item Recall increased from 72.258\% to 82.039\% + \item F1-score increased from 65.796\% to 76.509\% +\end{itemize} + +The relatively close values between training and validation accuracy suggest that the model did not suffer from severe overfitting. + +\begin{table}[h] +\centering +\caption{Training and Validation Metrics Summary for the Transformer-Based Text Classification Model} +% @kumar please layout sahi kar sakta hai? love you +\begin{tabular}{|l|l|c|c|c|c|} +\hline +\textbf{Split} & \textbf{Metric} & \textbf{Min.} & \textbf{Epoch} & \textbf{Max.} & \textbf{Epoch} \\ +\hline +Train & Accuracy & 57.968 & 1 & 81.474 & 5 \\ +Train & Loss & 0.507 & 5 & 0.981 & 1 \\ +Train & Precision@Top1 [Macro] & 58.893 & 1 & 81.606 & 5 \\ +Train & Recall@Top1 [Macro] & 57.741 & 1 & 81.334 & 5 \\ +Train & F1-Score@Top1 [Macro] & 50.201 & 1 & 76.001 & 5 \\ +Train & Learning Rate & $3.733 \times 10^{-6}$ & 5 & $1.107 \times 10^{-5}$ & 1 \\ +Train & CPU Memory (GB) & 2.401 & 2 & 2.736 & 5 \\ +Train & CPU Usage (\%) & 16.529 & 1 & 17.160 & 5 \\ +\hline +Valid & Accuracy & 72.280 & 1 & 81.640 & 4 \\ +Valid & Loss & 0.507 & 5 & 0.731 & 1 \\ +Valid & Precision@Top1 [Macro] & 72.230 & 1 & 81.739 & 5 \\ +Valid & Recall@Top1 [Macro] & 72.258 & 1 & 82.039 & 5 \\ +Valid & F1-Score@Top1 [Macro] & 65.796 & 1 & 76.509 & 5 \\ +Valid & CPU Memory (GB) & 2.263 & 1 & 2.747 & 5 \\ +Valid & CPU Usage (\%) & 20.331 & 1 & 22.190 & 3 \\ +\hline +\end{tabular} +\label{tab:transformer_text_classification_metrics} +\end{table} + +\subsection{Resource Utilization} + +The CPU memory usage remained relatively stable throughout training. Training memory usage ranged from 2.401 GB to 2.736 GB, while validation memory usage ranged from 2.263 GB to 2.747 GB. + +CPU utilization also remained moderate, with training CPU usage ranging from 16.529\% to 17.160\% and validation CPU usage ranging from 20.331\% to 22.190\%. + +CPU temperature values were unavailable during the experiment and therefore recorded as NaN. + +\subsection{Execution Time and Failure} + +The complete training run required: + +\begin{itemize} + \item Real time: 32 minutes and 37.872 seconds + \item User CPU time: 36 minutes and 52.313 seconds + \item System CPU time: 1 minute and 41.258 seconds +\end{itemize} + \section{PyTorch Training Pipeline} This section details the Python implementation of the text classification training pipeline. The code mimics the architecture and logic of the Rust version to ensuring comparable performance and behavior. \subsection{Code Highlights} @@ -193,6 +369,77 @@ \subsection{Code Highlights} The training loop is a standard PyTorch implementation using \texttt{tqdm} for progress tracking. It uses \texttt{CrossEntropyLoss} as the criterion and the \texttt{Adam} optimizer. Crucially, the scheduler step is called after every batch (not every epoch), consistent with the Noam schedule requirements. \end{itemize} + +\section{Python: Text Classification (News) Transformer Model} + +The model is a Transformer-based architecture designed for multi-class news classification. It consists of a multi-layer encoder with multi-head self-attention and feedforward networks. + +\textbf{Model Architecture:} + +\begin{verbatim} +Model { + transformer_encoder: { + d_model: 256, + nhead: 8, + num_layers: 4, + dim_feedforward: 1024 + } + max_seq_len: 256 + num_classes: 4 + total params: 10649092 +} +\end{verbatim} + +The model was trained for 5 epochs and showed steady convergence across all evaluation metrics. + +Training accuracy improved from 56.19\% to 79.70\%, while validation accuracy increased from 68.14\% to 79.00\%. +Training loss decreased from 1.0145 to 0.5483, and validation loss reduced from 0.8137 to 0.5628. + +The model achieved a macro F1-score of 0.7903 on the validation/test set, indicating reasonably strong classification performance for a Transformer trained over a small number of epochs. + +\begin{table}[h] +\centering +\begin{tabular}{|l|l|c|c|c|c|} +\hline +\textbf{Split} & \textbf{Metric} & \textbf{Min} & \textbf{Epoch} & \textbf{Max} & \textbf{Epoch} \\ +\hline + +Train & Accuracy & 56.19 & 1 & 79.70 & 5 \\ +Train & Loss & 0.5483 & 5 & 1.0145 & 1 \\ +Train & Grad Norm (Total) & 10.39 & 4 & 22.95 & 3 \\ +Train & Iteration Speed (it/s) & 44.33 & 2 & 45.05 & 1 \\ +Train & CPU Memory (GB) & 1.12 & -- & 1.12 & -- \\ +Train & CPU Usage (\%) & 22.2 & -- & 23.5 & -- \\ +\hline + +Valid & Accuracy & 68.14 & 1 & 79.00 & 5 \\ +Valid & Loss & 0.5628 & 5 & 0.8137 & 1 \\ +Valid & Precision@Top1 [Macro] & 0.7187 & 1 & 0.7942 & 5 \\ +Valid & Recall@Top1 [Macro] & 0.6815 & 1 & 0.7899 & 5 \\ +Valid & F1-Score@Top1 [Macro] & 0.6773 & 1 & 0.7903 & 5 \\ +Valid & Iteration Speed (it/s) & 44.33 & 2 & 45.05 & 1 \\ +Valid & CPU Memory (GB) & 1.12 & -- & 1.12 & -- \\ +Valid & CPU Usage (\%) & 22.2 & -- & 23.5 & -- \\ +\hline + +\end{tabular} +\caption{Transformer Text Classification Training and Validation Metrics} +\end{table} + +\textbf{Training Efficiency and Stability:} +\begin{itemize} + \item Total Training Time: 706.99 seconds + \item Average Epoch Time: 139.83 seconds + \item Iteration Speed (Mean): 44.70 it/s + \item Gradient Norm (Mean): 15.75 + \item GPU Memory Usage: 178.79 MB + \item NaN Events: 0 + \item Convergence: Monotonic loss decrease + \item Overfitting Detected: No +\end{itemize} + +The results indicate stable training and consistent improvement across epochs. While performance is lower than simpler CNN-based tasks, this is expected due to the increased complexity of natural language understanding tasks. + \section{PyTorch Inference Pipeline Docker Image: Hybrid NFS and Docker Inference Architecture} This section details the hybrid deployment strategy designed to optimize Docker image size and leverage a centralized machine learning environment. The architecture splits the responsibilities between a \textbf{Library VM} (storage-heavy) and a \textbf{Docker VM} (compute-centric). @@ -252,27 +499,27 @@ \subsection{Impact on Image Size} This architecture drastically reduces the storage footprint of the inference artifact. By decoupling the static libraries from the application logic, we achieve the following reduction: -\begin{table}[h] -\centering -\begin{tabular}{|l|c|c|} -\hline -\textbf{Component} & \textbf{Traditional Approach} & \textbf{Hybrid NFS Approach} \\ \hline -Base Image (Python Slim) & $\sim$150 MB & $\sim$150 MB \\ \hline -PyTorch & $\sim$3.5 GB & \textbf{0 MB (Mounted)} \\ \hline -Transformers & $\sim$500 MB & \textbf{0 MB (Mounted)} \\ \hline -Application Code & $<1$ MB & $<1$ MB \\ \hline -Model Weights & $\sim$100 MB & $\sim$100 MB \\ \hline -\textbf{Total Image Size} & \textbf{8.93 GB} & \textbf{88.5 MB} \\ \hline -\end{tabular} -\caption{Comparison of Docker Image Sizes} -\end{table} - -This \textbf{99.03\% reduction} in image size results in: -\begin{itemize} - \item Faster deployment and rollback times. - \item Significantly lower network bandwidth usage. - \item Efficient storage utilization on the Docker VM. -\end{itemize} +% \begin{table}[h] +% \centering +% \begin{tabular}{|l|c|c|} +% \hline +% \textbf{Component} & \textbf{Traditional Approach} & \textbf{Hybrid NFS Approach} \\ \hline +% Base Image (Python Slim) & $\sim$150 MB & $\sim$150 MB \\ \hline +% PyTorch & $\sim$3.5 GB & \textbf{0 MB (Mounted)} \\ \hline +% Transformers & $\sim$500 MB & \textbf{0 MB (Mounted)} \\ \hline +% Application Code & $<1$ MB & $<1$ MB \\ \hline +% Model Weights & $\sim$100 MB & $\sim$100 MB \\ \hline +% \textbf{Total Image Size} & \textbf{8.93 GB} & \textbf{$~250$ MB} \\ \hline +% \end{tabular} +% \caption{Comparison of Docker Image Sizes} +% \end{table} + +% This \textbf{99.03\% reduction} in image size results in: +% \begin{itemize} +% \item Faster deployment and rollback times. +% \item Significantly lower network bandwidth usage. +% \item Efficient storage utilization on the Docker VM. +% \end{itemize} \section{Hybrid Inference Architecture with NFS and Docker} @@ -341,6 +588,65 @@ \subsubsection{2. The Docker VM (Inference Runner)} \caption{Distinction between Library VM and Docker VM} \end{table} -\section{Python Inference Code} + + + +\newpage + +\section{Container Comparison: Python vs Rust} + +\begin{table}[h] +\centering +\begin{tabular}{|l|c|c|} +\hline +\textbf{Feature} & \textbf{Python (GPU)} & \textbf{Rust (WGPU)} \\ +\hline +Image Name & text\_classification\_image & text\_class\_rs \\ +\hline +Size & 4.02 GB & $\sim$1 GB \\ +\hline +Backend & PyTorch + CUDA & Native Rust (WGPU) \\ +\hline +Startup Time & Slower & Faster \\ +\hline +Dependencies & Heavy (Torch, CUDA, Python) & Minimal (compiled binary) \\ +\hline +Deployment Complexity & Higher & Lower \\ +\hline +Flexibility & High (research-friendly) & Moderate \\ +\hline +Runtime Stability & Medium & High \\ +\hline +\end{tabular} +\caption{Comparison of Python GPU-based and Rust-based inference containers} +\end{table} + +\noindent +The Python-based container provides flexibility and rapid experimentation using the PyTorch ecosystem, but at the cost of larger image size and dependency complexity. In contrast, the Rust-based container offers a lightweight, production-ready solution with faster startup time and minimal runtime dependencies, making it more suitable for deployment scenarios. + + +\section{Model Size Comparison} + +\begin{table}[h] +\centering +\begin{tabular}{|l|c|c|} +\hline +\textbf{Model} & \textbf{Rust (.mpk/.bin)} & \textbf{Python (.pt/.pth)} \\ +\hline +MNIST & 1.1 MB & 2 MB \\ +\hline +Text Classification (AG News) & 21 MB & 40.6 MB \\ +\hline +Regression & 4 KB & 6 MB \\ +\hline +LSTM & 60 KB & 120 KB \\ +\hline +\end{tabular} +\caption{Comparison of model sizes between Rust and Python implementations} +\end{table} + +\noindent +Rust-based serialized models are consistently smaller than their Python counterparts. This reduction is most significant in simpler models such as regression, and remains substantial for larger models like text classification. The smaller footprint of Rust models makes them more suitable for lightweight deployment and resource-constrained environments. + \end{document} From 070f389c42bf6a9d105d5c72564634d06cc0cb21 Mon Sep 17 00:00:00 2001 From: valmikGit Date: Fri, 17 Apr 2026 15:29:24 +0530 Subject: [PATCH 18/21] Tried to align the regression python pipeline with the corresponding rust pipeline. --- python_ml/pytorch/regression/Inference/app.py | 10 ++- .../pytorch/regression/Training/training.py | 82 ++++++------------- 2 files changed, 34 insertions(+), 58 deletions(-) diff --git a/python_ml/pytorch/regression/Inference/app.py b/python_ml/pytorch/regression/Inference/app.py index 5e4ea49..366e1a1 100644 --- a/python_ml/pytorch/regression/Inference/app.py +++ b/python_ml/pytorch/regression/Inference/app.py @@ -11,7 +11,10 @@ # Constants (must match training) # ========================================================== -NUM_FEATURES = 13 +NUM_FEATURES = 8 + +FEATURES_MIN = torch.tensor([0.4999, 1., 0.8461, 0.375, 3., 0.6923, 32.54, -124.35], dtype=torch.float32) +FEATURES_MAX = torch.tensor([15., 52., 141.9091, 34.0667, 35682., 1243.3333, 41.95, -114.31], dtype=torch.float32) GENERATED_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "generated") @@ -81,12 +84,13 @@ class HousingFeatures(BaseModel): def preprocess(features): - x = np.array(features, dtype=np.float32) + x = torch.tensor(features, dtype=torch.float32) if len(x) != NUM_FEATURES: raise ValueError(f"Expected {NUM_FEATURES} features") - x = torch.tensor(x).unsqueeze(0) + x = x.unsqueeze(0) + x = (x - FEATURES_MIN) / (FEATURES_MAX - FEATURES_MIN) return x diff --git a/python_ml/pytorch/regression/Training/training.py b/python_ml/pytorch/regression/Training/training.py index 096b368..78e5b2a 100644 --- a/python_ml/pytorch/regression/Training/training.py +++ b/python_ml/pytorch/regression/Training/training.py @@ -3,10 +3,10 @@ import shutil import time import random -import urllib.request import psutil import torch import numpy as np +from datasets import load_dataset from dataclasses import dataclass from torch import nn from torch.utils.data import Dataset, DataLoader @@ -15,49 +15,12 @@ # Constants # ========================================================== -NUM_FEATURES = 13 +NUM_FEATURES = 8 OUTPUT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "generated") -DATASET_URL = "https://storage.googleapis.com/tensorflow/tf-keras-datasets/boston_housing.npz" -RAW_DATA_FILE = os.path.join(OUTPUT_DIR, "boston_housing.npz") - -TRAIN_FILE = os.path.join(OUTPUT_DIR, "train_data.npz") -VALID_FILE = os.path.join(OUTPUT_DIR, "valid_data.npz") - - -# ========================================================== -# Dataset preparation -# ========================================================== - -def prepare_dataset(): - - os.makedirs(OUTPUT_DIR, exist_ok=True) - - if not os.path.exists(RAW_DATA_FILE): - print("Downloading Boston Housing dataset...") - urllib.request.urlretrieve(DATASET_URL, RAW_DATA_FILE) - print("Download complete.") - - if not os.path.exists(TRAIN_FILE) or not os.path.exists(VALID_FILE): - - data = np.load(RAW_DATA_FILE) - - X = data["x"] - y = data["y"] - - split = int(0.8 * len(X)) - - X_train = X[:split] - y_train = y[:split] - - X_valid = X[split:] - y_valid = y[split:] - - np.savez(TRAIN_FILE, x=X_train, y=y_train) - np.savez(VALID_FILE, x=X_valid, y=y_valid) - - print("Dataset prepared.") +FEATURES_MIN = torch.tensor([0.4999, 1., 0.8461, 0.375, 3., 0.6923, 32.54, -124.35], dtype=torch.float32) +FEATURES_MAX = torch.tensor([15., 52., 141.9091, 34.0667, 35682., 1243.3333, 41.95, -114.31], dtype=torch.float32) # ========================================================== @@ -66,9 +29,28 @@ def prepare_dataset(): class HousingDataset(Dataset): - def __init__(self, inputs, targets): - self.inputs = torch.tensor(inputs, dtype=torch.float32) + def __init__(self, split="train"): + hf_dataset = load_dataset("gvlassis/california_housing", split=split) + + # Extract features in the correct order: + # MedInc, HouseAge, AveRooms, AveBedrms, Population, AveOccup, Latitude, Longitude + features = [ + hf_dataset["MedInc"], + hf_dataset["HouseAge"], + hf_dataset["AveRooms"], + hf_dataset["AveBedrms"], + hf_dataset["Population"], + hf_dataset["AveOccup"], + hf_dataset["Latitude"], + hf_dataset["Longitude"] + ] + + targets = hf_dataset["MedHouseVal"] + + inputs = torch.tensor(features, dtype=torch.float32).T self.targets = torch.tensor(targets, dtype=torch.float32) + + self.inputs = (inputs - FEATURES_MIN) / (FEATURES_MAX - FEATURES_MIN) def __len__(self): return len(self.inputs) @@ -78,21 +60,11 @@ def __getitem__(self, idx): @staticmethod def train(): - - prepare_dataset() - - data = np.load(TRAIN_FILE) - - return HousingDataset(data["x"], data["y"]) + return HousingDataset("train") @staticmethod def validation(): - - prepare_dataset() - - data = np.load(VALID_FILE) - - return HousingDataset(data["x"], data["y"]) + return HousingDataset("validation") # ========================================================== From b23f57bfe597f5f9e2c87074ea0fb750e4ce1b5b Mon Sep 17 00:00:00 2001 From: valmikGit Date: Mon, 20 Apr 2026 01:46:29 +0530 Subject: [PATCH 19/21] added drafts 1 and 2 of latex IEEE access format. --- ...ombined_research_paper.tex => draft_1.tex} | 306 ++-- latex_reports/draft_2.tex | 1297 +++++++++++++++++ 2 files changed, 1498 insertions(+), 105 deletions(-) rename latex_reports/{combined_research_paper.tex => draft_1.tex} (94%) create mode 100644 latex_reports/draft_2.tex diff --git a/latex_reports/combined_research_paper.tex b/latex_reports/draft_1.tex similarity index 94% rename from latex_reports/combined_research_paper.tex rename to latex_reports/draft_1.tex index 25e0987..042886f 100644 --- a/latex_reports/combined_research_paper.tex +++ b/latex_reports/draft_1.tex @@ -1,32 +1,98 @@ -\documentclass[10pt,a4paper,twocolumn]{article} - -\usepackage{geometry} -\geometry{margin=0.75in, columnsep=0.25in} - -\usepackage{amsmath,amssymb} -\usepackage{graphicx} -\usepackage{booktabs} -\usepackage{hyperref} -\usepackage{enumitem} -\usepackage{array} +\documentclass{ieeeaccess} +\usepackage{cite} +\usepackage{amsmath,amssymb,amsfonts} +\usepackage{algorithmic} +\usepackage{textcomp} + +\def\BibTeX{{\rm B\kern-.05em{\sc i\kern-.025em b}\kern-.08em + T\kern-.1667em\lower.7ex\hbox{E}\kern-.125emX}} + +% Encoding and fonts +\usepackage[T1]{fontenc} +\usepackage[utf8]{inputenc} +\usepackage{pdfpages} + +% Math and graphics +\usepackage{graphicx} +\usepackage{booktabs} +\usepackage{array} \usepackage{float} -\usepackage{listings} -\usepackage{xcolor} -\usepackage{pdfpages} -\usepackage{minted} +% URLs (robust line breaking) +\usepackage{url} +\usepackage[hidelinks]{hyperref} +\def\UrlBreaks{\do/\do-\do_} + +% Code listings (stable for IEEE) +\usepackage{listings} +\usepackage{xcolor} +\lstset{ + basicstyle=\ttfamily\footnotesize, + breaklines=true, + breakatwhitespace=false, % <-- IMPORTANT (change this) + columns=fullflexible, + keepspaces=true, + showstringspaces=false +} + +% Custom Dockerfile language \lstdefinelanguage{Dockerfile}{ keywords={FROM, RUN, CMD, LABEL, MAINTAINER, EXPOSE, ENV, ADD, COPY, ENTRYPOINT, VOLUME, USER, WORKDIR, ARG, ONBUILD, STOPSIGNAL, HEALTHCHECK, SHELL}, sensitive=true, comment=[l]{\#}, - morestring=[b]", + morestring=[b]" } -\title{\textbf{System-Level Evaluation of Rust and Python for Machine Learning}} -\author{Project Elective} -\date{\today} - \begin{document} +\history{Date of publication xxxx 00, 0000, date of current version xxxx 00, 0000.} +\doi{10.1109/ACCESS.2017.DOI} + +\title{System-Level Evaluation of Rust and Python for Machine Learning} +\author{\uppercase{Project Elective}\authorrefmark{1}, +\IEEEmembership{Member, IEEE}} +\address[1]{Project Elective (e-mail: project@elective.com)} +\tfootnote{This paragraph of the first footnote will contain support +information, including sponsor and financial support acknowledgment. For +example, ``This work was supported in part by the U.S. Department of +Commerce under Grant BS123456.''} + +\markboth +{Project Elective \headeretal: System-Level Evaluation of Rust and Python for Machine Learning} +{Project Elective \headeretal: System-Level Evaluation of Rust and Python for Machine Learning} + +\corresp{Corresponding author: Project Elective (e-mail: project@elective.com).} + +\begin{abstract} +These instructions give you guidelines for preparing papers for +IEEE Access. Use this document as a template if you are +using \LaTeX. Otherwise, use this document as an +instruction set. The electronic file of your paper will be formatted further +at IEEE. Paper titles should be written in uppercase and lowercase letters, +not all uppercase. Avoid writing long formulas with subscripts in the title; +short formulas that identify the elements are fine (e.g., "Nd--Fe--B"). Do +not write ``(Invited)'' in the title. Full names of authors are preferred in +the author field, but are not required. Put a space between authors' +initials. The abstract must be a concise yet comprehensive reflection of +what is in your article. In particular, the abstract must be self-contained, +without abbreviations, footnotes, or references. It should be a microcosm of +the full article. The abstract must be between 150--250 words. Be sure that +you adhere to these limits; otherwise, you will need to edit your abstract +accordingly. The abstract must be written as one paragraph, and should not +contain displayed mathematical equations or tabular material. The abstract +should include three or four different keywords or phrases, as this will +help readers to find it. It is important to avoid over-repetition of such +phrases as this can result in a page being rejected by search engines. +Ensure that your abstract reads well and is grammatically correct. +\end{abstract} + +\begin{keywords} +Enter key words or phrases in alphabetical +order, separated by commas. For a list of suggested keywords, send a blank +e-mail to keywords@ieee.org or visit \underline +{http://www.ieee.org/organizations/pubs/ani\_prod/keywrd98.txt} +\end{keywords} + +\titlepgskip=-15pt \maketitle \section{Overview of the Project} @@ -43,7 +109,7 @@ \section{Overview of the Project} To ensure clarity and rigor, the work is organized into \textbf{two clearly separated experimental tracks}. ---- +\hrule \section{Project Structure: Two-Track Evaluation} @@ -65,12 +131,10 @@ \subsection*{Track 2: Inference-Based DevOps Evaluation} \item Rust-based ONNX inference. \end{itemize} -The focus is on deployment, security, containerization, CI/CD behavior, and runtime efficiency. +The focus is on deployment, security, containerization, CI/CD behavior, and runtime efficiency. Each track is designed to answer a distinct research question while remaining complementary. ---- - \section{Machine Learning Tasks Considered} To ensure coverage of diverse ML workloads, the following tasks are identified: @@ -87,7 +151,7 @@ \section{Machine Learning Tasks Considered} At the current stage, the \textbf{MNIST image classification task has been fully implemented}. The corresponding training code is available in the project GitHub repository. ---- +\hrule \section{Related Work} @@ -100,7 +164,7 @@ \section{Related Work} \item \url{https://www.ijsred.com/volume8/issue2/IJSRED-V8I2P143.pdf} \end{itemize} ---- +\hrule \section{Code Repository and Current Status} @@ -116,7 +180,7 @@ \section{Code Repository and Current Status} \item Initial Rust (Burn) training setup completed \end{itemize} ---- +\hrule \section{Track 1: Training-Based Systems Evaluation} @@ -130,9 +194,9 @@ \subsection{Objective} This track explicitly avoids speed-centric benchmarking and instead focuses on system behavior. ---- +\hrule -\subsection{Frameworks Compared} +\subsection{Frameworks Compared} \subsubsection{PyTorch (Baseline)} \begin{itemize} @@ -148,7 +212,7 @@ \subsubsection{Rust (Burn)} \item Design: Idiomatic Rust, native training support \end{itemize} ---- +\hrule \subsection{Experimental Controls} @@ -169,7 +233,7 @@ \subsection{Experimental Controls} \item Memory management \end{itemize} ---- +\hrule \subsection{Metrics Collected} @@ -182,7 +246,7 @@ \subsection{Metrics Collected} \item Dependency footprint and artifact size \end{itemize} ---- +\hrule \section{Track 2: Inference-Based DevOps Evaluation} @@ -190,7 +254,7 @@ \subsection{Objective} The objective of this track is to compare \textbf{deployment, security, and operational characteristics} of Python-based and Rust-based ML inference services executing the same ONNX model. ---- +\hrule \subsection{Inference Services Compared} @@ -208,7 +272,7 @@ \subsection{Inference Services Compared} Both services expose identical inference endpoints and return identical outputs. ---- +\hrule \subsection{Evaluation Dimensions} @@ -221,7 +285,7 @@ \subsection{Evaluation Dimensions} \item Security and supply-chain surface \end{itemize} ---- +\hrule \section{Upcoming Work} @@ -398,23 +462,23 @@ \subsubsection{Builder Stage} The first stage begins with: -\begin{verbatim} +\begin{lstlisting} FROM ubuntu:16.04 AS builder -\end{verbatim} +\end{lstlisting} This instruction uses Ubuntu 16.04 as the base image for building the Rust application. The alias \texttt{builder} is assigned to this stage so that its outputs can later be referenced in the runtime stage. \paragraph{Working Directory} -\begin{verbatim} +\begin{lstlisting} WORKDIR /app/rust_ml -\end{verbatim} +\end{lstlisting} The \texttt{WORKDIR} instruction sets the default working directory inside the container to: -\begin{verbatim} +\begin{lstlisting} /app/rust_ml -\end{verbatim} +\end{lstlisting} All subsequent commands in the builder stage are executed relative to this directory. @@ -422,14 +486,14 @@ \subsubsection{Builder Stage} The following command installs the required packages for compiling the Rust project: -\begin{verbatim} +\begin{lstlisting} RUN apt-get update && apt-get install -y \ curl \ build-essential \ pkg-config \ ca-certificates \ && rm -rf /var/lib/apt/lists/* -\end{verbatim} +\end{lstlisting} Each package serves a specific purpose: @@ -442,9 +506,9 @@ \subsubsection{Builder Stage} The final cleanup command: -\begin{verbatim} +\begin{lstlisting} rm -rf /var/lib/apt/lists/* -\end{verbatim} +\end{lstlisting} removes cached package lists to reduce image size. @@ -452,9 +516,9 @@ \subsubsection{Builder Stage} Rust is installed using the official Rust installer: -\begin{verbatim} +\begin{lstlisting} RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y -\end{verbatim} +\end{lstlisting} This command downloads and executes the \texttt{rustup} installer. @@ -469,25 +533,25 @@ \subsubsection{Builder Stage} After Rust is installed, the PATH environment variable is updated: -\begin{verbatim} +\begin{lstlisting} ENV PATH="/root/.cargo/bin:${PATH}" -\end{verbatim} +\end{lstlisting} This ensures that Rust tools such as \texttt{cargo} and \texttt{rustc} are available in subsequent commands. \paragraph{Copying Source Code} -\begin{verbatim} +\begin{lstlisting} COPY . . -\end{verbatim} +\end{lstlisting} This instruction copies the entire project directory from the host system into the current working directory inside the container. \paragraph{Building the Application} -\begin{verbatim} +\begin{lstlisting} RUN cargo build --release -p mnist_infer -\end{verbatim} +\end{lstlisting} This command compiles the Rust project in release mode. @@ -500,17 +564,17 @@ \subsubsection{Builder Stage} The generated executable is stored in: -\begin{verbatim} +\begin{lstlisting} /app/rust_ml/target/release/mnist_infer -\end{verbatim} +\end{lstlisting} \subsubsection{Runtime Stage} The second stage begins with: -\begin{verbatim} +\begin{lstlisting} FROM nvidia/vulkan:1.3-470 -\end{verbatim} +\end{lstlisting} This stage uses an NVIDIA Vulkan runtime image as the base image. The purpose of using this image is to provide Vulkan-related runtime libraries and GPU compatibility for applications that may rely on Vulkan acceleration. @@ -519,23 +583,23 @@ \subsubsection{Runtime Stage} \paragraph{Runtime Working Directory} -\begin{verbatim} +\begin{lstlisting} WORKDIR /app -\end{verbatim} +\end{lstlisting} This sets the runtime working directory to: -\begin{verbatim} +\begin{lstlisting} /app -\end{verbatim} +\end{lstlisting} All runtime files are placed relative to this location. \paragraph{Copying the Compiled Binary} -\begin{verbatim} +\begin{lstlisting} COPY --from=builder /app/rust_ml/target/release/mnist_infer /app/binary -\end{verbatim} +\end{lstlisting} This instruction copies the compiled executable from the builder stage into the runtime image. @@ -543,31 +607,31 @@ \subsubsection{Runtime Stage} The binary is renamed from: -\begin{verbatim} +\begin{lstlisting} mnist_infer -\end{verbatim} +\end{lstlisting} to: -\begin{verbatim} +\begin{lstlisting} /app/binary -\end{verbatim} +\end{lstlisting} inside the runtime container. \paragraph{Copying the Model File} -\begin{verbatim} +\begin{lstlisting} COPY ./model/mnist_rust/model.mpk /app/model/mnist_rust/model.mpk -\end{verbatim} +\end{lstlisting} This instruction copies the trained model file into the runtime container. The model file is stored at: -\begin{verbatim} +\begin{lstlisting} /app/model/mnist_rust/model.mpk -\end{verbatim} +\end{lstlisting} The application can later load this file during inference. @@ -575,10 +639,10 @@ \subsubsection{Runtime Stage} Two environment variables are defined: -\begin{verbatim} +\begin{lstlisting} ENV RUST_LOG=info ENV MODEL_PATH=/app/model/mnist_rust/model.mpk -\end{verbatim} +\end{lstlisting} Their purposes are: @@ -591,9 +655,9 @@ \subsubsection{Runtime Stage} \paragraph{Exposing the Application Port} -\begin{verbatim} +\begin{lstlisting} EXPOSE 9050 -\end{verbatim} +\end{lstlisting} This instruction documents that the containerized application listens on port 9050. @@ -601,9 +665,9 @@ \subsubsection{Runtime Stage} \paragraph{Container Startup Command} -\begin{verbatim} +\begin{lstlisting} CMD ["./binary"] -\end{verbatim} +\end{lstlisting} This instruction defines the default command executed when the container starts. @@ -632,7 +696,12 @@ \subsubsection{Dockerfile Analysis} The \texttt{Dockerfile} is kept intentionally lightweight. By excluding large dependencies like \texttt{torch} from the \texttt{pip install} command, the image size remains very small (only containing the base Python runtime and lightweight web frameworks). -\begin{lstlisting}[language=Dockerfile, caption={Optimized Inference Dockerfile}, label={lst:dockerfile_inference}] +\vspace{0.5em} +\noindent\textbf{Listing 1: Optimized Inference Dockerfile} +\label{lst:dockerfile_inference} +\vspace{0.3em} + +\begin{lstlisting}[language=Dockerfile] FROM python:3.12-slim ENV PYTHONDONTWRITEBYTECODE=1 @@ -644,7 +713,7 @@ \subsubsection{Dockerfile Analysis} ENV PYTHONPATH=/external-libs/ml_env/lib/python3.12/site-packages WORKDIR /app - + # Only install lightweight app dependencies RUN pip install --no-cache-dir --upgrade pip && \ pip install fastapi==0.110.0 uvicorn==0.29.0 python-multipart==0.0.9 @@ -661,7 +730,10 @@ \subsubsection{Dockerfile Analysis} \item \textbf{Environment Configuration:} \begin{itemize} \item \texttt{PYTHONDONTWRITEBYTECODE=1}: Prevents Python from writing \texttt{.pyc} files to disk. - \item \textbf{\texttt{PYTHONPATH}}: Crucially set to \texttt{/external-libs/ml\_env/lib/python3.12/site-packages}. This instructs the Python interpreter to look for libraries in the mounted volume directory, not just the default system paths. + + \item \textbf{\texttt{PYTHONPATH}}: Crucially set to + \path{/external-libs/ml_env/lib/python3.12/site-packages}. + This instructs the Python interpreter to look for libraries in the mounted volume directory, not just the default system paths. \end{itemize} \item \textbf{Minimal Dependencies:} The \texttt{pip install} command only installs \texttt{fastapi}, \texttt{uvicorn}, and \texttt{python-multipart}. Heavy ML libraries are assumed to be present in the mounted volume. \end{itemize} @@ -681,7 +753,12 @@ \subsubsection{Volume Mounting Strategy} \paragraph{Runtime Execution (\texttt{run\_container.sh})} This script launches the Docker container with the necessary runtime configurations to access the external libraries. -\begin{lstlisting}[language=Bash, caption={Container Execution Command}] +\vspace{0.5em} +\noindent\textbf{Listing 2: Container Execution Command} +\label{lst:docker_run} +\vspace{0.3em} + +\begin{lstlisting}[language=Bash] docker run -d \ -v /mnt/ml-libs:/external-libs \ -e PYTHONPATH=/external-libs/ml_env/lib/python3.12/site-packages \ @@ -715,7 +792,7 @@ \subsection{Rust: CNN Model Architecture and Training Performance} The convolutional neural network (CNN) model used for the experiment consisted of two convolutional layers followed by adaptive average pooling, dropout, and two fully connected layers. The complete architecture is shown below: -\begin{verbatim} +\begin{lstlisting} Model { conv1: Conv2d {ch_in: 1, ch_out: 8, stride: [1, 1], kernel_size: [3, 3], dilation: [1, 1], groups: 1, padding: Valid, params: 80} conv2: Conv2d {ch_in: 8, ch_out: 16, stride: [1, 1], kernel_size: [3, 3], dilation: [1, 1], groups: 1, padding: Valid, params: 1168} @@ -726,7 +803,7 @@ \subsection{Rust: CNN Model Architecture and Training Performance} activation: Relu params: 531178 } -\end{verbatim} +\end{lstlisting} The model was trained for 10 epochs. Over the course of training, both the training and validation performance improved consistently. Training accuracy increased from 81.575\% in the first epoch to 97.300\% in the final epoch, while validation accuracy improved from 92.133\% to 98.517\%. @@ -775,7 +852,7 @@ \subsection{Python: CNN Model Architecture and Training Performance} \textbf{Model Architecture:} -\begin{verbatim} +\begin{lstlisting} Model { conv1: Conv2d {ch_in: 1, ch_out: 8, kernel_size: [3, 3], stride: [1, 1]} conv2: Conv2d {ch_in: 8, ch_out: 16, kernel_size: [3, 3], stride: [1, 1]} @@ -786,7 +863,7 @@ \subsection{Python: CNN Model Architecture and Training Performance} activation: ReLU total params: 531178 } -\end{verbatim} +\end{lstlisting} The model was trained for 10 epochs. Training and validation performance improved consistently over time. @@ -891,7 +968,6 @@ \subsection{Model Size Comparison} \clearpage \section{Task: Regression} -\newpage \subsection{Introduction} This document outlines the architectural, mathematical, and deployment specifics of implementing a Neural Network-based Regression model across two disparate machine learning environments: Rust (utilizing the Burn framework) and Python (utilizing PyTorch). It covers the distinct model architecture decisions, dataset handling strategies, and specialized pipeline deployment techniques leveraging Network File Systems (NFS) mapping via Docker bounds. @@ -970,14 +1046,14 @@ \subsubsection{Model Architecture} The architecture of the regression model is shown below: -\begin{verbatim} +\begin{lstlisting} RegressionModel { input_layer: Linear {d_input: 8, d_output: 64, bias: true, params: 576} output_layer: Linear {d_input: 64, d_output: 1, bias: true, params: 65} activation: Relu params: 641 } -\end{verbatim} +\end{lstlisting} The model contains: @@ -1035,9 +1111,9 @@ \subsubsection{Prediction Example} A sample prediction generated by the model is shown below: -\begin{verbatim} +\begin{lstlisting} Predicted 2.021734 Expected 2.158 -\end{verbatim} +\end{lstlisting} The predicted value is reasonably close to the expected value, indicating that the model was able to approximate the target variable with acceptable accuracy. @@ -1327,7 +1403,12 @@ \subsubsection{Training Strategy} \paragraph{Learning Rate Scheduling} A **Noam Learning Rate Scheduler** is used to stabilize training. The learning rate increases linearly during a warmup phase and then decays proportionally to the inverse square root of the step number. \begin{equation} - LR = d_{model}^{-0.5} \cdot \min(step\_num^{-0.5}, step\_num \cdot warmup\_steps^{-1.5}) +\begin{aligned} +LR &= d_{model}^{-0.5} \cdot \min( \\ + &\quad step\_num^{-0.5}, \\ + &\quad step\_num \cdot warmup\_steps^{-1.5} +) +\end{aligned} \end{equation} \begin{itemize} \item \textbf{Warmup Steps}: 1000 @@ -1395,7 +1476,7 @@ \subsubsection{Model Architecture} The architecture of the model is shown below: -\begin{verbatim} +\begin{lstlisting} TextClassificationModel { transformer: TransformerEncoder { d_model: 256, @@ -1426,7 +1507,7 @@ \subsubsection{Model Architecture} n_classes: 4 params: 10648580 } -\end{verbatim} +\end{lstlisting} The Transformer encoder used four encoder layers with eight attention heads per layer. Each layer had a model dimension of 256 and a feed-forward dimension of 1024. A dropout rate of 0.1 was used to reduce overfitting. @@ -1539,9 +1620,9 @@ \subsubsection{Code Highlights} The code utilizes the Hugging Face \texttt{datasets} library to load the "ag\_news" dataset. It explicitly shuffles and subsets the data (50,000 train, 5,000 test) to match the constraints applied in the Rust implementation, ensuring a fair apples-to-apples comparison between the two languages. \item \textbf{Collate Function with Padding Masks:} A custom \texttt{collate\_fn} handles dynamic batching. It tokenizes text using the \texttt{bert-base-cased} tokenizer and generates a boolean padding mask. Note the inversion logic: PyTorch's \texttt{TransformerEncoder} expects \texttt{True} for padded positions (unlike some other implementations where 1 implies validity), requiring careful mask generation: - \begin{verbatim} + \begin{lstlisting} mask_pad = (encoding['attention_mask'] == 0) - \end{verbatim} + \end{lstlisting} \item \textbf{Training Loop:} The training loop is a standard PyTorch implementation using \texttt{tqdm} for progress tracking. It uses \texttt{CrossEntropyLoss} as the criterion and the \texttt{Adam} optimizer. Crucially, the scheduler step is called after every batch (not every epoch), consistent with the Noam schedule requirements. \end{itemize} @@ -1553,7 +1634,7 @@ \subsection{Python: Text Classification (News) Transformer Model} \textbf{Model Architecture:} -\begin{verbatim} +\begin{lstlisting} Model { transformer_encoder: { d_model: 256, @@ -1565,7 +1646,7 @@ \subsection{Python: Text Classification (News) Transformer Model} num_classes: 4 total params: 10649092 } -\end{verbatim} +\end{lstlisting} The model was trained for 5 epochs and showed steady convergence across all evaluation metrics. @@ -1634,7 +1715,12 @@ \subsubsection{Implementation Details} \paragraph{1. Library Sharing via NFS} The Library VM exports the directory containing the Python site-packages. On the Docker VM, this directory is mounted using the \texttt{mount\_libs.sh} script. -\begin{lstlisting}[language=bash, caption={Mounting the NFS Library Volume}] +\vspace{0.5em} +\noindent\textbf{Listing 3: Mounting the NFS Library Volume} +\label{lst:nfs_mount} +\vspace{0.3em} + +\begin{lstlisting}[language=bash] # Configuration from mount_libs.sh NFS_SERVER_IP="172.16.203.14" NFS_EXPORT_PATH="/home/iiitb/Documents/textClassificationVolume" @@ -1647,7 +1733,12 @@ \subsubsection{Implementation Details} \paragraph{2. Lightweight Docker Image} The Docker image is built using \texttt{Dockerfile.cpu} and excludes heavy ML libraries. It only contains the application code, the model weights, and minimal system dependencies. -\begin{lstlisting}[language=Dockerfile, caption={Dockerfile.cpu Configuration}] +\vspace{0.5em} +\noindent\textbf{Listing 4: Dockerfile.cpu Configuration} +\label{lst:dockerfile_cpu} +\vspace{0.3em} + +\begin{lstlisting}[language=Dockerfile] FROM python:3.12-slim # Point Python to the external NFS mount @@ -1663,7 +1754,12 @@ \subsubsection{Implementation Details} \paragraph{3. Runtime Execution} The container is launched via \texttt{run\_inference.sh}, which mounts the NFS volume into the container at \texttt{/external-libs}. -\begin{lstlisting}[language=bash, caption={Mounting the NFS Library Volume}] +\vspace{0.5em} +\noindent\textbf{Listing 5: GPU-Based Container Execution Command} +\label{lst:docker_gpu_run} +\vspace{0.3em} + +\begin{lstlisting}[language=bash] docker run --gpus all \ -v /mnt/text-libs:/external-libs \ -v text_model_vol:/models \ @@ -1926,9 +2022,9 @@ \subsubsection{PyTorch Inference Architecture} \item \textbf{External Library Mounting:} A host-level script (\texttt{mount\_libs.sh}) maps an external NAS/NFS storage partition (from \texttt{172.16.203.14}) loaded with Python environments targeting \texttt{/mnt/LSTM-libs}. \item \textbf{Optimized Dockerfile:} The image leverages the \texttt{nvidia/cuda:12.1.1-cudnn8-runtime-ubuntu22.04} base image and installs basic \texttt{python3.11} runtime headers without calling \texttt{pip install torch}. Thus, the final image size is structurally negligible compared to standard ml-images. \item \textbf{Runtime Binding:} The inference container bootloader scripts (\texttt{run\_container.sh}) bind these volume mounts (\texttt{-v \$NFS\_MOUNT\_POINT:/external-libs}) and crucially overrides the \texttt{PYTHONPATH} env-variable: - \begin{verbatim} + \begin{lstlisting} -e PYTHONPATH="$CONTAINER_LIB_MOUNT/LSTM_env/lib/.../site-packages" - \end{verbatim} + \end{lstlisting} \item \textbf{Inference Execution:} \texttt{app.py} loads the model weights off an abstracted configuration path, builds a zero-gradient loader, runs inference iteratively over a single collapsed batch, and yields predictions natively. \end{enumerate} @@ -2111,7 +2207,7 @@ \subsection{Container Comparison: Python vs Rust} \subsection{Model Size Comparison} - + \begin{table*}[t!] \centering \begin{tabular}{|l|c|c|} @@ -2135,5 +2231,5 @@ \subsection{Model Size Comparison} \clearpage - -\end{document} +\EOD +\end{document} \ No newline at end of file diff --git a/latex_reports/draft_2.tex b/latex_reports/draft_2.tex new file mode 100644 index 0000000..b92a0d1 --- /dev/null +++ b/latex_reports/draft_2.tex @@ -0,0 +1,1297 @@ +\documentclass{ieeeaccess} +\usepackage{cite} +\usepackage{amsmath,amssymb,amsfonts} +\usepackage{algorithmic} +\usepackage{textcomp} + +\def\BibTeX{{\rm B\kern-.05em{\sc i\kern-.025em b}\kern-.08em + T\kern-.1667em\lower.7ex\hbox{E}\kern-.125emX}} + +% Encoding and fonts +\usepackage[T1]{fontenc} +\usepackage[utf8]{inputenc} +\usepackage{pdfpages} +\usepackage{enumitem} +\setlist{noitemsep,topsep=0pt,parsep=0pt,partopsep=0pt} + +% Math and graphics +\usepackage{graphicx} +\usepackage{booktabs} +\usepackage{array} +\usepackage{float} + +% URLs (robust line breaking) +\usepackage{url} +\usepackage[hidelinks]{hyperref} +\def\UrlBreaks{\do/\do-\do_} + +% Code listings (stable for IEEE) +\usepackage{listings} +\usepackage{xcolor} +\lstset{ + basicstyle=\ttfamily\footnotesize, + breaklines=true, + breakatwhitespace=false, % <-- IMPORTANT (change this) + columns=fullflexible, + keepspaces=true, + showstringspaces=false +} + +% Custom Dockerfile language +\lstdefinelanguage{Dockerfile}{ + keywords={FROM, RUN, CMD, LABEL, MAINTAINER, EXPOSE, ENV, ADD, COPY, ENTRYPOINT, VOLUME, USER, WORKDIR, ARG, ONBUILD, STOPSIGNAL, HEALTHCHECK, SHELL}, + sensitive=true, + comment=[l]{\#}, + morestring=[b]" +} + +\begin{document} +\history{Date of publication xxxx 00, 0000, date of current version xxxx 00, 0000.} +\doi{10.1109/ACCESS.2017.DOI} + +\title{System-Level Evaluation of Rust and Python for Machine Learning} +\author{\uppercase{Project Elective}\authorrefmark{1}, +\IEEEmembership{Member, IEEE}} +\address[1]{Project Elective (e-mail: project@elective.com)} +\tfootnote{This paragraph of the first footnote will contain support +information, including sponsor and financial support acknowledgment. For +example, ``This work was supported in part by the U.S. Department of +Commerce under Grant BS123456.''} + +\markboth +{Project Elective \headeretal: System-Level Evaluation of Rust and Python for Machine Learning} +{Project Elective \headeretal: System-Level Evaluation of Rust and Python for Machine Learning} + +\corresp{Corresponding author: Project Elective (e-mail: project@elective.com).} + +\begin{abstract} +These instructions give you guidelines for preparing papers for +IEEE Access. Use this document as a template if you are +using \LaTeX. Otherwise, use this document as an +instruction set. The electronic file of your paper will be formatted further +at IEEE. Paper titles should be written in uppercase and lowercase letters, +not all uppercase. Avoid writing long formulas with subscripts in the title; +short formulas that identify the elements are fine (e.g., "Nd--Fe--B"). Do +not write ``(Invited)'' in the title. Full names of authors are preferred in +the author field, but are not required. Put a space between authors' +initials. The abstract must be a concise yet comprehensive reflection of +what is in your article. In particular, the abstract must be self-contained, +without abbreviations, footnotes, or references. It should be a microcosm of +the full article. The abstract must be between 150--250 words. Be sure that +you adhere to these limits; otherwise, you will need to edit your abstract +accordingly. The abstract must be written as one paragraph, and should not +contain displayed mathematical equations or tabular material. The abstract +should include three or four different keywords or phrases, as this will +help readers to find it. It is important to avoid over-repetition of such +phrases as this can result in a page being rejected by search engines. +Ensure that your abstract reads well and is grammatically correct. +\end{abstract} + +\begin{keywords} +Enter key words or phrases in alphabetical +order, separated by commas. For a list of suggested keywords, send a blank +e-mail to keywords@ieee.org or visit \underline +{http://www.ieee.org/organizations/pubs/ani\_prod/keywrd98.txt} +\end{keywords} + +\titlepgskip=-15pt + +\maketitle +\section{Overview of the Project} + +This project studies the use of \textbf{Rust} as an alternative systems language for machine learning workflows traditionally implemented in \textbf{Python}. +Rather than focusing on state-of-the-art model performance, the emphasis is on: + +\begin{itemize} + \item feasibility of end-to-end ML workflows, + \item system stability and reproducibility, + \item developer experience and DevOps complexity, + \item deployment and operational characteristics. +\end{itemize} + +To ensure clarity and rigor, the work is organized into \textbf{two clearly separated experimental tracks}. + +\hrule + +\section{Project Structure: Two-Track Evaluation} + +The project consists of the following two tracks: + +\subsection*{Track 1: Training-Based Systems Evaluation} +This track compares \textbf{machine learning training pipelines} implemented in: +\begin{itemize} + \item PyTorch (Python), and + \item Burn (Rust). +\end{itemize} + +The goal is to evaluate training feasibility, stability, compile-time guarantees, and DevOps impact, rather than raw training speed. + +\subsection*{Track 2: Inference-Based DevOps Evaluation} +This track compares \textbf{production-style inference services} implemented in: +\begin{itemize} + \item Python-based ONNX inference, and + \item Rust-based ONNX inference. +\end{itemize} + +The focus is on deployment, security, containerization, CI/CD behavior, and runtime efficiency. + +Each track is designed to answer a distinct research question while remaining complementary. + +\section{Machine Learning Tasks Considered} + +To ensure coverage of diverse ML workloads, the following tasks are identified: + +\begin{itemize} + \item \textbf{Text Classification}: Dataset to be finalized. + \item \textbf{Image Classification}: MNIST dataset. + \item \textbf{Credit Score Assignment}: Supervised classification task. + \item \textbf{Multi-Objective Machine Learning}: Brain Tumor dataset with a MOML formulation. + \item \textbf{Fine-Tuning Task}: BERT-based classification (ANLP Assignment 1), with optional LoRA / QLoRA. + \item \textbf{Autoregressive Decoding}: Experiments using the Burn framework. +\end{itemize} + +At the current stage, the \textbf{MNIST image classification task has been fully implemented}. +The corresponding training code is available in the project GitHub repository. + +\hrule + +\section{Related Work} + +The following research papers are being used to guide experimental design and evaluation: + +\begin{itemize} + \item \url{https://ieeexplore.ieee.org/document/11126113} + \item \url{https://ieeexplore.ieee.org/document/11261485} + \item \url{https://ieeexplore.ieee.org/document/11212348} + \item \url{https://www.ijsred.com/volume8/issue2/IJSRED-V8I2P143.pdf} +\end{itemize} + +\hrule + +\section{Code Repository and Current Status} + +Project repository: +\begin{center} +\url{https://github.com/Abhinav-Kumar012/Rust_Python_ML_PE.git} +\end{center} + +Current progress includes: +\begin{itemize} + \item MNIST training pipeline implemented + \item PyTorch baseline established + \item Initial Rust (Burn) training setup completed +\end{itemize} + +\hrule + +\section{Track 1: Training-Based Systems Evaluation} + +\subsection{Objective} + +The objective of this track is to answer the following research question: + +\begin{quote} +\textit{Can Rust realistically support end-to-end machine learning training pipelines, and what system-level trade-offs does this introduce compared to PyTorch?} +\end{quote} + +This track explicitly avoids speed-centric benchmarking and instead focuses on system behavior. + +\hrule + +\subsection{Frameworks Compared} + +\subsubsection{PyTorch (Baseline)} +\begin{itemize} + \item Language: Python + \item Training maturity: Very high + \item Ecosystem: Extensive +\end{itemize} + +\subsubsection{Rust (Burn)} +\begin{itemize} + \item Language: Rust + \item Training maturity: Emerging + \item Design: Idiomatic Rust, native training support +\end{itemize} + +\hrule + +\subsection{Experimental Controls} + +\textbf{Fixed Across Both Implementations} +\begin{itemize} + \item Dataset splits + \item Number of epochs + \item Batch size + \item Optimizer type + \item Learning rate + \item Hardware +\end{itemize} + +\textbf{Allowed Differences} +\begin{itemize} + \item Internal kernel implementations + \item Graph execution model + \item Memory management +\end{itemize} + +\hrule + +\subsection{Metrics Collected} + +\begin{itemize} + \item Training time per epoch (reported cautiously) + \item Loss curves and convergence behavior + \item Runtime failures and numerical stability + \item Reproducibility across runs + \item Environment setup and build complexity + \item Dependency footprint and artifact size +\end{itemize} + +\hrule + +\section{Track 2: Inference-Based DevOps Evaluation} + +\subsection{Objective} + +The objective of this track is to compare \textbf{deployment, security, and operational characteristics} of Python-based and Rust-based ML inference services executing the same ONNX model. + +\hrule + +\subsection{Inference Services Compared} + +\textbf{Python Service} +\begin{itemize} + \item FastAPI + Uvicorn + \item ONNX Runtime (Python) +\end{itemize} + +\textbf{Rust Service} +\begin{itemize} + \item Axum / Actix + \item burn-rs +\end{itemize} + +Both services expose identical inference endpoints and return identical outputs. + +\hrule + +\subsection{Evaluation Dimensions} + +\begin{itemize} + \item CI/CD build behavior + \item Container image size and layering + \item Cold-start latency + \item Inference latency and throughput + \item Resource utilization + \item Security and supply-chain surface +\end{itemize} + +\hrule + +\section{Upcoming Work} + +The following tasks are planned for the next phase of the project: +s +\begin{itemize} + \item Develop production-style inference services for both Python and Rust. + \item Write Dockerfiles for Python and Rust inference services. + \item Set up Jenkins-based CI pipelines for inference, including build, test, containerization, and security scanning. +\end{itemize} + +\section{Task: MNIST Image Classification} +\subsection{Architecture Details} + + + + +\subsection{Rust Backend: Load Testing with Locust} + +The performance of the Rust-based backend was evaluated using the Locust load testing framework. The objective was to analyze system behavior under concurrent user load and measure key performance characteristics such as throughput and latency. + +\textbf{Testing Setup:} +\textbf{Testing Setup:} +\begin{itemize} + \item Tool: Locust + \item Backend: Rust (HTTP service) + \item Test Type: Concurrent user load simulation + \item Environment: Linux system +\end{itemize} + +\textbf{Dashboard Visualization:} +\textbf{Full Report:} + +The complete load testing dashboard has been exported as a PDF and is included below for detailed inspection. + +\begin{figure}[H] +\centering +\includegraphics[width=\linewidth]{RustLocust/MNIST.pdf} +\caption{Rust Backend Load Testing Dashboard for MNIST} +\end{figure} + +\subsection{Python Backend: Load Testing with Locust} + +The performance of the Python-based backend was evaluated using the Locust load testing framework. The goal was to assess system behavior under concurrent user load and analyze key performance characteristics such as throughput and response latency. + +\textbf{Testing Setup:} +\begin{itemize} + \item Tool: Locust + \item Backend: Python (HTTP service) + \item Test Type: Concurrent user load simulation + \item Environment: Linux system +\end{itemize} + +\textbf{Dashboard Visualization:} + +\textbf{Full Report:} + +The complete load testing dashboard has been exported as a PDF and is included below for detailed inspection. + +\begin{figure}[H] +\centering +\includegraphics[width=\linewidth]{PythonLocust/MNIST.pdf} +\caption{Python Backend Load Testing Dashboard for MNIST} +\end{figure} + +\subsection{Docker Containerization Strategy} +Both Rust and Python inference workflows leverage highly optimized container strategies. For Rust, a multi-stage Docker build compiles the application within an \texttt{ubuntu:16.04} builder and transfers the standalone binary to a minimal \texttt{nvidia/vulkan:1.3-470} runtime image. + + + + +The Python (PyTorch) container minimizes footprint by avoiding framework installation inside the image. Utilizing \texttt{python:3.12-slim}, it only installs \texttt{fastapi} and maps heavy ML dependencies at runtime via an external NFS volume (\texttt{PYTHONPATH} override). This reduces the image size from gigabytes to under 150MB, drastically accelerating deployments. + + +\subsection{Rust: CNN Model Architecture and Training Performance} + +The convolutional neural network (CNN) model used for the experiment consisted of two convolutional layers followed by adaptive average pooling, dropout, and two fully connected layers. The complete architecture is shown below: + +\begin{lstlisting} +Model { + conv1: Conv2d {ch_in: 1, ch_out: 8, stride: [1, 1], kernel_size: [3, 3], dilation: [1, 1], groups: 1, padding: Valid, params: 80} + conv2: Conv2d {ch_in: 8, ch_out: 16, stride: [1, 1], kernel_size: [3, 3], dilation: [1, 1], groups: 1, padding: Valid, params: 1168} + pool: AdaptiveAvgPool2d {output_size: [8, 8]} + dropout: Dropout {prob: 0.5} + linear1: Linear {d_input: 1024, d_output: 512, bias: true, params: 524800} + linear2: Linear {d_input: 512, d_output: 10, bias: true, params: 5130} + activation: Relu + params: 531178 +} +\end{lstlisting} + +The model was trained for 10 epochs. Over the course of training, both the training and validation performance improved consistently. Training accuracy increased from 81.575\% in the first epoch to 97.300\% in the final epoch, while validation accuracy improved from 92.133\% to 98.517\%. + +Similarly, the training loss decreased significantly from 0.656 to 0.087, and the validation loss reduced from 0.258 to 0.054 by the end of training. The macro F1-score also improved substantially, reaching 96.974\% for training and 98.321\% for validation. + + + +The results indicate that the model achieved strong generalization performance with minimal overfitting, as the validation accuracy remained slightly higher than the training accuracy throughout the experiment. The consistently high Top-5 accuracy values further demonstrate that the model was able to correctly identify the correct class within its top predictions. + +It should also be noted that the execution terminated with a segmentation fault after training completion. However, since the fault occurred after all epochs had been completed and metrics had already been recorded, it did not affect the validity of the training results. \\ + +The time taken to train the model is 229.916s (3min 49.916s). + + + +\subsection{Python: CNN Model Architecture and Training Performance} + +The convolutional neural network (CNN) model implemented in Python mirrors the Rust architecture, consisting of two convolutional layers followed by adaptive average pooling, dropout regularization, and two fully connected layers. + +\textbf{Model Architecture:} + +\begin{lstlisting} +Model { + conv1: Conv2d {ch_in: 1, ch_out: 8, kernel_size: [3, 3], stride: [1, 1]} + conv2: Conv2d {ch_in: 8, ch_out: 16, kernel_size: [3, 3], stride: [1, 1]} + pool: AdaptiveAvgPool2d {output_size: [8, 8]} + dropout: Dropout {prob: 0.5} + linear1: Linear {d_input: 1024, d_output: 512, params: 524800} + linear2: Linear {d_input: 512, d_output: 10, params: 5130} + activation: ReLU + total params: 531178 +} +\end{lstlisting} + +The model was trained for 10 epochs. Training and validation performance improved consistently over time. + +Training accuracy increased from 82.01\% to 97.29\%, while validation accuracy improved from 92.87\% to 98.20\%. +Training loss decreased significantly from 0.5947 to 0.0867, and validation loss reduced from 0.2475 to 0.0579. + +The macro F1-score reached 0.9814, demonstrating strong classification performance. Additionally, the Top-5 accuracy achieved 99.98\%, indicating highly reliable predictions. + + + +The results indicate strong generalization performance with no signs of overfitting. Validation accuracy remained consistently high and closely followed training accuracy. + +Training was stable with zero NaN events observed. The total training time was 182.23 seconds, with an average epoch time of 15.05 seconds and an average iteration speed of 50.58 iterations per second. + + + +\section{Task: Regression} + +\subsection{Introduction} +This document outlines the architectural, mathematical, and deployment specifics of implementing a Neural Network-based Regression model across two disparate machine learning environments: Rust (utilizing the Burn framework) and Python (utilizing PyTorch). It covers the distinct model architecture decisions, dataset handling strategies, and specialized pipeline deployment techniques leveraging Network File Systems (NFS) mapping via Docker bounds. + +\subsection{Model Architecture and Mathematical Formulation} + +\subsubsection{Mathematical Foundation} +The core mathematical foundation deployed across both frameworks is a classical Feed-Forward Neural Network consisting of a single hidden dimension mapping inputs directly onto a continuous single-variable regression output. + +For a given input feature vector $X \in \mathbb{R}^N$ (where $N$ dictates the feature size depending on the target dataset), the network's forward transformation can be represented sequentially as: +\begin{align} + Z_1 &= X \cdot W_1^T + b_1 \quad &\text{(Input Projection)} \\ + A_1 &= \max(0, Z_1) \quad &\text{(ReLU Activation)} \\ + \hat{Y} &= A_1 \cdot W_2^T + b_2 \quad &\text{(Output Projection)} +\end{align} + +Where: +\begin{itemize} + \item $W_1 \in \mathbb{R}^{H \times N}$ and $b_1 \in \mathbb{R}^H$ map the inputs onto the hidden vector space $H$. + \item $\max(0, \cdot)$ denotes the Non-Linear Rectified Linear Unit (ReLU) mapping algorithm. + \item $W_2 \in \mathbb{R}^{1 \times H}$ and $b_2 \in \mathbb{R}$ collapse the hidden abstraction onto the finalized regression scalar prediction $\hat{Y}$. +\end{itemize} + +\subsubsection{Architectural Configurations} +While the mathematical foundations are identical, implementations slightly differ based on dataset selections within the modules: +\begin{itemize} + \item \textbf{PyTorch Architecture:} Configures $N=13$ input features mapping to $H=64$ hidden parameters. + \item \textbf{Rust (Burn) Architecture:} Configures $N=8$ input features concurrently mapping to $H=64$ hidden parameters. +\end{itemize} +In both configurations, standard parameter biases (`bias=True`) are included and automatically initialized. + +\subsection{Training Pipelines} + +Both codebases train the model iteratively tracking gradients via the Adam optimizer scaled against Mean Squared Error (MSE) loss logic: +\[ \text{MSE} = \frac{1}{B} \sum_{i=1}^{B} (Y_i - \hat{Y}_i)^2 \] + +\subsubsection{PyTorch Context} +\begin{itemize} + \item \textbf{Data Loading:} Automatically pulls the \textbf{Boston Housing} dataset array (.npz file) from an external Google API via `urllib` and manually partitions it down into an explicit $80/20$ split. + \item \textbf{Telemetry Metrics:} Generates explicit hardware tracking loops inside the main epoch runner. Uses the `psutil` library to compute and stream epoch `iteration\_speed`, raw RAM consumption, and `cpu\_temp` hardware sensors parallel to the loss parameters. +\end{itemize} + +\subsubsection{Rust (Burn) Context} +\begin{itemize} + \item \textbf{Data Loading:} Links into Huggingface's dataset registry asynchronously targeting the \textbf{California Housing} SQLized splits mapping onto memory arrays via localized `HousingDistrictItem` structs. + \item \textbf{Normalization Mapping:} Computes spatial min-max normalizations programmatically over inputs during training: + \[ X_{norm} = \frac{X - \text{min}}{\text{max} - \text{min}} \] + This logic restricts features within standard boundaries precluding exploding gradient derivations. +\end{itemize} + +\subsection{Inference Pipeline and Docker NFS Integration} + +Deploying these isolated pipelines necessitates radically different execution strategies, highlighting Python's heavyweight runtime dependency bottlenecks versus Rust's compile-time optimizations. + +\subsubsection{PyTorch Inference Architecture} +Standard PyTorch Docker environments routinely eclipse several gigabytes due to CUDA bindings and generic scientific computation loops. To circumvent this inside microservices, the PyTorch inference pipeline mandates a hybrid Network File System (NFS) mapping architecture: +\begin{enumerate} + \item \textbf{NFS Mounting (\texttt{mount\_libs.sh}):} Installs an external `nfs-common` client locally and binds the extensive python library volume from an external dedicated storage server (`172.16.203.14`) into the host machine's `/mnt/LSTM-libs` map. + \item \textbf{Lightweight Container Image:} The backend \texttt{Dockerfile} avoids `pip install` commands completely, simply initializing a barebone `nvidia/cuda:12.1.1` image mapping Python $3.11$ system links. + \item \textbf{Volume Inject (\texttt{run\_container.sh}):} The script initializes the container enforcing `-v` flags that sync the NFS `/mnt/LSTM-libs` directory seamlessly onto the Docker's `/external-libs`. Crucially, it overrides the system \texttt{PYTHONPATH} to target those external `site-packages` at runtime. + \item \textbf{Execution:} The `FastAPI` instance loads, bypasses massive disk pulls, links the models iteratively, and fields inbound `HousingFeatures` lists continuously. +\end{enumerate} + +\subsubsection{Rust (Burn) Inference Architecture} +Rust handles Docker microservices inherently via statically linked deployments: +\begin{itemize} + \item \textbf{Multi-Stage Compiling:} Executes a build phase operating within an oversized `rust:1.92-alpine` chain, ejecting the resulting binary onto an isolated stripped `alpine:3.23` environment structure. + \item \textbf{Native Routing:} Utilizes \texttt{Axum} servers to establish the HTTP logic endpoints securely routing JSON payloads mapping to specific feature names (e.g. \texttt{median\_income}, \texttt{house\_age}). +\end{itemize} + +\subsection{Rust: Regression Model Performance Analysis} + +The regression model used in this experiment was a simple feed-forward neural network consisting of one hidden layer followed by an output layer. The model was designed to predict the median house value based on eight input features. + +\subsubsection{Model Architecture} + +The architecture of the regression model is shown below: + +\begin{lstlisting} +RegressionModel { + input_layer: Linear {d_input: 8, d_output: 64, bias: true, params: 576} + output_layer: Linear {d_input: 64, d_output: 1, bias: true, params: 65} + activation: Relu + params: 641 +} +\end{lstlisting} + +The model contains: + +\begin{itemize} + \item An input layer that maps 8 input features to 64 hidden units + \item A ReLU activation function applied after the hidden layer + \item An output layer that maps the 64 hidden units to a single scalar value +\end{itemize} + +The total number of trainable parameters in the model was only 641, making it a lightweight model suitable for fast training and inference. + +\subsubsection{Training Configuration} + +The model was trained for 100 epochs. A constant learning rate of: + +\[ +1.0 \times 10^{-3} +\] + +was used throughout the entire training process. + +\subsubsection{Training Performance} + +The training loss decreased substantially over the 100 epochs. Initially, the model started with a training loss of 3.086 during the first epoch. By the final epoch, the loss had reduced to 0.414. + +This significant reduction in loss indicates that the model successfully learned the underlying relationship between the input features and the target variable. + +\subsubsection{Validation Performance} + +Validation loss also showed a considerable improvement during training. The validation loss decreased from 4.132 in the first epoch to a minimum of 0.635 at epoch 51. + +The difference between the final training loss and the minimum validation loss suggests that the model achieved good generalization performance without severe overfitting. + + + +\subsubsection{Prediction Example} + +A sample prediction generated by the model is shown below: + +\begin{lstlisting} +Predicted 2.021734 Expected 2.158 +\end{lstlisting} + +The predicted value is reasonably close to the expected value, indicating that the model was able to approximate the target variable with acceptable accuracy. + +Since the median house value was measured in units of 100,000 dollars, the prediction corresponds to: + +\begin{itemize} + \item Predicted value: approximately 202,173 dollars + \item Expected value: approximately 215,800 dollars +\end{itemize} + +\subsubsection{Predicted vs. Expected Distribution} + +The predicted-versus-expected plot suggests that the model captures the general trend in the target values, although some prediction errors remain for certain samples. + +Most of the predicted values appear concentrated around the central region of the distribution, indicating that the model performs better on common house value ranges than on extreme values. + +\subsubsection{Resource Utilization} + +The model required relatively little memory during execution. Training memory usage ranged from 2.125 GB to 2.325 GB, while validation memory usage ranged from 2.124 GB to 2.325 GB. + +CPU utilization remained moderate throughout training. Training CPU usage ranged from 19.539\% to 37.989\%, while validation CPU usage ranged from 19.550\% to 37.960\%. + +CPU temperature values were unavailable and therefore recorded as NaN. + +\subsubsection{Execution Time and Failure} + +The complete training and evaluation process required: + +\begin{itemize} + \item Real time: 3 minutes and 18.257 seconds + \item User CPU time: 4 minutes and 13.554 seconds + \item System CPU time: 50.340 seconds +\end{itemize} + +\subsection{Language Specific Implementation Details} + +\subsubsection{PyTorch-Specific Paradigms} +\begin{itemize} + \item \textbf{Thread Clamping:} Due to inference optimization restrictions (especially running CPU variations alongside container structures), the `app.py` enforces explicit core binding calls via `torch.set\_num\_threads(1)` and `torch.set\_num\_interop\_threads(1)` securing computational resources and restricting OS context-switching overheads. + \item \textbf{Matrix Array Verifications:} Manually inspects raw matrix vector mappings validating dimensions dynamically against numeric constraints: \texttt{len(x) != NUM\_FEATURES} triggering runtime panics before pipeline evaluations fail. + \item \textbf{Manual Hardware Moving:} The framework is heavily littered with required `.to(device)` mapping configurations switching inputs, datasets, targets, and models manually between the host and external components. +\end{itemize} + +\subsubsection{Rust (Burn)-Specific Paradigms} +\begin{itemize} + \item \textbf{Generic Compile-Time Shapes:} Dimension mappings and tensor validations are fundamentally enforced inside the Rust compiler boundaries via `` arrays indicating batches of distinct input structures mapping to `targets: Tensor`. Invalid sizes fail compilation, voiding the requirement for manual PyTorch matrix validations. + \item \textbf{Struct Batching Protocols:} Inference doesn't evaluate primitive float arrays. Intead, the API relies on executing an overarching `HousingBatcher` which transforms specific struct domains (\texttt{HousingDistrictItem}) safely into tensor primitives while executing implicit `self.normalizer.to\_device(device)` logic silently against constants behind boundaries. + \item \textbf{Record Deserialization:} States are strictly detached from models via standard `.mpk` maps. They invoke explicit \texttt{NoStdTrainingRecorder::new().load()} tracking traits unbinding memory limits inherent to standard dict serialization configurations natively loaded via `RegressionModelConfig`. +\end{itemize} + +\newpage +\subsection{Python: Regression Model Architecture and Training Performance} + +The regression model used in this experiment is a lightweight fully connected neural network with a small number of parameters (961 total). The model is optimized using mean squared error loss. + + + +The model was trained for 100 epochs. Training loss decreased significantly from 8265.55 to 69.86 (99.15\% reduction), while validation loss decreased from 9045.34 to 55.70. + +Despite strong loss reduction, the model struggled to achieve good generalization. The validation $R^2$ score remained negative (-1.07), indicating that the model performs worse than a simple baseline predictor. + + + +\textbf{Training Efficiency and Stability:} +\begin{itemize} + \item Total Training Time: 47.39 seconds + \item Average Epoch Time: 0.225 seconds + \item Iteration Speed (Mean): 10.36 it/s + \item Gradient Norm (Mean): 7981.31 + \item NaN Events: 0 + \item Convergence: Non-monotonic loss decrease + \item Overfitting Detected: No +\end{itemize} + +The results indicate that while optimization was successful in reducing loss, the model lacks sufficient capacity or feature representation to generalize well. The persistently negative validation $R^2$ suggests underfitting or a mismatch between model complexity and data characteristics. + + + + +\section{Task: Text Classification (AG News)} +\subsection{Model Architecture and Training Strategy} + +The text classification system is built using the \texttt{Burn} framework in Rust, leveraging a Transformer-based architecture for feature extraction and a linear classification head. This section details the mathematical formulation of the model and the strategy employed for training. + +\subsubsection{Model Architecture} +The core of the model is a Transformer Encoder, which processes a sequence of token embeddings to capture contextual relationships. The architecture consists of three primary stages: embedding, encoding, and classification. + +\paragraph{Embedding Layer} +Input text is tokenized and converted into a sequence of indices $X \in \mathbb{N}^{B \times L}$, where $B$ is the batch size and $L$ is the sequence length. The model utilizes two parallel embedding layers: +\begin{enumerate} + \item \textbf{Token Embedding ($E_{tok}$)}: Maps token indices to dense vectors of dimension $d_{model}$. + \item \textbf{Positional Embedding ($E_{pos}$)}: Maps position indices $[0, \dots, L-1]$ to dense vectors of dimension $d_{model}$ to inject sequence order information. +\end{enumerate} + +The final embedding representation $E$ is obtained by averaging the token and positional embeddings: +\begin{equation} + E = \frac{E_{tok}(X) + E_{pos}(\text{positions})}{2} +\end{equation} + +\paragraph{Transformer Encoder} +The embedding tensor $E$ is passed through a multi-layer Transformer Encoder. Each layer consists of a Multi-Head Self-Attention (MHSA) mechanism followed by a Position-wise Feed-Forward Network (FFN), with residual connections and layer normalization. + +The configuration used in this implementation is as follows: +\begin{itemize} + \item \textbf{Model Dimension ($d_{model}$)}: 256 + \item \textbf{Feed-Forward Dimension ($d_{ff}$)}: 1024 + \item \textbf{Number of Heads ($N_{heads}$)}: 8 + \item \textbf{Number of Layers ($N_{layers}$)}: 4 + \item \textbf{Normalization}: Layer norm applied before sub-layers (Pre-Norm). +\end{itemize} + +Let $H = \text{TransformerEncoder}(E)$, where $H \in \mathbb{R}^{B \times L \times d_{model}}$ represents the contextualized representations of the input sequence. + +\paragraph{Classification Head} +For classification, the model utilizes the representation of the first token (typically acting as the [CLS] token) from the encoded sequence. This vector is passed through a linear layer to project it into the class space: +\begin{equation} + Y = \text{Linear}(H_{[:, 0, :]}) +\end{equation} +where $Y \in \mathbb{R}^{B \times N_{classes}}$ represents the logits. For inference, a Softmax function is applied to obtain probabilities: +\begin{equation} + \hat{P} = \text{Softmax}(Y) +\end{equation} + +\begin{table*}[t!] +\centering +\caption{Model Architecture Summary} +\label{tab:model_arch} +\begin{tabular}{|l|l|c|c|} +\hline +\textbf{Component} & \textbf{Configuration / Details} & \textbf{Input Shape} & \textbf{Output Shape} \\ \hline +Token Embedding & $V \to d_{model}$ ($V$: Vocab Size) & $(B, L)$ & $(B, L, 256)$ \\ \hline +Pos Embedding & $L_{max} \to d_{model}$ & $(B, L)$ & $(B, L, 256)$ \\ \hline +Embedding Merge & Average ($E_{tok} + E_{pos}$) & - & $(B, L, 256)$ \\ \hline +Transformer Block & 4 Layers, 8 Heads, $d_{ff}=1024$ & $(B, L, 256)$ & $(B, L, 256)$ \\ \hline +Feature Extract & Slice First Token (Index 0) & $(B, L, 256)$ & $(B, 256)$ \\ \hline +Classifier Head & Linear ($256 \to N_{classes}$) & $(B, 256)$ & $(B, N_{classes})$ \\ \hline +\end{tabular} +\end{table*} + + + +\subsection{Rust Backend: Load Testing with Locust} + +The performance of the Rust-based backend was evaluated using the Locust load testing framework. The objective was to analyze system behavior under concurrent user load and measure key performance characteristics such as throughput and latency. + +\textbf{Testing Setup:} +\textbf{Testing Setup:} +\begin{itemize} + \item Tool: Locust + \item Backend: Rust (HTTP service) + \item Test Type: Concurrent user load simulation + \item Environment: Linux system +\end{itemize} + +\textbf{Dashboard Visualization:} +\textbf{Full Report:} + +The complete load testing dashboard has been exported as a PDF and is included below for detailed inspection. + +\begin{figure}[H] +\centering +\includegraphics[width=\linewidth]{RustLocust/text.pdf} +\caption{Rust Backend Load Testing Dashboard for Text Classification} +\end{figure} + +\subsection{Python Backend: Load Testing with Locust} + +The performance of the Python-based backend was evaluated using the Locust load testing framework. The goal was to assess system behavior under concurrent user load and analyze key performance characteristics such as throughput and response latency. + +\textbf{Testing Setup:} +\begin{itemize} + \item Tool: Locust + \item Backend: Python (HTTP service) + \item Test Type: Concurrent user load simulation + \item Environment: Linux system +\end{itemize} + +\textbf{Dashboard Visualization:} + +\textbf{Full Report:} + +The complete load testing dashboard has been exported as a PDF and is included below for detailed inspection. + +\begin{figure}[H] +\centering +\includegraphics[width=\linewidth]{PythonLocust/text.pdf} +\caption{Python Backend Load Testing Dashboard for Text Classification} +\end{figure} + +\subsubsection{Training Strategy} +The model is trained using a supervised learning approach with the following configuration: + +\paragraph{Loss Function} +The training objective is to minimize the Cross-Entropy Loss between the predicted logits $Y$ and the ground truth class labels $C$: +\begin{equation} + \mathcal{L} = \text{CrossEntropy}(Y, C) = -\sum_{c=1}^{N_{classes}} \mathbb{1}_{c=C} \log\left(\frac{e^{Y_c}}{\sum_{j} e^{Y_j}}\right) +\end{equation} + +\paragraph{Optimization} +We employ the **Adam** optimizer with the following parameters: +\begin{itemize} + \item \textbf{Weight Decay}: $5 \times 10^{-5}$ + \item \textbf{Beta Coefficients}: Standard defaults (typically $\beta_1=0.9, \beta_2=0.999$) +\end{itemize} + +\paragraph{Learning Rate Scheduling} +A **Noam Learning Rate Scheduler** is used to stabilize training. The learning rate increases linearly during a warmup phase and then decays proportionally to the inverse square root of the step number. +\begin{equation} +\begin{aligned} +LR &= d_{model}^{-0.5} \cdot \min( \\ + &\quad step\_num^{-0.5}, \\ + &\quad step\_num \cdot warmup\_steps^{-1.5} +) +\end{aligned} +\end{equation} +\begin{itemize} + \item \textbf{Warmup Steps}: 1000 + \item \textbf{Base Learning Rate}: 0.01 +\end{itemize} + +\paragraph{Metrics} +During training and validation, the following metrics are tracked to monitor performance: +\begin{itemize} + \item \textbf{Loss}: Cross-Entropy Loss. + \item \textbf{Accuracy}: Percentage of correct predictions. + \item \textbf{F1-Score, Precision, Recall}: Macro-averaged metrics to account for class balance. +\end{itemize} + +\subsection{Burn Code Specifications} + +This section outlines the significant implementation details of the text classification system, focusing on the architectural choices in \texttt{model.rs} and the robust training pipeline defined in \texttt{training.rs}. +\subsubsection{Model Implementation (\texttt{model.rs})} +The \texttt{TextClassificationModel} leverages the \textbf{Burn} framework's modular design to implement a Transformer-based classifier. Key features of this implementation include: +\begin{itemize} + \item \textbf{Dual Embedding Strategy:} The model employs two distinct embedding layers: \texttt{embedding\_token} for semantic content and \texttt{embedding\_pos} for positional information. A unique characteristic of this implementation is the fusion strategy, where these embeddings are combined via averaging: + \[ + E_{final} = \frac{E_{pos} + E_{token}}{2} + \] + This differs from the standard summation approach often found in BERT implementations, potentially stabilizing the initial magnitude of the embedding vectors. + + \item \textbf{Configurable Architecture:} The system uses a \texttt{TextClassificationModelConfig} struct derived with the \texttt{Config} macro. This allows for type-safe and serializable hyperparameter management, ensuring the model architecture (hidden size, vocabulary size, sequence length) can be easily saved, loaded, and reproducible. + + \item \textbf{Masked Attention:} The forward pass actively utilizes padding masks (\texttt{mask\_pad}). These masks are passed into the \texttt{TransformerEncoderInput}, ensuring that the self-attention mechanism strictly ignores padding tokens, which is critical for handling variable-length text sequences correctly. + + \item \textbf{Separation of Train and Inference Logic:} The model explicitly implements the \texttt{TrainStep} and \texttt{InferenceStep} traits. + \begin{itemize} + \item \textbf{Training:} Returns a \texttt{ClassificationOutput} struct containing the calculated Cross-Entropy loss for backpropagation. + \item \textbf{Inference:} Returns raw probabilities by applying a softmax activation on the output logits, facilitating direct class prediction. + \end{itemize} +\end{itemize} +\subsubsection{Training Pipeline (\texttt{training.rs})} +The training module is designed for reliability and comprehensive observability. It integrates advanced optimization techniques and hardware-aware monitoring. +\begin{itemize} + \item \textbf{Noam Scheduler:} Transformer models are notoriously sensitive to learning rates. The code implements the \textbf{Noam Learning Rate Scheduler} (popularized by "Attention Is All You Need"), which features a linear warmup phase (1000 steps) followed by an inverse square root decay based on the model dimension ($d_{model}$). This prevents gradient explosions during early training stages. + + \item \textbf{Distributed Training Support:} The implementation explicitly handles distributed computing scenarios. It utilizes Rust's feature flags (\texttt{cfg[feature = "ddp"]}) to switch between single-device training and \textbf{Distributed Data Parallel (DDP)} strategies. When enabled, it employs a tree-based \texttt{AllReduceStrategy} for synchronizing gradients across multiple GPUs or nodes. + + \item \textbf{Comprehensive Telemetry:} The training loop is instrumented with an extensive suite of metrics beyond simple accuracy. It tracks: + \begin{itemize} + \item \textbf{Classification Metrics:} Macro-averaged F1-Score, Precision, and Recall, providing a holistic view of model performance on imbalanced datasets. + \item \textbf{Hardware Diagnostics:} CPU temperature, memory usage, and utilization are logged alongside training progress, aiding in the detection of thermal throttling or memory leaks during long training runs. + \end{itemize} + + \item \textbf{Efficient Data Sampling:} To manage large datasets efficiently, the loader utilizes a \texttt{SamplerDataset}. This limits the effective epoch size to 50,000 training samples and 5,000 validation samples, allowing for rapid iteration and feedback loops without needing to process the entire corpus in every epoch. +\end{itemize} + +\subsection{Rust: Transformer-Based Text Classification Model Performance} + +The text classification model used in this experiment was based on a Transformer encoder architecture. The model consisted of token embeddings, positional embeddings, a multi-layer Transformer encoder, and a final linear classification layer. + +\subsubsection{Model Architecture} + +The architecture of the model is shown below: + +\begin{lstlisting} +TextClassificationModel { + transformer: TransformerEncoder { + d_model: 256, + d_ff: 1024, + n_heads: 8, + n_layers: 4, + dropout: 0.1, + norm_first: true, + quiet_softmax: true, + params: 3159040 + } + embedding_token: Embedding { + n_embedding: 28996, + d_model: 256, + params: 7422976 + } + embedding_pos: Embedding { + n_embedding: 256, + d_model: 256, + params: 65536 + } + output: Linear { + d_input: 256, + d_output: 4, + bias: true, + params: 1028 + } + n_classes: 4 + params: 10648580 +} +\end{lstlisting} + +The Transformer encoder used four encoder layers with eight attention heads per layer. Each layer had a model dimension of 256 and a feed-forward dimension of 1024. A dropout rate of 0.1 was used to reduce overfitting. + +The token embedding layer mapped a vocabulary of 28,996 tokens into 256-dimensional vectors. Positional embeddings of length 256 were also used so that the Transformer could capture token order information. + +The final output layer mapped the Transformer representation into four output classes. + +The total number of trainable parameters in the model was 10,648,580. + +\subsubsection{Training Configuration} + +The model was trained for a total of 5 epochs. During training, the learning rate decayed from $1.107 \times 10^{-5}$ in the first epoch to $3.733 \times 10^{-6}$ in the final epoch. + +\subsubsection{Training Performance} + +Training accuracy improved steadily from 57.968\% in the first epoch to 81.474\% in the fifth epoch. Similarly, the training loss decreased from 0.981 to 0.507. + +The macro precision, recall, and F1-score also improved significantly during training: + +\begin{itemize} + \item Precision increased from 58.893\% to 81.606\% + \item Recall increased from 57.741\% to 81.334\% + \item F1-score increased from 50.201\% to 76.001\% +\end{itemize} + +These results indicate that the model learned meaningful semantic patterns in the text data over successive epochs. + +\subsubsection{Validation Performance} + +Validation performance also improved consistently. Validation accuracy increased from 72.280\% in the first epoch to a maximum of 81.640\% in the fourth epoch. + +The validation loss decreased from 0.731 to 0.507, showing that the model generalized reasonably well to unseen data. + +The validation precision, recall, and F1-score also showed strong improvement: + +\begin{itemize} + \item Precision increased from 72.230\% to 81.739\% + \item Recall increased from 72.258\% to 82.039\% + \item F1-score increased from 65.796\% to 76.509\% +\end{itemize} + +The relatively close values between training and validation accuracy suggest that the model did not suffer from severe overfitting. + +\begin{table*}[t!] +\centering +\caption{Training and Validation Metrics Summary for the Transformer-Based Text Classification Model} +% @kumar please layout sahi kar sakta hai? love you +\begin{tabular}{|l|l|c|c|c|c|} +\hline +\textbf{Split} & \textbf{Metric} & \textbf{Min.} & \textbf{Epoch} & \textbf{Max.} & \textbf{Epoch} \\ +\hline +Train & Accuracy & 57.968 & 1 & 81.474 & 5 \\ +Train & Loss & 0.507 & 5 & 0.981 & 1 \\ +Train & Precision@Top1 [Macro] & 58.893 & 1 & 81.606 & 5 \\ +Train & Recall@Top1 [Macro] & 57.741 & 1 & 81.334 & 5 \\ +Train & F1-Score@Top1 [Macro] & 50.201 & 1 & 76.001 & 5 \\ +Train & Learning Rate & $3.733 \times 10^{-6}$ & 5 & $1.107 \times 10^{-5}$ & 1 \\ +Train & CPU Memory (GB) & 2.401 & 2 & 2.736 & 5 \\ +Train & CPU Usage (\%) & 16.529 & 1 & 17.160 & 5 \\ +\hline +Valid & Accuracy & 72.280 & 1 & 81.640 & 4 \\ +Valid & Loss & 0.507 & 5 & 0.731 & 1 \\ +Valid & Precision@Top1 [Macro] & 72.230 & 1 & 81.739 & 5 \\ +Valid & Recall@Top1 [Macro] & 72.258 & 1 & 82.039 & 5 \\ +Valid & F1-Score@Top1 [Macro] & 65.796 & 1 & 76.509 & 5 \\ +Valid & CPU Memory (GB) & 2.263 & 1 & 2.747 & 5 \\ +Valid & CPU Usage (\%) & 20.331 & 1 & 22.190 & 3 \\ +\hline +\end{tabular} +\label{tab:transformer_text_classification_metrics} +\end{table*} + +\subsubsection{Resource Utilization} + +The CPU memory usage remained relatively stable throughout training. Training memory usage ranged from 2.401 GB to 2.736 GB, while validation memory usage ranged from 2.263 GB to 2.747 GB. + +CPU utilization also remained moderate, with training CPU usage ranging from 16.529\% to 17.160\% and validation CPU usage ranging from 20.331\% to 22.190\%. + +CPU temperature values were unavailable during the experiment and therefore recorded as NaN. + +\subsubsection{Execution Time and Failure} + +The complete training run required: + +\begin{itemize} + \item Real time: 32 minutes and 37.872 seconds + \item User CPU time: 36 minutes and 52.313 seconds + \item System CPU time: 1 minute and 41.258 seconds +\end{itemize} + +\subsection{PyTorch Training Pipeline} +This section details the Python implementation of the text classification training pipeline. The code mimics the architecture and logic of the Rust version to ensuring comparable performance and behavior. +\subsubsection{Code Highlights} +\begin{itemize} + \item \textbf{Custom Transformer Model:} + The \texttt{TextClassificationModel} is a custom \texttt{nn.Module} containing: + \begin{itemize} + \item Dual embedding layers (\texttt{embedding\_token} and \texttt{embedding\_pos}). + \item A unique fusion strategy averaging the two embeddings: $E = (E_{pos} + E_{tok}) / 2$. + \item A standard \texttt{TransformerEncoder} stack. + \item A classification head that projects the encoded features to the 4 output classes of the AG News dataset. + \end{itemize} + \item \textbf{Noam Learning Rate Scheduler:} + A custom \texttt{NoamLR} scheduler is implemented to replicate the specific warmup and decay behavior used in the Rust implementation (and the original "Attention Is All You Need" paper). + \[ + lr = \text{factor} \cdot (d_{model}^{-0.5}) \cdot \min(step^{-0.5}, step \cdot warmup^{-1.5}) + \] + This ensures stable training dynamics for the Transformer architecture. + \item \textbf{Dataset Handling:} + The code utilizes the Hugging Face \texttt{datasets} library to load the "ag\_news" dataset. It explicitly shuffles and subsets the data (50,000 train, 5,000 test) to match the constraints applied in the Rust implementation, ensuring a fair apples-to-apples comparison between the two languages. + \item \textbf{Collate Function with Padding Masks:} + A custom \texttt{collate\_fn} handles dynamic batching. It tokenizes text using the \texttt{bert-base-cased} tokenizer and generates a boolean padding mask. Note the inversion logic: PyTorch's \texttt{TransformerEncoder} expects \texttt{True} for padded positions (unlike some other implementations where 1 implies validity), requiring careful mask generation: + \begin{lstlisting} + mask_pad = (encoding['attention_mask'] == 0) + \end{lstlisting} + \item \textbf{Training Loop:} + The training loop is a standard PyTorch implementation using \texttt{tqdm} for progress tracking. It uses \texttt{CrossEntropyLoss} as the criterion and the \texttt{Adam} optimizer. Crucially, the scheduler step is called after every batch (not every epoch), consistent with the Noam schedule requirements. +\end{itemize} + + +\subsection{Python: Text Classification (News) Transformer Model} + +The model is a Transformer-based architecture designed for multi-class news classification. It consists of a multi-layer encoder with multi-head self-attention and feedforward networks. + +\textbf{Model Architecture:} + +\begin{lstlisting} +Model { + transformer_encoder: { + d_model: 256, + nhead: 8, + num_layers: 4, + dim_feedforward: 1024 + } + max_seq_len: 256 + num_classes: 4 + total params: 10649092 +} +\end{lstlisting} + +The model was trained for 5 epochs and showed steady convergence across all evaluation metrics. + +Training accuracy improved from 56.19\% to 79.70\%, while validation accuracy increased from 68.14\% to 79.00\%. +Training loss decreased from 1.0145 to 0.5483, and validation loss reduced from 0.8137 to 0.5628. + +The model achieved a macro F1-score of 0.7903 on the validation/test set, indicating reasonably strong classification performance for a Transformer trained over a small number of epochs. + + + +\textbf{Training Efficiency and Stability:} +\begin{itemize} + \item Total Training Time: 706.99 seconds + \item Average Epoch Time: 139.83 seconds + \item Iteration Speed (Mean): 44.70 it/s + \item Gradient Norm (Mean): 15.75 + \item GPU Memory Usage: 178.79 MB + \item NaN Events: 0 + \item Convergence: Monotonic loss decrease + \item Overfitting Detected: No +\end{itemize} + +The results indicate stable training and consistent improvement across epochs. While performance is lower than simpler CNN-based tasks, this is expected due to the increased complexity of natural language understanding tasks. + + +\section{Task: LSTM implementation} + +\subsection{Introduction} +This document outlines the detailed architectural, mathematical, and translational specifics of implementing a Long Short-Term Memory (LSTM) model across two prominent machine learning environments: Rust (using the Burn framework) and Python (using PyTorch). It covers the model architecture, training pipelines, specialized deployment techniques using network filesystems (NFS) with Docker, and language-specific design implications. + +\subsection{Model Architecture and Mathematical Formulation} + +\subsubsection{Mathematical Foundation of the LSTM Cell} +The core of the model revolves around a custom, manually-implemented LSTM cell. Instead of relying on the standard un-inspectable black-box LSTM implementations provided by typical ML libraries, both codebases explicitly define the cell-level math. + +For a given timestep $t$, the input tensor $x_t$ and the previous hidden state $h_{t-1}$ are used to compute the various gates. The mathematical formulation utilized is: +\begin{align} + f_t &= \sigma(W_f \cdot [h_{t-1}, x_t] + b_f) \quad &\text{(Forget Gate)} \\ + i_t &= \sigma(W_i \cdot [h_{t-1}, x_t] + b_i) \quad &\text{(Input Gate)} \\ + g_t &= \tanh(W_g \cdot [h_{t-1}, x_t] + b_g) \quad &\text{(Candidate State)} \\ + o_t &= \sigma(W_o \cdot [h_{t-1}, x_t] + b_o) \quad &\text{(Output Gate)} +\end{align} +\begin{align} + c_t &= f_t \odot c_{t-1} + i_t \odot g_t \quad &\text{(New Cell State)} \\ + h_t &= o_t \odot \tanh(c_t) \quad &\text{(New Hidden State)} +\end{align} + +Where: +\begin{itemize} + \item $\sigma$ represents the Sigmoid activation function. + \item $\tanh$ represents the Hyperbolic Tangent activation function. + \item $\odot$ denotes element-wise multiplication (Hadamard product). + \item $[h_{t-1}, x_t]$ symbolizes the concatenation of the previous hidden state and the current input. +\end{itemize} + +\subsubsection{Architectural Details} +Both implementations adhere strictly to the following architectural design: +\begin{enumerate} + \item \textbf{Layer Normalization:} Pre-activation gates, the cell state ($c_t$), and the hidden state ($h_t$) pass through separate \texttt{LayerNorm} layers. This design choice stabilizes training dynamics since the feature distributions inside the LSTM evolve at every sequence step (making standard Batch Normalization ineffective). + \item \textbf{Optimized Gate Compute:} Instead of computing 4 separate linear transformations per timestep for the features, the model employs a single combined projection that outputs a $4 \times \text{hidden\_size}$ tensor. This tensor is subsequently split into four chunks corresponding to the $i, f, g$, and $o$ gates. + \item \textbf{Bidirectional Support:} An encapsulated \texttt{StackedLstm} module stacks multiple manual LSTM layers (applying dropout between layers except on the final one). The main \texttt{LstmNetwork} integrates a forward processing stack and an optional backward processing stack (which flips the temporal dimension of the input sequence). Their respective output hidden states are concatenated along the feature dimension before passing through a fully-connected projection head. + \item \textbf{Initialization bias:} The forget-gate bias parameters are explicitly initialized to $1.0$ (via Xavier Normal parameter slicing) to prevent fatal early-training gradient decay. +\end{enumerate} + + +\subsection{Rust Backend: Load Testing with Locust} + +The performance of the Rust-based backend was evaluated using the Locust load testing framework. The objective was to analyze system behavior under concurrent user load and measure key performance characteristics such as throughput and latency. + +\textbf{Testing Setup:} +\begin{itemize} + \item Tool: Locust + \item Backend: Rust (HTTP service) + \item Test Type: Concurrent user load simulation + \item Environment: Linux system +\end{itemize} + +\textbf{Dashboard Visualization:} +\textbf{Full Report:} + +The complete load testing dashboard has been exported as a PDF and is included below for detailed inspection. + +\begin{figure}[H] +\centering +\includegraphics[width=\linewidth]{RustLocust/LSTM.pdf} +\caption{Rust Backend Load Testing Dashboard for LSTM} +\end{figure} + +\subsection{Python Backend: Load Testing with Locust} + +The performance of the Python-based backend was evaluated using the Locust load testing framework. The goal was to assess system behavior under concurrent user load and analyze key performance characteristics such as throughput and response latency. + +\textbf{Testing Setup:} +\begin{itemize} + \item Tool: Locust + \item Backend: Python (HTTP service) + \item Test Type: Concurrent user load simulation + \item Environment: Linux system +\end{itemize} + +\textbf{Dashboard Visualization:} + +\textbf{Full Report:} + +The complete load testing dashboard has been exported as a PDF and is included below for detailed inspection. + +\begin{figure}[H] +\centering +\includegraphics[width=\linewidth]{PythonLocust/LSTM.pdf} +\caption{Python Backend Load Testing Dashboard for LSTM} +\end{figure} + +\subsection{Training Pipeline} +The training behavior is intentionally synchronized to ensure parity between the languages: +\begin{itemize} + \item \textbf{Data Loading:} Operates synchronously on synthetically generated noisy sequential datasets. The validation set is scaled symmetrically relative to the training set ($20\%$ of training size). + \item \textbf{Optimization Algorithm:} Utilizes the Adam Optimizer. + \item \textbf{Loss Function:} Mean Squared Error (MSE), with reduction set to \textit{mean}. Both explicitly weigh loss accumulation during epoch passes by scaling local batch losses by the discrete batch size, averaging properly at the conclusion of the epoch. + \item \textbf{Gradient Clipping:} Ensures numerical stability on longer sequence inputs. The gradient norm is strictly clipped to $\max = 1.0$ right before the optimizer steps. + \item \textbf{Artifacts Output:} Training scripts generate an \texttt{artifact\_dir} where they store a \texttt{config.json} representation of hyperparameters, and the full state dictionary (\texttt{model.pt} in PyTorch; CompactRecorder files in Rust Burn). +\end{itemize} + +\subsection{Inference Pipeline and Docker NFS Integration} +\subsubsection{PyTorch Inference Architecture} +A critical requirement for modern PyTorch inference deployments is resolving the massive disk footprint of CUDA-enabled PyTorch backend libraries. The PyTorch pipeline employs a sophisticated Network File System (NFS) logic to achieve a highly optimized, lightweight Dockerized inference deployment: +\begin{enumerate} + \item \textbf{External Library Mounting:} A host-level script (\texttt{mount\_libs.sh}) maps an external NAS/NFS storage partition (from \texttt{172.16.203.14}) loaded with Python environments targeting \texttt{/mnt/LSTM-libs}. + \item \textbf{Optimized Dockerfile:} The image leverages the \texttt{nvidia/cuda:12.1.1-cudnn8-runtime-ubuntu22.04} base image and installs basic \texttt{python3.11} runtime headers without calling \texttt{pip install torch}. Thus, the final image size is structurally negligible compared to standard ml-images. + \item \textbf{Runtime Binding:} The inference container bootloader scripts (\texttt{run\_container.sh}) bind these volume mounts (\texttt{-v \$NFS\_MOUNT\_POINT:/external-libs}) and crucially overrides the \texttt{PYTHONPATH} env-variable: + \begin{lstlisting} + -e PYTHONPATH="$CONTAINER_LIB_MOUNT/LSTM_env/lib/.../site-packages" + \end{lstlisting} + \item \textbf{Inference Execution:} \texttt{app.py} loads the model weights off an abstracted configuration path, builds a zero-gradient loader, runs inference iteratively over a single collapsed batch, and yields predictions natively. +\end{enumerate} + +\subsubsection{Rust Inference Architecture} +Rust's inference pipeline diverges significantly regarding deployment complexity due to compilation structures: +\begin{itemize} + \item \textbf{Stateless Binaries:} No containerized runtime libraries are mandated because Burn compiles statically down to heavily optimized binaries, pulling model states directly via the \texttt{CompactRecorder}. + \item \textbf{Visualization:} Results are mapped into native Polars \texttt{DataFrame} objects (\texttt{df![]}) rendering lightweight native tables detailing \textit{expected targets} versus \textit{computed predictions}. +\end{itemize} + +\subsection{Implementation Specifics} +\subsubsection{PyTorch Specific Constraints} +\begin{itemize} + \item \textbf{Dynamic computation graphing:} The \texttt{model.py} cleanly slices and chunks gates natively on tensors (e.g., \texttt{gates.chunk(4, dim=1)}). + \item \textbf{Sequence Reversals:} Done programmatically via continuous \texttt{Tensor.flip(dims=[1])} which mandates that tensors must remain contiguously stored within PyTorch internals to avoid memory reallocation overhead. + \item \textbf{Seed Setting API:} Requires deterministic locking across four sub-systems (\texttt{random, numpy, torch, torch.cuda}) to match Rust's reproducibility parameters. +\end{itemize} + +\subsubsection{Rust (Burn) Specific Constraints} +\begin{itemize} + \item \textbf{Compile-Time Dimension Types:} Rust explicitly binds Tensor dimensionality at compile time (\texttt{Tensor} vs \texttt{Tensor}). This offers un-matched safety by forbidding invalid dimension injections that PyTorch would crash on dynamically. + \item \textbf{Trait Encapsulation:} Leverages explicit trait architectures (\texttt{\#[derive(Module, Config)]}) that automate saving hyperparameters and generating gradient backends. Burn models must be mapped cleanly from standard states to \texttt{autodiff} states. + \item \textbf{No-Mutation Logic:} State mutations generated sequentially in LSTMs are represented safely utilizing explicit tuple destructuring via \texttt{LstmState\{hidden, cell\}}, bypassing complex internal pointer tracking. + \item \textbf{Explicit Initialization Handling:} Since Burn limits orthogonal initializes out-of-the-box, Xavier Normalization was invoked explicitly, paired with \texttt{slice\_assign} tensor mappings to safely load the 1.0 uniform fill into the forget-gate components. +\end{itemize} + +\subsection{Rust: Training Loss Progression and Model Convergence} + +The model was trained for a total of 30 epochs. During training, both the training loss and validation loss decreased substantially, indicating that the model was able to learn meaningful patterns from the data. + +\subsubsection{Training Progress} + +The training process began with relatively high loss values. However, as training progressed, both the average training loss and average validation loss consistently decreased. + +The recorded loss values at different stages of training are shown below: + + + +\subsubsection{Loss Trend Analysis} + +The training loss decreased from 4456.9658 at epoch 5 to 52.1122 at epoch 30. Similarly, the validation loss decreased from 4473.4448 to 17.5850 over the same period. + +This large reduction in both training and validation loss suggests that the model successfully converged during training. + +Although the training loss slightly increased between epoch 25 and epoch 30, the validation loss continued to decrease. This indicates that the model continued to improve its ability to generalize to unseen data. + +The lowest validation loss achieved during the experiment was: + +\[ +17.5850 +\] + +at epoch 30. + +\subsubsection{Generalization Performance} + +The close alignment between the training loss and validation loss throughout training suggests that the model did not suffer from severe overfitting. + +In the earlier epochs, both losses were very high, which is expected because the model parameters were still being optimized. As training continued, the losses dropped rapidly, especially between epochs 10 and 25. + +This behavior indicates that the model learned most of its predictive capability during the middle phase of training. + +\subsubsection{Execution Time} + +The complete training process required: + +\begin{itemize} + \item Real time: 6 minutes and 42.185 seconds + \item User CPU time: 6 minutes and 42.414 seconds + \item System CPU time: 3.49 seconds +\end{itemize} + +The relatively low system CPU time compared to user CPU time suggests that most of the runtime was spent performing model computation rather than operating system overhead. +\subsection{Python: LSTM Model Architecture and Training Performance} + +The Long Short-Term Memory (LSTM) model consists of a 2-layer bidirectional LSTM followed by a fully connected output layer. Dropout is applied between LSTM layers to improve generalization. + +\textbf{Model Architecture:} + + +The model was trained for 30 epochs. Training and validation performance improved significantly and consistently. + +Training loss decreased from 5543.18 to 56.11 (98.99\% reduction), while validation loss decreased from 5699.26 to 48.92. +The model achieved strong regression performance, with validation RMSE reaching 6.99 and MAE reaching 4.33. + +The $R^2$ score improved from negative values to 0.9517, indicating strong predictive capability. + + + +\textbf{Training Efficiency and Stability:} +\begin{itemize} + \item Total Training Time: 158.63 seconds + \item Average Epoch Time: 5.18 seconds + \item Iteration Speed (Mean): 6.22 it/s + \item Gradient Norm (Mean): 1394.59 + \item NaN Events: 0 + \item Convergence: Monotonic loss decrease + \item Overfitting Detected: No +\end{itemize} + + + + +\section{Overall Evaluation Summary} + +\subsection{Container Comparison: Python vs Rust} + +\begin{table*}[t!] +\centering +\begin{tabular}{|l|c|c|} +\hline +\textbf{Feature} & \textbf{Python (GPU)} & \textbf{Rust (WGPU)} \\ +\hline +Image Name & text\_classification\_image & text\_class\_rs \\ +\hline +Size & 3.98GB & 974MB \\ +\hline +Backend & PyTorch + CUDA & Native Rust (WGPU) \\ +\hline +Startup Time & Slower & Faster \\ +\hline +Dependencies & Heavy (Torch, CUDA, Python) & Minimal (compiled binary) \\ +\hline +Deployment Complexity & Higher & Lower \\ +\hline +Flexibility & High (research-friendly) & Moderate \\ +\hline +Runtime Stability & Medium & High \\ +\hline +\end{tabular} +\caption{Comparison of Python GPU-based and Rust-based inference containers} +\end{table*} + +\noindent +The Python-based container provides flexibility and rapid experimentation using the PyTorch ecosystem, but at the cost of larger image size and dependency complexity. In contrast, the Rust-based container offers a lightweight, production-ready solution with faster startup time and minimal runtime dependencies, making it more suitable for deployment scenarios. + + +\subsection{Model Size Comparison} + +\begin{table*}[t!] +\centering +\begin{tabular}{|l|c|c|} +\hline +\textbf{Model} & \textbf{Rust (.mpk/.bin)} & \textbf{Python (.pt/.pth)} \\ +\hline +MNIST & 1.1 MB & 2 MB \\ +\hline +Text Classification (AG News) & 21 MB & 40.6 MB \\ +\hline +Regression & 4 KB & 6 MB \\ +\hline +LSTM & 60 KB & 120 KB \\ +\hline +\end{tabular} +\caption{Comparison of model sizes between Rust and Python implementations} +\end{table*} + +\noindent +Rust-based serialized models are consistently smaller than their Python counterparts. This reduction is most significant in simpler models such as regression, and remains substantial for larger models like text classification. The smaller footprint of Rust models makes them more suitable for lightweight deployment and resource-constrained environments. + +\EOD +\end{document} \ No newline at end of file From 2e2a11a2fb9faf78c3413bdc35e85a1a6571c8c9 Mon Sep 17 00:00:00 2001 From: valmikGit Date: Fri, 24 Apr 2026 17:18:41 +0530 Subject: [PATCH 20/21] added screenshot white bg --- reports/rust/Screenshots/LSTM_TRPS.png | Bin 0 -> 28025 bytes .../rust/Screenshots/LSTM_response_times.png | Bin 0 -> 36678 bytes reports/rust/Screenshots/MNIST_TRPS.png | Bin 0 -> 28804 bytes reports/rust/Screenshots/MNIST_request_time.png | Bin 0 -> 36353 bytes reports/rust/Screenshots/regression_TRPS.png | Bin 0 -> 28092 bytes .../Screenshots/regression_respose_time.png | Bin 0 -> 41591 bytes .../Screenshots/text_class_respose_time.png | Bin 0 -> 33102 bytes reports/rust/Screenshots/text_class_trps.png | Bin 0 -> 28380 bytes 8 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 reports/rust/Screenshots/LSTM_TRPS.png create mode 100644 reports/rust/Screenshots/LSTM_response_times.png create mode 100644 reports/rust/Screenshots/MNIST_TRPS.png create mode 100644 reports/rust/Screenshots/MNIST_request_time.png create mode 100644 reports/rust/Screenshots/regression_TRPS.png create mode 100644 reports/rust/Screenshots/regression_respose_time.png create mode 100644 reports/rust/Screenshots/text_class_respose_time.png create mode 100644 reports/rust/Screenshots/text_class_trps.png diff --git a/reports/rust/Screenshots/LSTM_TRPS.png b/reports/rust/Screenshots/LSTM_TRPS.png new file mode 100644 index 0000000000000000000000000000000000000000..01c054e509245a6ce07b60a756cd344f9e4956f1 GIT binary patch literal 28025 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 AM*si- literal 0 HcmV?d00001 diff --git a/reports/rust/Screenshots/MNIST_TRPS.png b/reports/rust/Screenshots/MNIST_TRPS.png new file mode 100644 index 0000000000000000000000000000000000000000..1fdbc62c270c0d792c717bfd7fb9f664d60286ef GIT binary patch literal 28804 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�>71HW%>B=Q8E+0AeE1gn{hc+d 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�BM1m`Eqs7ii=ByVif7QZBNhIL+BCk7==fX zjGScaic3bw;lxDT*8`@{DDjT zNf{#9gd0EWBPa>#yy94j-n^c0O=-6F|`rgbczpGc&&irl9ryF!~rse)bjt z#b7~PM{XMBo~MhQ4yv$wxZk`NVVCl-P(6yzzR)86O0!?NOk>bZe@1kZL;q+P+pW%X z>8F}vY;D(Fz)fD6Z=O$Z&%u@89J*ST(yk2`f|iA&E%GdTmWtoW$KemUVHDR)3N`P>J%YVNc+DXZB`B{j2N^dc>jtd2Wk9kPCSjwROdpEy00INq(!_1| zBF?AnztnZR5r#KlW5Up6#YLTP#5`pDftmby@dDd($c=Rro9-d4^z*}c1GOT?_2vYb z_!7i&2HrZmR1L?F$#1h@OFIG;wwB_QBT%}1lc~rARS&*uj?126D~~{bUlY4UmpFXE z+o*emrk##A+dFT}f!1+#xWvfI#^jUhH_LgZ$94CmEaqK`fj%-ouU1g^@V25rbPJD; zwQ<49Y+vYQ5JnDxZkxCStLGh0GOBL35wb;h7mXx6M&_VpDk!CxBd9nvZbmnbkRW4k zPY(;6^e|5ythF>``dUqO3i$b!Caf>A+~FgQ<)MmWtrZ1WlzN0$ zQ>~02q@{j5Y|;MBpj+b zQ?Q!1)qDCA#>H@ zB}TY6h32xwKc_s3XQ&{s8$sWdxG%?C0 zi?wPCpl}U`?<302Lh?bOr@Wjz#=auW%0qdIrqi=b2TIeDy^3ZQZZ)+onPj z7QYso^YN8pgGUyyUa}-36pp$t`D-E)Gc(*_696@_Xs)^?S7*4nV)Lkeu0`3cny zxO>3+#yx+s@M@!%*Ogp2e*rmz0jT8~amvbIB#eb+xt=6LK*GcT+_9LiLek$H`%DV6 znmQ}VJogz?0F7d*SWwfXq0?mTq(VolnIwkm#7tSu@i`VDXi${-#P{&|`H}h2a1&;I zbLJ~Oxtcdjt0Fi9Vy;Q4J0^35Je#%C5h2x8R?oJkxy}e2trZD`R7}47*6xtaUBXw= z;fqDa1eh*!TZ`8rg+H6~5!e(5N926{h9minXA+IyuFMbE7}q__V2y=+yc#)xt|#9v wBI7SW3Y?MUGXejPL+1W_;N1UjzpqR3H6H0&5@GR%lLJw5(n?aPSM(nLA5Ag$mH+?% literal 0 HcmV?d00001 diff --git a/reports/rust/Screenshots/MNIST_request_time.png b/reports/rust/Screenshots/MNIST_request_time.png new file mode 100644 index 0000000000000000000000000000000000000000..6465c819ce61b3246c98e55bd57909ab9870297d GIT binary patch literal 36353 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 M#W132*EHS!4@rYoP5=M^ literal 0 HcmV?d00001 diff --git a/reports/rust/Screenshots/regression_respose_time.png b/reports/rust/Screenshots/regression_respose_time.png new file mode 100644 index 0000000000000000000000000000000000000000..1bcd9b610d466d2c29d49c1176a0fca93eb7489d GIT binary patch literal 41591 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 z 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 Tqhj^|1TOPkuE_>x`*yY80i?gh8l(( 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 literal 0 HcmV?d00001 diff --git a/reports/rust/Screenshots/text_class_trps.png b/reports/rust/Screenshots/text_class_trps.png new file mode 100644 index 0000000000000000000000000000000000000000..aae066951c833347baf8c9d4899ab1396f6c8b0e GIT binary patch literal 28380 zcmd42Wl$Vj*e;3_f?I&#PH=Y#!Gnbm+}&+(ClD-naJK*(Tcvz%c#N6<;{!2y5lHxFEcX*ZVG@OWMllyRa1 zS+4DgJUuYK)NvRq{K7)GXvBZVK#&zT!~eeM{QfrffBl0h|9_n0a%yi6yeVVaX{*Sz zwzZ|95e*K#U}vCVUDj6GELXFuGTR70QP&M;LT({d%Zr z(oFkwCMx1<1h?fdRipb|zy0I2)Ytc4U79oDUtnUHEdQuk_L_0G`jl6g_anYP$0zH2 z=;b>pDV^V&SjFE}`CsAVJ|77@5l+?`kTZnXO^Tzd9qjwio)EdQN5%>>9ui1->oD4&}8OIVms;JbHR z>8(1&aQM_+UG1WF5y_pMoed|G6~OK2I-V}f>{lMSEC{#2hl%W5;t7*IatTR-y|Cu} zLl4OODgRb7A$Z*?3zfn*?aB8_!Q*g2A++L8n!l{q(NSO>kC}jD9I1Lxbd z-&E0y-Nb)hrxzBzvi)#4WOyU0?SUm6Cr^)eY7DW8PInY5phgTAA%n3@A1rK$uSDEq z2h??ROUnd{7ty$^M(xv|fa{s`?N7%MCBti3X6@rbCWl@WI#(i&Pgk&}yQ`x6JS^BpwOHIiLS$`}xYK&0qoapQsglyt->WYAODE>%jg|Hjuoy*-2bW2OZiLFc zH~0WU_Drr)QBei`h>*8b5=kYk`kfQMY7BYWMVxJaK)J-c6wFqE3ucu>%0NsPcX{av zxs@7X9`e3eaW}WL`e>jol~@-^rHJ!J;mxx1R=rX>C+;n$+*qv~1zjT*QPA)!{h`sh z>eFCxHp>Oqt+fhTxE3Vlh)*=q>m%TAp;x_aqD~`*?HPG2d>7>d}(D zx@v4W-Wng=y|GESjc<;%o>67c*Jsog@qk*VeY4KIPr4r&6=|^tJErp<_y9+7J4HDl zmb0VPJ|mI|vFJf1EiJEw$H6D;k*Vt#(nvL2Ycx9=nIG+ddPi-@^xJ^=O=`4(fLPtO zbEIjcio>LAms3~A2bK&83CXxd`vnHZ<{$5;K6j(1u#-B!c3;N&E8SDC5YI%g@@7fn z%HExbY}Mlq)c*QZ46ax?>)pro50EEo9(Q*S)>k}&+E zN5p`s++C%|-d_b$ZlBiB!bOt{WkBx_wO05$bEZR6A{&g1o?f-^z&_el^=+Kt;E{EI zKYN5uZae7BpLWTA6oyU}bLEWB1A5Q&q0?veDiHO$Syb*aR;=;qgD6!iVX3)>9W7)zn%fWsg z7RTT%&`pDbhrkA?Sw33Tcs3e+{`Z5~Y+FZ;urK*2o0wG!f%0gv~~MuI7+Qm(FwVi}pw`8TSqMS&ti8nD{3qd=hHCBc%1eMwCzE;fZe zJ5e*T_otap-XRRfmJG4kooNY$Z`j$^3dbe7)|_$7(M*By`d>m$yY7;-QtpyHTUPGf zFI~%*e!=_ti&4a(#`T93c6EL32_Ik?+3CI*Lp27Hdfon3hq_7GslO+X^%zPLft&=S zd)k8!{1oGVf;F$HGa-K<`Mi%0gSY<%X*_YE@}!--crRD3F-u(^NW%>ZJj zra|A8fk*^K92MW2KJ8yx?t_ElW;auTrqC`{sHe1IW6SKxz>&wr<^_xQU7P2R%F?q- z3{cC;^z>}471Yqy)-Fxlh8Yeuj?7eR+c6^}!`j;3xF3=|Jv*~@=4I{C@`jD0sJvhV zrc-8DcGF??_|Ec5+(^$B#GjxSzf-0zcl7C>%xPZz=FOYp*4F5sKbe32v==190`8@2 zsD(>GA!%q>c~XDI$jqD(6a=@iz9vm@aF@dEW! z+-9(}{fIGE(Q0iOIZNP@3=Tn=;bT~_^Fa)Bdk`uyme>)PwU=0ei-YqvyDe6=<9ejg z!D#E%=Gmb{j@1G!_IXzWThoeTFzYM!$9=zbvkm8#mDb0x%un;oTsqE_PLk0iBNqr?U+CO2qgd|6) z(Aj#UjtsRP*>{Q`YO0&!8FK^ z9oQXQHF9xuauRSYE^>9;d*orppP4Rp@h;%}fs)I2J2Y+75(q%O#=sFq^21>OPRL@C zc4b4`vIp@QZm>|~KyfZ~J4|p@4?FI$+vFiB8grG+_sovXCR_kCdZ-rw zlnMjEumeZ5#&Y{+qAFLjh8e*=%BMEF*(qOi0St!`nY;3k#ZvX|*wID_lhW@(xdgHu&WO6ILkxF_nRmuQk! z5|C!-#Q9@Mt6F1SOO$r92B(^~!ur01dn01O!^10)rH-))ux_&r_1yudD@~DAqJkq~K1X3_QWIRLSjf5P)o(NM0ADwwIv2kqO1h#kLm*retChgf6EFKfhPJBr5 zyAWY*zhW3m7mcA8Sn1|CX(bIZNcVMa)(0x@>CA=IS)pD)=g1z<^(31FmH&vBHA!qE zi& zsDM&l+G%HO{{lCZ3Tmk^Zog~!Fl*yt_j+grzY zdHK&9`8YuR{t{~<5|83AF+T~X_PYkB7m*dWy@*1RDOAD1!7pCC=sa@SaO20Nc(j-+ zTCe?>k);erkV4>k#}PQ<0D}xx1w1|ZmFe>F@_*FVRnkzz8Mf!=vD~zV`qK6G;p365 z`>qhxUkO9~*p4j)svfP_8drQ7pI-<34yTy#wHn?BoP7Q?42fuQ{Zg`5;sKAV{>x9{ z(Av6?AW6Am)r@`gPctH9_YR{`_p1<*o23jQ8*6*sS0_m>62#v)dttsB${JM?S}cIC zP5)I`8FQavre@D+aWWbTfw68ZznMa-^@7O&a6VQ{Ltm!u~S!jCQCS zmywapJr!sCm+3Sp%FCPIU%PR0d#JBqbG;~BUM7pBOb-xleavs`qTXz=plc?CMv?QA0o8jfZ6z!bUo8jYYhk5!g0~WpApPZilQeqL1 z5|)x)QT4DW(6s-j>dwakNOz#~3op|d^>W`psZ7sf?RWa3o+~~gX0owY$cp>qSdgXP z>Ch^#r2R#F=;x4-VBd%4Mea+Jg;QJ`+JCV7En^3r_$=uU5MRrHIH+37-KNFJt+=6b zjOb8^WS{(!@$%t6x2>(NR-ZyI=1apTytH5pef$<5|5i=SBrfG62QxFBh;N6{4t^n! z*TU%hgi2MF4JwQby)A?QAZ62CZqh65>r^0;2%zr!cPA6cT(q)h*CBz%3#tKKn`iiU z7VCKpKSf7q4wFOU1-$}LQa#V5Vj(hl< zutM^|3{P*R4xH%#jxt~vSlKR^;rq*A!f7Nz4MUM++t(eAEZ#^rIIDkf=+)ECG$pn)V^YR+*gaG$!(uWw! z0<=ljmu;qg=5W4l%BiUA@mogH$TE7zdS?I56o>dmB8RwHciNeBgLKRJ%X6=Tl}7(1 zz~>;)-|5liN`S%MNVN?IH?KH~-yWJ_?T{7a*Cf;*K=R7FMcz2-y{bcY%@dISWS|ak z?zXOkMyPK|Prg-Qc$_sFnD-1ai|yAIX03DnFp6n3do+sal$e;ikvX?r8^u8_I3U6> zK|F1(%)-o84BT~p(U!ZG+dJ^!xl*6ixGaMnMg#CsJm*M@W+q$B?3ZiNf*K+InNo6X zB9`%e?{4Q^o)H95Rn-$V?EZMJwf&Lp<=2;`l@*pNcL#&wew|tjZze@H%SzlyL_iq0 zY?DSnEq?ObAbc5x4ln5Tm-8g>FFYI&N8RS!Hw=7^xo6*zt5++eMCY= z=5knUo}Qa?JvDCN$_zoREB#r^<$v33a0Sli7>;AaRw($Rsh|?TUb$_FMJCh>5XQt9 zJ^mX829=1Y9g#VxlAB`c_l>?#GEpC5Ai)B)8%Xl}eHQd1!YA(lV1X|ug$N{&)0Ep3FA1u5}#aAR+Hf}*5~KXGB2kChFOQj z&VvJ}ElIE?j1B#wEHyR{)Lb9N9xRwME?dn(8JL89+I)TU)2w zElwL}L1oS9f%m?c&(nA6=*STexDWsK=cA}sL`>}3{&3~tUy-qa#r`!jIluy?Vs}@y z9xJ4WBJo?9Z@otbxcu&ZI{`R*0-!9nfpb^Bdt;17_B+mFxL1@TSM9Jn(t!a;cpW_z zF#slGvA#CRz#}INZEMew>W^LhhjW;1q$rmgz)&ayY6;cf!qr*ocMh2J42rwq z=+$G&g@mZK&yOv-JDv7H40weyf!b@l*!#xs;^zU{ix;cTV`|*khy6z$cUMlrHl9-W z;j3=KgThJ5bxX=Xq>_ry4}@X!e>But%cLEgSNSg&i?p=GhM}OIok0@#R)hjtg8)cz zEvxxgT0wzpa&q#^ujuwY6+=U;o-oXlDW(E|%Yk_XLA?{{66efw2O60Wx5Q$jCZae~qsiKO)aAnvjd ztcYg2C-Z01o<@)&Q5+h6Z5*7CIP`?eDuLDN=tJloJ$r*n!o-R2w zLC{hA%a2jf@s|CKR3ayGnzf_j)hKj5TWM~I_65~|DLABGU_5GSkB*5c;>IYwzY&1T zTF2*6L|orP$f2e+#@y+C7YOw#&RN{NkthBKnjIZ8&-Di=(i^@G0GknXL{RJqL@iEf z_Vl@EzNMjNhcr^wTX3fnC-6s8`olaJVlUWBA(_9Q*_`ub0n1_qmc>g>TrvU-2hEX; zZ{iCA(p4IOR*K&4gDcT?#18eG)I;Af7y(fF9fMPGQTX^F2@wwY!qbMpzvlb?G(A0i z9h0G;n)_wJzPO+u`2JoWP&ySsqcRCzlK74-Nos_no2S zy28->(}zzH6+gcL=tdTkQbxWSCfoG8AtU&mQ(Q{JUP;K((e^}54{!a*-8Wvk$ZITc zaXs6dt|h=6zv;zCE!b~oY8i|J)To6Onhi*-fCSswXgZnZ(G`@FWCO&(OoP6vMAf9C z-Q4LI!^xMS9hLMkRi~D+{@f}7_v4CL|LBw;9nU*;r~N;-50ibc>52IPV2FTV<9Qd* zHggN1h$~rYVE8{Z9g}WpGtF@EyF>M2rS$jK!)}~nHCE&700x$H%N#>9SonR3*nXbe z-1d9?z&`Wv@Bwfd9^0fRrlkFS=2&u(p}%`Ow?&P@Yj0!yO3U7krGtqFgc2gZ{=1^m z(PMw#s#yI--~4-M@DIs`;DTi*xH4X5fZ(y&2~YqDHmpSwrLgHj$I+%|a$vjo1deS{@&4t*NY+*SH!Qz3GmN&fY*Bq~j^zD_7HG#a(qA3>I zvvyiUA`vqA7H!kq+~WK%`Z%bM-pFk7o`*{N8GOIirKh0*6}PxO^%$VIz%l>iX?BB3 zeQG`gms0?GLs5BvDPbI|?~92fD)0r zKZucCfQ1>OdbtU3`>d{jGRL|BDa3OnqKcjkc^dvsA-jZ_qXOXYnLH9{z33haNf@p4eLOXyr0$}am zY%I@X4F7M8F&e&IE9Oeavp!2IW*-|N3%@=CPc!NJS)%=WGgifSh8R)o8O6f^#IwVx zNy8}(o1B-yec105u1O!f$=E17J)Ytrw29Sd^Go$+V3Gb| z&DM|f&t4B?Vn^0L9MO{B8xq+h{Gf=>?k)~(>*07YJ4x4{olFSii7uJXWY2A~=^ydb z;O`V9GsE-)OS-&))O%*&x`2z8l`&uS51$4&9`7FUUov^&-D+y7Dn)aZ&N18b12SZ_ z_2WBee9bWs-T0Q6R!?2_);!bYJQZR#iztmbevdY@P7ry(EyvsL5lR&XmtFhEBVwZq zx-}Qp{`Uyv{u@E}ZmIE0ZU#>~wUK7&ykZ{1%`ZEKcqvjY}&o!wx_>^n3FI=L&>?Tk*?|APmU^SJx5X<`YYBG|BtyJh+UeH*nfie z`IIK)%l31s|K}i~`zP)He|U~iTZio(9a+*1)P@0Jzn~(;V`IQtMz^=4q&GXHRjZ)p z7nqor!zawDz+|6~O(oAE-_6!v9+*)ly6;?U-bh+T85siZR#oAf9qP(qd5K>pPsx+B zWB4e7EE8(5{jc^4EHkExH`NZ%NHYgM0W_Mu!K_!7N3=lD14#@{8}K}plEjfi|Nq-J zN>~8h3`0?YmhTNoP}(lQanK1HhS)Fu9SvY^6h!8Db(1=hM8Drd|2Ck3LP?78-78UJ))eguZr#9 z#}J+DZMKY#iIcV%EDBLmCA|M`B1F9bj^Ss>b_1SjhB5XF^Uy8%Up)$NIey{~Qe{Ar zumoC9&q?$-)dPiO8Hk6K|G5r&aX|e4d+cSy&pdnIzlU(i|9~X_b4S4cf6g&{DCfvl z1-$L9o3?}g3AoK@y!dSY0W&26Ahi`$4}#(QU1UQsEWDgsxmx4`5*x$JFzJ&-?9$DZ zHv;7X`n9FML)pY`MNbwzOu2NLgpUy(edlAqG%LS_58}*Ioi5sPa3nJ4rUKWrWTjQI zm%*S|egYnG-q6iFVzUOok*JVC($Xl+(9MdqI4W7_ zca%Pm&4`<0bgly{17lv-(Pn&M{2P;=7@d;kNJu#eA}3sGNkB+TVpU$(ihN?fjK3?b ziYRYWrfxtMueW|Xv~kwFrqCRpth%SmhuA9g%;etZe*9WxU9#;UrOi5B zeFXOlG9W_}zyBqa#Z;FZ(TVeVS*7^oH|hSx!~XUBcj)Qm{KfsB--QrK@;hb7Yr{b! z^kdm-tc|WU=%ISv$abyk_xvvro$=z7Jib14ntMMB0rQ@LU!HU*6B6)hempFvX<6qv zZCb4%b6WG2B^EeoZX8-2{n*5fTPbg>yDmB#{ghslG!S%gVa8EiC7$c0h(s$D<~ART z5*3)_-+NL^84_kW;1GnHk7K`1xx>1%e5wXsH~1}J#TQN%s*Qk)mIvy}H>|g)E^gu6zaXp`*pT zYqR(rS4U<$>Qvz5LFP9PF|=}Q&blF-in;C^0o6~m&QgL+Lf2F9-(L; znWOUOg~@=E1(v|E&^CAHCnjQwmtH||W~H&nVQ*a#r}=s=kU6_wYSH1m=8CQA&k3ie ziU({|fKVZwUeTM@qlX|(EuGn;P%ZsV&f#}lTgVvic(VQaWC%;%5ZX*-$`j@F_(*az z8i^?@q?B)K$&2bB&WPdfTH3;~Wd(hZAV4)#RC#2S`E4_p^YW*gZ2A{bKnMt3WN4pX zaq3WYzWebeVnQWYIeEEh?NM5>el{iOV`!`|EVa3kDEP=Alze7WU?+jScQkYGYL*cVoE&@Hnz79*+WKQ~6;eVf=Iq2l_h1m*pW9Q4 zr^@SSqoXHEGw#N?_Lz%q*^evwCl4+&>Ql>a9qK~fTe0yuqRv$M{M7dl_4-jRf-xEs z26+L`)|F^@TmHIX-q;Z%i&Eypwe_Eh(l2SONXThue8~9rwQh?Srr8dRR~d*9Dy10o zZy{ZHeV2o&8&_UGKr?Dnhj~L5C9^AUlZRD8hOyXDn-1D?3pFYcDTH6D_pJ4|wMKC3 zv_e0(lp37mJqfJsC}ERM9xyrp$uA6<5yzt8b)8BnMARK>lsldfhF41#O6Eb}GNC5# zas=;`cStEy-Hb;;w($`7lJj#v=I1Ws+F+q199QDWZB81cXBUjXz@VSSll<}~O#}G} z#i}04#lvpPo!Xxjva%u#xPOM_Nr3Lr&ywE@pNB2Nw|$&vueE*^X7a_EJYiyd&LdtA zUYEy;r60tT0)4MD{$AHqf1u~2EZWx#utnsKkZ!q))6-01lB@v$Eo&o&#AG%knv7e>t*Cv? z)`-SbUjN;M{*T2Q3b$JM`OTT`4laZiKMZw+(Yz#m->J5%RY~3>R0`7_3mvC~wYTyi z2d_7Y@8Kbw;*ji$Rmx;m^k(#+@gU#%M960LRA&SWi}U}bc3zo;{YlOH<8(fI`hIN* zvhjod+d*Q->;Y_7)o!RUK__?odcwR|C;W0>1paz&az`id9`otodV=nl5~z6nRW0r> z>=UbCRWMH{>@|kCsBgu#u}dBec8Fh!IqCffW;>085k}bNxL>_feif`NiG+%TXZn(+ zmq5CDa0E~h$SST!s@SP<%@)KS)XeH-#)w~TV$c9jK6^VBwaT=AftI(DI4_TW!tIeqXi`*+!pzy)u7Wk&&g&yN@%`BNDQ$4XD$*D>quF+idF4&ysTGwBHgcy<&fm+4P)Tp z=Z&nPmLeg|P-?DAk)RA1>d4}YIhuNWtL!R9KlXYkn~#8RfyqTveeWj#(_~BQ!a!Dh zm&*K3dCkp}q~9NBs84+F1MKJA_v8;;m4jyjNUZI! zs-&6SCmNh%4UQo!ONSR<`N;E=XD4F7R|bP$zRYBP&xv7_*_w|9oxxv!JvT zAw#!4?y)KMq!ROQQa-yn-gY(9HW}dLmbx4rvO-BG#$~T0boF81tbTTP3mh7lkWHq{mEUMh1)cpc=m0z zk*+8AVRJc?Px!+nax@zG&=%Lu6*Jt_PXN|hOK|}e2&|_b!52;%LByp}EClH#>kj2<3`={LTZqW+wcp&Zn=Zu8aRD^!CEg)IkRakjPdx{0H5 zd<$fygSODZ(Tr_nSIs-*4m46p0O)-M|2al%f!pSEqPnlMJ-M7{C4PZLCu~dOJ{r@c zUk~Y+S~gy;jY*-3JPTVSkhbsDHmbJNx8??7E!|rA;8)26kZ#NCjy9_c(;{a-r7#%G z9(cGxj*U#K#NY_o<_cV1;T4pCyhobJuRs+xmHi9Ls}B^>J13sQYsq*`0Wb9b+!;2B zHy&L)-i^?isbK%|>i`=@SL~$_SAW4bi~K7#5Xl#g>lA5wBk;}36-kw-Ls51?v%?cB zhPYpEufx6>)%tta(cddSmlkKwi5}0!2jX!ugP8z9TX`B@k*)>2yuCL)J)tgVbwDx# z{AP((u-BZrAd#Y`eiUn#sP))?vKZ5i(B3Y4=VaJWT!q`VuFaunB_x&~K}sQ-c(^>m z#g7k!P)7sCcZxjaKy1}LdlOD3L#Itc@`?aj7H(ScOwp7zNInUkH*;S31?1dc8aBcw z95co9$cq|Y^RvW>PTip8H7<(n9k2Tip zSX;=)dp^cK?vhK(sS@h`-p)i+vab@8mRVVgDPHJqqixALFKm_g8LWuabo1lRDCkh7wEIVSB9qf+rCE6COK_D376iu&T<1G z<4MnEtCf089OCddA4bRq-y5jmH&px+qn*N+qT~HTSj|AtrOpwxA@kBuSZ#%oxW;jw zoPoKyaIW%Qn^DG#>|eCO_)T;H{JFCfe-Gfd@%e+JXc%j$e7G-Y7`f?~GBAAEq2hm$ zFzX>XjO57Dar{Z3^87EfnDk_JtZRTD1FX~b_1BK&K%2c4@6O|+6(RBR9{<;_g4#4M zo~v@?>E(TNqx5N20VV`y=PK34x%K{0)Hd?THwyuPk?pR#3S?|;RwB2Czc1_u??gRL z$0z17gfKa2kb)7LxsQa|qlu%2r=o`eEd;e>DRgQ|sjej5QnXz(wIscRuyqa7@z5F# zXEXvqkuH^Ptbfa8?Yzn6s>x+Cdg`bflXnBW=TU#Zq@-e23X|MN!xpleq?D?~#P7YU zk$(FDLrO2KyBN7Ykzn&djGieLp#7T3j*fFEx93mh_KFW@6mTwTi2z$bF`AG2rsi30 zFm7=w&Pzfn9=4FMkxtGP+m8-)^i-W(x-gS-@5%s=?Zvsjz9-m22Pi|Nr+v${&UVO= z3S9PR$QrrG;%hWmEHx&Vj#sHYOLMZweXd(LhQ-l$i!W)xXfI#GFRQ$QC$WH$LyT-}#%{G0|5%n~TPTTdMzWsAcA7+{YQDp`3cgi0=RSW9W< zdz4=pA`+JK-fsNPwQ6T0KKB3AK01O7>dSJ--Q}*k1EN>lRS@ z+g>maaS-50aWIkFLxrpwEs@eG5-bost~5oyn4{EIk=;x2n8S1km5$yj7>FEkLR*&h ztl{3F@Wrx`bm{e&kjfK*k@<9 z@Nc6TW>Fd2;+8wjvKDCqtd-d59k$^NPT7#UG6wy~Dh2 z4aK5i$hlLtC;XRLJ7P1ce!RVGMz&5226XCTs`6s05jNLBq{d}zq|A|deRs0b>vDs* zEYh&RC_Q`*Q*(hi35N#;C(MP|4WO^3^iuiAHE!Sn7s=m zurdiapVKDb$&So_1CSX0M~wtSQs>WEK=WrUTbUFN*cbe~|3C+53cvx~j?G-B*5KhS z8eZO{e-v7u@$gR=mK;d|15adL%V~bP+g;W(uSFHGmDf^wy-O~op+pWLA_rfgh*}OU zIq#tqXAUVv26oGzdw6{}t@W~Xvx+4L46IU-@pHbB84U`wQ zMpIY#`|nn_3QEIR$~&Vp5dB=QL1#jkX)Iro%lG`WEHL*^vc8E=Yg)5$Yr*B&tJ-0@ z;at>o>1eYA3}MCJ9F@DNI z^3GpS38oz2bL`K=oW4CXxfD07xK4_ZA#D|y4m$`-mtx=E{vtFIlMyjPV7`3+eRp@U zvZ_Dry%ss@ejL6<12?nzF3BXzjkeSqp_roR7b=Gu@dXuuT7KZkFg!RwhFl|c*#Ur2 z*-?C5J64FH&tGqd*khQHu2GnIW6P`kp3CXM? zv(+9BkL{wS4tMK^ke9pNB{+3|z01r`5jfRRf(J)2vSxM-G&Fp~%w<;-7o`pxC^w#B zMR)u^uECIi=B6L7dQTYFjBpmoTA`qTy?F~`qC+@{H6o`Q(}n12uVCZYEO6tk;#+G! zYrJ8(fH`fTPXeU+XRNsvhG)vQru0I2?}}y@;3q(&J!b)VDcpl0@pG&>ZvFVt*T3VN z6Yv2AmwqMVy>y-l_qIybfV!tp^Ww_dE((_*uG<>Z%8pQZwAUhhb9C~GGACCOq5p@N0; zDdyl+Q{=DWD{uKL049Qd4VrqvibMh4*gr@(iKHwG~%z6g@v<+m6r}tQz2n z1{VHKhO<6A#>p$cxBMTC0V}6j@#n4`QPbeS(B^+lf$f7DSB7W33U&;%wkcyC;T01( z1YRWsOiWIdPr48}e?R_lQozOvLUV$KtcRTj-XXzaTa#@}AQS>Lh?8wXW!@}L8fM(N zV?L$BCV1R}54!_A_O(Yy#y-7*Sb|^uT-~B$I^wP5o+bpwGyf_91gax7U;rBXFe>75 zWG3Wnh@>x&6jY5XDtL%e>{$)hwb#&}(<3n~u?;QK%g?u9Vuv(KZG!`oFS_N zDoNcE>A9QgNoD8ybKd`huNI|N%e(BAw*+kFZpHjK>}wMMo5hKR`^O@1AD~w+x#llS zek`E>U@vGH8`Bcs$N<^#r4)JqK#`exy{AhIvYPts^k6|1$|ovRV_=sHH}GQwqbkxM zV6*T(Oyv1@8P7WxcuK~B-&u`~ByP7fh_S9b;WTBxUUz+Y?ZeC`z*%7NQ6PvhYW8C1 zK2Xw({+CkDl|+w|so^IhO*TPyVV~d2OHm`S2>McmB`yc?H-|Ml)l)fAE=MNLGdB)9 z_!+&{2b{6J7qXS{S=3Zr84uTVY!!e1j+-NPeAEWA`}!1COjSS&206~ZDE@+Ju-)EearKcMs7tLX3ftmAMK9f(k)pe zUbm;|g|ku>L^UgIOTW#y{D z;CcAuWbL@QXae0_TZ8hxN>D#k4RyM3gLL=}>oW9b_0aZo^xcr_Tjw~oJ060!V%gMs zb=70~L^Srw&sDZF)lf-?*8X^<{W<&4S{>n{WY-KQ!C_AaRE|5#o$>0O^R^yUb+hqs zIDGs9MwrcEd&IzAb%%Ja6S@9c=7zQ3z5S!`Evl9BNmg0iqrAt8iSsND3I88~r02Ft zoL%h#K=WoZ9+GA$4xOn#$DeZX@}>mU&2sqL-?K7j*MFLi3oe6jj5FE`0*ec%o(cda z&!*3OsPz>gQnI?R!X-dghM@AFZoT|82hNsQrCg2}NZ)B)>F9*~q5Vak7)pZX+QhS0 zi-iaQ*(%MS(K-|OghN9F=3iwfT}5{gN97OzUA+rnqlMq;;43H{*kNa6A#kE4dSd}+ z;N}NI)QOe&i&4z|dd|*i0fE|rUGhiG(~Of~H|ayAN6z(a!>aNr==+SH+Ez<_f%Zn# z;|)toir}VY7o#>ktg58WOR3dwUe(1e8q3Hd7?eG9cCYs|dlEH(od6Rj=SEwU6>YAp zK%8#J^0a%-uJk{Ge+SP!;IpaW5Fm!#khXd!L`M*hxBgHVi6uTj4>q57_5NYZ=F8kc z#cq!Tq$1eeF^F_{!EW3{ZUxt4ntV&%za56qOdFP5Ktsc5jCN|o^^?ba6q9#`+~z3{ zk%}%s#-*-_I^b0uEhDi2A+C@Gpfa!u-SgJF%XN{BeHl2rciyGHZ{8)3{bc`Co5vSJ z$-uVW5=mq;0DHr~wn5sO2zeU1iw}p!A*?&~OAqW{<{(kd@CBNHpW9_b0 z5q4=An-zi@N1TXKZZJkhQT;$D8l!~O7XK943elEEfomyA=nxV&4S7Ix_RXa!4pZ95 zIUV+@2_QA%J?yrn=nj=Eys??52an##(6Wm=iVpNEhuLarsb;I{WPkCx7w_v!0RLQPV{K#=;hexD*{Zf(({x^v*dl#BhY5-_HCl9Z`C z=NtP|#=92#pBI(CltZWPoU%gWBWezF9)B1uBwD;h2L0YoNaW@nK(jQ5Y`*}pR^~VYU4YbdBu{SGB zln>^;6bYVquH^LbJwMmX=UKQe0E~w1G5P6rQ}&Uj&G=dIPawEmIV%3Wypi!&x#B>)yD0A@RC*;Y(G}BO+ z^ygJ|S3_oS@q2JM!$k-+5zOb@@)yDHIF}#wlOcN@f)--#sLw?L*j95mGLISb?_S6g zfan8J*;+S9dh<0dMx-!>0%|tC25y6el*KBb8Ew_0!HA;<;53iRw^D`5ZkoKLT1d9h zFsPY$ldbELpH;TEED4|U0aY4M`tkp1h?diHL>pWcin@3eJyCM%wc*RWPGO0T${3k5 zvvT$x5mGwX%v+`$Ca1JxqA#gQ;-Xggj@(aDCnG)f-uK9R+3i%7ca9v{_pp5@KT^i+iCWn15FRc@6N0IyInCcfg(Uxh@4jCZ|`u zMkG-KRPj*lvF`D9Xh7Ia`~DA7UuT3mv$3^A7>ROoz>W^@qIbw3 zl{&=Oca@75EY6yVz<*9u9|OTO+!1cPJ@@7L=*h*bg0V6mnEI&b7?HaLgV4}$a~_t93^iVfZ5rI{29aaU_6V{>V{goD z1cE*Bc1Q$BaGsND{=L>iPJ^tR@+Q$6t!y=^J|KqpjTU@61gh*Ov$=UOY>ZJ;_?q@RGr)4{PvKt5wZPUTkn8{e-GGJ- zpv@8k-KU$iLVtWjN@s#|+Z)DK-5T<66Y=`pACdZmzT#SaIsMXhi>Y&%j*O|jS;NoW z++=~D9y6}3rNITo%&`I*wfVN#MoIR;(Zcf|uu{Q2NzT>MXB%tK^)YyEun{mjKPQdj zRfs<}N#aA6h`G3g%I_?l5@RnXfFp@rdXqU5aEtS%FIu8LrQUJ#F2FT8&c}%I-ZKC> z^((OSK~5undY|+C1ULS#?#?@$%Kv}-2O*V8ifm0OA=$eqNmOKnW0pm|&^E6JU5DUIe{Nc_gqp*wjWC91sgp~E4r!~Wc6pDkmJ?`bF8{wh!aPg;jXO|>t)&g$GKJi+>$!itu= z`G~xUB?o)?^l6QF*?s?<;S&r~vniN1FQ(>aUSr@5yQ*`%HASm^T$U4JcNIcCt8T6< z7JKAgoAhiF`9{GCK>-r7rC5f3Lz_qSCI^M~W$oF+Qyz&QeM|sr3NZx0>)IFM@@nt0ts?92(Z%wmgl%53l^&!i&+mVnjHC^n zh{sK%ArV*2J*WX^Spcf1f7 zDD~>E=0#)9b(fK!iHd9QlXqS(Sm@DEQuk&eUo*;}dcq0iU|OYpoFUTe4?6?u5S?mjp;N^s@Fa!K6;l(n zxRP~P=j0hj;ShCCerhSDSI5Ju4$HlqzPM@zAuvjF;xy9o^e;w-XSGFKgvTLAmr+T6a{NOFWvthJAI`x?`jfmgVgs-IDH`2iZHcj$*{{&+p(}74 z^p?#dk=!+OnR1fLie@s1FCdo9txN6KyEtYcHrb@?uy&CNlq%C9`Qo7Qeoo$>(+h-D;$~C z9oFH&HvR9qrqe}-57)fCWWQ4o>1AmTY)EgI)NRk|I^{P#h{(RA@u!f|iMNl|j2cGv zs%`yWfriw2`7V@jNQetH)i5%t%STEFGyl&q`-)g-ZKSl*%{}`%HRIOLHL|ZI6(ri> z-Ez}p@|N3uz!m`4O7C7(n+#Vu%-lxi!A0S~XcKopqZwg+-!Qvjt39u7v5t zI4wxB0V0quXr9jJ*Gux$a> z%(?V)4}Yx6sjXF>=hU!dXL0`E1!({fQma4Rl$`hR%5Gb;3&7qG#V)0!hFG;B73T!R z{&v4}sr+0CTW|gaW+x&n-|6eOhmeS#o%gVPc8~nEn3QYU=EmJ2@WX1?SQMA|uJX~5 zL3mq?Yv28hfLvfGB6(Yj;VW~x-5QZ)}oIGmiIJ+R;Z^3G_^E-SD~2#{s#a^E+2 zTs7GdGBU&$AMBS4?ftxo9%@)#Xg8AgkeRW* zRCNKEP5|oYDIRmw4vN|~+yhukWJoSZLiK~M%%KeT-aU5p4vIDdTSK*mov)Wzh@=iQ zw_?@YaBzISH96$bQ9Ie_R!%vc9dK=Hno29YB@$?#mi%qeqQzXZd`iO)r=(oM(y4#~ zPis~Db|0|8P(=$4p8LK2;x2j6ki&*~6e#eUhxI=8=S^ObKOHBzu2EW+O|0dc7w!}y zQBr}n%KPkNw^y_z3;t8<9sJuVS%XIvSyT46en!`TL_5s5cc6k*@HxeF_D9Z%tNM4h zVwQtL6w*{M38}SJtAu>d+J8lc#cFGwOY3i)rV3WNDrQ}nRUNZmQf<*_GfxMZ$b&iX zY=)v=)u;#qCWCJ~5!FdB*{dv^4x&KkKc2lc<)szaVON|5rrq7xcPS^9P$M&Nl zs4i9QNI~nH$O+I49Xb#598F`d`5$LMuR!@zw!ViXt<6Jd4PN=LMbAIV5D>iGm?>Cj z%r^7yq&i^9(+}HetQ2UhFJpnk++w%HAnthc7@SpNO`#?Y2P5+DJfs#`c9ht^2o4?1mv?KwKa6%<)uc?@>YYD#_wBu&V!i zHT_!@AGy1i4K0#J`>ngx;On|>n%JJQS?-Hs+oPAH+I>HgdiH@ZbGu-w_?7GEe@Ozk z`N^7X3^a{`s4~X-sa^*pHhlA_F}@6v29gg%7k>A=60@NhjvZjBdGo$~RV$XGh#-Wj zG*JuPy7mA!V)Dxr&_45xz8(^qjJ}=ZJX-}h;J=LTXPFeLyxVDrv#cU3&#NOGgpqOq z&;h7K`ZN_vKGd|-QeJgc6$@g$7dCcN<-DroR~bbeDkGZTuqc0$yi&nz5m@ZOAjQSK zBTi7<7M(`=jj{qd#6s7R;?6e@e8Y-A0r{VW?lk}#h^t5RI*)EOQ|~#wIiatwUJq4o z35W+MI!HArNQlWkb9H)qO~y_j)ka63y!9Tc@C$TI9e`fdwoTL5Me0N z2xE>-PTDOM%~KuQv;1*2$*3Qzh${jcD`r~|%olv4U%^OD4ySjfF(VlQggCy3u*JJsdDaYGkYu=yYERCr$r;TF4H zzIi4Ejmfi#jz+mc2P zbVSlfguwh_#6ce7HQR7>x5wSBRmiR9n}>;XvXZHeTdKGn+pghWY|9Tn+_5xpX7cV9 zT+r|_tIW*Ka@9-qC$mE|8WK3m>;1b>z*YQUTg!AyHugZ-ZjKOsXzue%wEY0sPePQ| zqx=$5b`?FcmyqFN2+p1xG4S`H++5k2z_{H&LCM>mbv0(VMeym)5dfYPI# zF?O2FjCB~%KB7Wv0jcyn^5reBIs*>ttLov*y9){(2iXJGKjbeqjm0H`+@|9mJTFyx zY|8>-!;x9=%!=6VG49CB1eAz2XhSGMg8sgtnHR4ff7YR$~$r5ImU3oE`ho9n4_{ z;*v&{f_Cy73A^nnNq@%!t27iunwdS;))T=udnATs<KST&tO{-q^foL!90qzl1&i*^a!l7yUnr?y)kTGZ z81BD>Pfj)@ll8wNQ)P^LnE52Fn zRQ6aybZWUWc`_?vzuVp+N|SkIpM0MEb%GE;O??Ts+et>)V;b z@kH}avZ)oTdZ{%j3Z~*wchC5%%uZug2+Ryo1Zq-*_5o~$rbt|XA z?9^ygJbGXIta!4&Sj5*amS;})#_$AqlhQKyVJ^(ZZm@C-_@MI1?VUyERJFVeYwqZ0 zao*>-ychqQ$ivHvO)K2fIW@C7`lWwD5GKp4>#B}1#YD}G$du^ItYiHY`xAutD60iN zJ>?@qjrF6a@&V%Zlrf@L{f@CRMvypplt=Fo( zx#kSfwq{0|5@Vi$l+k08g(GcKN$cS|*0l=2*;+tk)ZRx-qSd zJ@nM%@f*eY%%(=?0H%VHlt*9euyelo1yUlCfdib<|7fH7@z7zM*~#K|>Bv*h|1EZ@ zL@#Pr#YgY^hQB=$m8t(Wlf3Mn_U+!Rgn}0@rca+f?q`2~P~y>%oSFT^k&$~X`E}oB zoP1w!eE9KY|7#-0bh4@DzcodenD*@%J5t=oV8+idEj(yG6ZhJqtWnWY*qHe;POVVM6=PL&sRgG~ z`z_82XZC*9`9=(8Zhyqg%w8jl2^%zrlO6T%d5s9hz$UG5JM*|a8aV?p`C%;i>(^EN z(I3|J`F<8_5@Y4}@bXcqso#`z5zDX|05@1m2B(V|i8k)Dtb0>?%6ywP(rWr_37jtG zB*Of*@6ZelSwzcOobc~o51NxQ%_Xb719MH`oTLN}x+5^!AL*=-wuUS_6u}Bs1j3 zR~D*XsfAN_O{y;}I&*dymw!$7vGxN&t$AYpLAvED` zwRZ;PVBze;4KeC*>-ZJ43?b|PeWR5|dh^<{Kvg{3=eSp=$m9Msm|3$(ds{hbdxVyH zTs2u)lAN6PcI>*}>#EV&mHhnWc~ud+c#N@fd$Y8rB{$gIE~ROt$EMU0&T0erWAZcwit2Q_@_eFD@yw=ZcI%4rjXm3i=WD*L)k?!|Ag0NrZa5 zQF{fg;j$z;D}@#oK4|+AM-8o9>PpTe^rJ9`XTrY3>O+S<+yn7*8xyzQ$+;2i&F6+@ z8V+GbM+6)09_W8S$17%yz_OOT3_te2$jpL-e$cP3{Mkk5aO^AVlUR$i`MM^7uU|w} ztDMqUX6-YCkqI%6)mXi1mv3RWIb*#ZvchJ`=6WqAZ zE|hXkTCh5NC3iK>$l)D(v=OYWu1ZW#|IA_Jiat-^BiA(50mj<~X!>C->47uh^dTZG zchM-5NjKE(8_zC%RKkV2m1AQk!*LyjY9PDTh9&%Ko4YqqnJ#(0ra!eTzKz{+Yfj4M zzvDE?H7$LOAw=ZZ{I{FLHa4q`^oDMsRbM~-49A7mk2QAiH{3-#b}+A`{TcJPB&&&Q z#f*)e19r`K(Z}7Zy_L}6^?3I{$~C3%a!WI{Xz}=JLTdKBU_&5fi7^RNi&s-KG6K~M zF{9=TBdMGv=0WI|q0mNsF$=jTeQgaa$QWEV4l-=Y>KmGJXU$SHlVSE|hxO!5V zuLTfL!oa%nXc`(s0H-z2_Uhk7naNrm{Yj|!Wmd&JazX_6bd*8h2t(J*bXd&d9^$>M zae3bE%Z{DX(}G;s0GppnAo@+1MGh(%vlH2gF#CD^Onktoil*!Xt4#EMit=c|r)9sb ziO0s4wZebsbBBiMnV1m=G1%WcADH&(M^Rw}$g6uQ{Z@ig($&4#xnq%>1r2H+T409$M>14Hej)O9QZS;>hwSR^@te{?hP7ILU;N~6|bN% ze9S3LnVk1O`rKgY&Yn(Py9=U9$LI*9_NNy=bxSlz>q8@N>R4S8jCSz!v}SB4m9Ui`SQ`R=AGFGc!0g1(JQudM&V=z)3(lq#+1Hcc9w)-9Pcn z`KR=q7*nggyJ11l|8r6v?TAsJ-%|}>N03_zb23h%QAm%;5XKs-`5Rb3c3m=Z9DKI- zUDt<<>K_JFqY?Pv8eb)mBcm)Pawpj)%*^euBMY#P38?%BzDK}B~kY$8K#S>$Nyv`0E{z>f|M+9lmLvFLtZ}iTUS2U+*fKLDgaCzc5*@ctcXkQ z@NrPR0JvP6FMk*$dc)!hr|l1KqwGUPnw-FhTAQEAb4xJSAlx!_qR95nH?>J8bgO+= zK{yqS|1=)#cy&YjqV`A6cNOG?otgL;E`Rel^Ocbd${cf>sJEf0bgY%& z_~~d&2=C?d^RfYwf*%!9OUzeaD4M-AAR_9TZ_+{LzoMf`NJ!7X-&7q=3$(>3n5>CZ zIW`)I#8udxZB*?-cjy|CFy)5UPu$0kO)RIU5VDjF=a_4TPtVm*jlPD>mqpQn0-(Um z!7x*0h7eo;toAeb;h`+ps&QbQE+KKO@GCTEP0#wQ>K2cuAFiNcVWfBiEKm0L#LjerKTP7b_mdmARXn%l9V-;wVjwOM4{zrtok%csxpt&dkMP>B0 z=k5LBoQmJ(6&?hYAxAr!2709H^2WqmS1RTq?Ct<0!yx z!Hs&;xdOqMMfXQNgwYZe`_@Nd=0WMOe!O{`hjHlEMa6~yCS>$AxEvn;{K2-=(x|PI zW$XGw6pTX%_=$`AGM~#4C~dW$pZ~R`sgU%i_bWvFdnY6!Btm`q8x;Yqe;90c(2n|4iXlq)E_Z_m5A`xLPn{6&&U{zni||R24x_G?=Qdc{ z+@Qx~AkWj&JP8K7(pA7O0i^~(_<&2{yjdQ=kkA4E4u9rLFDeV9VLb8}P}7)zfv%hLT;YZe4aM{{nps5Jao|5-A`(EF8O)c^ z{^=wJI3I-+Y7OOwMe~;);TSzR$_e#UP&2TPEr{7h03--zP`ev9L!pp1CULHl2N2Wu zlsoXG;zR(JZl5TZGA+?NsgHkobhM~B@d1L-Mp_nTGeh=Tr^||B&zyRPY0<<^FFKWu z-LfA}jHo#ti47oFNG`B^GiZJv>}q_HEk32y4gpPN+J76Q2mu@sa2KCyb(&%@Yq+TS zHWS#b0F9`SL*VdtSzuYsK7r9`4F*}eMcxN+E>crL(fjODQvP+TVNLFih2@luNi9TK zfZ-0j6yJeHm!SbYJs|!w3OlAYiE4x{NE*!oSPQ;gzyo!MXA3(MM;?!T_E~Sb$hWKD zA$5gfBOuMq|Ivjhs`ma&b5Z>LQqd$$*C&A}zh)HadpQ0cREz4RGL?c5xtS-Jh>{ z!r?Sn&Y4LtQw~MPWC}Ni*u40xr>k}|vZrnoELYHC{w!iR7{;}Ycrt%=5+3so1w(xI zX2+nNco8!Vh%N*^NL`&|k1sN3jX~y_kS}6CuevyC12O!BDnYl!S``i&IUgIUwzucA zT3hKthya*P;LW_ea>!tX^WfRQC`kk&72Q1=9t;e zG>7o6Iv1wa+Dyy{o=K8ECAR7qn)jo(I`%K%_CF1DoWx43X&M<1voC_7<|HAveJ;eWqg4`BRseqJ3eZ>$tI#SqpzeGhQk+q1&8f z8Vet~xgmkL{l1GXCu-k8Oxvq70ji7eX_|Zn;THboj<>l^ld#~PIV+sk#92z!M-}}O zQ`r{sIxS=k=+dQ^;u!MHP70q-u8<6=scp!k%u$CNi-nE8d_V)uMAb|~g3NV2O+B9( zS0t|j`Jf6Shcs^uccC>pq;CT>RX&2RvsJmZBecYYwiA4t-bsnmOA}1atL#7rYz%vdna*=uS;p%FDhB#Qq%SlHk7taAEV`RW)20qauOrT;h`RZE(U zB1p(zfsXxp#bTZ?0jePnz;f|MM@jE>~SabzJFeIcTjHTH&cIMcXg zUMnqaJbwb8aJNJ2Em}-d2lZ!sQXmyL#?Rok(xfeN;AxKdni&&$S1@jZkbd4Q6!=Le zPsIz@ji;uq0?^G;kg;_CoP_8{MxTlw7Ff26Y%7Rp%;u_CsJu65`xwR;Vx5)vD1y<~5{o*tgm-so~`J+#VnNtDIw+V-|S>gy>@qXMlO zviB);ahCACka?KiFx?pslBa1qy+UAi;O-c(6clFsT?9L)BLhd4Y=;eDcs5JjBlE|( zWY5*Q7!ndN5%Gsde(OasUYbi)TkXMqA zACe+4cEmOXmhP9TDmhWA{}4GSD6+~Vvg9!3w=d@m0GQL16M5~;h)9Jf!s@cp6NOcO3tkWt3CC^9BH=J(l!RLA4)(R7J zea&Mz^GsHxlL)JCpW%|OwQ*ffIcN{`{14r+A;;8QZSH! zZv3etkKLLl7Y6Fe9Hpw3AqTD=pm_nX(!zlM)yk}~FpT4G6`r-LC(SMr)uvlm!7;}J zmTtmcp%BoFOb3^ehRAr?Bvi$uhhAQe34zfP_lN}zvk4R>p#FD1T#2h&W$nrh&8Szs zULwW@6s+ffTeSXd(;{_G;bWf7AbgmnM6BJ2`o?JOlYzVibu{`Drj@w^A2r0JlDmkH zTCJA_znDF}CjE`!)4*z4zVDU(Vhm5eJ~e3Fqx1rLa2ms4Mwwwd+x#gV33lK%6+~Jd z`GdvDSt_USi)mW!{-WvL7W%srBNRra3!3_IvV-?NC@X`4wBq<8cRfdIFT&MAICGeMf3inb9MDSPlpb`SwwVf1~3=|HxT@p%e*ioB;* Date: Mon, 25 May 2026 21:54:47 +0530 Subject: [PATCH 21/21] pushing draft_3.tex --- latex_reports/draft_3.tex | 755 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 755 insertions(+) create mode 100644 latex_reports/draft_3.tex diff --git a/latex_reports/draft_3.tex b/latex_reports/draft_3.tex new file mode 100644 index 0000000..d336998 --- /dev/null +++ b/latex_reports/draft_3.tex @@ -0,0 +1,755 @@ +\documentclass{ieeeaccess} +\usepackage{cite} +\usepackage{amsmath,amssymb,amsfonts} +\usepackage{algorithmic} +\usepackage{textcomp} + +\def\BibTeX{{\rm B\kern-.05em{\sc i\kern-.025em b}\kern-.08em + T\kern-.1667em\lower.7ex\hbox{E}\kern-.125emX}} + +\usepackage[T1]{fontenc} +\usepackage[utf8]{inputenc} +\usepackage{enumitem} +\setlist{noitemsep,topsep=0pt,parsep=0pt,partopsep=0pt} +\usepackage{graphicx} +\usepackage{booktabs} +\usepackage{array} +\usepackage{url} +\usepackage{xurl} +\usepackage[hidelinks]{hyperref} +\def\UrlBreaks{\do/\do-\do_} +\usepackage{listings} +\usepackage{placeins} +\usepackage{caption} +\usepackage{float} +\lstset{ + basicstyle=\ttfamily\footnotesize, + breaklines=true, + breakatwhitespace=false, + columns=fullflexible, + keepspaces=true, + showstringspaces=false, + frame=single, +} + +\lstdefinelanguage{Dockerfile}{ + keywords={FROM,RUN,CMD,COPY,ENV,WORKDIR,EXPOSE,ARG,ENTRYPOINT,VOLUME}, + sensitive=true, + comment=[l]{\#}, + morestring=[b]" +} + +\begin{document} + +% \history{Date of publication xxxx 00, 0000, date of current version xxxx 00, 0000.} +\doi{10.1109/ACCESS.2017.DOI} + +\title{Rust vs. Python for Production Machine Learning: A System-Level Evaluation of Training, Inference, and Deployment} + +\author{ +\uppercase{Ajitesh Kumar Singh}\authorrefmark{1}, +\uppercase{Valmik Belgaonkar}\authorrefmark{2}, +\uppercase{Abhinav Kumar}\authorrefmark{2}, +\uppercase{B. Thangaraju}\authorrefmark{2},~\IEEEmembership{Member,~IEEE} +} + +\address[1]{Department of Electronics and Communication Engineering, International Institute of Information Technology Bangalore, Bengaluru, India} +\address[2]{Department of Computer Science and Engineering, International Institute of Information Technology Bangalore, Bengaluru, India} + +\markboth +{Singh \headeretal: System-Level Evaluation of Rust and Python for Production Machine Learning} +{Singh \headeretal: System-Level Evaluation of Rust and Python for Production Machine Learning} + +\corresp{Corresponding author: B. Thangaraju (e-mail: b.thangaraju@iiitb.ac.in).} + +%----------------------------------------------------------------------- +\begin{abstract} +The dominance of Python in machine learning has been built on the strength of frameworks such as PyTorch, but this comes with well-known operational costs: large runtime footprints, heavy container images, and complex dependency chains. Rust, with its compiled, memory-safe nature, has emerged as a credible systems-level alternative through the Burn deep learning framework. This paper presents a structured, two-track empirical evaluation of Rust (Burn) and Python (PyTorch) across four machine learning tasks: image classification with a convolutional neural network on MNIST, multi-class text classification on AG News using a Transformer encoder, sequential regression using a bidirectional LSTM, and tabular regression using a feed-forward network on the California Housing dataset. Track~1 evaluates end-to-end training pipelines, measuring convergence, numerical stability, and reproducibility. Track~2 evaluates production-style HTTP inference services under concurrent load using the Locust framework with 100 simultaneous users over five-minute test windows. All experiments were repeated across eight independent runs to report mean and variance. Results show that training accuracy is functionally equivalent between the two frameworks across all tasks. In inference, Python achieves higher throughput for compute-intensive models up to $3.0\times$ more requests per second for LSTM while Rust is competitive for lightweight models such as regression. Rust's primary advantages lie in deployment: Docker container images are approximately $4\times$ smaller and serialized model artifacts are 45--50\% smaller for large models. A novel NFS-augmented deployment strategy for Python is described that reduces dependency overhead without sacrificing GPU capability. The findings provide evidence-based guidance for practitioners choosing between these two ecosystems for production machine learning systems. +\end{abstract} + +\begin{keywords} +Burn framework, container deployment, inference latency, load testing, Locust, machine learning systems, NFS, PyTorch, Rust, production ML +\end{keywords} + +\titlepgskip=-15pt +\maketitle +%======================================================================= +\section{Introduction} +\label{sec:intro} + +Production machine learning systems demand more than predictive accuracy. Engineers must contend with container image size, startup latency, memory overhead, security surface area, and sustained throughput under concurrent client load. For nearly a decade, Python and PyTorch~\cite{paszke2019pytorch} have served as the default stack for both research and production. The ecosystem is broad, the tooling is mature, and CUDA acceleration is tightly integrated. However, these advantages come with well-documented operational costs: a standard PyTorch GPU container typically exceeds 3--4~GB; Python's Global Interpreter Lock (GIL) constrains true multi-threaded execution; and deep dependency trees create complex supply-chain and security challenges. + +Rust~\cite{rust2023} has established itself as a premier systems language, offering memory safety without a garbage collector, zero-cost abstractions, and a type system that catches many classes of bugs at compile time. The Burn deep learning framework~\cite{burn2024}, built natively in Rust, has matured to the point where it can support end-to-end ML pipelines from dataset loading through training to HTTP inference serving. Burn compiles models into statically linked binaries that require no runtime interpreter, no pip-installed dependencies, and no dynamic library resolution at deployment time. + +Despite this, there is limited empirical evidence comparing these two ecosystems across the full ML lifecycle in a controlled, reproducible setting. Most comparisons either focus narrowly on raw training speed, omit deployment considerations, or evaluate only a single model architecture. This paper addresses that gap. + +The evaluation is organized into two tracks. \textbf{Track~1} examines training feasibility and convergence across four model architectures. \textbf{Track~2} examines production inference deployment, measuring throughput, latency, and deployment footprint under realistic concurrent load. The goal is not to declare a universal winner, but to characterize precisely where each ecosystem excels and where trade-offs arise. + +The key contributions of this paper are as follows: +\begin{itemize} + \item A controlled, multi-task training comparison of Burn (Rust) and PyTorch (Python), with variance reported over eight independent runs. + \item A quantified inference deployment comparison using HTTP load testing across four model types, with full latency percentile distributions. + \item An NFS-augmented Docker deployment architecture for Python that decouples ML library dependencies from container images while preserving full GPU capability. + \item A practical decision framework for practitioners choosing between Rust and Python for production ML workloads. +\end{itemize} + +The paper is organized as follows. Section~\ref{sec:related} reviews related work. Section~\ref{sec:fairness} addresses evaluation fairness. Section~\ref{sec:structure} describes the experimental structure. Section~\ref{sec:track1} presents Track~1 training results. Section~\ref{sec:track2} presents Track~2 inference and deployment results. Section~\ref{sec:discussion} discusses the broader implications. Section~\ref{sec:conclusion} concludes. + +%======================================================================= +\section{Background and Related Work} +\label{sec:related} + +\subsection{PyTorch and the Python ML Ecosystem} + +PyTorch~\cite{paszke2019pytorch} provides dynamic computation graphs, native CUDA acceleration, and a broad ecosystem of Hugging Face Transformers, torchvision, torchaudio that has made it the dominant framework for deep learning in both research and production. Its typical inference serving stack, FastAPI combined with Uvicorn, is mature and widely deployed. The primary operational cost is image weight: a standard CUDA-enabled container regularly exceeds 3--4~GB, driven by \texttt{libtorch}, the full CUDA toolkit, and Python's runtime alongside its dependency graph. A comparative survey of PyTorch and TensorFlow~\cite{pytorchvstf2025} confirms that both frameworks achieve similar training throughput by leveraging shared low-level libraries (cuDNN, MKL-DNN), meaning that performance differences at the application level are largely attributable to Python overhead and deployment tooling rather than kernel computation. This observation motivates examining whether replacing the Python host layer with a compiled language is feasible and beneficial. + +\subsection{Rust and the Burn Framework} + +Rust~\cite{rust2023} offers memory safety without a garbage collector, zero-cost abstractions, and a type system that eliminates entire classes of runtime errors at compile time. Interest in Rust for ML has grown substantially: Vella~\cite{vella2023rust} demonstrated that replacing Python with Rust bindings over LibTorch (\texttt{tch-rs}) could yield measurable training speedups on MNIST, though the comparison was limited to a single architecture and did not evaluate deployment characteristics. Crespo~\cite{crespo2023rust} reported up to $4\times$ training speed improvement using Rust over PyTorch for a five-layer MLP, attributing the gain to reduced Python interpreter overhead. Both studies are limited to \texttt{tch-rs} which wraps PyTorch's own C++ backend and thus conflate language-level effects with backend effects. Neither evaluates Transformer or LSTM architectures, nor deployment footprint. + +The Burn framework~\cite{burn2024} takes a different approach: it is a \emph{native} Rust deep learning library with no dependency on LibTorch. It supports multiple backends (NdArray, GPU, LibTorch), compiles models to statically linked binaries, and serializes weights in a compact binary format. A comparative survey of Rust ML frameworks~\cite{athanx2024rust} identifies Burn as the most comprehensive for end-to-end training while noting that its kernel optimization lags behind PyTorch's mature CUDA implementations. \textbf{No prior work evaluates Burn across multiple architectures in a controlled deployment setting with production-style load testing.} + +\subsection{ML Inference Serving and Deployment} + +Containerized ML inference is standard practice~\cite{merkel2014docker}, and the image-size problem for PyTorch containers has been documented extensively~\cite{gujarati2020clockwork}. Common mitigation strategies include ONNX export~\cite{onnxruntime}, TorchScript, and model quantization. Li~\textit{et al.}~\cite{li2024mobileinference} benchmark TFLite and PyTorch Mobile for on-device inference, finding that performance depends heavily on runtime backend rather than host language, a finding consistent with our results. De~Rosa \textit{et~al.}~\cite{derosa2024modelserving} evaluate the overhead of different model-serving frameworks at the HTTP layer and find that framework selection can contribute 5--15\% of end-to-end latency for lightweight models a relevant context for interpreting the regression results in Section~\ref{sec:track2}, where Python and Rust achieve nearly identical throughput. + +The NFS-based library sharing strategy introduced in this paper has precedent in HPC cluster environments~\cite{buyya2009cloud} but has not been characterized in the context of ML microservice containers. Our work documents both its benefits and operational trade-offs. + +\subsection{Cloud, DevOps, and MLOps Context} + +Several recent studies have examined ML workflows from an infrastructure and DevOps perspective. Vasiliev \textit{et~al.}~\cite{vasiliev2024scaling} survey the use of cloud technologies for scaling ML workflows, identifying container image size and cold-start latency as primary bottlenecks for autoscaling deployments. The ML-DaaS framework~\cite{mldaas2024} proposes an integrated training and deployment pipeline for hybrid cloud, noting that framework-level artifact size directly constrains scheduling flexibility in resource-limited clusters. Mohanty \textit{et~al.}~\cite{mohanty2024cicd} study machine learning integration in CI/CD pipelines, highlighting that large container images increase pipeline execution time by 20--40\% in representative cloud-native environments. Kubernetes-based ML serving analysis~\cite{k8sml2024} similarly identifies container footprint as a first-class operational metric, finding that pod startup time scales linearly with image size in typical cluster configurations. + +\subsection{The Gap This Paper Addresses} + +Existing work on Rust-for-ML either (a) uses \texttt{tch-rs}, which wraps PyTorch's own backend and thus does not isolate language effects from kernel effects; (b) evaluates only a single architecture; (c) focuses exclusively on training speed without examining deployment characteristics; or (d) evaluates inference serving without evaluating training feasibility. No prior work presents a controlled, multi-architecture comparison of a \emph{native} Rust ML framework (Burn) against PyTorch that spans training convergence, serialized artifact size, container footprint, and HTTP inference throughput under concurrent load all within a single reproducible experimental campaign. This paper fills that gap. + +Critically, this work also explicitly controls for the backend confound that weakens prior comparisons. The Burn GPU backend and PyTorch's CUDA/cuDNN backend are both treated as properties of their respective deployable stacks not as extraneous variables to be equalized because a practitioner choosing between these ecosystems inherits both the language and its available backends. This framing is made explicit in Section~\ref{sec:fairness}. + +%======================================================================= +\section{A Note on Evaluation Fairness} +\label{sec:fairness} + +% A reviewer of this type of comparison will correctly note that Burn uses a GPU backend while PyTorch uses CUDA/cuDNN, and may ask: \emph{is this a fair comparison?} We address this directly. + +The comparison in this paper is between \textbf{deployable stacks}, not between abstract languages. A practitioner adopting Rust/Burn inherits the GPU backend as the currently available GPU path; a practitioner using Python/PyTorch inherits CUDA/cuDNN. Equalizing backends---for example, by using Burn with its LibTorch backend---would measure a different question (``what is the overhead of Rust as a wrapper over LibTorch?'') and has been examined elsewhere~\cite{vella2023rust,crespo2023rust}. + +The relevant question for this paper is: \emph{given the production stack a developer would actually deploy today, what are the system-level trade-offs?} From this perspective, GPU runtime versus CUDA is a factual property of the two ecosystems, not a methodological flaw. Where the backend difference is the most likely explanation for an observed result (e.g., inference throughput gaps for compute-intensive models), we state this explicitly rather than attributing the gap to the language itself. + + + +%======================================================================= +\section{Experimental Structure} +\label{sec:structure} + +\subsection{Two-Track Design} + +The evaluation is divided into two tracks, each designed to answer a distinct research question. + +\textbf{Track~1 (Training):} \textit{Can Rust realistically support end-to-end ML training, and what system-level trade-offs does this introduce?} This track compares PyTorch and Burn on four tasks: MNIST image classification, California Housing regression, AG News text classification, and synthetic sequential LSTM regression. Metrics include loss convergence, accuracy, F1 score, training time, and resource utilization. + +\textbf{Track~2 (Inference):} \textit{How do Python-based and Rust-based inference services compare under concurrent production load?} This track deploys trained models behind HTTP endpoints and measures throughput (RPS), latency percentiles, container image size, and model artifact size under 100-user concurrent Locust load. + +\subsection{Experimental Controls} + +The following parameters are held fixed across both language implementations for each task: +\begin{itemize} + \item Dataset and train/validation splits + \item Number of training epochs + \item Batch size and optimizer type (Adam~\cite{kingma2015adam}) + \item Learning rate and scheduling strategy + \item Model architecture (layer counts, dimensions, activation functions) + \item Hardware platform +\end{itemize} + +Inherent differences that cannot be equalized such as internal kernel implementations, graph execution models, and memory management are noted explicitly where they affect results. All experiments were repeated across \textbf{eight independent runs} with different random seeds. Reported values are means with standard deviation ($\pm$). + +\subsection{Hardware and Software Environment} + +All training and inference experiments were conducted on an identical hardware platform to ensure fair comparison. The host machine is an Ubuntu 24.04.4 LTS server equipped with an Intel Xeon Silver 4114T CPU (8 vCPUs at 2.20\,GHz), 16\,GB of system RAM, and a single NVIDIA GeForce RTX 2080 Ti GPU with 11\,GB of VRAM. + +The host system runs Linux kernel 6.14.0 and uses Docker 29.1.3 for containerized deployments. Hardware acceleration is supported by NVIDIA driver 535.288.01 and CUDA 12.2. The Python environments were managed via Conda using Python 3.10. The Rust pipelines were compiled using the stable Rust toolchain (rustc and cargo version 1.95.0). + +\subsection{Inference Service Design} + +\textbf{Python stack:} FastAPI + Uvicorn, serving on port 8000. Model weights loaded via PyTorch's \texttt{torch.load}. Deployed inside an NVIDIA CUDA base container. + +\textbf{Rust stack:} Axum HTTP framework, serving on port 9050. Model weights loaded via Burn's \texttt{CompactRecorder}. Deployed as a statically compiled binary in a minimal container. + +Both services expose a \texttt{POST /predict} endpoint accepting JSON payloads and returning structured JSON responses. + +%======================================================================= +\section{Track 1: Training Results} +\label{sec:track1} + +\subsection{Task 1: MNIST Image Classification} + +\subsubsection{Model Architecture} + +Both implementations use an identical CNN: two convolutional layers (\texttt{Conv2d}: $1{\to}8$ and $8{\to}16$ channels, $3{\times}3$ kernels, valid padding), adaptive average pooling to $8{\times}8$, dropout ($p=0.5$), and two fully connected layers ($1024{\to}512{\to}10$) with ReLU activations. Total trainable parameters: 531,178. The architecture is summarized in Listing~\ref{lst:cnn}. + +\vspace{4pt} +\begin{lstlisting}[caption={CNN architecture (Rust/Burn notation)}, label={lst:cnn}] +Model { + conv1: Conv2d {ch_in:1, ch_out:8, kernel:[3,3]} + conv2: Conv2d {ch_in:8, ch_out:16, kernel:[3,3]} + pool: AdaptiveAvgPool2d {output:[8,8]} + dropout: Dropout {prob: 0.5} + linear1: Linear {1024 -> 512, params: 524800} + linear2: Linear {512 -> 10, params: 5130} + activation: ReLU + total params: 531178 +} +\end{lstlisting} +\vspace{0pt} + +\subsubsection{Training Results} + +Both models were trained for 10 epochs on the standard MNIST split (60,000 train / 10,000 test). Results are averaged over eight independent runs. + +\textit{Rust (Burn):} Training accuracy improved from $81.6\%{\pm}0.4\%$ (epoch~1) to $97.3\%{\pm}0.2\%$ (epoch~10). Validation accuracy reached $98.5\%{\pm}0.1\%$. Training loss decreased from $0.656{\pm}0.012$ to $0.087{\pm}0.005$. Validation loss: $0.054{\pm}0.003$. Macro F1: $98.32\%{\pm}0.15\%$. Total training time: $229.9{\pm}3.2$\,s. + +\textit{Python (PyTorch):} Training accuracy improved from $82.0\%{\pm}0.5\%$ to $97.3\%{\pm}0.2\%$. Validation accuracy reached $98.2\%{\pm}0.1\%$. Training loss decreased from $0.595{\pm}0.011$ to $0.087{\pm}0.004$. Validation loss: $0.058{\pm}0.003$. Macro F1: $98.14\%{\pm}0.18\%$. Top-5 accuracy: $99.98\%$. Total training time: $182.2{\pm}2.8$\,s at $50.6$\,it/s. + +Both implementations converge to statistically equivalent final accuracy and loss. The Python implementation is approximately $1.26{\times}$ faster in wall-clock time, attributable to PyTorch's more optimized CPU/GPU kernel backends. Zero NaN events were observed in either implementation. + +\begin{figure}[htbp] +\centering +\includegraphics[width=\linewidth]{figures/fig2_mnist_curves.png} +\caption{MNIST CNN training curves (Rust/Burn vs.\ PyTorch). Both accuracy and loss curves are shown over 10 epochs. Convergence is closely matched across both frameworks.} +\label{fig:mnist_curves} +\end{figure} + +Table~\ref{tab:mnist} summarizes the final metrics. + +\begin{table}[htbp] +\centering +\caption{MNIST CNN Final Training Metrics (10 Epochs)} +\label{tab:mnist} +\begin{tabular}{|l|c|c|} +\hline +\textbf{Metric} & \textbf{Rust (Burn)} & \textbf{Python (PyTorch)} \\ +\hline +Train Accuracy & $97.30\%{\pm}0.2\%$ & $97.29\%{\pm}0.2\%$ \\ +Val Accuracy & $98.52\%{\pm}0.1\%$ & $98.20\%{\pm}0.1\%$ \\ +Train Loss & $0.087{\pm}0.005$ & $0.087{\pm}0.004$ \\ +Val Loss & $0.054{\pm}0.003$ & $0.058{\pm}0.003$ \\ +Val Macro F1 & $98.32\%{\pm}0.15\%$& $98.14\%{\pm}0.18\%$\\ +Training Time (s) & $229.9{\pm}3.2$ & $182.2{\pm}2.8$ \\ +\hline +\end{tabular} +\end{table} + +\FloatBarrier + +\subsection{Task 2: Tabular Regression (California Housing)} + +\subsubsection{Model Architecture} + +Both implementations use a lightweight feed-forward network: Linear($8{\to}64$) ${\to}$ ReLU ${\to}$ Linear($64{\to}1$), trained with MSE loss and the Adam optimizer. The forward pass is: +\begin{align} + Z_1 &= XW_1^T + b_1, \quad A_1 = \max(0, Z_1), \quad \hat{Y} = A_1 W_2^T + b_2 +\end{align} +where $W_1 \in \mathbb{R}^{64 \times 8}$, $b_1 \in \mathbb{R}^{64}$, $W_2 \in \mathbb{R}^{1 \times 64}$, $b_2 \in \mathbb{R}$. Total parameters: 641. + +\subsubsection{Training Results} + +Both models were trained for 100 epochs with a constant learning rate of $10^{-3}$. Results are averaged over eight independent runs. + +\textit{Rust (Burn):} Training loss decreased from $3.944{\pm}0.08$ (epoch~1) to $0.449{\pm}0.012$ (epoch~100). Validation loss decreased from $5.295{\pm}0.11$ to $0.634{\pm}0.018$, with the best validation loss of $0.629{\pm}0.015$ achieved near epoch~83. Mean iteration speed: $\sim$185\,it/s. CPU memory usage was stable at $\sim$884\,MB. Zero NaN events were observed. + +\textit{Python (PyTorch):} Training loss decreased from $8265.6{\pm}142.3$ to $69.9{\pm}4.1$ over 100 epochs. Validation loss reached $55.7{\pm}3.8$. Validation $R^2$: $0.71{\pm}0.03$. Total training time: $47.4{\pm}1.1$\,s at $10.4$\,it/s. + + + +\begin{figure}[htbp] +\centering +\includegraphics[width=\linewidth]{figures/fig1_regression_loss.png} +\caption{Regression training curves. Left: Rust/Burn on normalized California Housing features. Right: Python/PyTorch on normalized features . Both converge cleanly over 100 epochs.} +\label{fig:reg_curves} +\end{figure} + +\FloatBarrier + +\subsection{Task 3: Text Classification (AG News)} + +\subsubsection{Model Architecture} + +Both implementations use a Transformer encoder~\cite{vaswani2017attention} with the following configuration: $d_{\text{model}}=256$, $d_{\text{ff}}=1024$, 8 attention heads, 4 encoder layers, dropout $=0.1$, Pre-Norm. Token and positional embeddings are fused by averaging: +\begin{equation} + E = \frac{E_{\text{tok}}(X) + E_{\text{pos}}(\text{pos})}{2} +\end{equation} + +Classification uses the first-token representation projected through a linear head to 4 output classes: +\begin{equation} + Y = \text{Linear}(H_{[:,0,:]}) \in \mathbb{R}^{B \times 4}, \quad \hat{P} = \text{Softmax}(Y) +\end{equation} + +Total parameters: 10,648,580 (Rust), 10,649,092 (Python). Both use the Noam learning rate schedule~\cite{vaswani2017attention}: +\begin{equation} + \text{lr} = d_{\text{model}}^{-0.5} \cdot \min\!\left(\text{step}^{-0.5},\ \text{step} \cdot \text{warmup}^{-1.5}\right) +\end{equation} +with 1000 warmup steps. Each epoch samples 50,000 training and 5,000 validation examples from the AG News dataset. Table~\ref{tab:text_arch} provides a full architecture summary. + +\begin{table}[htbp] +\centering +\caption{Transformer Text Classification Architecture} +\label{tab:text_arch} +\begin{tabular}{|l|c|c|} +\hline +\textbf{Component} & \textbf{Input Shape} & \textbf{Output Shape} \\ +\hline +Token Embedding & $(B, L)$ & $(B, L, 256)$ \\ +Pos. Embedding & $(B, L)$ & $(B, L, 256)$ \\ +Embedding Merge & --- & $(B, L, 256)$ \\ +Transformer (4L,8H) & $(B, L, 256)$ & $(B, L, 256)$ \\ +First-Token Slice & $(B, L, 256)$ & $(B, 256)$ \\ +Classifier Head & $(B, 256)$ & $(B, 4)$ \\ +\hline +\end{tabular} +\end{table} + +\subsubsection{Training Results} + +Both models were trained for 5 epochs. Results are averaged over eight independent runs. + +\textit{Rust (Burn):} Training accuracy improved from $57.97\%{\pm}0.8\%$ to $81.47\%{\pm}0.6\%$. Validation accuracy peaked at $81.64\%{\pm}0.5\%$ (epoch~4). Validation macro F1: $76.51\%{\pm}0.4\%$. Total training time: $1957.9{\pm}28$\,s ($\sim$32\,min). + +\textit{Python (PyTorch):} Training accuracy improved from $56.19\%{\pm}0.9\%$ to $79.70\%{\pm}0.7\%$. Validation accuracy: $79.00\%{\pm}0.6\%$. Validation macro F1: $79.03\%{\pm}0.5\%$. Total training time: $707.0{\pm}14.2$\,s at $44.7$\,it/s. GPU memory: $178.79$\,MB. + +Python is $2.77{\times}$ faster to train due to CUDA-accelerated matrix operations. Both implementations achieve comparable final validation accuracy, with Rust's validation accuracy ($81.64\%$) marginally higher than Python's ($79.00\%$), which may reflect differences in weight initialization or the Noam scheduler's interaction with Burn's default parameter setup. + +\begin{figure}[htbp] +\centering +\includegraphics[width=\linewidth]{figures/fig3_text_curves.png} +\caption{AG News text classification training curves. From left: accuracy, cross-entropy loss, and validation macro F1 across 5 epochs. Both frameworks show consistent improvement.} +\label{fig:text_curves} +\end{figure} + +Table~\ref{tab:text_metrics} provides complete epoch-level metrics for the Rust implementation. + +\begin{table}[htbp] +\centering +\caption{Text Classification Metrics Summary (Rust/Burn)} +\label{tab:text_metrics} +\begin{tabular}{|l|c|c|c|c|} +\hline +\textbf{Split} & \textbf{Metric} & \textbf{Min} & \textbf{Max} & \textbf{Epoch} \\ +\hline +Train & Accuracy (\%) & 57.97 & 81.47 & 5 \\ +Train & Loss & 0.507 & 0.981 & 1 \\ +Train & F1 [Macro] (\%) & 50.20 & 76.00 & 5 \\ +\hline +Valid & Accuracy (\%) & 72.28 & 81.64 & 4 \\ +Valid & Loss & 0.507 & 0.731 & 1 \\ +Valid & F1 [Macro] (\%) & 65.80 & 76.51 & 5 \\ +Valid & CPU Mem (GB) & 2.26 & 2.75 & 5 \\ +\hline +\end{tabular} +\end{table} + +\FloatBarrier + +\subsection{Task 4: Bidirectional LSTM Regression} + +\subsubsection{Model Architecture} + +Both implementations use a custom, manually-implemented bidirectional stacked LSTM with layer normalization applied to gates, cell state, and hidden state. The standard LSTM cell equations are: +\begin{align} + f_t &= \sigma(W_f [h_{t-1}, x_t] + b_f), \quad + i_t = \sigma(W_i [h_{t-1}, x_t] + b_i) \\ + g_t &= \tanh(W_g [h_{t-1}, x_t] + b_g), \quad + o_t = \sigma(W_o [h_{t-1}, x_t] + b_o) \\ + c_t &= f_t \odot c_{t-1} + i_t \odot g_t, \quad + h_t = o_t \odot \tanh(c_t) +\end{align} + +Key design decisions include: (1)~forget-gate biases initialized to $1.0$ via Xavier Normal to prevent early gradient decay~\cite{ba2016layernorm}; (2)~a single combined gate projection ($4{\times}\text{hidden\_size}$) split at runtime for efficiency; (3)~bidirectional processing with forward and backward hidden states concatenated before the output projection head. Gradient clipping (max norm $=1.0$) is applied before each optimizer step. + +\subsubsection{Training Results} + +Both models were trained for 30 epochs on synthetically generated noisy sequential data. Results are averaged over eight independent runs. + +\textit{Rust (Burn):} Training loss decreased from $4456.97{\pm}88.4$ (epoch~5) to $52.11{\pm}3.2$ (epoch~30). Validation loss decreased from $4473.44{\pm}92.1$ to $17.59{\pm}1.8$. Notably, the final validation loss is lower than the training loss, indicating strong generalization on the synthetic dataset. Total training time: $402{\pm}11$\,s. + +\textit{Python (PyTorch):} Training loss decreased from $5543.18{\pm}112.6$ to $56.11{\pm}4.1$ (a $98.99\%$ reduction). Validation loss: $48.92{\pm}3.6$. Validation RMSE: $6.99{\pm}0.3$. MAE: $4.33{\pm}0.2$. $R^2$: $0.9517{\pm}0.008$. Total training time: $158.6{\pm}4.2$\,s at $6.22$\,it/s. + +\begin{figure}[htbp] +\centering +\includegraphics[width=\linewidth]{figures/fig4_lstm_curves.png} +\caption{LSTM training loss curves (log scale). Both Rust and Python show rapid early convergence, with most learning occurring between epochs 5--25.} +\label{fig:lstm_curves} +\end{figure} + +Table~\ref{tab:lstm_metrics} summarizes the LSTM training outcomes. + +\begin{table}[htbp] +\centering +\caption{LSTM Sequential Regression Final Metrics} +\label{tab:lstm_metrics} +\begin{tabular}{|l|c|c|} +\hline +\textbf{Metric} & \textbf{Rust (Burn)} & \textbf{Python (PyTorch)} \\ +\hline +Final Train Loss & $52.11{\pm}3.2$ & $56.11{\pm}4.1$ \\ +Final Val Loss & $17.59{\pm}1.8$ & $48.92{\pm}3.6$ \\ +Val RMSE & --- & $6.99{\pm}0.30$ \\ +Val MAE & --- & $4.33{\pm}0.20$ \\ +Val $R^2$ & --- & $0.9517{\pm}0.008$\\ +Training Time (s) & $402{\pm}11$ & $158.6{\pm}4.2$ \\ +\hline +\end{tabular} +\end{table} + +\FloatBarrier + +\subsection{Track 1 Summary} + +Table~\ref{tab:track1_summary} consolidates the training outcomes across three of the four tasks (excluding regression due to incomparable loss scales). + +\begin{table*}[t] +\centering +\caption{Track 1 Training Summary: Rust (Burn) vs.\ Python (PyTorch) Across All Tasks} +\label{tab:track1_summary} +\begin{tabular}{|l|c|c|c|c|c|} +\hline +\textbf{Task} & \textbf{Epochs} & \textbf{Rust Val.\ Metric} & \textbf{Python Val.\ Metric} & \textbf{Rust Time (s)} & \textbf{Python Time (s)} \\ +\hline +MNIST (Val Acc.) & 10 & $98.52\%{\pm}0.10\%$ & $98.20\%{\pm}0.10\%$ & $229.9{\pm}3.2$ & $182.2{\pm}2.8$ \\ +Text Class.\ (Val Acc.) & 5 & $81.64\%{\pm}0.50\%$ & $79.00\%{\pm}0.60\%$ & $1957.9{\pm}28$ & $707.0{\pm}14.2$ \\ +LSTM (Val Loss) & 30 & $17.59{\pm}1.80$ & $48.92{\pm}3.60$ & $402{\pm}11$ & $158.6{\pm}4.2$ \\ +\hline +\end{tabular} +\end{table*} + +\FloatBarrier + +%======================================================================= +\section{Track 2: Inference and Deployment Evaluation} +\label{sec:track2} + +\subsection{Deployment Architecture} + +\subsubsection{Python/PyTorch: NFS-Augmented Docker Strategy} + +A central challenge in containerizing PyTorch inference services is the size of the ML runtime. A standard NVIDIA CUDA-enabled PyTorch container, which is what was used in this study for GPU-accelerated inference, occupies approximately 3.98--4.02~GB per service instance. While this includes the full GPU stack necessary for fast inference, it creates significant operational costs: slow registry pulls, high cold-start latency, and large storage requirements. + +To address the dependency management challenge without abandoning GPU capability, an NFS-augmented architecture was developed. Rather than reinstalling Python ML libraries inside each container via \texttt{pip install}, a central NFS server VM exports a shared directory (\texttt{/external-libs/}) containing pre-built virtual environments for each model (e.g., \texttt{MNIST\_venv/}, \texttt{LSTM\_venv/}). Each Docker container mounts this shared directory at runtime and overrides \texttt{PYTHONPATH} to point to the NFS-hosted \texttt{site-packages}, as illustrated in Fig.~\ref{fig:nfs_arch}. + +\begin{figure}[htbp] +\centering +\includegraphics[width=\linewidth]{figures/fig10_nfs_architecture.png} +\caption{NFS-augmented Docker deployment architecture for Python/PyTorch. A central NFS server VM exports per-model virtual environments. Each inference container mounts the shared library volume at runtime, eliminating redundant dependency installation across services.} +\label{fig:nfs_arch} +\end{figure} + +The Dockerfile for this approach (CPU variant) uses \texttt{python:3.12-slim} as the base image and installs only minimal system dependencies: + +\begin{lstlisting}[language=Dockerfile, caption={Python CPU inference Dockerfile using NFS-mounted libraries}, label=lst:py_dockerfile, belowskip=0pt, aboveskip=4pt] +FROM python:3.12-slim +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 +ENV PYTHONPATH=/external-libs/LSTM_env/lib/\ +python3.12/site-packages +WORKDIR /app +RUN apt-get update && apt-get install -y \ + libgomp1 curl \ + && rm -rf /var/lib/apt/lists/* +COPY app.py model.py config.py dataset.py ./ +COPY lstm_train_python/model.pt \ + ./generated/model.pt +EXPOSE 8000 +CMD ["python", "-m", "uvicorn", "app:app", \ + "--host", "0.0.0.0", "--port", "8000"] +\end{lstlisting} + +Using this slim-base approach with NFS-mounted libraries reduces the container image from $\sim$2~GB (if \texttt{torch} were pip-installed) to approximately 62--75~MB. The GPU-enabled production containers that served the inference benchmarks use the NVIDIA CUDA base image, resulting in the 3.98--4.02~GB sizes reported in Table~\ref{tab:docker_sizes}. The CPU slim approach demonstrates that the Python dependency footprint can be reduced to near-Rust levels when GPU support is not required, though it was not benchmarked in this study. + +Key trade-offs of the NFS approach include: +\begin{itemize} + \item \textbf{Pros:} Eliminates redundant installs, ensures consistent library versions across all containers, and enables instant library updates without image rebuilds. + \item \textbf{Cons:} Introduces a runtime dependency on network connectivity to the NFS server VM; CUDA driver compatibility must still be managed at the host level; NFS latency is higher than local disk for library loading. +\end{itemize} + +\subsubsection{Rust/Burn: Statically Compiled Microservice} + +The Rust deployment pipeline requires no equivalent workaround because Burn compiles model weights, HTTP routing logic, and inference code into a single statically linked binary. A multi-stage Dockerfile builds the release binary inside a full Rust toolchain container, then transfers the resulting artifact into a minimal \texttt{alpine:3.23} or \texttt{nvidia/vulkan:1.3-470} runtime image. No Python interpreter, pip packages, or dynamic library resolution occurs at container startup. + +HTTP routing uses the Axum framework. Model weights are loaded via Burn's \texttt{CompactRecorder} from \texttt{.mpk} files serialized during training. Tensor dimension validation occurs at compile time via Rust's generic type system (\texttt{Tensor} vs.\ \texttt{Tensor}), which eliminates a class of shape-mismatch bugs that would manifest as runtime errors in Python. + +\subsection{HTTP Load Testing with Locust} + +\subsubsection{Setup} + +Both services were subjected to identical 5-minute Locust campaigns with 100 concurrent virtual users targeting the \texttt{POST /predict} endpoint. Tests were conducted independently for each of the four model types across both backends. Each test was repeated eight times; results reported are means with variance. Zero failures were recorded across all tests for both languages. + +\subsubsection{Results} + +Tables~\ref{tab:locust_python} and~\ref{tab:locust_rust} present complete request statistics and latency percentile distributions for Python and Rust services respectively. + +\begin{table*}[t] +\centering +\caption{Python Inference Service: Locust Load Test Results (100 Users, 5 Min, 0 Failures)} +\label{tab:locust_python} +\begin{tabular}{|l|c|c|c|c|c|c|c|c|c|} +\hline +\textbf{Model} & \textbf{Requests} & \textbf{RPS} & \textbf{Avg (ms)} & \textbf{Min} & \textbf{Max} & \textbf{P50} & \textbf{P90} & \textbf{P95} & \textbf{P99} \\ +\hline +Regression & 175,491 & 584.66 & 15.09 & 2 & 552 & 11 & 28 & 41 & 79 \\ +MNIST CNN & 161,484 & 538.12 & 30.75 & 3 & 1793 & 27 & 54 & 65 & 93 \\ +Text Class. & 73,583 & 245.16 & 250.66 & 5 & 666 & 250 & 290 & 310 & 390 \\ +LSTM & 42,241 & 140.77 & 548.36 & 8 & 8781 & 550 & 620 & 640 & 690 \\ +\hline +\end{tabular} +\end{table*} + +\begin{table*}[t] +\centering +\caption{Rust Inference Service: Locust Load Test Results (100 Users, 5 Min, 0 Failures)} +\label{tab:locust_rust} +\begin{tabular}{|l|c|c|c|c|c|c|c|c|c|} +\hline +\textbf{Model} & \textbf{Requests} & \textbf{RPS} & \textbf{Avg (ms)} & \textbf{Min} & \textbf{Max} & \textbf{P50} & \textbf{P90} & \textbf{P95} & \textbf{P99} \\ +\hline +Regression & 176,356 & 587.46 & 14.51 & 2 & 206 & 11 & 26 & 35 & 77 \\ +MNIST CNN & 89,445 & 329.87 & 146.32 & 8 & 572 & 140 & 220 & 250 & 320 \\ +Text Class. & 24,794 & 82.62 & 1038.78 & 12 & 1462 & 1000 & 1200 & 1200 & 1300 \\ +LSTM & 15,026 & 50.08 & 1808.99 & 154 & 5436 & 1700 & 3000 & 3400 & 4200 \\ +\hline +\end{tabular} +\end{table*} + +\subsubsection{Throughput Analysis} + +Fig.~\ref{fig:rps} shows the throughput comparison across all four models. + +\begin{figure}[htbp] +\centering +\includegraphics[width=\linewidth]{figures/fig5_rps_comparison.png} +\caption{Inference throughput (RPS) under 100-user concurrent Locust load. Python and Rust are nearly identical for regression; Python leads significantly for compute-heavy models.} +\label{fig:rps} +\end{figure} + +For the regression task, both services achieve near-identical throughput (584.66 vs.\ 587.46~RPS), confirming that for lightweight inference, HTTP overhead dominates and language choice is immaterial. For MNIST, Python achieves $1.63{\times}$ higher throughput (538.12 vs.\ 329.87~RPS). The gap is most pronounced for sequential and attention-based models: Python outperforms Rust by $2.97{\times}$ for text classification (245.16 vs.\ 82.62~RPS) and by $2.81{\times}$ for LSTM (140.77 vs.\ 50.08~RPS). These gaps reflect PyTorch's mature, highly optimized kernel library (\texttt{libtorch}, cuDNN, MKL-DNN) rather than any fundamental language-level limitation. + +\subsubsection{Latency Analysis} + +Fig.~\ref{fig:latency} and Fig.~\ref{fig:percentiles} present the average latency and percentile distributions respectively. + +\begin{figure}[htbp] +\centering +\includegraphics[width=\linewidth]{figures/fig6_latency_comparison.png} +\caption{Average inference latency (ms, log scale). For compute-heavy models (LSTM, Text Classification), the Python advantage grows substantially.} +\label{fig:latency} +\end{figure} + +\begin{figure}[htbp] +\centering +\includegraphics[width=\linewidth]{figures/fig7_percentile_dist.png} +\caption{Latency percentile distribution (P50/P95/P99) across all four model types. The tail-latency advantage of Python is most visible in the LSTM task, where P99 is 690~ms (Python) vs.\ 4200~ms (Rust)---a $6.1{\times}$ gap.} +\label{fig:percentiles} +\end{figure} + +Examining tail latency (P99) reveals that Rust's variance under load is higher than Python's for complex models. For LSTM, the P99 latency gap between Rust (4200~ms) and Python (690~ms) represents a $6.1{\times}$ difference---substantially larger than the mean latency gap, suggesting that under peak concurrent load the Rust LSTM service experiences more variance. For regression, P99 latencies are nearly identical (77~ms Python vs.\ 79~ms Rust), confirming parity for lightweight models. + +\FloatBarrier + +\subsection{Container Image Size} + +\begin{figure}[htbp] +\centering +\includegraphics[width=\linewidth]{figures/fig9_docker_sizes.png} +\caption{Docker container image sizes. Python GPU containers consistently occupy $\sim$4~GB; Rust containers occupy $\sim$1~GB, a $4{\times}$ reduction.} +\label{fig:docker_sizes} +\end{figure} + +\begin{table}[htbp] +\centering +\caption{Docker Container Image Size Comparison} +\label{tab:docker_sizes} +\begin{tabular}{|l|c|c|c|} +\hline +\textbf{Task} & \textbf{Python (GB)} & \textbf{Rust (GB)} & \textbf{Ratio} \\ +\hline +MNIST CNN & 3.98 & 1.02 & $3.9{\times}$ \\ +Text Classification & 4.02 & $\approx$1.00 & $4.0{\times}$ \\ +Regression & 3.98 & 0.97 & $4.1{\times}$ \\ +LSTM & 3.98 & 0.97 & $4.1{\times}$ \\ +\hline +\textbf{Mean} & \textbf{3.99} & \textbf{0.99} & $\mathbf{4.0{\times}}$ \\ +\hline +\end{tabular} +\end{table} + +The Python containers use the NVIDIA CUDA base image with a full GPU stack. The Rust containers use a minimal Vulkan-enabled runtime image with only the compiled binary and model weights. The $4{\times}$ size difference translates directly into reduced registry pull latency, lower storage costs, and faster Kubernetes pod startup times. + +\FloatBarrier + +\subsection{Model Artifact Size} + +\begin{figure}[htbp] +\centering +\includegraphics[width=\linewidth]{figures/fig8_model_sizes.png} +\caption{Serialized model artifact sizes (KB, log scale). For the regression model, both formats are compact (4~KB Rust vs.\ 5~KB Python). For large models, Rust is 45--48\% smaller.} +\label{fig:model_sizes} +\end{figure} + +\begin{table}[htbp] +\centering +\caption{Serialized Model Artifact Size Comparison} +\label{tab:model_sizes} +\begin{tabular}{|l|c|c|c|} +\hline +\textbf{Model} & \textbf{Rust (.mpk)} & \textbf{Python (.pt)} & \textbf{Reduction} \\ +\hline +MNIST CNN & 1.1~MB & 2.0~MB & 45.0\% \\ +Text Class. & 21~MB & 40.6~MB & 48.3\% \\ +Regression & 4~KB & 5~KB & 20.0\% \\ +LSTM & 60~KB & 120~KB & 50.0\% \\ +\hline +\end{tabular} +\end{table} + +For the regression model, both artifacts are negligibly small (4~KB Rust, 5~KB Python), as the 641-parameter model contains insufficient weight data for either format's overhead to dominate. The more operationally significant differences appear at scale: text classification serializes to 21~MB (Rust) versus 40.6~MB (Python) 48\% reduction driven by PyTorch's embedded optimizer state and Python pickle metadata. Burn's \texttt{CompactRecorder} stores only weight tensors in a flat binary encoding with no embedded metadata. + +\FloatBarrier + +%======================================================================= +\section{Discussion} +\label{sec:discussion} + +\subsection{Training Feasibility and Convergence} + +Track~1 results establish that Burn is a viable end-to-end training platform across all four evaluated architectures. Final validation accuracy on MNIST is $98.52\%$ (Rust, 95\% CI: $[98.36\%, 98.68\%]$) versus $98.20\%$ (Python, 95\% CI: $[98.04\%, 98.36\%]$). These confidence intervals do not overlap, indicating a small but statistically detectable difference in favor of Rust on this task, though the practical magnitude ($0.32\%$) is negligible. + +For text classification, Python achieves a higher macro F1 ($79.03\%$, CI: $[78.23\%, 79.83\%]$) than Rust ($76.51\%$, CI: $[75.87\%, 77.15\%]$), with non-overlapping confidence intervals. This difference while modest is statistically detectable and may reflect differences in weight initialization defaults or the interaction between the Noam scheduler and Burn's optimizer implementation. + +For LSTM, Rust achieves a substantially lower final validation loss ($17.59$, CI: $[14.73, 20.45]$) compared to Python ($48.92$, CI: $[43.19, 54.65]$). These intervals do not overlap, suggesting a genuine difference; however, this gap is most likely attributable to differences in synthetic data generation and normalization between the two pipelines rather than a fundamental framework advantage. + +Across all tasks, both frameworks converge without numerical instability. Zero NaN events were recorded in any run, confirming that Burn's automatic differentiation and gradient accumulation are numerically stable across the tested architectures. + +\subsection{The Inference Throughput Gap: Technical Explanation} + +The most counter-intuitive finding of this study is that Rust, despite eliminating Python's GIL and offering language-level concurrency guarantees, achieves lower inference throughput than Python for compute-intensive models. For LSTM, Python achieves $2.81\times$ higher RPS; for text classification, $2.97\times$ higher RPS. This result requires careful interpretation. + +The bottleneck in ML inference is not thread scheduling or request dispatching---it is tensor kernel throughput. PyTorch's \texttt{libtorch} backend integrates cuDNN for convolutions, MKL-DNN for CPU matrix operations, and fused attention kernels developed over years of production use. Burn's GPU backend, while architecturally sound, currently lacks: (1)~\emph{fused kernels} for multi-head attention, layer normalization, and LSTM gating, which PyTorch executes in a single GPU dispatch; (2)~\emph{cuDNN integration} providing hardware-specific convolution algorithms; and (3)~\emph{dynamic request batching} at the HTTP layer. + +The tail latency disparity amplifies this picture. For the LSTM task, Python's P99 latency is 690~ms versus Rust's 4200~ms, a $6.1\times$ gap substantially wider than the mean gap ($3.3\times$). This widening is consistent with GPU dispatch queue saturation under concurrent load: when GPU kernel dispatch lacks the throughput to clear the queue at the rate imposed by 100 concurrent users, requests accumulate and tail latency grows super-linearly. For regression, both achieve $\sim$585~RPS, confirming that the performance gap is a function of kernel maturity relative to model complexity, not a general property of the language or runtime. + +\subsection{Deployment Advantages: Quantified} + +Rust's deployment advantages are consistent across all four models. Container images average $0.99$~GB versus $3.99$~GB for Python a $4.0\times$ reduction translating to faster registry pulls, lower cold-start latency, and reduced storage costs~\cite{vasiliev2024scaling}. + +For the regression model, both artifacts are negligibly small (4~KB Rust, 5~KB Python), as the 641-parameter model contains insufficient weight data for either format's overhead to dominate. The more operationally significant differences appear at scale: text classification serializes to 21~MB (Rust) versus 40.6~MB (Python) 48\% reduction driven by PyTorch's embedded optimizer state and Python pickle metadata. + +\subsection{NFS Strategy: Scope and Limitations} + +The NFS-augmented deployment architecture addresses the dependency management challenge for Python inference containers. Rather than reducing the deployed image size, the GPU containers remain at 3.98--4.02~GB as the NVIDIA CUDA base image is required for accelerated inference the NFS approach eliminates redundant library installation across container instances and provides a single point of version control for ML dependencies. A CPU-only slim container using \texttt{python:3.12-slim} with NFS-mounted \texttt{torch} reduces the image to approximately 62--75~MB, at the cost of GPU acceleration. + +This strategy is most appropriate for controlled, on-premises deployments where NFS infrastructure can be maintained reliably. For public cloud or serverless deployments, it introduces a network dependency that may not be acceptable. Kubernetes-based deployments with NFS persistent volume claims represent a portable middle ground~\cite{k8sml2024}. + + +\subsection{Limitations} + +Several limitations bound the scope of the conclusions. First, all experiments use the Burn GPU backend; Burn also supports a LibTorch backend that would be expected to substantially close the inference throughput gap. Second, the load testing campaigns do not include request batching, which disproportionately benefits GPU-accelerated backends. Third, training comparisons do not include profiling traces, preventing direct attribution of training time differences to specific operations. Fourth, energy consumption and carbon footprint---increasingly relevant metrics for production ML systems~\cite{strubell2019energy}---are not measured. + +\subsection{Practical Decision Framework} + +Based on the empirical results, the following guidance is offered for practitioners. + +\textit{Choose Rust/Burn when:} deployment footprint is the primary constraint---edge, embedded, or bandwidth-limited environments where $<$1~GB containers and kilobyte-scale model artifacts are material requirements; compile-time tensor dimension safety is required to reduce production incidents from shape mismatches; or memory-safe, dependency-minimal binaries are required for security-critical deployments. + +\textit{Choose Python/PyTorch when:} maximum inference throughput is the primary requirement (demonstrated $3\times$ RPS advantage for sequential and attention-based models); lowest latency is essential (demonstrated $6\times$ P99 advantage for LSTM); or the Hugging Face and torchvision ecosystems are required for pre-trained model access. + +\textit{A hybrid strategy}---training in PyTorch for ecosystem richness and iteration speed, followed by weight export and reloading in a Rust/Burn inference service for deployment efficiency---may represent the best practical trade-off as Burn's cross-framework import capabilities mature. + +%======================================================================= +\section{Conclusion} +\label{sec:conclusion} + +This paper presented a structured, two-track empirical evaluation of Rust (Burn) and Python (PyTorch) for machine learning across training, inference deployment, and system-level characteristics. Four model architectures were evaluated---CNN, Transformer, LSTM, and feed-forward regression---with all experiments repeated eight times to report mean and variance. + +The principal findings are as follows. First, training accuracy is functionally equivalent between Burn and PyTorch across all evaluated tasks, establishing Rust as a viable end-to-end training platform. Second, Python inference services outperform Rust in throughput for compute-intensive models by up to $3.0{\times}$, and in P99 tail latency by up to $6.1{\times}$, a result driven by PyTorch's mature kernel library rather than any language-level property. Third, Rust provides a consistent $4{\times}$ reduction in container image size and 45--50\% smaller model artifacts for large models, with both regression artifacts being negligibly small at this scale. Fourth, an NFS-augmented deployment strategy for Python is described that decouples library dependencies from container images, applicable in controlled on-premises environments. + +These findings suggest that the two ecosystems are best understood as complementary rather than competing. Python/PyTorch dominates for throughput-sensitive serving and training workflows requiring ecosystem breadth. Rust/Burn is the superior choice for minimal-footprint, memory-safe, and edge-optimized deployment. As the Burn framework matures its GPU-accelerated backends---particularly LibTorch and native CUDA integration---the inference performance gap documented here is expected to narrow, potentially making Rust a viable primary platform across the full ML lifecycle. + +%======================================================================= +\begin{thebibliography}{00} + +\bibitem{paszke2019pytorch} +A. Paszke \textit{et al.}, ``PyTorch: An imperative style, high-performance deep learning library,'' in \textit{Adv. Neural Inf. Process. Syst.}, vol.~32, 2019. + +\bibitem{rust2023} +The Rust Programming Language Community, ``The Rust programming language,'' 2023. [Online]. Available: \url{https://www.rust-lang.org} + +\bibitem{burn2024} +N. Gagnon-Marchand \textit{et al.}, ``Burn: A flexible and comprehensive deep learning framework in Rust,'' GitHub, 2024. [Online]. Available: \url{https://github.com/tracel-ai/burn} + +\bibitem{vaswani2017attention} +A. Vaswani \textit{et al.}, ``Attention is all you need,'' in \textit{Adv. Neural Inf. Process. Syst.}, vol.~30, 2017. + +\bibitem{hochreiter1997lstm} +S. Hochreiter and J. Schmidhuber, ``Long short-term memory,'' \textit{Neural Computation}, vol.~9, no.~8, pp.~1735--1780, 1997. + +\bibitem{kingma2015adam} +D. P. Kingma and J. Ba, ``Adam: A method for stochastic optimization,'' in \textit{Proc. ICLR}, 2015. + +\bibitem{ba2016layernorm} +J. L. Ba, J. R. Kiros, and G. E. Hinton, ``Layer normalization,'' \textit{arXiv preprint arXiv:1607.06450}, 2016. + +\bibitem{merkel2014docker} +D. Merkel, ``Docker: Lightweight Linux containers for consistent development and deployment,'' \textit{Linux Journal}, vol.~2014, no.~239, 2014. + +\bibitem{gujarati2020clockwork} +A. Gujarati \textit{et al.}, ``Serving DNNs like clockwork: Performance predictability from the bottom up,'' in \textit{Proc. 14th USENIX OSDI}, 2020. + +\bibitem{locust} +J. Hamren \textit{et al.}, ``Locust: An open-source load testing tool,'' GitHub. [Online]. Available: \url{https://github.com/locustio/locust} + +\bibitem{bezanson2017julia} +J. Bezanson, A. Edelman, S. Karpinski, and V. B. Shah, ``Julia: A fresh approach to numerical computing,'' \textit{SIAM Review}, vol.~59, no.~1, pp.~65--98, 2017. + +\bibitem{onnxruntime} +Microsoft, ``ONNX Runtime: Cross-platform, high performance ML inferencing and training,'' 2021. [Online]. Available: \url{https://onnxruntime.ai} + +\bibitem{pytorchvstf2025} +Z. Ba Alawi, ``A comparative survey of PyTorch vs TensorFlow for deep learning: Usability, performance, and deployment trade-offs,'' \textit{arXiv preprint arXiv:2508.04035}, 2025. + +\bibitem{vella2023rust} +V. Vella, ``Boosting machine learning performance with Rust,'' \textit{Better Programming}, June 2023. [Online]. Available: \url{https://betterprogramming.pub/boosting-machine-learning-performance-with-rust-aab1f3ae1424} + +\bibitem{crespo2023rust} +J. Crespo, ``Supercharging machine learning performance: 4x improvement with Rust over Python,'' \textit{LinkedIn}, May 2023. + +\bibitem{athanx2024rust} +A. X. Athan, ``Choosing the right Rust machine learning framework: Candle, Burn, DFDX, or tch-rs?,'' \textit{Medium}, March 2024. + +\bibitem{li2024mobileinference} +Z. Li, M. Paolieri, and L. Golubchik, ``A benchmark for ML inference latency on mobile devices,'' in \textit{Proc. 7th Int. Workshop on Edge Systems, Analytics and Networking (EdgeSys)}, ACM, 2024. + +\bibitem{derosa2024modelserving} +P. De Rosa \textit{et al.}, ``On the cost of model-serving frameworks: An experimental evaluation,'' in \textit{Proc. IEEE Int. Conf. Cloud Engineering (IC2E)}, 2024, pp.~221--232. + +\bibitem{buyya2009cloud} +R. Buyya, C. S. Yeo, S. Venugopal, J. Broberg, and I. Brandic, ``Cloud computing and emerging IT platforms: Vision, hype, and reality for delivering computing as the 5th utility,'' \textit{Future Generation Computer Systems}, vol.~25, no.~6, pp.~599--616, 2009. + +\bibitem{vasiliev2024scaling} +G. Ramesh \textit{et al.}, ``A comprehensive review on scaling machine learning workflows using cloud technologies and DevOps,'' \textit{IEEE Access}, 2024. [Online]. Available: \url{https://ieeexplore.ieee.org/document/11126113} + +\bibitem{mldaas2024} +K. P. Ravikumar, N. Ahmed, and M. S. Singh, ``ML-DaaS: An integrated ML training and deployment framework for hybrid cloud,'' \textit{IEEE Access}, 2024. [Online]. Available: \url{https://ieeexplore.ieee.org/document/11261485} + +\bibitem{mohanty2024cicd} +M. V. Velumani and P. K. Muthukamatchi, ``Cloud-native software defect prediction: Leveraging machine learning models in scalable CI/CD pipelines,'' \textit{IEEE Access}, 2024. [Online]. Available: \url{https://ieeexplore.ieee.org/document/11212348} + +\bibitem{k8sml2024} +D. Kutsa, ``Using machine learning for analyzing performance metrics in Kubernetes,'' \textit{IJSRED}, vol.~8, no.~2, 2025. [Online]. Available: \url{https://www.ijsred.com/volume8/issue2/IJSRED-V8I2P143.pdf} + +\bibitem{strubell2019energy} +E. Strubell, A. Ganesh, and A. McCallum, ``Energy and policy considerations for deep learning in NLP,'' in \textit{Proc. ACL}, 2019. + +\bibitem{watada2019containers} +J. Watada \textit{et al.}, ``Emerging trends, techniques and open issues of containerization: A review,'' \textit{IEEE Access}, vol.~7, pp.~152443--152472, 2019. + +\end{thebibliography} +\EOD +\end{document} \ No newline at end of file