Tensor Memory Accountant
Turn three preprocessing blocks into byte ledgers: storage, views, copies, and the line that actually hurts.
- Module
- PyTorch and the memory wall
- Objective
- Account for tensor storage and traffic, and distinguish a free view from a copy that moves the working set.
The reading is a page. This is the reps. Do all three blocks on paper first, then check with PyTorch if you have it. The point is to be right before print(x.nbytes).
Block 1 — The original
x = torch.randn(16, 1024, 4096, dtype=torch.float16, device="cuda")
a = x.transpose(1, 2)
b = a.contiguous()
c = b.reshape(16 * 4096, 1024)
d = c.to(torch.float32)
e = d[:, :512]
f = e.clone()
Deliverable: one row per name.
| name | shape | dtype | storage bytes | view or copy | keeps alive |
|---|
Then:
- Live tensor storage after
f, nothing freed. Binary units. - First unavoidable full copy.
- Copy you could delete by changing the next op.
- Bytes moved by
d = c.to(torch.float32)(read + write).
Block 2 — The "it's just a view" PR
w = torch.randn(4096, 4096, dtype=torch.bfloat16, device="cuda") # weights
h = torch.randn(8, 4096, dtype=torch.bfloat16, device="cuda")
y = h @ w.t()
z = y.view(2, 4, 4096)
p = z.permute(1, 0, 2)
q = p.reshape(4, 8192)
- Which of
y, z, p, qallocate? w.t()— view or copy? Does the matmul care?- If the next kernel needs
qcontiguous, where does the copy land, and how many bytes?
Block 3 — The silent cast
ids = torch.randint(0, 32000, (4, 2048), device="cuda") # int64 default
emb = torch.randn(32000, 4096, dtype=torch.bfloat16, device="cuda")
hidden = emb[ids] # gather
hidden = hidden.float() # "for stability"
logits = hidden @ torch.randn(4096, 32000, dtype=torch.float32, device="cuda")
- Size of
emb,hiddenbefore the cast,hiddenafter the cast. - Is
emb[ids]a view intoemb? (Be precise about gather.) - The author says the float cast "shouldn't matter, it's the same shape." Write the traffic ratio vs staying in bf16 for that line.
Rules
float16/bfloat16= 2 bytes,float32= 4,int64= 8.- Transpose/permute/view are views when they only rewrite stride.
.contiguous(),.clone(), dtype change, and gather allocate.reshapeis a view if the storage is already contiguous in that order; otherwise it copies.
Acceptance
- Block 1:
xis 128 MiB.ais a view.bcopies 128 MiB.dcopies to 256 MiB.eis a view ofd.fcopies 128 MiB. Live storage includesx,b/c(same storage),d,f. - Block 2:
w.t()is a view;yallocates;zis a view ofy;pis a view;qmay copy because permute broke contiguity. - Block 3: gather allocates. The cast doubles
hidden. It is not free.
Stretch
Rewrite Block 1 so the only full copies are the ones you can justify in a review. List each remaining allocation in one clause.
Check your work
Block 1. x: (16, 1024, 4096) fp16 = 16 × 1024 × 4096 × 2 = 134217728 bytes = 128 MiB.
a = transpose(1,2): shape (16, 4096, 1024), same storage, not contiguous.
b = contiguous(): 128 MiB copy. First unavoidable full copy if anything later needs dense row-major.
c = reshape: view of b (now contiguous).
d = to(fp32): 256 MiB new storage. Traffic ≈ 128 MiB read + 256 MiB write.
e = d[:, :512]: view. Shape (65536, 512), still holding all of d alive.
f = clone(): 65536 × 512 × 4 = 128 MiB.
Live if nothing freed: x 128 + b 128 + d 256 + f 128 = 640 MiB. (If you dropped x after b, 512 MiB.) Downstream could take fp16 and skip d/f.
Block 2. w.t() view. GEMM reads it with whatever layout the backend accepts (may transpose internally — that is the vendor's copy, not yours). y is (8, 4096) bf16 = 64 KiB, allocates. z view. p view (strides swapped). q = reshape on a non-contiguous permute typically copies 64 KiB.
Block 3. emb = 32000 × 4096 × 2 ≈ 250 MiB. emb[ids] is an index/gather, not a view: it allocates (4, 2048, 4096) bf16 = 64 MiB. .float() allocates 128 MiB and doubles traffic for that tensor. Same shape, twice the bus.
Debrief
Shape is a story. Storage is the bill. If you cannot fill the table, you are not ready to profile the block.