Posted on
Artificial Intelligence

Artificial Intelligence Hardware Case Studies

Author
  • User
    linuxbash
    Posts by this author
    Posts by this author

Artificial Intelligence Hardware Case Studies for Linux Users: What Works, Why, and How To Reproduce It

If your model feels “slow,” odds are your hardware is mismatched to the job. The right Linux-friendly hardware choice can cut latency, cost, and power draw by double-digit percentages—without rewriting your whole stack. Below are practical, reproducible case studies you can run from a Bash shell to see how different choices (CPU-only, single GPU, and portable C++ stacks) perform for real workloads.

This article explains why the question matters, then walks through 3 hands-on, Linux-native case studies with commands you can paste into a terminal. Each includes actionable steps and the “why” behind them.

Why hardware case studies are worth your time

  • Performance isn’t just about FLOPs. Latency, memory bandwidth, PCIe topology, and kernel/driver maturity decide whether your silicon actually gets used.

  • Cost efficiency depends on the whole system. CPUs with vector extensions (AVX2/AVX-512) can beat aging GPUs for small-batch inference; a single prosumer GPU can beat a fleet of CPUs for fine-tuning; and portable C/C++ inference stacks make edge devices viable.

  • Linux is where AI runs. Package managers, shells, and drivers are the control plane. Knowing how to install, test, and measure makes you faster and safer.

Prerequisites: tools you’ll use repeatedly

Install common build, monitoring, and developer tools. Pick the command block for your distro.

  • Debian/Ubuntu (apt):

    sudo apt update
    sudo apt install -y python3 python3-venv python3-pip git cmake build-essential \
      numactl lm-sensors nvtop pciutils hwloc wget curl
    sudo sensors-detect --auto
    
  • Fedora/RHEL (dnf):

    sudo dnf install -y python3 python3-pip git cmake gcc gcc-c++ make \
      numactl lm_sensors nvtop pciutils hwloc wget curl
    sudo sensors-detect --auto
    
  • openSUSE (zypper):

    sudo zypper refresh
    sudo zypper install -y python3 python3-pip git cmake gcc gcc-c++ make \
      numactl lm_sensors nvtop pciutils hwloc wget curl
    sudo sensors-detect --auto
    

Notes:

  • If python3 -m venv is missing on your distro, also install python3-venv (apt) or python3-virtualenv (dnf/zypper).

  • nvtop shows GPU utilization in a top-like UI (works with NVIDIA; AMD/Intel support varies by driver).


Case Study 1: CPU-first inference with ONNX Runtime on x86_64

Scenario: An API endpoint does small-batch (1–4) image classification. Replacing a low-end GPU with a modern CPU delivers lower tail latency and simpler ops.

What you’ll learn:

  • Use ONNX Runtime with optimized CPU kernels (oneDNN/OpenMP).

  • Pin threads and the process to a NUMA node to stabilize latency.

  • Measure power/thermals with lm-sensors.

Step 1 — Create a clean Python environment:

python3 -m venv ~/venvs/onnxrt && source ~/venvs/onnxrt/bin/activate
pip install --upgrade pip
pip install onnxruntime onnx numpy pillow requests

Step 2 — Download a small model (ResNet50) and a sample image:

wget -O resnet50-v1-12.onnx https://github.com/onnx/models/raw/main/vision/classification/resnet/model/resnet50-v1-12.onnx
wget -O cat.jpg https://raw.githubusercontent.com/pytorch/hub/master/images/dog.jpg

Step 3 — Benchmark script (save as cpu_infer.py):

import onnxruntime as ort
import numpy as np
from PIL import Image
import time, os

os.environ.setdefault("OMP_NUM_THREADS", "8")       # tune for your CPU
os.environ.setdefault("MKL_NUM_THREADS", "8")       # if MKL present
os.environ.setdefault("OMP_WAIT_POLICY", "ACTIVE")

sess = ort.InferenceSession("resnet50-v1-12.onnx", providers=["CPUExecutionProvider"])
inp_name = sess.get_inputs()[0].name

img = Image.open("cat.jpg").resize((224,224)).convert("RGB")
x = np.asarray(img).astype("float32") / 255.0
x = np.transpose(x, (2,0,1))[None, ...]  # NCHW
mean = np.array([0.485, 0.456, 0.406])[None, :, None, None]
std  = np.array([0.229, 0.224, 0.225])[None, :, None, None]
x = (x - mean) / std

