Training runs, graphics processing unit (GPU) monitoring, log tailing and remote shells all live on the command line. This lesson builds the working set: pipes and redirects that turn a log into an answer, tmux that keeps a run alive while you are gone, htop, nvtop and nvidia-smi that say what the hardware is doing, and SSH for the GPU box you rent by the hour.
A training run, its hardware and its logs — all in the same frame.
Training runs, GPU monitoring, log tailing and remote sessions are the four jobs of an AI terminal. One tmux session splits them into panes, so the run keeps printing in one, watch -n1 nvidia-smi reports the card in another and tail -f follows the evidence in a third. Then the detail that changes everything: you can detach (Ctrl+B then d), close the laptop, SSH back in and reattach — the server owns the panes, your window was only a view.
tmux new -s train · Ctrl+B " · Ctrl+B d · tmux attach -t train02 / LINES IN, ANSWERS OUT
Pipes and redirects turn a log into a number you can use.
A pipe hands one command's standard output to the next: cat train.log | grep "loss" | wc -l is three processes in a chain, and the answer is one number — 12,480 for the lesson's illustrative run of 10 epochs × 1,248 steps. A redirect hands the stream to a file instead: > overwrites, >> appends, 2> keeps stderr separate and 2>&1 merges it in. The order rule that decides everything: 2>&1 copies where file descriptor 1 points at that moment.
grep "loss:" train.log | awk '{print $NF}' > losses.txt · > out.log 2>&103 / THE MACHINE AND THE REMOTE BOX
Read the hardware, then reach it wherever it lives.
htop shows the processes, nvtop and nvidia-smi show the card: utilisation is how much of the last sample window the GPU spent computing, memory used out of total is what fits. Low utilisation with a live process means the data loader is the bottleneck; memory near the top is the out-of-memory (OOM) error waiting to happen. The same skills run on a rented box over SSH — Secure Shell — with scp and rsync moving files, and ssh -L 8888:localhost:8888 turning a remote Jupyter server into a local URL.
The shell is a workbench with a very short memory: pipes and redirects get the numbers out of it, tmux keeps the job alive after your window is gone, and SSH moves the whole workbench to the machine with the GPU — so the terminal stops being a place where work scrolls past and becomes the place where you steer it.
By the end you will be able to read any log with grep, awk, sort and wc, and say exactly where each stream goes with >, >>, 2> and 2>&1; choose between &, nohup and tmux for a long run — and prove the choice with jobs, ps and pgrep; split a tmux session into the three-pane workflow and detach without stopping anything; read nvidia-smi for the three verdicts that matter; reach a rented GPU box with ssh, move files with scp and rsync -avz, forward a port for Jupyter or TensorBoard, and turn all of it into aliases you will actually use.
01
WHERE THE WORK HAPPENS
You will live in the terminal. Learn the furniture.
Training runs, graphics processing unit (GPU) monitoring, log tailing, remote shells — every AI workflow touches the command line. This chapter is the map: what actually runs there, and why the terminal’s short memory is the first thing to plan around.
The word “terminal” gets used for two programs, and the distinction pays off immediately. The terminal emulator is the window — it draws text, handles the scrollbar, and forwards keystrokes. The shell is the program running inside it: bash, zsh, fish. The shell reads what you type, decides what it means, and starts programs. One line checks which one you have:
the first command of the lessonbash
$ echo $SHELL
/bin/zsh
# illustrative: on a Linux box the same command usually prints /bin/bash.# Both shells run every command in this course.
The output is a path to the shell program, not a version number. If it says zsh, read on — the source's commands and this lesson's work in bash and zsh alike.
Here is the honest scale of it: you will type more commands than you write lines of Python on some days. Four jobs live in the shell, and the rest of this lesson is one chapter per job.
The four jobs of an AI engineer’s terminal — and where each one gets its lesson.
job
what it looks like
where it is taught here
start a training run
python train.py --epochs 100 --lr 1e-4
chapters 04 and 05 — background jobs and tmux
watch the hardware
watch -n1 nvidia-smi, htop, nvtop
chapter 06 — monitoring
follow the logs
tail -f train.log, grep, wc
chapter 03 — pipes and redirects
work on a remote GPU box
ssh user@gpu-box-ip, scp, rsync
chapter 07 — remote boxes
Do the arithmetic on that terminal window. A training loop prints one line per optimiser step. The source’s example pace is modest — three steps a second — so one hour of training prints 3 × 3,600 = 10,800 lines, and a six-hour run prints 6 × 10,800 = 64,800 lines. The editor lesson configures a 10,000-line scrollback, which at three lines a second holds 10,000 ÷ 3 ÷ 60 ≈ 56 minutes of history. If the loss went bad at step 8,400 — forty-seven minutes in — it is still on screen at breakfast only because the process stopped printing; a run that keeps going buries it within the hour.
Nothing here is a warning against the terminal. It is an instrument panel: the run, the hardware and the evidence, all in one window. The skill is knowing which part of the panel remembers things — and that is what the labs below let you feel.
The log-volume calculator
Set how fast your training loop prints and how long it runs, and watch the terminal fill. Scrollback is 10,000 lines — the figure from the editor lesson — at ≈ 80 bytes a line. The rate is illustrative; the arithmetic is the kind you can do on the back of an envelope.
A RUN AGAINST A 10,000-LINE SCROLLBACK
the run prints 64,800 lines (4.9 MiB)
kept by scrollback: 10,000 lines (15.43 %)gone: 54,800 lines
The same run, four ways — all figures illustrative.
view
number
what it means
lines per hour
10,800
3 steps/s × 3,600 s — a step line is the unit a training loop prints
scrollback holds
56 min
10,000 lines ÷ 3 lines/s ÷ 60
bytes on screen
781 KiB
10,000 lines × ≈ 80 B — a terminal is cheap
bytes the run wrote
4.9 MiB
64,800 lines × ≈ 80 B — small for a file, fatal for scrollback
rate 3.0 lines/s
length 6.0 h
lines total 64,800
lines per hour 10,800
bytes total 4.9 MiB (≈ 80 B/line)
scrollback 10,000 lines ≈ 781 KiB
holds 56 minutes of this run
kept / lost 10,000 / 54,800 lines
so what do you do instead of scrolling?
tail -f train.log follow the file, not the terminal
tail -f train.log | grep --line-buffered "ERROR"
only the lines you care about
python train.py 2>&1 | tee train.log write the file *and* watch it
python train.py > output.log 2> errors.log
keep the two streams apart
grep "loss:" train.log | awk '{print $NF}' > losses.txt
extract the numbers for a plot
the rule
scrollback is a convenience with a hard limit; a file is a record.
if the number matters, get it out of the terminal and into a file.
The editor lesson configures a 10,000-line scrollback; this lab is the reason. Neither number is a measurement of your machine.
02
KNOW YOUR SHELL
Three commands to move. Four keystrokes to save hours.
You do not need a Unix history course. You need to know where you are, what is in the directory, how to find a command you typed last week, and how to stop a runaway process without killing the wrong thing.
The shell answers two questions constantly: where am I? and what is here? Three commands handle both, and they are the ones you will type thousands of times:
cd — change directory. cd ~/projects/ai-engineering-from-scratch moves you there; cd .. goes up one level; cd alone goes home. ~ is shorthand for your home directory.
pwd — print working directory. It prints the absolute path of where you are, which is the answer to “why did that command not find my file?” more often than anything else.
ls — list. ls -la adds two flags: -l for the long format (permissions, owner, size, date) and -a for all entries, including the dotfiles like .venv and .gitignore that the plain version hides. In an AI project that hidden .venv is usually the thing you were looking for.
the source's shell basics, with illustrative outputbash
$ cd ~/projects/ai-engineering-from-scratch
$ pwd
/Users/you/projects/ai-engineering-from-scratch
$ ls -la
drwxr-xr-x 9 you staff 288 Sep 1409:12 .
drwxr-xr-x 14 you staff 448 Sep 1321:40 ..
drwxr-xr-x 3 you staff 96 Sep 1409:04 .venv
-rw-r--r-- 1 you staff 2118 Sep 1409:12 train.py
drwxr-xr-x 4 you staff 128 Sep 1409:10 data
drwxr-xr-x 2 you staff 64 Sep 1409:11 logs
# illustrative output — your files, sizes and dates will differ# history search: the shortcut you will use most# Ctrl+R, then type part of a previous command# Ctrl+R again cycles to older matches
$ (reverse-i-search)`train': python train.py --epochs 10 --lr 1e-4# clear the screen
$ clear # or Ctrl+L# cancel the running command# Ctrl+C# suspend it — resume later with fg# Ctrl+Z
The four chords at the bottom are not shell commands; they are keybindings that the terminal and the kernel handle. Ctrl+R searches history, Ctrl+C sends SIGINT, Ctrl+Z sends SIGTSTP, Ctrl+L redraws. The source prints them as comments because that is how you will remember them.
Of those four, Ctrl+R is the one that pays for itself daily. The commands worth repeating in AI work are the long, exact ones — python train.py --epochs 10 --lr 1e-4 --batch-size 64 is forty-odd keystrokes to retype and one Ctrl+R plus train to recall. The search is incremental: every keystroke narrows the fragment and jumps to the most recent match; pressing Ctrl+R again without typing walks one match older. Enter runs the line, and the arrow keys drop it at the prompt so you can edit it.
Ctrl+C and Ctrl+Z are different animals. Ctrl+C sends SIGINT — interrupt — and most programs, including Python, exit. Ctrl+Z sends SIGTSTP — terminal stop — which suspends the process: it stops using the central processing unit (CPU), keeps every byte of memory it had, and waits. fg brings it back to the foreground; bg lets it continue in the background; kill %1 is how you actually end it.
The Ctrl+R history trainer
Type a fragment of a command you ran before and press Ctrl+R: the shell walks its history backwards from the most recent match. Press it again to go one match older. The history below is an illustrative twelve-command fixture.
SIMULATED TERMINAL · REVERSE SEARCH
(reverse-i-search)`train': tmux attach -t training
The fixture history, newest first — exactly how reverse search walks it.
query "train"
matches 5
showing tmux attach -t training
key presses Ctrl+R × 1
what the shell is doing
starts at the newest command and walks backwards
every keystroke narrows the fragment and restarts at the newest match
another Ctrl+R (before typing) moves one match older; at the oldest
match readline beeps and stays there — it does not wrap
Enter runs the line · Ctrl+G aborts the search · the arrow keys
leave the found command at the prompt to edit
why this is the shortcut you use most
the commands you repeat are the expensive, exact ones:
python train.py --epochs 10 --lr 1e-4
typing it again is 40 keystrokes; Ctrl+R + "train" is 6.
the neighbours
Ctrl+C cancels the running command (SIGINT)
Ctrl+Z suspends it (SIGTSTP) — resume with fg
Ctrl+L clears the screen; clear does the same thing
Ctrl+D sends end-of-input; at an empty prompt it closes the shell
this lab is a model: a real shell searches its own history file, and
zsh's wording differs — but every keystroke above behaves the same way.
Quick check
You typed a long training command yesterday and want it back. You press Ctrl+R and type “train”. What exactly is the shell doing?
03
PIPES & REDIRECTS
One command’s output is the next one’s input.
This is how logs become answers. A pipe hands a stream of lines from one program to the next; a redirect hands it to a file. The source calls the combination the thing you will use constantly — and it is right.
Every process starts with three standard streams: standard input (file descriptor 0), standard output (1, the normal results) and standard error (2, warnings and tracebacks). By default all three point at your terminal, which is why output and errors look identical on screen.
The shell’s superpower is that it can re-point those descriptors before a program starts. The pipe operator | points the left command’s standard output at the right command’s standard input — and the two run at the same time, with the kernel moving bytes between two file descriptors. It is not a temporary file, and the two programs never need to agree on anything beyond “lines of text”. Here are the recipes the source gives, with the shape of their output:
the source's log recipesbash
# count how many times "loss" appears in a log
$ cat train.log | grep "loss" | wc -l
12480# extract just the loss values from training output
$ grep "loss:" train.log | awk '{print $NF}' > losses.txt
# losses.txt now holds one number per line# watch a log update in real time, filtering for errors
$ tail -f train.log | grep --line-buffered "ERROR"
ERROR: cuda out of memory — batch 96 too large
# sort experiments by final accuracy
$ grep "final_accuracy" results/*.log | sort -t= -k2 -n -r | head -3
results/exp7.log:final_accuracy=0.9432
results/exp3.log:final_accuracy=0.9318
results/exp1.log:final_accuracy=0.9275# redirect stdout and stderr to separate files
$ python train.py > output.log 2> errors.log
# redirect both to the same file
$ python train.py > train_full.log 2>&1
Every output above is illustrative — the numbers match the lesson's running example (10 epochs × 1,248 steps = 12,480 lines) but no command here was executed on your machine. The commands themselves are the source's, verbatim.
Read the recipes one at a time; each one is a single idea.
Count.wc -l counts the lines it receives, so the pipeline’s answer is one number: 12,480 step lines in the illustrative run. Note the redundant cat — it is in the source, and grep "loss" train.log | wc -l is equivalent and starts one fewer process. Copy whichever you will remember.
Extract.awk’s $NF is the last whitespace-separated field of each line, so '{print $NF}' turns epoch 1/10 · step 100 · loss 1.8821 into 1.8821. Twelve thousand four hundred eighty of those, at roughly seven bytes each, is a losses.txt of about 85 KiB — small enough to plot, mail or commit.
Watch.tail -f follows a file as it grows, and grep keeps the lines you asked for. The flag matters: when grep’s output is a pipe rather than a terminal, it block-buffers — it collects output in a buffer of several KiB before flushing (GNU grep uses the C library’s BUFSIZ, commonly 8 KiB). At ≈ 90 bytes per log line that is on the order of a hundred lines of silence, so a rare ERROR can sit invisible while the buffer waits to fill. --line-buffered makes grep flush every line as it matches. (An estimate, not a specification — the buffer size is a GNU grep implementation detail.)
Sort.sort -t= -k2 -n -r is four flags doing one job: -t= says fields are separated by =, -k2 sorts on the second field, -n compares them as numbers (not text, which would put 0.9 above 0.88), and -r reverses to descending. Because grep searched several files, each line carries its filename — which is exactly what you want when the top line names the winning experiment.
Split.> output.log 2> errors.log keeps the megabytes of normal output away from the few kilobytes of errors, so reading the failures is a one-second command instead of a search.
Merge.> train_full.log 2>&1 puts both streams in one file, in the order they happened — the format you want when a traceback needs the line that caused it.
The pipeline simulator
Add the stages one at a time and watch the log lines move. Two of the twenty-eight fixture lines never contain “loss” — change the pattern to see the filter do more work. This is a drawing of the pipeline, not a real shell.
build the pipeline
$ cat train.log
lines read 28 (illustrative fixture)
what each stage does
cat copies the file to stdout — one line at a time
the honest note
a real training log is 12,480 step lines, not 28 — cat
just copies them; add grep to filter the mixed lines and wc -l to
turn the answer into a number you can put in a report
The source writes the first stage as cat train.log | grep "loss" | wc -l. grep "loss" train.log is equivalent and starts one fewer process — the shape above is kept because it is the one you will see in the wild.
The redirect table from the source — five symbols, no more.
symbol
what it does
the detail that bites
>
write stdout to a file, overwriting it
it truncates before the program starts, even if the program then fails
>>
append stdout to a file
the safe choice for a log you cannot re-create
2>
write stderr to a file
only descriptor 2; stdout still goes to the terminal
2>&1
send stderr to the same place as stdout
it is a copy of fd 1’s destination at that moment — order decides
|
send stdout of one command as stdin to the next
stderr is not piped; add 2>&1 before the pipe to catch it
The redirect visualizer
Redirect the two streams, then run the script. Run it twice with > selected and watch the first run’s lines disappear — that is the truncation at work. The files live in this page; the script and its nine lines of output are illustrative.
presetsstdout (file descriptor 1)stderr (file descriptor 2)the order of the two
$ python train.py > output.log 2>&1
2>&1 copies fd 1 after it points at output.log — output.log receives both streams, in the order they happened.
output.log 0 lines · 0 B
errors.log 0 lines · 0 B
terminal 0 lines
output.log contents
(empty)
errors.log contents
(empty)
remember
> truncates at run start, then writes
>> keeps what was there and appends
2> same idea for stderr
2>&1 copies fd 1's current destination — order decides the answer
Byte counts are string lengths, not real file sizes — a teaching stand-in. What is exact is where each of the nine lines lands, which is routeLine obeying the shell’s rules.
Quick check
You want only the lines of train.log that contain “loss” saved into losses.txt. Which command does that?
Three more patterns you will meet constantly. The first is tee, which writes a stream in two directions at once — into a file and onwards — so you can watch a run and keep the record. The source’s version even pings you when it is done:
the patterns that keep showing upbash
# run training, log everything, announce when done
$ python train.py 2>&1 | tee train.log; echo "DONE" | mail -s "Training complete" you@email.com
# tee writes the file and the screen; the mail half needs a configured# mail command — the part that matters here is "2>&1 | tee"# compare two experiment logs side by side
$ diff <(grep "accuracy" exp1.log) <(grep "accuracy" exp2.log)
5c5
< accuracy=0.9214
---
> accuracy=0.9432# <( ) is process substitution: bash/zsh present each grep as a temp path# how big is the project, really?
$ find . -name "*.py" | xargs wc -l | tail -14187 total
Process substitution (<( )) is a bash/zsh feature, not POSIX sh — the same trick in a plain shell needs temp files. And xargs attaches the file names find produced as arguments to wc; the last line of wc's output is the total.
04
BACKGROUND & PERSISTENCE
Leave the run alone. Just do not leave it behind.
A training run takes hours. The terminal you started it in is one closed lid away from killing it. Three tools solve that — one pretends to, one works but goes mute, and one keeps a door open.
A trailing & tells the shell “start this program, then give me my prompt back”. The process becomes a background job of that shell: it keeps printing to the terminal (which gets messy), and the shell keeps a handle on it — jobs lists it, fg %1 brings it back to the foreground, and kill %1 ends it. All of that machinery lives inside that one shell, which is the detail that decides everything that follows.
the source's background commandsbash
# run in background (output still goes to the terminal)
$ python train.py &
[1] 18422# run in background, immune to hangup:
$ nohup python train.py > train.log 2>&1 &
[1] 18466# check what is running in the background
$ jobs
[1]+ Running python train.py &
$ ps aux | grep train.py
you 1842294.23.1 ... python train.py
# bring a background job to the foreground
$ fg %1# kill a background job
$ kill %1# or find its PID and kill that
$ kill $(pgrep -f "train.py")
Illustrative output: the [1] 18422 line is how bash reports a background job — job number in brackets, process ID (PID) after it. No nohup message appears here because the command already redirects stdout: nohup only prints 'ignoring input and appending output to nohup.out' when its output still points at the terminal.
What happens when the terminal closes? The window is the terminal emulator; when it exits, the kernel closes the controlling terminal and sends SIGHUP — signal 1, “hang up”, a name inherited from telephone modems — to the processes in that terminal’s session. The default action for SIGHUP is to terminate.
nohup changes exactly one thing: the process starts with SIGHUP ignored. It does not detach the process from your terminal in any other way, and it does not give you a way back in — that is what tmux (next chapter) is for. One documented detail worth knowing before it surprises you: when a nohup’d command’s output still points at a terminal, nohup quietly appends it to a file called nohup.out in the current directory (or in $HOME if that is not writable). That is why the source writes > train.log 2>&1 explicitly: it makes the destination a decision instead of a surprise.
The source’s comparison table — the two questions that decide which tool you need.
method
survives terminal close?
can reattach?
use it when
command &
no — SIGHUP terminates it
no
a job of seconds or minutes while you stay in the shell
nohup command &
yes — SIGHUP is ignored
no — read the log file instead
a long job whose only output is a file you will read later
tmux
yes — the server owns the terminal
yes — tmux attach -t name
anything longer than a few minutes, which is most training
The background-lifecycle board
Pick how a training run was started, then pick what happens to the terminal. The answers are the source’s comparison table — close the terminal does not mean the same thing to &, nohup and tmux.
& × CLOSE THE TERMINAL
✗ SIGHUP → the run dies
the job is in the terminal's session; closing the window sends SIGHUP and the default action terminates. `&` is background, not persistence.
started as: python train.py &
The source’s comparison table, as a grid — each cell answers one runner/event pair.
method
close the terminal
reattach in a new terminal
stop it
&
nohup
tmux
how the run was startedwhat happens next
runner & (python train.py &)
event close the terminal
outcome ✗ does not survive — SIGHUP → the run dies
this runner
survives terminal close no
reattach to the session no
the three methods, side by side
& close: dies reattach: no background of this shell
nohup close: survives reattach: no output in train.log
tmux close: survives reattach: yes panes and scrollback
kill recipes
kill %1 the job table of this shell
kill $(pgrep -f "train.py") the PID, when the shell is gone
pkill -f "python.*train" what the lesson ships: killtraining
tmux kill-session -t training ends the session and its panes
what a hangup costs
a 6-hour run at an example $2.34 per GPU-hour dies at hour 5 →
≈ 5 × $2.34 ≈ $11.70 for nothing, plus every hour since the last
checkpoint (illustrative arithmetic, not a market rate)
The lesson’s rule of thumb: for anything longer than a few minutes, use tmux — the other two are for quick jobs and for cases where a file is the only output you need.
Quick check
You start a run with `python train.py &` in an SSH session, close your laptop, and come back an hour later. What is most likely true?
What a hangup costs, roughly. Suppose the run is six hours long and the machine costs an example $2.34 per GPU-hour. If SIGHUP lands at hour five, the money spent is about 5 × 2.34 ≈ $11.70 — and every hour since the last checkpoint is work you will pay for twice. If checkpoints land hourly, the loss is about an hour of compute; if the script only saves at the end, it is all of it. That is the arithmetic behind the lesson’s rule: anything longer than a few minutes goes in tmux. (Rates and durations here are illustrative; your provider’s invoice is the real one.)
Two closing details for the toolbox. kill sends SIGTERM by default — a polite request that a well-written program can catch, clean up on, and honour; kill -9 sends SIGKILL, which cannot be caught and exists for the case where SIGTERM was ignored. And if you started a job in the background of a shell you want to close anyway, disown removes the job from the shell’s table so it will not be hung up with it — a bash/zsh builtin, and the third escape hatch the source does not mention because tmux makes it obsolete.
05
TMUX, THE KEEPER
One terminal. Three jobs. Any machine, any time.
tmux — the terminal multiplexer — is the single most useful tool in the source for managing training runs. It splits one window into panes, keeps them alive in a server that outlives your window, and lets you walk back in from anywhere.
The mental model has three parts. A server process owns pseudo-terminals and runs independently of any window. A session is a named collection of those terminals inside the server. A client is your window attaching to the server — a view, nothing more. That split is why closing your terminal, losing Wi-Fi or moving to another machine changes nothing about the run: the server never depended on the client.
Every command is prefixed with one chord, Ctrl+B by default: you press it, release, then press the command key. It feels clumsy for about a day and then becomes muscle memory.
tmux, from install to a named sessionbash
# install# macOS
$ brew install tmux
# Ubuntu
$ sudo apt install tmux
# start a named session
$ tmux new -s training
# inside tmux, every command starts with the prefix key# split the active pane so one sits above the other# Ctrl+B, then "# split the active pane so two sit side by side# Ctrl+B, then %# move between panes# Ctrl+B, then an arrow key# detach — the session keeps running# Ctrl+B, then d
$ tmux attach -t training # come back
$ tmux ls # list sessions
training: 1 windows (created Tue Sep 1509:12:042026)
$ tmux kill-session -t training # end it on purpose
The quoted keys are literal: prefix then a double-quote gives you two panes stacked, prefix then a percent sign gives you two side by side. The tmux ls output is illustrative; the session name, window count and creation time are what it prints.
The source’s three-pane workflow is the reason to learn all of it. A training run occupies one pane and prints for hours; the hardware wants a second pane with watch -n1 nvidia-smi; the evidence wants a third with tail -f logs/experiment.log. One window, three live views, and — the important part — you can detach from all three, go home, SSH back in from another machine, and find them exactly where you left them.
the source's three-pane training sessionbash
$ tmux new -s train
# pane 1: start training
$ python train.py --epochs 100 --lr 1e-4# Ctrl+B, " to split, then run the GPU monitor
$ watch -n1 nvidia-smi
# Ctrl+B, % to split vertically, then tail the logs
$ tail -f logs/experiment.log
# now detach with Ctrl+B, d# SSH out, go get coffee, come back
$ tmux attach -t train
Layout note: the first split puts pane 2 below pane 1; the second splits whichever pane is active, so after Ctrl+B then the double-quote key followed by Ctrl+B then the percent key, you have a wide training pane on top and the monitor and log panes beneath it. Ctrl+B then an arrow key decides which pane is active before splitting.
The tmux session simulator
Create a session, split it, run a command in each pane, then detach and fast-forward five simulated minutes: the training pane keeps counting because the server owns it. This is a drawing of tmux’s state machine — no real session runs here.
the sessionsplit the active panethe active pane (Ctrl+B then an arrow key)attach / detach
no session yet — create one with tmux new -s training
session none
panes —
prefix Ctrl+B · " split above/below · % split side by side · d detach
scrollback 2,000 lines per pane (tmux's documented default history-limit)
the run 100 epochs × 45 s ≈ 75 min of training — long enough to detach
what survives what
closing your terminal tmux server keeps the session; a plain & job would die
detaching nothing stops; the client just stops looking
reattaching same panes, scrollback and live output
tmux kill-session panes die and their processes get SIGHUP — on purpose
the source's workflow
tmux new -s train
pane 1: python train.py --epochs 100 --lr 1e-4
Ctrl+B then " → watch -n1 nvidia-smi
Ctrl+B then % → tail -f logs/experiment.log
Ctrl+B then d → detach · tmux attach -t train → back
Time is simulated and the pane output is generated — the step counter advances at ~3 steps per second like the lesson’s example run. What is real: a detached tmux session does keep running.
Detach is not stop, and close is not detach. The simulator above exists to make that difference visible: pressing Ctrl+B d closes your view while every process keeps running and every pane keeps its scrollback. Killing the session — tmux kill-session -t training — does the opposite: the panes are destroyed and their processes receive SIGHUP, exactly as if you had closed the window on a background job. And tmux keeps its own scrollback per pane, with a documented default history-limit of 2,000 lines: the same lesson as the terminal’s 10,000-line buffer, one layer down. (Need more? set -g history-limit 50000 before creating panes; need to see one pane full-window? Ctrl+B z zooms it, and Ctrl+B x closes it.)
A small trap to file away: screen — the older tool with the same idea — uses Ctrl+A as its prefix, so advice written for screen will look wrong in tmux. Same concept, different chord; when a keybinding from a tutorial does nothing, check which multiplexer it was written for.
06
WATCHING THE MACHINE
A run is never simply slow. It is one of three states.
The processor is busy, the processor is waiting on data, or the processor is out of memory. htop, nvtop and nvidia-smi are how you tell which — and the aliases at the end of the chapter turn the longest of those commands into three letters.
Start with htop, the source’s preferred version of top: a live list of processes with colour-coded central processing unit (CPU) and memory meters, and — the part that makes it a tool rather than a display — keys for finding, sorting and killing. On a GPU box those processes include the python running your training, and the reason htop matters is that “the machine is slow” usually turns out to be a specific number about a specific process.
Then nvidia-smi — the NVIDIA System Management Interface — for the card itself, and nvtop, which is htop’s idea applied to GPUs: per-process utilisation and memory, updating live. Two numbers carry almost all the information you need: utilisation (what fraction of the last sample window the GPU spent executing kernels) and memory used out of total.
the source's monitoring commandsbash
# system processes (better than top)
$ htop
# GPU processes, if you have an NVIDIA GPU# install: sudo apt install nvtop (Ubuntu) · brew install nvtop (macOS)
$ nvtop
# quick GPU check without nvtop
$ nvidia-smi
# watch GPU usage update every second
$ watch -n1 nvidia-smi
# see which processes are using the GPU
$ nvidia-smi --query-compute-apps=pid,name,used_memory --format=csv
pid, name, used_memory
18422, python, 6842 MiB
18466, python, 7494 MiB
# illustrative values — the two processes sum to 14,336 MiB, which is the# memory.used the plain table reports as ~14.0 GB of 24 GB
The query takes three decisions: what to ask for (pid,name,used_memory), how to separate it (csv — comma-separated values), and whether to include the header row (the source's gpu alias adds ,noheader). Learn it once and you can reshape the table for any script.
The htop keys the source lists, and what each one is for.
key
action
the AI-work reason
F5
toggle tree view
see the dataloader workers as children of your training process
F6 or >
sort by a column
sort by memory to find the leak that grows every epoch
F9
kill the selected process
end the run you cannot find the terminal for
/
search for a process name
jump to train.py on a shared machine
The nvidia-smi decoder
Move the two numbers a GPU actually reports and read the verdict. Utilisation near zero with memory held is the data-loader trap; memory near the top is the OOM you will meet. Figures are the lesson’s illustrative readings.
ONE READING · TWO NUMBERS THAT MATTER
GPU-Util 78 % — busy in the last sample window
Memory 14,336 / 24,576 MiB · 58 %
healthy
78 % utilisation with 58 % of memory in use — the GPU is the bottleneck, which is where you want it to be.
What the source’s two commands print — the source fixture, illustrative.
command
output
nvidia-smi
GPU: 78% · Mem: 14.0/24.0 GB
nvidia-smi --query-compute-apps=… --format=csv
18422, python, 6842 MiB · 18466, python, 7494 MiB
--query-gpu=…
NVIDIA GeForce RTX 4090, 14.00 GiB, 24.00 GiB
readings worth knowing
reading util 78 % · mem 14,336 / 24,576 MiB (58 %)
verdict healthy
the two processes the query shows (source fixture)
pid 18422 python 6,842 MiB
pid 18466 python 7,494 MiB
sum 14,336 MiB — the same memory.used the card reports
what the numbers mean
utilization.gpu % of the last sample window the GPU spent executing kernels
memory.used MiB allocated by every process on the card
temperature.gpu °C — the other number in the gpu alias
the three readings you will act on
util 0 % + process alive the data loader is the bottleneck → num_workers
memory near the top shrink the batch, or the next step is CUDA OOM
util high + memory moderate the GPU is the bottleneck — leave it alone
how to watch instead of guess
watch -n1 nvidia-smi reprints the whole table every second
nvtop interactive, per-process, like htop for GPUs
alias gpu='nvidia-smi --query-gpu=index,name,utilization.gpu,
memory.used,memory.total,temperature.gpu --format=csv,noheader'
alias gpuprocs='nvidia-smi --query-compute-apps=pid,name,used_memory --format=csv'
every number here is illustrative — this page does not talk to a GPU.
Now the shortcuts. The commands above are long, exact and repeated — the ideal candidates for aliases. The lesson ships a file of them; the four the source highlights are these:
the four aliases from code/shell_aliases.shbash
# GPU status at a glance
alias gpu='nvidia-smi --query-gpu=index,name,utilization.gpu,memory.used,memory.total,temperature.gpu --format=csv,noheader'# kill all Python training processes
alias killtraining='pkill -f "python.*train"'# quick virtual environment activate
alias ae='source .venv/bin/activate'# watch training loss
alias watchloss='tail -f logs/*.log | grep --line-buffered "loss"'
Add them to ~/.zshrc or ~/.bashrc and reload with source ~/.zshrc — or source the whole file the way the lesson suggests: source phases/00-setup-and-tooling/10-terminal-and-shell/code/shell_aliases.sh. The file also defines gpuwatch (watch -n1 nvidia-smi), gpumem, gpuprocs, watcherr, watchacc, diskuse, bigfiles, bigmodels, checkcuda, checkenv, checkgpu, the tmux shortcuts ta/tls/tn/tk, the rsync wrappers syncto/syncfrom, psg, memhogs and the trainenv layout function.
Disk space is a training problem too. Datasets and checkpoints fill a disk without asking, and a full disk turns a working run into a crash whose message mentions everything except space. The two commands to reach for: df -h for a whole-filesystem view in human units, and du -sh ./data/* to find which directory inside the project is the culprit. When you know it is models, the source’s find . -name "*.pt" -o -name "*.safetensors" | xargs du -h | sort -rh | head -20 ranks the biggest files first — -size +100M narrows the search if the list is long.
And before blaming your code for a CUDA error, ask the environment: env | grep -i cuda and env | grep -i torch print the environment variables that decide which CUDA libraries are even visible — CUDA_VISIBLE_DEVICES is the one that silently hides a GPU from a process.
Quick check
nvidia-smi shows GPU-Util 2 % while your training process is alive and using 14 GB. What is the most useful reading of that?
07
REMOTE BOXES
The GPU is in another building. Go get it.
When you rent a cloud GPU — Lambda, RunPod, Vast.ai — the machine is reached with SSH, fed with scp and rsync, and watched through a forwarded port. This chapter is the four commands plus the one habit that saves the most time.
SSH stands for Secure Shell: an encrypted protocol for running commands on a remote machine. ssh user@host opens an interactive shell there; everything you learned in the last six chapters applies to it unchanged, because it is a shell — just one whose window is a network connection. A private key replaces the password, and -i names it explicitly:
the source's SSH commandsbash
# basic connection
$ ssh user@gpu-box-ip
# with a specific key
$ ssh -i ~/.ssh/my_gpu_key user@gpu-box-ip
# copy files to the remote
$ scp model.pt user@gpu-box-ip:~/models/
# copy files from the remote
$ scp user@gpu-box-ip:~/results/metrics.json ./
# sync a whole directory (faster for many files)
$ rsync -avz ./data/ user@gpu-box-ip:~/data/
# port forward: reach the remote Jupyter/TensorBoard locally
$ ssh -L 8888:localhost:8888 user@gpu-box-ip
# now open localhost:8888 in your browser# an SSH config entry turns all of that into one word# ~/.ssh/config:# Host gpu# HostName 192.168.1.100# User ubuntu# IdentityFile ~/.ssh/gpu_key
$ ssh gpu
The config example's address is the source's; use your instance's real address (documentation ranges like 203.0.113.0/24 belong to RFC 5737 and are never routable). Once the Host block exists, ssh, scp and rsync all accept the alias — scp model.pt gpu:~/models/ works exactly like the long form.
scp or rsync?scp copies what you point it at, every time — perfect for a single small file you will move in one attempt. rsync -avz compares the two sides first and sends only what differs, so it is the tool for anything large or retry-expensive — a dataset, a results folder, a checkpoint. The three flags: -a archive mode (recurse, preserve permissions and times), -v verbose (print what is happening), -z compress on the wire — a real win for text and a small one for already-compressed files like .npz. Add --partial and a dropped transfer resumes instead of starting over.
Price it once, and the habit sticks. An illustrative dataset directory: 240 files × 12 MB = 2,880 MB. Send it the first time and both tools move all 2,880 MB — rsync has nothing to compare against. Edit three files and send it again: scp -r moves 2,880 MB again, while rsync -avz moves 3 × 12 = 36 MB. That is 80× less traffic, and at an example 10 MB/s link it is the difference between about 4 min 48 s and about 3.6 seconds. The lab below lets you change the number of changed files and rerun both. (Times and sizes are teaching estimates on a constant link; the comparison is the point, not the stopwatch.)
The scp-versus-rsync transfer lab
A 2.88 GB directory, a few files edited, and two ways to send it to the GPU box. Change the number of changed files and run the second sync. Byte counts and times are teaching estimates on a constant link.
link speed (illustrative)
directory ./data · 240 files × 12 MB = 2.81 GB
sync second · 3 of 240 files changed
link 50 MB/s (estimate)
bytes on the wire
scp -r 2.81 GB · 58 s
rsync -avz 36 MB · 0.7 s
ratio 80× less with rsync
rsync -avz, decoded
-a archive mode: recurse, and preserve permissions, times and symlinks
-v verbose: print each file as it is considered and transferred
-z compress the bytes on the wire — a real win for text, small for already-compressed .npz
the trailing-slash rule
rsync -avz ./data/ gpu:~/data/ copies the *contents* of ./data
rsync -avz ./data gpu:~/data/ copies the directory itself, as ~/data/data
scp has no such rule: scp -r ./data gpu:~/data/ always copies the directory
so when does scp win?
one small file that fits in one attempt — scp model.pt gpu:~/models/ is perfect
anything large or retry-expensive → rsync -avz --partial --progress
OpenSSH 9+ note: scp now speaks the SFTP protocol underneath, which is
why sftp (and rsync-over-ssh) are the forward-looking options
The harmonized rule: a single small file that will fit in one attempt goes with scp; anything large or retry-expensive goes with rsync -avz --partial --progress — --partial is the flag that makes the second attempt cheap.
Port forwarding is how remote tools become local ones. A Jupyter server started on the box with --no-browser is deliberately unreachable from the internet; ssh -L 8888:localhost:8888 user@gpu-box-ip opens port 8888 on your machine and carries the traffic, encrypted, to a service listening on the box’s own loopback. Read the three parts as local-port : remote-host : remote-port — and note that localhost in the middle is resolved on the remote side, which is why the same command works for any service bound to the box’s loopback. TensorBoard’s default port, 6006, is the other one you will forward this way. By default the local end of -L binds to 127.0.0.1, so only your machine can use the tunnel; that is a feature, and it is why you should not add the option that exposes it unless you mean to share.
Getting data onto the box is usually three commands: download the archive on the remote (or locally), unpack it, and check the disk before and after. wget fetches a URL — wget https://huggingface.co/model/resolve/main/model.safetensors is the source’s model example — and tar xzf dataset.tar.gz -C ./data/ unpacks a gzipped tarball into place. The flags decode as x extract, z gzip, f use this file, -C change to this directory first. Then df -h and du -sh ./data/* confirm that a 200 GB dataset did not fill the disk you need for checkpoints.
When each tool comes up — the source’s “Use It” table, expanded with where it lands in this course.
tool
when you use it
tmux
every training run, Phases 3 and beyond
tail -f + grep
monitoring training logs (this lesson; used throughout)
nohup / &
quick background tasks that write a file and nothing else
working on cloud GPUs, moving datasets and checkpoints
piping + redirects
processing experiment results into numbers you can plot
aliases
saving time on the commands you type fifty times a day
One honest update to the source. Since OpenSSH 9.0, scp is implemented on top of the SFTP protocol (SSH File Transfer Protocol) rather than the old SCP protocol — same command, same flags for the common cases, a different engine underneath. Nothing in this lesson changes because of it; the practical consequence is that sftp and rsync-over-SSH are the forward-looking ways to move files, while scp stays as the quick answer it has always been.
08
CHECK YOURSELF
Five questions. Then the terms worth keeping.
Answer before you look. The tmux-versus-nohup question and the redirect-order question are the two that separate “I can type the commands” from “I know why the commands are shaped that way” — and the port-forwarding question is the one people meet on their first rented GPU box.
0 / 5 answered · 0 correct
01What does the pipe operator '|' do in a shell command?
02What happens to a running process when you close the terminal that started it?
03What is the key advantage of tmux over using 'nohup command &' for long-running training jobs?
04What does 'python train.py > output.log 2>&1' accomplish?
05Which command lets you access a remote Jupyter notebook running on port 8888 of a GPU box from your local browser?
Key terms, demystified
Click a card to swap the lazy description for what it actually means.
Exercises from the lesson
Four problems with exact commands — build a three-pane tmux session and prove it survived a detach, adopt the aliases and prove one works, generate a fake training log and extract its losses, and set up an SSH config entry. Try first; a worked answer is one click away.
Install tmux, create a session with three panes, run htop in one, watch -n1 date in another, and a Python script in the third — then detach, reattach, and prove the script kept running while you were gone.Show one worked answer
brew install tmux (macOS) or sudo apt install tmux (Ubuntu), then tmux new -s practice: that is pane 1. Ctrl+B then " splits it into top and bottom; Ctrl+B then % splits the current pane left/right, so three panes are two splits. Navigate with Ctrl+B then an arrow key, and give each pane a job: htop, watch -n1 date, and python -c "import time; print('start'); [print(f'tick {i}') or time.sleep(1) for i in range(1, 61)]" — a one-liner that prints a tick a second for a minute. Now count five ticks, then Ctrl+B then d to detach. The window is back at your normal prompt; the panes are not gone. Wait a minute, then tmux attach -t practice: the Python pane has reached the end of its count, and the date pane is still refreshing — the detach did not stop either of them, because the tmux server owns the pseudo-terminal and your window was only ever a view. Verify a session's state from outside with tmux ls (it prints the session name, its window count and whether a client is attached), and finish with tmux kill-session -t practice. A detail that makes the experiment honest: run `date` inside the pane before detaching and after reattaching — the clock moved, the process did not restart, which is exactly what a detach is.
Add the aliases from code/shell_aliases.sh to your shell config, reload it, and prove one alias works — then explain what the file would do if you sourced the whole thing instead of copying the four lines.Show one worked answer
The four the source highlights are: alias gpu='nvidia-smi --query-gpu=index,name,utilization.gpu,memory.used,memory.total,temperature.gpu --format=csv,noheader'; alias killtraining='pkill -f "python.*train"'; alias ae='source .venv/bin/activate'; and alias watchloss='tail -f logs/*.log | grep --line-buffered "loss"'. Add them to ~/.zshrc (macOS's default shell) or ~/.bashrc, then reload with source ~/.zshrc — or, to try the whole file first, source phases/00-setup-and-tooling/10-terminal-and-shell/code/shell_aliases.sh, the command the lesson itself gives. Prove one: ae in a project directory with a .venv activates the environment (the prompt usually gains a (.venv) marker), and gpu prints one CSV line per card — index, name, utilisation, memory used, memory total, temperature — which is the same table nvidia-smi prints, minus the box drawing. Sourcing the whole file adds the rest of the inventory: gpuwatch and gpumem for monitoring, gpuprocs for the compute processes, watcherr and watchacc for the other two log patterns, diskuse and bigfiles for the disk-space problems training data creates, checkcuda for env | grep -i cuda, the tmux shortcuts ta/tls/tn/tk, the syncto/syncfrom rsync wrappers, and trainenv — a function that builds the lesson's three-pane layout with a single call by starting a detached session, splitting it twice and send-keys-ing watch -n1 nvidia-smi and htop into panes 1 and 2. One caution: the file is a set of shortcuts, not a program — read each alias before you adopt it, because killtraining and killtrain() really do kill every process matching "python.*train".
Create a fake training log with the source's one-liner, then use grep, tail and awk to extract just the loss values. Say exactly what each stage contributes and what the numbers should be.Show one worked answer
The source's generator: for i in $(seq 1 100); do echo "epoch $i loss: $(echo "scale=4; 1/$i" | bc)"; sleep 0.1; done > fake_train.log — one hundred lines, one per tenth of a second, each shaped epoch N loss: 0.XXXX (the value is 1 ÷ i to four decimals, so the first is 1.0000 and the hundredth is .0100). Three extractions, each one lesson's tool: (1) grep -c "loss" fake_train.log prints 100 — every line contains the word, so the filter changes nothing yet; (2) grep "loss:" fake_train.log | awk '{print $NF}' > losses.txt writes one number per line, because awk splits on whitespace and $NF is the last field — the newline-separated file is what a plotting script or np.loadtxt can read; (3) wc -l losses.txt confirms 100 lines. The tail half of the exercise is the live view: tail -f fake_train.log | grep --line-buffered "loss" follows the file while the loop is still writing (Ctrl+C stops it), and the --line-buffered flag is what makes each match appear immediately instead of in buffered bursts. Add the honest caveats: the generator uses bc, which exists on macOS and most Linux systems but is not guaranteed everywhere (awk or python -c prints the same values), and the 0.1 s sleep makes the whole file take about ten seconds — which is why it is a fake log and not a real training run. The real thing prints the same shape of line, at roughly 3 a second instead of 10.
Set up an SSH config entry so that `ssh gpu` connects to a host — using localhost to practise the syntax if you do not have a remote machine — then write down what each line fixes and what changes when a key is required.Show one worked answer
Create ~/.ssh/config (chmod 600) with: Host gpu / HostName 203.0.113.50 / User ubuntu / IdentityFile ~/.ssh/gpu_key / ServerAliveInterval 60. The Host line is the alias you will type — after the file is saved, `ssh gpu` expands to ssh ubuntu@203.0.113.50 -i ~/.ssh/gpu_key. HostName is the real address (203.0.113.50 is from the documentation block TEST-NET-3, RFC 5737 — replace it with your instance's IP; it is never a routable host). User is the account name your provider assigned. IdentityFile names the private key, which is what makes this answer to the “with a specific key” form: ssh -i ~/.ssh/my_gpu_key user@gpu-box-ip and the config line are the same instruction, one typed and one stored. ServerAliveInterval 60 makes the client send a keepalive every minute so an idle connection is not dropped by a NAT or a load balancer — insurance for a long quiet training session. To practise locally: run ssh localhost (macOS has Remote Login off by default — enable it in System Settings → General → Sharing) or merely verify the alias expands by adding -v: ssh -v gpu prints the configuration lines it matched before it tries to connect. The honest check: the connection either completes, or the error names the layer that failed — DNS, port 22, the key, or the password prompt — which is exactly the debugging habit the lesson wants you to build.
Terms this lesson borrows from later lessons (or outside)
You do not need to master these here. Each one gets a proper treatment in its own lesson; the one-line meaning is enough to keep reading. Orange dotted underlines in the prose point back to this list.
GPU utilisation & VRAM budgeting — What the numbers in nvidia-smi mean and when they say “the run is data-starved” versus “this batch will not fit”. This lesson reads the table; Phase 0, Lesson 03 (GPU Setup & Cloud) teaches the VRAM budget behind the memory-used column.
SSH keys & remote editing — The ed25519 key pair and ssh-copy-id step that remove the password prompt, and the editor session that runs on the other end of the tunnel. Phase 0, Lesson 08 (Editor Setup) sets up Remote SSH, and Phase 0, Lesson 03 (GPU Setup & Cloud) covers connecting to a rented instance for the first time.
Training loops, epochs & checkpoints — The program producing every line in train.log: steps, epochs and the .pt checkpoint files that `find . -name "*.pt"` hunts for. Phase 3, Lesson 11 (Introduction to PyTorch) writes the loop, and Phase 0, Lesson 07 (Docker for AI) explains the ≈ 14 GB fp16 model file those commands keep finding.
NaN losses & failed runs — The failure that sends you back to the terminal to grep a log: a loss that becomes nan at step 8,400, an OOM that kills the process, a dataloader that stalls. Phase 3, Lesson 13 (Debugging Neural Networks) is this lesson's commands turned into a debugging method.
Remote notebooks & dashboards — What the forwarded port is for — a Jupyter kernel or a TensorBoard session running on the GPU box and rendered in your browser. Phase 0, Lesson 05 (Jupyter Notebooks) covers the notebook side; this lesson's Chapter 07 (Remote Boxes) covers the `ssh -L` tunnel itself, including why a server bound to the box's localhost is reachable only through it.
KEEP GOING
A picture is a start. Practice is the rest.
This lesson is a port of an open course. Everything here traces back to it — and the next step is running the code yourself.
Lesson text, quiz and the alias definitions (from code/shell_aliases.sh) are adapted from AI Engineering from Scratch (Phase 00, Lesson 10) and the Math Foundations Notebook reference build. The eight labs — the pipeline simulator, the redirect visualizer, the tmux session simulator, the background-lifecycle board, the scp/rsync transfer lab, the Ctrl+R history trainer, the nvidia-smi decoder and the log-volume calculator — are original to this page, as is the arithmetic they work with: the log-volume estimates (3 lines/s → 10,800 lines/h; a 10,000-line scrollback ≈ 56 minutes at that rate, ≈ 780 KiB at ≈ 80 bytes per line); the illustrative run of 10 epochs × 1,248 steps = 12,480 lines, the ≈ 7-byte loss values ≈ 85 KiB in losses.txt, and the on the order of 90 lines a GNU grep block buffer (BUFSIZ, commonly 8 KiB) delays without --line-buffered; the redirect semantics (order-sensitive 2>&1, truncation at run start); the background-lifecycle outcomes and the invented $2.34 per GPU-hour example rate ≈ $11.70 for a hangup at hour five; the tmux three-pane workflow with the documented 2,000-line default history-limit; the GPU reading (two compute processes of 6,842 and 7,494 MiB summing to 14,336 MiB of 24,576 MiB ≈ 58 %, with the three verdicts); and the transfer comparison (240 files × 12 MB = 2,880 MB versus 36 MB after three changed files ≈ 80× less, times at an illustrative constant link speed). Every simulated byte, second, byte count and card reading is labelled in its lab or in the prose as a teaching estimate; the commands, the redirect table, the tmux keybindings, the htop keys, the nvidia-smi queries, the SSH examples and the alias definitions are the source's. The two documentation addresses (203.0.113.50, RFC 5737 TEST-NET-3) are examples only.