๐ง Memory Management in the Age of AI
Why Your GPU Says โOut of Memoryโ Before Your Model Says Hello

Hey there! ๐ I'm Hardeep Jethwani (HJ), your resident cloud aficionado and code maestro, proudly navigating the ever-changing seas of AWS Cloud and Full Stack Development for ~5 glorious years and counting. โ๏ธ๐ป
Currently, I'm orchestrating the tech symphony as part of Team HSBC Bank, where I'm on a mission to enhance the banking experience through the magic of technology. ๐๐ผ
In my past life at Capgemini, I led exciting adventures like migrating critical applications to the cloud (18 and counting!). I had databases waltzing into the AWS Cloud, sprinkling a bit of containerization magic along the way. AWS managed services like RDS, Lambda, ECS, and friends? They were my trusty sidekicks. ๐ฉ๐ง
When not automating deployments with CI/CD finesse (think AWS CodePipeline, CodeBuild, and CodeDeploy), you might find me designing infrastructure like a digital architect using AWS CloudFormation. Security is my jam โ I've got WAF, Security Groups, MFA, Cognito, and even a secret club in private subnets to keep things safe. ๐๐โโ๏ธ
On top of all that, I'm on a mission to reduce carbon footprints because, why not? HSBC's commitment to sustainability is my heart and soul. We're going for NET ZERO carbon footprint, and I'm leading the charge, one container at a time! ๐๐ฑ
And yes, the fun doesn't stop at work. In my past life at Tata Consultancy Services, I co-created a multi-tier Point of Sale application with a global footprint, touching the lives of billions. My automation tools were so efficient that even Father Time was left scratching his head. โณ๐ก
If you're in need of a cloud-savvy comedian or a code deployment magician, look no further. Let's chat about tech, swap automation tales, or share some coding humor over a virtual coffee. Oh, and don't worry; I promise not to write code in my sleep (well, most of the time). Cheers to cloud adventures! โ๐
A beginner-friendly (though a long one) guide to variables, references, pointers, stack and heap memory, reference counting, garbage collection, memory leaks, GPU memory, and practical AI memory optimization using
๐ Every AI Engineer Eventually Meets This Guy
You finally save enough money.
You buy an expensive GPU.
You watch twenty videos titled:
โRun Any LLM Locally in Five Minutes!โ
Confidence level: 100%.
You type:
model.generate("Hello, AI!")
Three seconds later:
RuntimeError: CUDA out of memory
You stare at the screen.
You stare at your GPU.
Then you stare at the product box that proudly says:
24 GB VRAM
Your model has only โa few billion parameters.โ
Surely it should fit.
Right?
The GPU quietly replies:
โBrother, I never agreed to this.โ ๐ญ
Maybe your error looks different:
MemoryError
Killed
OOMKilled
Cannot allocate tensor
ResourceExhaustedError
Segmentation fault
Different language.
Different framework.
Different machine.
Same villain:
Memory Management
AI systems are excellent at generating text, images, music, code, and invoices from cloud providers.
But before an AI model can think, predict, classify, retrieve, or hallucinate confidently, it must first fit into memory.
That is why memory management is not an optional โadvancedโ topic for AI engineers.
It is the difference between:
Model loaded successfully โ
and:
Process killed at 99% loading โ
๐ค Why AI Engineers Must Understand Memory
A real AI application may keep all of these alive:
Model weights
Tokenizers
Conversation histories
Prompt templates
Embeddings
Vector indexes
Retrieved documents
Attention tensors
Activations
Gradients
Optimizer states
KV caches
API responses
Request queues
Logs and metrics
Cached outputs
One giant notebook variable nobody remembers creating
A small script may run perfectly with 100 records.
Then production introduces:
10 million records
5,000 concurrent users
Longer prompts
Larger batches
Multiple models
Repeated inference
GPU acceleration
Suddenly:
RAM fills up
VRAM fills up
The operating system kills the process
The service restarts
The monitoring dashboard turns red
Everyone begins blaming Kubernetes
Memory management affects:
Training speed
Inference latency
Batch size
Context length
Model size
Number of concurrent users
Cloud cost
Stability
Scalability
In short:
Your model may be intelligent, but it still needs somewhere to live.
๐จ Think of Memory Like a Hostel
Imagine a large hostel.
It contains many rooms.
Each room has an address.
Students arrive.
Students leave.
Some students stay for years.
Some leave after one night.
Now replace:
| Hostel Concept | Computer Concept |
|---|---|
| Hostel | Memory |
| Room | Memory location |
| Student | Object |
| Room number | Address |
| Reception register | Reference table |
| Hostel warden | Garbage collector |
| VIP hostel | GPU VRAM |
| Storeroom | Disk storage |
Suppose we write:
name = "Hardeep"
A beginner may imagine that name is a box containing the text.
A more accurate Python mental model is:
Python creates or finds the string object
"Hardeep".Python binds the name
nameto that object.The object lives somewhere managed by Python.
The name is not the object.
It is a way to reach the object.
Like this:
name โโโโโโโโโโโโโโโโบ "Hardeep"
Python variables are better understood as labels attached to objects.
This idea will make pointers, references, copying, mutation, and garbage collection much easier.
๐ The Memory Hierarchy
Computers do not have only one kind of memory.
They have layers:
โก CPU Registers
โ
๐ CPU Cache
โ
๐พ RAM
โ
๐ฎ GPU VRAM
โ
๐ SSD / Disk
โ
โ๏ธ Remote or Cloud Storage
The closer memory is to the processor, the faster and more expensive it tends to be.
โก Registers
Registers are tiny storage locations inside the CPU.
They hold values needed immediately for calculations.
Python developers rarely control registers directly.
The processor, interpreter, compiler, and native libraries handle them.
Think of registers as items currently in a chefโs hands.
Fastest access.
Very limited capacity.
๐ CPU Cache
CPU cache stores recently used data close to the processor.
Common levels include L1, L2, and L3.
The smaller cache levels are faster.
You normally do not manually place Python objects in CPU cache, but your data layout and access patterns can affect cache efficiency.
This is one reason optimized NumPy operations often beat ordinary Python loops.
๐พ RAM
RAM holds active programs and objects.
Your Python:
Lists
Dictionaries
Classes
DataFrames
NumPy arrays
Tokenizers
Web requests
Dataset batches
primarily live in RAM.
RAM is fast, but finite.
When RAM is exhausted, the operating system may use swap or terminate the process.
๐ฎ GPU VRAM
GPU memory stores data needed by GPU computations.
For AI, this may include:
Model parameters
Input tensors
Activations
Gradients
Optimizer state
Attention matrices
KV cache
VRAM is fast and expensive.
A machine may have:
128 GB RAM
but only:
16 GB VRAM
RAM is economy class.
VRAM is business class.
Disk is the airport parking lot. ๐
๐ Disk
Disk stores data persistently.
Examples:
Model files
Checkpoints
Training datasets
Vector indexes
Logs
Disk is much slower than RAM and VRAM.
Loading a model usually means moving data:
Disk โ RAM โ VRAM
Every transfer costs time.
โ๏ธ Remote Storage
Cloud storage can hold huge datasets and checkpoints, but accessing it introduces network latency.
An AI pipeline may stream data from object storage, preprocess it in RAM, and copy tensors to the GPU.
That means memory performance is not only about capacity.
It is also about movement.
๐ฆ Variables Are Not Boxes
Many beginner tutorials describe a variable as a box:
name = "AI"
โโโโโโโโโโโโโโโ
โ name โ
โ "AI" โ
โโโโโโโโโโโโโโโ
This is useful at first, but Python behaves more like this:
name โโโโโโโโบ "AI"
The name name is bound to the string object "AI".
Consider:
message = "Hello AI"
Python creates or reuses an object representing the string, then binds message to it.
Everything in Python is an object:
42
3.14
"hello"
[1, 2, 3]
{"model": "StudyBot"}
Functions are objects.
Classes are objects.
Modules are objects.
AI models, tokenizers, tensors, and datasets are objects too.
Your Python AI application is essentially a large network of names and objects connected by references.
๐ Object Identity and id()
Python provides id() to inspect an objectโs identity:
message = "Hello AI"
print(id(message))
You may see:
4378212080
In CPython, this value is commonly related to the objectโs memory address while the object is alive.
However, Python only guarantees that:
The
id()value is unique for that object during its lifetime.
Do not build application logic around a specific memory address.
Use id() mainly for learning and debugging identity.
Example:
model_config = {"temperature": 0.7}
backup_config = model_config
print(id(model_config))
print(id(backup_config))
Both values are usually the same because both names refer to the same dictionary.
๐ฅ One Object, Multiple Names
Consider:
model = {
"name": "StudyGPT",
"temperature": 0.7
}
assistant = model
Did Python create two dictionaries?
No.
It created one dictionary with two names pointing to it:
model โโโโโโโโโโโโโโโโ
โผ
{"name": "StudyGPT"}
โฒ
assistant โโโโโโโโโโโโ
We can verify:
print(model is assistant)
Output:
True
The is operator checks identity.
The == operator checks equality.
first = [1, 2, 3]
second = [1, 2, 3]
print(first == second) # Same contents
print(first is second) # Different objects
Output:
True
False
This distinction matters when debugging shared AI state.
Two configurations may contain the same values but still be separate objects.
๐ Two Developers, One Pizza
Imagine one pizza on a table.
Rahul points to it.
Aman points to it.
There are two people.
There is still only one pizza.
Rahul โโโโ
โผ
๐
โฒ
Aman โโโโโ
Now Aman adds pineapple.
Rahulโs pizza also has pineapple.
Why?
Because there was only one pizza.
The friendship may not survive, but the memory model is clear.
๐ Shared References and Mutation
Consider:
numbers = [1, 2, 3]
copy_name = numbers
copy_name.append(4)
print(numbers)
Output:
[1, 2, 3, 4]
Both names point to the same list.
The assignment:
copy_name = numbers
copies the reference, not the list.
This is a major source of bugs.
AI example: shared conversation history
history = []
agent_history = history
logger_history = history
agent_history.append("User: Explain transformers")
print(logger_history)
Output:
['User: Explain transformers']
This may be useful when components intentionally share state.
It becomes dangerous when sharing is accidental.
If two users share the same history list, your chatbot has created a privacy incidentโnot multi-agent intelligence.
๐ Shallow Copy vs Deep Copy
To create another list:
original = [1, 2, 3]
copied = original.copy()
copied.append(4)
print(original)
print(copied)
Output:
[1, 2, 3]
[1, 2, 3, 4]
But nested structures introduce a surprise.
original = {
"settings": {
"temperature": 0.7
}
}
copied = original.copy()
copied["settings"]["temperature"] = 1.5
print(original)
Output:
{'settings': {'temperature': 1.5}}
Why?
dict.copy() creates a shallow copy.
The outer dictionary is new, but the nested dictionary is shared.
Use copy.deepcopy() when you truly need nested objects duplicated:
from copy import deepcopy
original = {
"settings": {
"temperature": 0.7
}
}
copied = deepcopy(original)
copied["settings"]["temperature"] = 1.5
print(original)
print(copied)
Be careful with deep copies of large AI objects.
Duplicating a model, vector collection, or massive tensor may consume enormous memory.
Sometimes copying โfor safetyโ becomes the reason the process crashes.
๐ง Mutable vs Immutable Objects
Python objects can broadly be discussed as mutable or immutable.
Immutable objects
Common immutable types include:
intfloatboolstrtuplefrozenset
Immutable means the object cannot be changed after creation.
name = "AI"
name += " Engineer"
Python does not modify the original string.
It creates a new string object and rebinds name.
Conceptually:
Before:
name โโโโบ "AI"
After:
"AI" # Old object
name โโโโบ "AI Engineer"
Mutable objects
Common mutable types include:
listdictsetMost user-defined class instances
models = ["text-model"]
models.append("vision-model")
The list object changes in place.
Why this matters in AI
Suppose a training configuration is shared:
config = {
"batch_size": 32,
"learning_rate": 0.001
}
trainer_config = config
evaluation_config = config
trainer_config["batch_size"] = 128
Now evaluation also sees 128.
Was that intentional?
Maybe.
Will future-you remember?
Probably not.
Immutable configuration objects can reduce surprises:
from dataclasses import dataclass
@dataclass(frozen=True)
class TrainingConfig:
batch_size: int
learning_rate: float
๐ References vs Pointers
A pointer is a value that identifies a memory location.
Languages such as C expose pointers directly:
int number = 10;
int *pointer = &number;
Python does not normally expose raw pointer manipulation.
Instead, Python programmers work with object references.
A beginner-friendly comparison:
| Raw Pointer | Python Reference |
|---|---|
| Exposes memory address directly | Hides most address details |
| Supports pointer arithmetic | No normal pointer arithmetic |
| Can access invalid memory | Managed by Python |
| Can cause segmentation faults | Safer for application code |
| Manual memory concerns | Mostly automatic memory management |
Python references behave like safe directions to an object.
C gives you the full city map and permission to bulldoze roads.
Python gives you Google Maps and says:
โPlease do not touch the infrastructure.โ
Why AI developers still care about pointer concepts
Even when writing Python, low-level libraries use pointers underneath:
NumPy
PyTorch
TensorFlow
CUDA
C extensions
Tokenizer libraries
A tensor object in Python may reference memory allocated by a native library or GPU driver.
Understanding ownership and references helps explain why:
A slice may share memory
A tensor view may not copy data
Moving a tensor to GPU creates another allocation
Deleting one Python name may not free the underlying memory
Native extensions can leak memory
๐ช Views: New Object, Shared Data
NumPy and PyTorch frequently create views that share underlying memory.
Example with NumPy:
import numpy as np
array = np.array([10, 20, 30, 40])
view = array[1:3]
view[0] = 999
print(array)
Output:
[ 10 999 30 40]
The slice may share the same underlying buffer.
This is memory efficient.
It can also surprise you.
To force a copy:
copied = array[1:3].copy()
In AI workloads, views reduce unnecessary allocations, but accidental mutation can corrupt data.
The performance engineer says:
โExcellent, no copy!โ
The debugging engineer says:
โWho changed my tensor?โ
Both are correct.
๐๏ธ Stack and Heap: A Practical Python Mental Model
You may hear:
Stack memory
Heap memory
These ideas come from lower-level runtime design.
For Python beginners, use this practical model:
The call stack
The call stack tracks active function calls.
Each function call has a frame containing information such as:
Local names
Parameters
Return location
Execution state
Example:
def calculate_score(values):
total = sum(values)
return total / len(values)
scores = [80, 90, 100]
average = calculate_score(scores)
During calculate_score, Python maintains a frame for that function.
After the function returns, that frame can be removed.
The managed heap
Python objects generally live in a managed heap.
Examples:
Lists
Dictionaries
Class instances
Strings
Tensors
DataFrames
Local variables in a frame refer to these objects.
Conceptually:
Call Frame:
values โโโโโโ
total โ
โผ
Heap:
[80, 90, 100]
Avoid oversimplifying this into:
โAll local variables live on the stack and all objects live on the heap.โ
Python implementations manage details differently.
The useful idea is:
Function frames hold references; objects are managed separately and may outlive the function.
๐งณ Objects Can Outlive Functions
Consider:
def create_history():
messages = []
return messages
history = create_history()
The local name messages disappears when the function returns.
But the list does not disappear because history still refers to it.
Inside function:
messages โโโโบ []
After return:
history โโโโโบ []
The object survives because it is still reachable.
This ideaโreachabilityโis central to garbage collection.
๐ข Reference Counting
CPython, the most common Python implementation, primarily uses reference counting.
Each object tracks how many references point to it.
Example:
model = {"name": "StudyBot"}
Reference count is conceptually at least one.
Now:
backup = model
Another reference is added.
Then:
cache_entry = model
Another reference is added.
Conceptually:
model โโโโโโโโโโโโ
backup โโโโโโโโโโโผโโโบ Model object
cache_entry โโโโโโ
When references disappear, the count drops.
When the count reaches zero, CPython can usually reclaim the object immediately.
Inspecting reference counts
Python provides:
import sys
model = {"name": "StudyBot"}
print(sys.getrefcount(model))
The result may be higher than expected because passing model into getrefcount() temporarily creates another reference.
Use this tool for learning and debugging, not precise production accounting.
โ๏ธ What del Really Does
Consider:
model = {"name": "StudyBot"}
backup = model
del model
Did the dictionary get deleted?
No.
del model removes the name model.
The object remains alive because backup still refers to it.
print(backup)
Output:
{'name': 'StudyBot'}
Only when the final strong reference disappears can the object become collectible:
del backup
A useful rule:
deldeletes a reference or container entry. It does not guarantee immediate operating-system memory release.
This distinction becomes critical with large models and GPU tensors.
๐๏ธ Garbage Collection
Garbage collection means identifying objects that are no longer useful and reclaiming their memory.
In CPython, simple objects are often cleaned up through reference counting.
But reference counting has a weakness:
Cycles
Consider two objects that reference each other:
class Agent:
def __init__(self, name):
self.name = name
self.partner = None
researcher = Agent("Researcher")
reviewer = Agent("Reviewer")
researcher.partner = reviewer
reviewer.partner = researcher
Now:
researcher object โโโโบ reviewer object
โฒ โ
โโโโโโโโโโโโโโโโโโโโโโ
If the outside names disappear:
del researcher
del reviewer
the two objects may still reference each other.
Their reference counts are not zero.
But nothing useful can reach them.
This is cyclic garbage.
Pythonโs cyclic garbage collector helps detect and collect such unreachable cycles.
The two agents are still talking to each other.
The rest of the application has left the meeting.
The garbage collector eventually enters and says:
โBoth of you, room vacated.โ ๐งน
๐ The gc Module
Python exposes garbage-collector tools through the gc module:
import gc
collected = gc.collect()
print(f"Collected objects: {collected}")
You can inspect whether cyclic GC is enabled:
print(gc.isenabled())
You can disable or enable it:
gc.disable()
gc.enable()
Do not disable garbage collection casually in ordinary applications.
It may be useful in specialized performance experiments, but it can allow cyclic garbage to accumulate.
Should you call gc.collect() constantly?
Usually, no.
Python manages collection automatically.
Calling gc.collect() after every request may:
Add latency
Pause execution
Hide the actual source of a leak
Create a false sense of control
Use it selectively when:
Debugging cycles
Processing large temporary workloads
Running controlled batch phases
Measuring object cleanup
Garbage collection is not a magic โmake memory emptyโ button.
If objects are still referenced, gc.collect() cannot remove them.
๐ Reachability: The Most Important Idea
An object is effectively alive while it can still be reached through strong references.
Example:
global_cache = []
def process_document(text):
result = {
"text": text,
"embedding": [0.1] * 1_000_000
}
global_cache.append(result)
Even after the function returns, every result remains reachable through global_cache.
Garbage collection cannot remove it.
That is not a garbage-collector failure.
Your program explicitly asked to keep the object.
Memory leak debugging often becomes:
โWhich reference is still keeping this alive?โ
Not:
โWhy is Python refusing to clean?โ
๐ง Memory Leaks in a Garbage-Collected Language
People sometimes believe:
โPython has garbage collection, so Python cannot leak memory.โ
Python can absolutely experience memory growth and leak-like behaviour.
Common causes include:
Unbounded lists
Unbounded dictionaries
Caches without limits
Global variables
Event handlers
Closures
Background tasks
Circular structures
Native-library leaks
Retained computation graphs
Unclosed files and connections
Large allocator pools not returned to the OS
A โmemory leakโ in application practice often means:
Memory keeps growing because objects remain reachable or resources remain allocated longer than intended.
๐ฌ AI Leak Example: Conversation History Forever
class ChatSession:
def __init__(self):
self.history = []
def add_message(self, message):
self.history.append(message)
Nothing limits the history.
For one user, maybe fine.
For 100,000 users with long conversations:
Hello
Hello again
Here is a 40-page document
Please summarize it
Please remember everything forever
RAM:
โAbsolutely not.โ
Add limits:
from collections import deque
class ChatSession:
def __init__(self, max_messages=50):
self.history = deque(maxlen=max_messages)
def add_message(self, message):
self.history.append(message)
Or summarize older context instead of storing everything.
Memory is not a museum.
You do not need to preserve every token forever.
๐งฐ AI Leak Example: Unbounded Cache
Bad:
embedding_cache = {}
def get_embedding(text, model):
if text not in embedding_cache:
embedding_cache[text] = model.encode(text)
return embedding_cache[text]
This cache grows forever.
Better strategies:
Maximum cache size
Time-to-live
Least-recently-used eviction
External cache
Persistent vector database
Using functools.lru_cache for suitable hashable inputs:
from functools import lru_cache
@lru_cache(maxsize=1_000)
def normalized_prompt(prompt):
return prompt.strip().lower()
A cache without eviction is often just a memory leak wearing formal clothes.
๐ชถ Weak References
A weak reference points to an object without keeping it alive.
Python provides weakref:
import weakref
class Model:
pass
model = Model()
weak_model = weakref.ref(model)
print(weak_model() is model)
del model
print(weak_model())
After the original strong reference disappears, weak_model() may return:
None
Weak references are useful for:
Object registries
Caches
Observer systems
Metadata maps
Avoiding ownership cycles
Not every built-in type supports weak references directly.
Also, weak references are not a replacement for clear ownership design.
They are useful when you want to observe an object without becoming the reason it survives forever.
๐ช Context Managers: Clean Up After Yourself
Resources are not only Python objects.
Programs also hold:
Files
Database connections
Sockets
Locks
Temporary directories
GPU streams
Model sessions
Context managers ensure cleanup:
with open("training-data.txt", "r") as file:
data = file.read()
The file closes automatically, even if an exception occurs.
You can define your own:
class ModelSession:
def __enter__(self):
print("Loading model resources")
return self
def __exit__(self, exc_type, exc_value, traceback):
print("Releasing model resources")
with ModelSession() as session:
print("Running inference")
Output:
Loading model resources
Running inference
Releasing model resources
Context managers are the programming version of:
โTurn off the lights when you leave.โ
Simple rule.
Surprisingly rare behaviour.
๐งฏ try / finally for Cleanup
When a context manager is unavailable:
resource = acquire_resource()
try:
run_inference(resource)
finally:
release_resource(resource)
The finally block runs whether inference succeeds or fails.
This is important when handling expensive resources.
Errors should not leave connections, files, or GPU allocations hanging around like guests who missed the last train.
๐ Measuring Python Memory
Do not optimize memory by intuition alone.
Measure it.
tracemalloc
Pythonโs built-in tracemalloc tracks Python memory allocations:
import tracemalloc
tracemalloc.start()
data = [str(number) for number in range(100_000)]
current, peak = tracemalloc.get_traced_memory()
print(f"Current: {current / 1024 / 1024:.2f} MB")
print(f"Peak: {peak / 1024 / 1024:.2f} MB")
tracemalloc.stop()
Take snapshots:
import tracemalloc
tracemalloc.start()
before = tracemalloc.take_snapshot()
data = [str(number) for number in range(100_000)]
after = tracemalloc.take_snapshot()
for statistic in after.compare_to(before, "lineno")[:10]:
print(statistic)
This helps locate Python allocation growth.
sys.getsizeof()
import sys
values = [1, 2, 3]
print(sys.getsizeof(values))
Be careful:
getsizeof() often reports only the direct object size, not all nested objects.
A list contains references to other objects.
The total memory may be much larger.
Process-level memory
Operating-system tools can show process memory.
Useful concepts include:
RSS: resident memory currently in physical RAM
Virtual memory
Shared memory
Peak memory
Python object measurements and process measurements answer different questions.
๐งช Generator vs List: A Memory-Friendly Example
A list builds everything immediately:
squares = [number * number for number in range(10_000_000)]
This may consume substantial RAM.
A generator produces values lazily:
squares = (
number * number
for number in range(10_000_000)
)
for value in squares:
process(value)
Only a small amount of state is maintained.
This is powerful for AI data pipelines:
Stream documents
Read files line by line
Process batches
Generate tokens
Load examples lazily
Do not invite ten million records into RAM when you only need to speak with them one at a time.
๐ Streaming and Chunking
Bad:
with open("huge-dataset.txt", "r") as file:
entire_dataset = file.read()
Better:
with open("huge-dataset.txt", "r") as file:
for line in file:
process(line)
For documents:
def chunks(items, size):
for start in range(0, len(items), size):
yield items[start:start + size]
Usage:
for batch in chunks(documents, 100):
embeddings = embedding_model.encode(batch)
save_embeddings(embeddings)
Chunking keeps peak memory controlled.
AI pipelines are often limited by peak memory, not total dataset size.
๐บ๏ธ Memory Mapping
Memory mapping allows large files or arrays to be accessed without loading everything at once.
NumPy example:
import numpy as np
array = np.memmap(
"large-array.dat",
dtype="float32",
mode="r",
shape=(1_000_000, 768)
)
first_vector = array[0]
The operating system loads needed pages on demand.
Memory mapping is useful for:
Large embedding matrices
Huge datasets
Model weights
Read-only inference data
It is not magic.
Disk access is still slower than RAM.
But it can make datasets usable that would otherwise not fit.
๐ค What Occupies Memory Inside an AI Model?
When someone says:
โThis is a 7-billion-parameter model.โ
They are describing the number of learned values.
But parameters are only part of memory usage.
An AI workload may store:
Model weights
Activations
Gradients
Optimizer states
Input tensors
Temporary buffers
Attention scores
KV cache
Framework overhead
CUDA allocator cache
Inference and training have different memory demands.
โ๏ธ Estimating Model Weight Memory
A rough estimate:
Memory โ Number of parameters ร Bytes per parameter
For 7 billion parameters:
FP32
7,000,000,000 ร 4 bytes
โ 28 GB
FP16 or BF16
7,000,000,000 ร 2 bytes
โ 14 GB
INT8
7,000,000,000 ร 1 byte
โ 7 GB
4-bit
7,000,000,000 ร 0.5 bytes
โ 3.5 GB
Real usage may be higher due to:
Quantization metadata
Temporary buffers
Runtime overhead
KV cache
Framework allocations
This explains why โ7Bโ does not mean โ7 GB.โ
B stands for billion parameters.
Not โbro, it will fit.โ
๐ Why Training Needs Much More Memory Than Inference
Inference mainly needs:
Weights
Activations needed for the forward pass
KV cache for autoregressive generation
Runtime buffers
Training additionally needs:
Gradients
Optimizer states
Saved activations for backpropagation
For adaptive optimizers, optimizer state can consume multiple times the parameter memory.
The exact multiplier depends on:
Precision
Optimizer
Framework
Sharding
Gradient storage
Master weights
A model that fits comfortably for inference may be impossible to fine-tune without memory-saving techniques.
Inference asks the model to answer.
Training asks it to answer, remember how it answered, calculate how wrong it was, and update billions of values.
That is a lot of emotional processing.
๐ง Activations
Activations are intermediate outputs produced during the forward pass.
They depend on:
Batch size
Sequence length
Hidden dimension
Number of layers
Architecture
Precision
During training, many activations are kept for backpropagation.
Long sequences and large batches can make activation memory enormous.
This is why reducing batch size often fixes an out-of-memory error even when the model itself has not changed.
๐ Attention Memory
Standard full self-attention relates tokens to other tokens.
For sequence length L, the attention-score structure grows roughly with:
L ร L
Doubling sequence length can make this component approximately four times larger.
That is why increasing context from:
4,000 tokens
to:
8,000 tokens
is not always a simple doubling of cost.
Long context is not just โmore text.โ
It is more memory, more computation, and more ways for your GPU to submit a resignation letter.
๐๏ธ KV Cache
During autoregressive generation, transformer models often store key and value tensors for previous tokens.
This is called the KV cache.
It avoids recomputing everything for every new token.
That improves speed but consumes memory.
KV cache typically grows with factors such as:
Number of layers
Sequence length
Batch size
Attention dimensions
Precision
More concurrent users and longer conversations mean larger cache requirements.
This creates an important serving trade-off:
Longer context + more users = more memory
A model may fit for one user but fail for fifty simultaneous long conversations.
๐ฎ RAM vs VRAM in an AI Application
| RAM | VRAM |
|---|---|
| Used by CPU | Used by GPU |
| Stores Python objects and datasets | Stores tensors and model computation |
| Usually larger | Usually smaller |
| Cheaper per GB | More expensive per GB |
| Slower for GPU computation | Fast for GPU computation |
| Can stage data | Needed for accelerated kernels |
A model can exist in RAM but not VRAM.
Moving it to GPU:
model = model.to("cuda")
creates or transfers GPU allocations.
Moving a tensor back:
tensor = tensor.to("cpu")
places its data in CPU memory.
This can free VRAM only after no live GPU references remain.
๐ฅ PyTorch Autograd and Accidental Memory Growth
PyTorch builds computation graphs during gradient-tracked operations.
Consider:
losses = []
for batch in training_loader:
loss = model(batch).sum()
losses.append(loss)
Each loss tensor may retain its computation graph.
Memory can grow every iteration.
Better:
losses = []
for batch in training_loader:
loss = model(batch).sum()
losses.append(loss.item())
loss.item() stores a Python number rather than the graph-connected tensor.
Or:
losses.append(loss.detach().cpu())
depending on what you need.
A classic rule:
Do not accidentally store tensors that keep entire computation graphs alive.
One tiny tensor reference can become the landlord for a massive graph.
๐ซ torch.no_grad()
For inference:
import torch
with torch.no_grad():
output = model(input_tensor)
This prevents gradient tracking for operations in the block.
Benefits include:
Lower memory use
Faster inference
No unnecessary computation graph
For inference-heavy code, torch.inference_mode() may provide stronger optimization:
with torch.inference_mode():
output = model(input_tensor)
Use the mode appropriate for your workflow.
If you are not training, do not ask PyTorch to prepare for an exam.
โ๏ธ detach()
detach() creates a tensor sharing storage but disconnected from the current autograd graph:
detached = tensor.detach()
This is useful when:
Logging outputs
Saving intermediate values
Passing data into non-training logic
Preventing graph retention
Because storage may still be shared, use .clone() if you need independent storage:
independent = tensor.detach().clone()
Every copy costs memory.
Choose intentionally.
๐งน del and GPU Tensors
Suppose:
output = model(input_tensor)
Then:
del output
This removes that name.
But GPU memory may still appear occupied because:
Another reference exists
A computation graph retains it
A container stores it
PyTorchโs caching allocator reserves the block
Asynchronous GPU work is pending
Deleting one name is not the same as proving no references remain.
๐๏ธ torch.cuda.empty_cache()
PyTorch uses a caching allocator for GPU memory.
Freed tensor blocks may remain reserved by PyTorch for reuse.
import torch
torch.cuda.empty_cache()
This releases unused cached blocks so they may become available to other GPU applications.
Important:
It does not free live tensors.
It does not delete computation graphs.
It does not solve reference leaks.
It may not reduce memory needed by your own active workload.
Calling it constantly can hurt performance.
Use it after releasing large temporary GPU objects when sharing the GPU or during controlled workload transitions.
It is not a spiritual cleansing ritual for CUDA.
๐ Allocated vs Reserved GPU Memory
PyTorch distinguishes between memory used by live tensors and memory reserved by its allocator.
import torch
allocated = torch.cuda.memory_allocated()
reserved = torch.cuda.memory_reserved()
print(f"Allocated: {allocated / 1024**2:.2f} MB")
print(f"Reserved: {reserved / 1024**2:.2f} MB")
You may see:
Allocated: 4,000 MB
Reserved: 6,500 MB
The difference represents cached or reserved blocks that PyTorch may reuse.
Monitoring only system GPU tools can make it look like memory is โleakingโ when the framework is retaining memory for performance.
Measure both framework and process-level views.
๐งช A Safer Inference Function
import torch
def run_inference(model, input_tensor):
model.eval()
with torch.inference_mode():
output = model(input_tensor)
result = output.detach().cpu()
del output
return result
This:
Switches the model to evaluation mode
Disables gradient tracking
Moves the returned result to CPU
Removes a temporary GPU reference
Whether this is optimal depends on your application.
Do not move results to CPU if the next operation needs them on GPU.
Every transfer has a cost.
Memory optimization is about avoiding unnecessary workโnot blindly moving everything everywhere.
๐ Reduce Batch Size
Memory usually grows with batch size.
If training fails:
batch_size = 64
try:
batch_size = 16
Smaller batches reduce activation memory.
Trade-offs may include:
More iterations
Lower throughput
Different optimization behaviour
Use gradient accumulation to simulate a larger effective batch:
optimizer.zero_grad()
for step, batch in enumerate(loader):
loss = model(batch) / accumulation_steps
loss.backward()
if (step + 1) % accumulation_steps == 0:
optimizer.step()
optimizer.zero_grad()
This reduces peak memory while preserving a larger effective batch size.
๐งฎ Mixed Precision
Using lower precision reduces memory and can accelerate supported hardware.
Typical choices include:
FP32
FP16
BF16
Conceptually:
FP32: 4 bytes per value
FP16/BF16: 2 bytes per value
PyTorch automatic mixed precision:
import torch
with torch.autocast(
device_type="cuda",
dtype=torch.float16
):
output = model(input_tensor)
Training often uses a gradient scaler with FP16.
Mixed precision can reduce:
Weight memory
Activation memory
Bandwidth usage
But numerical stability must be considered.
Lower precision is not simply โsame maths, half priceโ in every situation.
๐๏ธ Quantization
Quantization stores model values using fewer bits.
Examples:
16-bit
8-bit
4-bit
Benefits:
Lower memory
Faster inference on supported hardware
Easier local deployment
Trade-offs:
Possible accuracy loss
Hardware and kernel compatibility
Calibration requirements
Quantization metadata
Some operations still use higher precision
Quantization allows a model to travel economy class.
It may complain slightly, but it reaches the destination.
๐ช Gradient Checkpointing
During training, frameworks normally store activations for backpropagation.
Gradient checkpointing stores fewer activations and recomputes some during the backward pass.
Trade-off:
Less memory โ More computation
Use it when model memory is the limiting factor and extra compute is acceptable.
This is like refusing to write notes during a lecture and rewatching parts of the recording later.
RAM saves space.
CPU and GPU do extra homework.
๐งฉ LoRA and Parameter-Efficient Fine-Tuning
LoRA adds small trainable adapter matrices instead of updating every model parameter.
Benefits:
Fewer trainable parameters
Lower gradient memory
Lower optimizer-state memory
Smaller fine-tuning checkpoints
Important:
The base model still occupies memory.
LoRA does not make the original model disappear.
It reduces fine-tuning overhead, not the existence of the base weights.
๐งฑ Sharding and Offloading
Large models can be split across:
Multiple GPUs
CPU and GPU
Disk and RAM
Techniques include:
Model parallelism
Tensor parallelism
Pipeline parallelism
Optimizer-state sharding
CPU offload
NVMe offload
These approaches allow larger models to run but introduce:
Communication cost
Synchronization complexity
Transfer overhead
More complicated failure modes
You have not removed the memory requirement.
You have distributed the argument among several machines.
๐งต Data Loader Memory
Training pipelines can consume too much RAM before the model even receives a batch.
Potential causes:
Too many workers
Large prefetch queues
Entire dataset loaded eagerly
Duplicate preprocessing caches
Pinned memory
Large decoded images
Worker process duplication
Use:
Lazy datasets
Bounded prefetching
Appropriate worker counts
Streaming
Smaller decoded formats
Shared or memory-mapped data
More data-loader workers do not automatically mean more speed.
Sometimes they mean four copies of your dataset and one confused operating system.
๐งฌ Multiprocessing and Memory Duplication
Multiple Python processes have separate memory spaces.
Starting several workers may duplicate:
Model objects
Caches
Dataset indexes
Tokenizers
Native-library state
Copy-on-write can reduce initial duplication on some systems, but modifying memory can create real copies.
GPU models generally require special care across processes.
Questions to ask:
Does each worker load its own model?
Can inference be centralized?
Should requests be batched?
Is memory shared safely?
Are process counts appropriate?
Concurrency increases throughput only when resources support it.
Eight workers loading eight copies of a large model is not parallelism.
It is a group booking for an out-of-memory error.
๐งน A Practical Cleanup Pattern
import gc
import torch
def process_large_batch(model, batch):
with torch.inference_mode():
gpu_batch = batch.to("cuda")
output = model(gpu_batch)
result = output.cpu()
del output
del gpu_batch
gc.collect()
if torch.cuda.is_available():
torch.cuda.empty_cache()
return result
This pattern may be useful after a large phase boundary.
But do not copy it blindly into every inner loop.
Repeated forced collection and cache clearing can reduce performance.
First fix ownership and references.
Then use explicit cleanup when the workload truly benefits.
๐จ Common Reasons Memory Does Not Drop
1. A reference still exists
outputs.append(large_tensor)
2. A computation graph is retained
losses.append(loss)
instead of:
losses.append(loss.item())
3. A cache is unbounded
cache[key] = huge_result
forever.
4. A closure captures a large object
def build_callback(model):
def callback():
return model.status()
return callback
The callback keeps model alive.
5. A global variable retains data
ALL_RESULTS.append(result)
6. A framework allocator reserves freed blocks
The memory is reusable but not returned immediately.
7. A native extension leaks memory
Pythonโs garbage collector cannot fix bugs inside native code.
8. Fragmentation
Free memory exists, but not in a suitable contiguous block.
9. Asynchronous GPU operations
Work may not have completed yet.
10. Monitoring reports reserved, not live, memory
The dashboard is not lying.
It is answering a different question.
๐ง Fragmentation
Imagine a hostel with ten empty rooms.
Excellent.
But every empty room is separate, and a family needs four adjacent rooms.
Total free space is sufficient.
Usable contiguous space is not.
Memory fragmentation creates similar issues.
Allocators try to reuse blocks efficiently, but mixed allocation sizes and long-running workloads can create fragmentation.
Mitigations may include:
Stable batch sizes
Reusing buffers
Avoiding extreme allocation churn
Restarting long-lived workers when appropriate
Framework allocator tuning
Grouping similar workloads
A process restart is sometimes operationally valid.
But restarting every ten minutes because of an unresolved leak is not memory management.
It is memory avoidance.
๐ก๏ธ Designing Memory-Safe AI Services
Keep request state local
Bad:
class SharedAssistant:
current_prompt = None
current_documents = []
Better:
class Assistant:
def answer(self, prompt, documents):
return self.generate(prompt, documents)
Do not store request-specific state globally unless intentionally managed.
Bound everything
Bound:
Conversation history
Cache size
Queue length
Batch size
Retrieved documents
Context length
Concurrent requests
Agent steps
Tool output size
Unbounded systems eventually discover the bound called โavailable memory.โ
Separate configuration from resources
from dataclasses import dataclass
@dataclass(frozen=True)
class ModelConfig:
name: str
max_tokens: int
device: str
Keep active GPU resources in a runtime object.
This improves:
Serialization
Testing
Reproducibility
Lifecycle management
Make ownership clear
Ask:
Who creates the model?
Who owns the cache?
Who closes the client?
Who removes the session?
Who releases GPU resources?
How long should this object live?
When everyone owns a resource, nobody cleans it.
๐งช A Mini Memory-Aware RAG Example
from collections import OrderedDict
class BoundedCache:
def __init__(self, max_items=100):
self.max_items = max_items
self.data = OrderedDict()
def get(self, key):
if key not in self.data:
return None
self.data.move_to_end(key)
return self.data[key]
def set(self, key, value):
self.data[key] = value
self.data.move_to_end(key)
while len(self.data) > self.max_items:
self.data.popitem(last=False)
Retriever:
class Retriever:
def __init__(self, documents):
self.documents = documents
def search(self, query, limit=3):
words = set(query.lower().split())
scored = []
for document in self.documents:
score = len(
words.intersection(
document.lower().split()
)
)
scored.append((score, document))
scored.sort(reverse=True)
return [
document
for score, document in scored[:limit]
if score > 0
]
Chat session with bounded history:
from collections import deque
class ChatSession:
def __init__(self, max_messages=20):
self.messages = deque(maxlen=max_messages)
def add(self, role, content):
self.messages.append({
"role": role,
"content": content
})
Application:
class RAGApplication:
def __init__(
self,
retriever,
model,
cache_size=100
):
self.retriever = retriever
self.model = model
self.cache = BoundedCache(cache_size)
def answer(self, question):
cached = self.cache.get(question)
if cached is not None:
return cached
documents = self.retriever.search(
question,
limit=3
)
context = "\n".join(documents)
prompt = (
f"Context:\n{context}\n\n"
f"Question:\n{question}"
)
response = self.model.generate(prompt)
self.cache.set(question, response)
return response
Memory-aware decisions:
Cache has a limit
Retrieved document count has a limit
Session history has a limit
Temporary prompt data stays local
Components have clear responsibilities
This is not perfect production code.
But it demonstrates a crucial idea:
Good memory management begins with architecture, not emergency calls to
gc.collect().
๐ Debugging a Suspected Memory Leak
Use a structured approach.
Step 1: Reproduce consistently
Does memory grow:
Every request?
Every training step?
Only for long prompts?
Only on GPU?
Only with multiple workers?
Step 2: Separate RAM and VRAM
Measure CPU process memory and GPU memory separately.
Step 3: Check live collections
Look for growing:
Lists
Dictionaries
Queues
Caches
Session maps
Logs
Result arrays
Step 4: Check tensor retention
Look for:
Stored losses
Stored outputs
Stored hidden states
Graph-connected tensors
GPU results never moved or released
Step 5: Compare snapshots
Use tracemalloc for Python allocations.
Use framework memory tools for GPU allocations.
Step 6: Test cleanup
Remove references.
Run controlled collection.
Observe allocated versus reserved memory.
Step 7: Inspect architecture
Ask which component owns each long-lived object.
Step 8: Check native libraries
If Python allocations are stable but process memory grows, native code may be responsible.
Debugging memory is detective work.
The culprit is often one innocent-looking line:
all_outputs.append(output)
๐ค Common AI Memory Interview Questions
What is the difference between a pointer and a Python reference?
A raw pointer exposes a memory location directly and may support address manipulation. A Python reference is a managed way to reach an object without ordinary pointer arithmetic.
What does del do?
It removes a name binding or container entry. The object is freed only when no strong references keep it alive and the runtime can reclaim it.
What is reference counting?
A technique where an object tracks how many references point to it. In CPython, an object is often reclaimed when its reference count reaches zero.
Why does Python need cyclic garbage collection?
Reference counting alone cannot reclaim unreachable groups of objects that reference one another.
Can Python have memory leaks?
Yes. Objects may remain reachable through globals, caches, containers, callbacks, closures, graphs, native libraries, or unbounded state.
Why does gc.collect() not reduce memory?
Objects may still be referenced, memory may be retained by Pythonโs allocator, or the memory may belong to a native or GPU allocator.
Why does GPU memory stay high after del tensor?
Other references may exist, autograd may retain the graph, or PyTorch may keep freed blocks in its caching allocator.
What does torch.cuda.empty_cache() do?
It releases unused cached GPU blocks held by PyTorch so other applications may use them. It does not free live tensors.
Why is training more memory-intensive than inference?
Training needs gradients, optimizer states, and saved activations in addition to weights and runtime buffers.
How does sequence length affect transformer memory?
Longer sequences increase activation and attention-related memory. Full attention includes components that grow quadratically with sequence length.
What is the KV cache?
Stored key and value tensors from previous tokens used to speed autoregressive generation. It grows with sequence length, batch size, layers, and model dimensions.
How can you reduce AI memory usage?
Common techniques include:
Smaller batches
Gradient accumulation
Mixed precision
Quantization
Gradient checkpointing
LoRA
Lazy loading
Streaming
Chunking
Memory mapping
Sharding
Offloading
Bounded caches
Shorter context
๐ Memory Management Cheat Sheet
โ Questions Every AI Engineer Should Ask
Before deploying an AI workload, ask:
How large are the model weights?
Which precision is being used?
What is the peak activation memory?
How large can the batch become?
How long can the context become?
How much KV cache is needed per user?
Are gradients enabled during inference?
Are tensors retained in lists or logs?
Are caches bounded?
Is conversation history bounded?
Does each worker load another model copy?
Are files and connections closed?
Is memory allocated or merely reserved?
Which component owns each resource?
What happens after 10,000 requests?
Can data be streamed instead of loaded eagerly?
Can large arrays be memory-mapped?
Can computation be recomputed instead of stored?
Can lower precision be used safely?
Is a restart hiding a leak?
If the answer to โWhat happens after 10,000 requests?โ is:
โWe have never tried that,โ
production is about to become your load-testing environment.
๐ฏ Final Thoughts
Memory management is not merely about calling:
del object
gc.collect()
torch.cuda.empty_cache()
It begins with understanding:
Names and objects
References
Identity
Mutation
Ownership
Lifetimes
Reachability
Resource boundaries
Framework allocators
AI workload structure
Pointers explain how programs find data.
References explain how Python names reach objects.
Reference counting explains why many objects disappear quickly.
Garbage collection explains how unreachable cycles are cleaned.
Architecture explains why memory grows in the first place.
In AI, memory management directly shapes:
Model size
Batch size
Context window
Training feasibility
Inference throughput
Concurrent users
GPU cost
System reliability
AI engineers spend months learning transformers.
Then they deploy one and discover:
The real final boss was memory management. ๐
The goal is not to force every object out of memory as quickly as possible.
The goal is to keep the right data alive for the right amount of timeโand release everything else before your GPU begins sending farewell messages.
Because in the age of AI, intelligence may live in the modelโฆ
โฆbut the model still has to fit in memory. ๐ค๐ง ๐พ
๐ Letโs Connect
Whether you are learning Python, debugging an out-of-memory error, building an AI application, or repeatedly asking why del did not magically empty your GPU, I am always happy to connect.
Let us continue building AI systems that are not only intelligent, but also stable, scalable, memory-aware, and financially acceptable to the cloud billing department.
LinkedIn: Connect with me for AI, AWS, cloud engineering, Python, and software-development content.
hardeepjethwani@LinkedIn
TopMate: Looking for guidance, brainstorming, or a technical discussion over a virtual coffee?
hardeepjethwani@TopMate
Instagram: Follow my learning journey, technology experiments, and behind-the-scenes builder content.
hardeepjethwani@Instagram
X: Join the conversation around AI, cloud, Python, software engineering, and developer humour.
hardeepjethwani@X
Want to support my AI, cloud, and educational adventures? Feel free to buy me a virtual coffee. After all, coffee may be the only resource developers intentionally keep allocated all day. โ๐
#Python #MemoryManagement #GarbageCollection #Pointers #PythonReferences #ArtificialIntelligence #AIEngineering #MachineLearning #GenerativeAI #GPU #CUDA #PyTorch #LLM #Transformers #VectorDatabase #RAG #SoftwareEngineering #DeepLearning #MLOps #Programming #ComputerScience #TechEducation #DeveloperCommunity #BeginnerFriendly