# warmup
for _ in range(10):
    sess.run(None, {inp_name: x})

# timed runs
N = 100
t0 = time.time()
for _ in range(N):
    sess.run(None, {inp_name: x})
t1 = time.time()
print(f"Avg latency: {(t1 - t0)/N*1000:.2f} ms")

Step 4 — Pin to a NUMA node and run:

numactl --cpunodebind=0 --membind=0 python cpu_infer.py

Step 5 — Watch thermals/power:

watch -n1 sensors

Actionable findings:

  • Threading matters: OMP_NUM_THREADS close to physical core count (not hyper-threads) often gives best P50–P99 latency.

  • NUMA pinning avoids cross-socket memory hops.

  • For even more speed, convert the model to int8 (post-training quantization) using onnxruntime’s quantization toolkit, then rerun the same script. CPU int8 often halves latency at tiny accuracy cost for many vision tasks.


Case Study 2: Prosumer NVIDIA GPU fine-tuning with PyTorch + LoRA

Scenario: You have a single 16–24 GB NVIDIA GPU (e.g., 3060/4060Ti/4070/3090/4090) and want to fine-tune a chat model for your domain. Using 4-bit quantization and LoRA fits real models on modest VRAM.

What you’ll learn:

  • Install GPU-enabled PyTorch wheels (no CUDA toolkit needed; only the driver).

  • Use bitsandbytes for 4-bit quantization to reduce VRAM.

  • Monitor GPU utilization with nvtop.

Step 1 — Create an environment and install packages:

python3 -m venv ~/venvs/lora && source ~/venvs/lora/bin/activate
pip install --upgrade pip
# Install PyTorch with prebuilt CUDA wheels (requires recent NVIDIA driver)
pip install --index-url https://download.pytorch.org/whl/cu121 torch torchvision torchaudio
pip install transformers datasets accelerate bitsandbytes peft sentencepiece

Step 2 — Start nvtop in another terminal to watch utilization:

nvtop

Step 3 — Minimal LoRA fine-tune (save as lora_ft.py):

from transformers import AutoModelForCausalLM, AutoTokenizer, TrainingArguments, Trainer
from datasets import load_dataset
from peft import LoraConfig, get_peft_model
import torch, os
os.environ["CUDA_VISIBLE_DEVICES"] = os.getenv("CUDA_VISIBLE_DEVICES", "0")

model_name = "TinyLlama/TinyLlama-1.1B-Chat-v1.0"  # pick a small model first
tok = AutoTokenizer.from_pretrained(model_name, use_fast=True)
model = AutoModelForCausalLM.from_pretrained(
    model_name,
    torch_dtype=torch.float16,
    device_map="auto",
    load_in_8bit=False
)

# 4-bit quantization alternative:
# import bitsandbytes as bnb
# model = AutoModelForCausalLM.from_pretrained(model_name, load_in_4bit=True, device_map="auto")

lora_cfg = LoraConfig(r=8, lora_alpha=16, lora_dropout=0.05, bias="none", task_type="CAUSAL_LM")
model = get_peft_model(model, lora_cfg)

ds = load_dataset("yhavinga/ultrachat_200k", split="train[:0.1%]")  # tiny slice for demo
def format_example(ex):
    prompt = ex.get("prompt", "Hello")
    resp = ex.get("response", "Hi")
    text = f"<s>[INST] {prompt} [/INST] {resp}</s>"
    ids = tok(text, truncation=True, max_length=512)
    ids["labels"] = ids["input_ids"].copy()
    return ids
ds = ds.map(format_example, remove_columns=ds.column_names)

args = TrainingArguments(
    output_dir="./out",
    per_device_train_batch_size=2,
    gradient_accumulation_steps=8,
    learning_rate=2e-4,
    num_train_epochs=1,
    fp16=True,
    logging_steps=5,
    save_steps=50,
)

trainer = Trainer(model=model, args=args, train_dataset=ds)
trainer.train()
print("Done. LoRA adapters saved in ./out")

Step 4 — Run it:

python lora_ft.py

Actionable findings:

  • 4-bit (bitsandbytes) or 8-bit loading can cut VRAM by 2–4x at modest quality cost—often the difference between OOM and done.

  • Batch size × sequence length dictates memory. Gradient accumulation helps when VRAM is tight.

  • nvtop should show high GPU utilization during training. If not, your bottleneck is likely dataloading or CPU.

Package manager recap (already installed above):

  • nvtop (apt/dnf/zypper) for monitoring.

  • Python packages via pip. You only need a recent NVIDIA driver; the PyTorch wheels bundle CUDA runtime.


Case Study 3: Portable C++ inference with llama.cpp (CPU-first, optional GPU later)

Scenario: You want a single, portable binary that runs LLM inference on almost anything (server CPUs, laptops, edge boxes). llama.cpp makes this practical and fast with quantized GGUF models.

What you’ll learn:

  • Build a high-performance C++ inference binary.

  • Run quantized models on CPU to compare latency vs. GPU rigs.

  • Add GPU acceleration later if you have CUDA/ROCm installed.

Step 1 — Clone and build:

git clone https://github.com/ggerganov/llama.cpp.git
cd llama.cpp
make -j$(nproc)                # CPU build
# Optional: CUDA build if you have toolkit installed
# make LLAMA_CUBLAS=1 -j$(nproc)

Step 2 — Obtain a GGUF model

  • Convert or download a small open model in GGUF format (e.g., TinyLlama, Phi-2 converted).

  • Place it as ./models/model.gguf. Example placeholder:

mkdir -p models
# Put your GGUF file here:
# cp /path/to/your/model.gguf models/model.gguf

Step 3 — Run a prompt and measure tokens/sec:

./main -m models/model.gguf -n 128 -p "You are a helpful assistant. Explain Linux process scheduling in simple terms."

Step 4 — Compare threading strategies:

# Try 1 thread per physical core; then 2x for SMT
./main -m models/model.gguf -n 128 -t $(nproc --ignore=1)
./main -m models/model.gguf -n 128 -t $(nproc)
# Pin to a NUMA node for consistency
numactl --cpunodebind=0 --membind=0 ./main -m models/model.gguf -n 128 -t 8

Actionable findings:

  • Quantization (Q4_K_M, Q5_K_M, etc.) trades a little quality for big speedups and memory savings—ideal for CPUs.

  • The best -t (threads) value is often “physical cores,” not hyper-threads. Use lscpu to inspect your topology:

    lscpu | egrep 'Model name|CPU\(s\)|Thread|Core|Socket'
    

Cross-cutting lessons you can apply today

  • Measure, don’t guess:

    • Latency: use time, perf (if available), and logging in your code.
    • Thermals/power: sensors and watch.
    • GPU: nvtop and nvidia-smi if NVIDIA drivers are present.
  • NUMA and PCIe topology matter:

    • See your layout:
    lspci -tv
    numactl -H
    
    • Keep processes and memory close to the device that does the work.
  • Prefer portable, repeatable environments:

    • Python venvs for PyTorch/ONNX Runtime.
    • Self-contained C++ builds (llama.cpp) when deploying to diverse machines.
  • Optimize the right lever for your workload:

    • Small-batch, low-latency APIs: CPU with int8 often wins.
    • Training/fine-tuning: a single solid GPU with mixed precision and LoRA.
    • Edge: quantized C++ runtimes to avoid heavy dependencies.

Conclusion and next steps (CTA)

Pick one case that matches your reality and run the exact commands:

  • If you serve small requests with tight SLAs, start with Case Study 1 and pin threads/NUMA.

  • If you customize LLMs on a single GPU, try Case Study 2 and watch nvtop while you iterate.

  • If you need portable inference, build llama.cpp (Case Study 3) and compare tokens/sec on your hardware.

From there:

  • Automate your measurements with simple Bash scripts.

  • Record latency, throughput, and power at each iteration.

  • Use those numbers to justify your next hardware purchase—or to prove you don’t need one yet.

If you want a follow-up, ask for a deep dive on:

  • Quantizing your own models (int8/4-bit) for CPU or GPU.

  • Containerizing inference (Docker/Podman) with GPU passthrough.

  • ROCm vs. CUDA tips on Fedora/openSUSE/Ubuntu.

Linux gives you the tools. These case studies show you how to use them, today.