This section is about how a video model turns a clip into tokens. A Transformer reads a sequence of vectors, called tokens, so the first thing every video Transformer does is cut the clip into pieces and turn each piece into one token. That step sets how many tokens the model pays for, what each token can see, and how the model learns where and when each piece came from. Get it wrong and the model is slow, blind to motion, or confused about time, and none of these mistakes raises an error.
Here is the problem in numbers. A Vision Transformer (ViT) cuts a 224×224 image into squares of 16×16 pixels, called patches, and makes one token from each, 196 tokens in all (§27.1). Hand it a 16-frame clip, and the obvious move is to cut every frame the same way. That gives 16⋅196=3,136 tokens. Attention compares every token with every other, so the cost of its scores grows with the square of the token count. For the clip that part costs 162=256 times as much as for one image (§4.7). Worse, no token contains any motion. Each one is a still square from a single frame, so everything the model learns about movement must be assembled later, inside attention.
Most modern video encoders fix both problems the same way. They cut the clip into small blocks that span a few frames as well as a few pixels on a side, and make one token per block. These blocks are called tubelets. Each one looks like a brick cut from the stack of frames, so we will also call them bricks. With bricks two frames deep, the same clip becomes 1,568 tokens, and each token sees a little motion.
This section works the idea out exactly. We count the tokens, parameters, and FLOPs of a tubelet embedding. We show that it is a 3D convolution and also a single matrix multiply. We start it from a trained image model without losing what that model knows. Then we tell each token where and when it is, from learned tables to the rotary positions of today's video language models, and we follow how time in seconds reaches the language model. We end with a tokenizer that skips bricks that did not change.
The input is a clip of Tf frames, already sampled from the video as in §29.1. We store it as X∈RTf×H×W×C, frames by height by width by channels, with C=3 for color. PyTorch's 3D layers expect the layout (C,Tf,H,W) instead, and the code always says which one it uses. The letter T is kept for the number of tokens, as everywhere in the book.
Cutting a clip into bricks
Start from the image case. A ViT cuts an image of H×W pixels with C channels into a grid of P×P patches. It flattens each patch into a vector of P2C numbers, row by row, with the channels last. It multiplies every patch vector by the same matrix WE∈RP2C×d and adds the same bias bE∈Rd. Here d is the model width, the length of every token vector. The result is one token of d numbers per patch (§27.1).
A tubelet embedding does the same thing with one more axis. The brick is Pt frames deep and P×P pixels across, so it holds PtP2C numbers. We flatten it, multiply it by one shared matrix, and add one shared bias. The name tubelet comes from ViViT, one of the first video Transformers (Arnab et al. 2021). That paper writes the depth as t, and we write Pt because t is a time index. Here is the cut on a clip small enough to check by hand.
The cell reproduces the example with a reshape and a transpose. Then it shows the most common bug in video tokenization, which is to reshape the clip straight into rows and skip the transpose. The rows have the right shape, but they are not bricks. Row 2 becomes pixels 16 to 23, the first two rows of frame 1.
Python · cell 1
The first reshape splits each axis into a slot and an offset inside the brick, giving the shape (nt,Pt,nh,P,nw,P). The transpose moves the three slot axes to the front, so each brick's numbers sit together. The final reshape flattens each brick into one row. Here is the general statement.
In plain words, cut the clip into bricks, flatten each brick, multiply every brick by the same matrix, add the same bias, and list the results time slot by time slot. With Pt=1 this is the image patch embedding of §27.1 applied to every frame on its own.
Leftovers are dropped. ViViT writes the floors explicitly, and PyTorch's nn.Conv3d without padding does the same. Clips are usually sampled so that nothing is left over. But a 17-frame clip with Pt=2 silently loses its last frame, and a 230×230 frame with P=16 silently loses a 6-pixel strip along its bottom and right edges.
The most common setting is 16 frames at 224×224 with 2×16×16 tubelets. Two video encoders trained by masking use it, VideoMAE (Tong et al. 2022) and the spatiotemporal masked autoencoder of Feichtenhofer et al. (2022), often called MAE-ST. Both hide most of the tubelets and train the model to reconstruct them (§29.4).
What a token costs, and what it sees
The embedding has PtP2Cd weights plus d biases. ViT-B, the base-size Vision Transformer, has 12 layers of width d=768. With 2×16×16 tubelets its embedding has 2⋅16⋅16⋅3⋅768=1,179,648 weights and 768 biases, twice the image model's patch embedding. Qwen2-VL, an open vision language model that reads images and video (Wang et al. 2024), uses 2×14×14 tubelets at width 1,280 with no bias. That is 2⋅14⋅14⋅3⋅1280=1,505,280 weights.
The embedding's compute is one matrix multiply, a T×PtP2C matrix times a PtP2C×d one. Multiplying an m×k matrix by a k×n one takes mkn multiply-adds, and we count each multiply-add as 2 FLOPs, as in §4.7. Try it on the 4×4×4 clip of the example with d=1. Cut into 1×2×2 patches, it gives 16 tokens of 4 numbers, so 64 multiply-adds. Cut into 2×2×2 bricks, it gives 8 tokens of 8 numbers, again 64. That is no accident.
In plain words, every pixel value is multiplied by d weights exactly once. For the 16-frame clip and ViT-B that is 2⋅16⋅224⋅224⋅3⋅768=3.70 GFLOPs, about 1 percent of the encoder that follows. We check this at real scale near the end of the section.
So the brick shape leaves the embedding's cost alone, but it changes everything after it. Each Transformer layer spends about 24Td2 FLOPs on its four d×d projections and its MLP of width 4d. It spends another 4T2d on the attention core, the scores and the weighted sum of values (§4.7). Doubling Pt halves T. That halves the first term and quarters the second. For ViT-B on 16 frames at 224, the whole 12-layer encoder costs about 899 GFLOPs with per-frame tokens and 361 GFLOPs with Pt=2, a factor of 2.5.
What you give up is time resolution inside the network. With Pt=2 the encoder never has separate tokens for frames 2i and 2i+1, because one linear map combines both into a single token. That map can represent a little motion, since its weights can subtract one frame from the other. But in ViT-B it maps 1,536 pixel values to d=768 numbers, so it cannot keep everything about the pair. That is why Pt stays small, almost always 2.
The choice also interacts with sampling. Take a training clip of 16 frames, read every 4th frame of a video at 30 frames per second. It spans about 2 seconds, and one time slot covers 8 source frames, about a quarter of a second. An interviewer who asks you to size a video model expects exactly this chain, from frames and tubelet shape to tokens, to the two cost terms, to how much time one token spans.
Warm-up
Count the tokens
Write three counting functions for the tubelet embedding of the definition.
tubelet_tokens(T_f, H, W, P_t, P) returns the tuple (n_t, n_h, n_w, T) of Python ints. Leftover frames and pixels are dropped.
tubelet_params(P_t, P, C, d, bias=True) returns the number of parameters, the weights plus the bias if there is one.
tubelet_flops(T_f, H, W, P_t, P, C, d) returns the forward FLOPs of the embedding's matrix multiply. Count a multiply-add as 2 FLOPs and ignore the bias.
def tubelet_tokens(T_f, H, W, P_t, P):
n = (T_f * H * W) // (P * P) # placeholder: forgets P_t and the floors per axis
return (0, 0, 0, n)
def tubelet_params(P_t, P, C, d, bias=True):
raise NotImplementedError
def tubelet_flops(T_f, H, W, P_t, P, C, d):
Tests, hints, verified solutions, and AI help, with Pro at $7.99 a month.
The widget draws a clip as a block, with frame 0 in front and time running back. Pick a tubelet depth and watch the grid of bricks, the token count, and the attention cost relative to one image change.
How does the tubelet's depth set the number of tokens, and what is left over?
Frame 0 is in front, time runs back, and each brick is a few frames of 16 by 16 pixels.
Loading interactive figure…
A tubelet embedding is a 3D convolution
A convolution slides a small filter over its input and takes a dot product at every position. §0.8 wrote the 2D case with F filters, a k×k kernel, stride s, and no padding as Yf,i,j=bf+∑c∑u∑vWf,c,u,vXc,si+u,sj+v. Along an axis of length n its output has ⌊(n−k)/s⌋+1 positions. A 3D convolution adds a kernel axis along time. Take kernel (kt,k,k), strides (st,s,s), PyTorch's layout X∈RC×Tf×H×W, and filters Wconv∈RF×C×kt×k×k. Then
The output has ⌊(Tf−kt)/st⌋+1 positions along time, and ⌊(H−k)/s⌋+1 and ⌊(W−k)/s⌋+1 along height and width. We write l for the output column here because k is the kernel size.
Now set the stride equal to the kernel. The windows then sit side by side with no overlap and no gaps, which is exactly how the bricks tile the clip.
∂ Proof that the convolution and the tubelet embedding agree
The output size along time is ⌊(Tf−Pt)/Pt⌋+1=⌊Tf/Pt−1⌋+1=⌊Tf/Pt⌋=nt, since subtracting the integer 1 commutes with the floor. The same holds along height and width.
Fix an output position (i,j,k) and a filter f. With st=Pt and s=P, the input index (Pti+τ,Pj+u,Pk+v) for 0≤τ<Pt and 0≤u,v<P runs exactly over the frames, rows, and columns of brick (i,j,k). By the definition of vec, entry ((τP+u)P+v)C+c of the flattened brick is that pixel in channel c. The map (τ,u,v,c)↦((τP+u)P+v)C+c is a bijection onto {0,…,PtP2C−1}, because it writes a number in mixed radix with digits τ,u,v,c. So the convolution sum is the dot product of the flattened brick with column f of Wtub, term by term. Adding bf to both gives zijk.
Finally, every frame index t<ntPt is written uniquely as t=Pti+τ with 0≤τ<Pt, by division with remainder, and likewise for rows and columns. So each used pixel lies in exactly one brick. ■
In plain words, a convolution is a small dense layer applied to every window. When the stride equals the kernel along all three axes, the windows tile the clip, so a dense layer on every window is exactly a dense layer on every brick. This is the patch result of §27.1 with one more axis.
It also explains why the matrix-multiply form is cheap to build. The usual way to turn a convolution into one matrix multiply, called im2col, copies every window into a row of a matrix (§0.8). Overlapping windows make it copy each pixel many times. Here the windows are disjoint, so building that matrix copies nothing. It is a reshape and a transpose.
Here is the recipe in NumPy with the channels last.
Crop the clip of shape (Tf,H,W,C) to (ntPt,nhP,nwP,C).
Reshape it to (nt,Pt,nh,P,nw,P,C).
Transpose it to (nt,nh,nw,Pt,P,P,C).
Reshape it to (T,PtP2C) and multiply by Wtub reshaped to (PtP2C,d).
The transpose does two jobs, and each can go wrong silently. First, it puts the slot axes in front, in time-row-column order. Get that wrong and a token mixes pixels from different bricks. Second, it keeps each brick's own axes in the order the weight uses, which is the order of vec. Get that wrong and a pretrained embedding is applied to permuted pixels. Neither mistake raises an error.
The cell checks the theorem on a clip whose last frame and last 4 columns do not fit. It computes the convolution sum one output position at a time and compares it with the reshape and one matrix multiply.
Python · cell 2
There are 64 tokens (4⋅4⋅4), and the two computations agree exactly. Frame 8 and columns 32 to 35 never entered a token. Writing this without loops is the video version of the patchify question that labs ask in interviews, and the next exercise asks you to do it.
Interview
Tubelet embedding without loops
Write tubelet_embed(X, W, b, P_t, P). The clip X has shape (T_f, H, W_in, C), the weights W have shape (P_t, P, P, C, d), and the bias b has shape (d,). Return the tokens as an array of shape (n_t * n_h * n_w, d), in time-row-column order, with leftover frames and pixels dropped. Use only slicing, reshape, transpose, and one matrix multiply, no loops. The tests catch the usual mistakes, which are a missing crop, a missing transpose, the wrong flattening order, and leftovers leaking into tokens.
import numpy as np
def tubelet_embed(X, W, b, P_t, P):
T_f, H, W_in, C = X.shape
d = W.shape[-1]
n_t, n_h, n_w = T_f // P_t, H // P, W_in // P
rows = X.reshape(n_t * n_h * n_w, -1) # bug: no crop, no transpose, so rows are not bricks
return rows @ W.reshape(-1, d) + b
Tests, hints, verified solutions, and AI help, with Pro at $7.99 a month.
Starting from an image model
Labeled video is scarce next to labeled images. Kinetics-400, the standard action dataset of short YouTube clips in 400 classes (§29.1), has about 240,000 training clips. Image models are trained on hundreds of millions of images or more. So a ViT trained from scratch on video does poorly, and VideoMAE measured how poorly.
Top-1 accuracy is the fraction of test clips whose highest-scoring class is the correct one. Something-Something v2 is a dataset of people moving objects, where the label often depends on the direction of motion, so a single frame cannot solve it. Its training set has about 169,000 clips. The gap of 29 points shows how far that is from enough data to train a ViT from nothing.
So we want to start a video model from an image model. Every Transformer weight carries over unchanged. The only new weights are the extra temporal taps of the tubelet embedding and the positions for time. A tap is one frame's slice of the filter, Wtub[τ] for τ=0,…,Pt−1. How should the taps be set?
The guiding idea comes from Carreira and Zisserman (2017), whose Inflated 3D ConvNet, I3D, turned whole image CNNs into video CNNs (§29.3). Turn an image into a boring video by copying it into every frame. A good initialization should make the video model's response to the boring video equal the image model's response to the image. They called this the boring-video fixed point. Here are two ways to get it, on numbers small enough to do in your head.
In the general case the filter has Pt temporal taps instead of two, and each tap is a whole patch embedding of shape P×P×C×d rather than one number.
Inflation spreads the image filter evenly over the taps, and central-frame initialization puts all of it on one tap. The hand example showed that both pass the boring-video test. The theorem below says the same holds for any depth Pt and any image filter.
The same sum says what each method does on a video that is not boring, as the hand example showed. The map is linear, so the inflated token is the image embedding of the brick's frames averaged, (Pt1∑τxτ)⊤W2D+b⊤. At initialization the model sees motion-blurred frames. The central-frame token is the image embedding of one frame of each brick, frame iPt+⌊Pt/2⌋, and the other frames are ignored. ViViT's simplest tokenizer, uniform frame sampling, does exactly this on purpose. It keeps nt evenly spaced frames, here one in every Pt, and cuts each kept frame into image patches. So at initialization a central-frame model is the uniform-sampling model, which is what ViViT means when it says the filter "behaves like uniform frame sampling". Training then teaches the other taps to use time.
The cell builds both initializations from a random image filter. It checks the fixed point on a static clip. Then it embeds a clip whose content moves 3 pixels per frame and confirms the two readings above.
Python · cell 3
On the static clip both initializations reproduce the image tokens to rounding error. On the moving clip they part ways, and the last two lines confirm that inflation embeds the average frame and central-frame initialization embeds frame 1.
The fixed point is about the first layer, but more is true. Suppose every token attends to every other (joint space-time attention), the positions added to the tokens do not depend on the time slot, and the model averages its output tokens at the end. Then the whole video ViT reproduces the image ViT on a boring video, exactly. The reason is one small fact about attention. If every key and value appears nt times, the attention output does not change, because the factor nt cancels in the softmax. The box below proves it and shows what breaks the equality.
So, at initialization, a video ViT built this way gives the image ViT's output on every boring video, up to rounding. Which of the two initializations trains better is an empirical question, and ViViT answered it.
A randomly initialized embedding throws away the image model's first layer and lands 6 points behind central frame. Central frame beats inflation by 1.6 points and uniform frame sampling by 0.7. A plausible reading, which the paper does not test, is that central frame starts from a model that is already good (the uniform-sampling model), while inflation starts from one that sees blurred inputs.
Later work kept the conclusion that tubelets should start from the image filter. VideoMAE, for its baseline that starts from a ViT-B pretrained as an image masked autoencoder on ImageNet-1k, inflates the image patch embedding into its tubelet embedding. Qwen2-VL feeds each image as two identical frames, a boring two-frame video. Its 2×14×14 embedding therefore acts on an image as a 2D convolution whose filter is the sum of its two taps, Wtub[0]+Wtub[1]. In an interview, "how would you start a video model from an image model?" is answered by the fixed point, the two recipes, and these numbers.
Core
Inflate an image model
Write inflate_patch_embedding(W2d, P_t, mode). It turns an image patch embedding W2d of shape (P, P, C, d) into a tubelet embedding of shape (P_t, P, P, C, d).
With mode="inflate", copy the filter into every temporal tap and divide by P_t.
With mode="central", put the filter at tap P_t // 2 and zeros at every other tap.
Do not modify W2d. The tests check the boring-video fixed point with their own embedding code.
import numpy as np
def inflate_patch_embedding(W2d, P_t, mode):
return np.repeat(W2d[None], P_t, axis=0) # placeholder: copies without rescaling, ignores mode
Tests, hints, verified solutions, and AI help, with Pro at $7.99 a month.
Telling each token where and when it is
Self-attention cannot see order. A Transformer without positions treats its input as a set. Shuffle the tokens, and the outputs are shuffled the same way and otherwise unchanged (§4.6). A video model without positions reads a bag of bricks. It cannot tell which brick came first, so it cannot tell a door opening from a door closing. Each token needs its time slot i, row j, and column k.
§27.1 did this for the two spatial axes of an image. Video adds a third axis, and with it two new problems. The tables get large, and one step in time means different durations at different frame rates. We go from the simplest scheme to the one frontier models use.
Learned tables, joint and separable
An image ViT marks position by adding a learned vector, a position embedding, to each patch token, one vector per grid position (§27.1). The direct extension to video gives every one of the T=ntnhnw grid positions of the clip its own learned vector. That is ntnhnwd parameters. For 1,568 tokens at d=768 it is 1,204,224 parameters, about as many as the tubelet embedding itself. And a model trained on 8 time slots has no vector at all for slot 9. ViViT uses such a joint table and initializes it by repeating the image model's spatial table along time, which meets the condition of the whole-network result above.
The cheaper option learns one table for time and one for space, and adds them.
MAE-ST uses separable embeddings in its encoder, noting that the "separable implementation prevents the size of positional embeddings growing too large in 3D". It reports that a fixed sine-cosine variant works similarly. At nt=8, ns=196, d=768, the separable tables have (8+196)⋅768=156,672 parameters, 7.7 times fewer than the joint one.
The price is structure. The position part of every token is a time vector plus a space vector. Any interaction between when and where must be computed by the network rather than stored in the table, which for most data is no loss. The gain is that each table can be resized on its own. To read 32 time slots with a model trained on 8, interpolate the 8 temporal vectors to 32 along one axis and fine-tune briefly. That is the one-dimensional version of the 2D resizing in §27.1.
Separable tables also make initialization easy. Set pspace to the image model's table and every pitime to the same vector (zero, for instance), and the positions no longer depend on the time slot, as the whole-network result needs.
Three-dimensional sines and cosines
The fixed alternative extends the two-dimensional sine-cosine table of §27.1 to three axes. Recall the one-dimensional encoding of §5.4. At width m, position x maps to the vector sm(x) with entries sin(xωl) and cos(xωl) in columns 2l and 2l+1, where ωl=b−2l/m. The scalar base b is 10,000 there, and it is unrelated to the bias b above. The useful identity is sm(x)⊤sm(x′)=∑lcos((x−x′)ωl), a function of the offset x−x′ alone. For video, give each axis its own block of the vector.
In plain words, the similarity of two positions is a time term plus a row term plus a column term, and each depends only on how far apart the two tokens are along that axis. The cell builds the table for VideoMAE's 8×14×14 grid with d=768 split into three parts of 256. It checks the identity by shifting two tokens together, and it compares the parameter counts of the two learned tables.
Python · cell 5
VideoMAE takes a cruder route that is worth knowing because it works. Its encoder adds a fixed one-dimensional sinusoidal table over the 1,568 flattened positions. A token's position is then its raster index(inh+j)nw+k, its row in the token matrix. The network must learn on its own that index r and index r+196 are the same place one time slot apart.
Three-dimensional RoPE
The schemes so far add a vector to each token. Attention scores then depend on absolute positions, through the cross terms between content and position (§5.4 showed this in one dimension). Video language models instead use rotary position embedding, RoPE, which rotates queries and keys and adds nothing.
Here is RoPE in plain words, from §5.5. Split a query or key q∈Rdk into dk/2 pairs of coordinates, pair j being coordinates (2j,2j+1). Treat each pair as the hand of a clock that turns at its own speed, ωj=b−2j/dk radians per position. For the token at position m, turn pair j by the angle mωj. The score of a rotated query and a rotated key then depends only on their content and the offset between them, (Rmq)⊤(Rnk)=q⊤Rn−mk.
For video, give each token a position triple (time, row, column), and let each clock be driven by one of the three coordinates. Here is the idea on numbers before the definition.
With 6 pairs split two per axis, the sectioned layout assigns pairs 0 to 5 to time, time, height, height, width, width. The interleaved layout assigns them to time, height, width, time, height, width.
∂ Proof of the 3D relative property
Both rotations are block diagonal with the same block structure, so the dot product is a sum over pairs, ∑j(R(ma(j)ωj)q(j))⊤(R(na(j)ωj)k(j)), where a(j) is the axis that owns pair j. For a 2×2 rotation, R(α)⊤=R(−α) and R(−α)R(β)=R(β−α), the two facts §5.5 used. So each term is q(j)⊤R((na(j)−ma(j))ωj)k(j), and grouping the pairs by axis gives the formula. If every coordinate of m equals m, every pair is rotated by mωj whichever axis owns it, which is the 1D rotation Rm. The same holds for n. ■
In plain words, each frequency pair is a clock that one axis drives. The score between two tokens depends on how far apart they are in time, in rows, and in columns, never on where they are. Text, whose three coordinates are always equal, sees ordinary RoPE, so the language model keeps everything it learned on text.
This differs from the axial 2D RoPE of §27.1, which gives each axis its own complete ladder of frequencies, from fast to slow. M-RoPE deals a single ladder out among three axes. So which pairs an axis receives decides which distances it can resolve, and the next part shows that this matters.
The cell implements 3D RoPE for both partitions. It checks the shift invariance with random queries and keys at the example's positions, and it checks that a text token gets plain 1D RoPE.
Python · cell 6
Which frequencies each axis gets
The partition looks like bookkeeping, but it decides what each axis can see. The frequencies fall geometrically with j, so sectioning gives time the fastest clocks and width the slowest. The cell reads off both released configurations, with head width 128 and base 106 for Qwen2-VL and 5⋅106 for Qwen3-VL (from their Hugging Face configuration files). For each axis it prints the fastest and slowest frequency and the largest angle an offset of 30 positions can produce.
Python · cell 7
Two problems show up in Qwen2-VL's sectioned layout. First, width gets only frequencies below 1.8⋅10−4 radians per step. A column offset of 30 turns its pairs by at most 0.005 radians, a third of a degree, so the language model's attention can barely tell columns apart through RoPE. (The vision encoder has its own 2D RoPE, so spatial layout also reaches the language model through the content of the visual tokens.)
Second, time gets only fast clocks. The slowest turns once every 2π/0.039≈160 positions. Beyond about 160 time steps every time clock has wrapped around at least once, so a distant frame can land in nearly the same phases as a near one. Wei et al. (2025) document exactly this. They place distractor images, similar to a target frame, around it in a long video, and show that a sectioned M-RoPE is misled by them. They propose giving time the low frequencies instead. Qwen3-VL's report says the original allocation "induces an imbalanced frequency spectrum". It interleaves the axes so that, as the cell shows, each axis owns both fast and slow clocks. An interviewer who asks why Qwen3-VL changed its position scheme wants this argument.
The widget draws every pair as a clock, for both released settings, with one row of clocks per axis from fast to slow. Press Play to move two tokens apart along every axis at once. Watch which clocks turn, and which fill in, the sign that a clock has gone all the way round.
Which distances can each axis of 3D RoPE tell apart?
Each clock is one frequency pair, turned by offset times frequency. Its shaded face is full after one whole turn.
Loading interactive figure…
Placing a video between text
In a video language model the video tokens sit in one sequence with text, as in "Here is a clip. ⟨video⟩ What does the man do after opening the door?". Every token needs a triple. Qwen2-VL's rule has three parts.
Text tokens get triples with all three coordinates equal to their running position, (p,p,p). By the theorem, text alone sees 1D RoPE.
A video that starts after o positions have been used gives token (i,j,k) the triple (o+i,o+j,o+k). Here (i,j,k) index the grid of tokens the language model reads, after any merging of neighboring tokens.
The text after the video starts at the largest coordinate used so far plus one, o+max(nt,nh,nw), and counts up from there.
The third rule is where implementations go wrong. With 1D positions, text after a video of T tokens would start at o+T. With M-RoPE it starts at o+max(nt,nh,nw), which is far smaller. A 16×16×16 grid of 4,096 tokens advances the text position by only 16. Qwen2-VL's paper says this "reduces the value of position IDs for images and videos, enabling the model to extrapolate to longer sequences during inference".
Starting at o+T instead is a silent bug. Every score between text and video still depends only on offsets, so nothing crashes. But the offsets are ones the model never saw in training, and its answers about the video quietly get worse.
Interview
3D RoPE for a video followed by text
Write two functions.
mrope_ids(n_t, n_h, n_w, n_text_before, n_text_after) returns an integer array of shape (3, n_text_before + n_t*n_h*n_w + n_text_after) of position triples, one column per token. Rows 0, 1, and 2 hold the time, height, and width coordinates. Follow the three rules above, with the video tokens in time-row-column order. Every grid size is at least 1.
apply_mrope(x, ids, sections, base=10000.0, layout="sectioned") rotates x of shape (T, d_k) using ids of shape (3, T). Pair j is coordinates (2j, 2j+1), rotated counterclockwise by the angle ids[a, t] * base**(-2j/d_k), where a is the axis that owns pair j under the definition's sectioned or interleaved partition. The tuple sections = (s_t, s_h, s_w) counts pairs per axis, with s_t + s_h + s_w = d_k // 2.
import numpy as np
def mrope_ids(n_t, n_h, n_w, n_text_before, n_text_after):
total = n_text_before + n_t * n_h * n_w + n_text_after
return np.tile(np.arange(total), (3, 1)) # placeholder: 1D positions in all three rows
def apply_mrope(x, ids, sections, base=10000.0, layout="sectioned"):
raise NotImplementedError
Tests, hints, verified solutions, and AI help, with Pro at $7.99 a month.
Frame indices, seconds, or timestamps
Rule 2 makes the time coordinate a slot index. But how long a slot lasts depends on how the video was sampled. At a sampling rate of fsmp frames per second (§29.1), a slot covers Pt/fsmp seconds. At 2 frames per second with Pt=2, one slot is one second. At 0.5 frames per second it is four seconds. A model whose time coordinate counts slots cannot tell a slow video from a fast one. Nor can it answer "at what second does the dog jump?" without being told the rate. The Qwen models have tried three answers, each fixing a problem of the one before.
The trade-off is easy to put in numbers. Under absolute time, the time coordinates of an hour-long video span about 2⋅3600=7,200 positions, while its height and width coordinates span only a few dozen, and the text after it starts more than 7,200 positions later. Text timestamps cost a few tokens per temporal patch. In return they put time where a language model reads best, in words, and they let the model write times back in the same format. That is what temporal grounding, finding when something happens in a video, needs (§29.5).
One numerical warning applies to every scheme with large coordinates. The bfloat16 format holds integers exactly only up to 256 (§5.5), so position 3,001 becomes 3,008. The angles must be computed from the coordinates in float32 or better.
Fewer tokens where nothing moves
Everything so far makes the same number of tokens for every clip of a given size, whether it shows a lecture slide that never changes or a football match. §29.1 measured how much consecutive frames repeat, and video codecs exploit exactly that. The tokenizer can do the same. Run-length tokenization (RLT) borrows the idea of run-length encoding, which stores a repeated value once along with how many times it repeats.
Here it is at one location of the frame, over 5 time slots, with a threshold of 0.1. Each tubelet is compared with the one just before it at the same location. Say the differences for slots 1 to 4 are 0.8, 0.3, 0.02, and 0.01. Slots 1 and 2 changed, so they are kept, along with slot 0, which is always kept. Slots 3 and 4 barely changed, so they are dropped, and slot 2 stands for a run of 3 slots. The location costs 3 tokens instead of 5, with run lengths 1, 1, and 3.
Choudhury et al. (2024) measure the difference as an L1 distance between the first frame of the earlier tubelet and the last frame of the later one, after ImageNet normalization, and typically use τrlt=0.1. Packing places several sequences in one row with a block-diagonal attention mask, so tokens attend only within their own sequence. It is the masking trick of §4.4.
The cell and the exercise below use a simpler measure, the mean absolute difference over the whole tubelet, with a threshold suited to raw pixels in [0,1]. The cell runs the rule on two synthetic clips. A static camera watching one moving square keeps a fifth of its tokens. The same scene with a camera panning one pixel per frame keeps all of them, because a pan changes every patch.
Python · cell 10
The pan is the weakness. RLT compares tubelets at a fixed location, so it sees any camera motion as change everywhere. A codec's motion compensation removes exactly this kind of redundancy, and RLT cannot.
A second, subtler weakness is drift. Each tubelet is compared with the one just before it. A slow fade whose every step is below the threshold is dropped entirely, even after the picture has changed completely. Comparing with the last kept tubelet bounds that error, at the cost of a sequential scan along time. Both weaknesses are reasons other ways of cutting tokens stay in use. Masking drops a fixed random fraction regardless of content (§29.4), and token merging averages similar tokens together inside the network (Bolya et al. 2023).
Core
Run-length tokenization
Write run_length_tokenize(patches, thresh). The input patches has shape (n_t, n_s, D), the flattened tubelets by time slot and spatial slot.
Keep every tubelet of slot 0.
For i≥1, drop tubelet (i, s) if the mean absolute difference between patches[i, s] and patches[i - 1, s] is strictly less than thresh, and keep it otherwise. Compare with the previous time slot, as RLT does, not with the last kept tubelet.
Return (kept, lengths). Here kept is an integer array of shape (K, 2) of (i, s) pairs in time-major order (sorted by i, then by s). The entry lengths[r] is the run length of kept token r, the number of time slots from it to the next kept tubelet at the same s, or to n_t if there is none.
Tests, hints, verified solutions, and AI help, with Pro at $7.99 a month.
Tubelets at real scale
Two published numbers let us check everything at once. VideoMAE lists the cost of its ViT-B, ViT-L, and ViT-H encoders on 16 frames at 224 pixels with 2×16×16 tubelets as 180, 597, and 1,192 GFLOPs per view. Our count is the tubelet embedding plus, per layer, 24Td2 for the projections and the MLP and 4T2d for the attention core. One caution about units. Vision papers report multiply-adds and call them FLOPs (§27.2), so we divide our count by two before comparing.
Python · cell 11
The language-model side runs the same arithmetic with merging. Qwen2-VL's encoder cuts a 448×448 frame pair into 32×32=1,024 tubelets, and the 2×2 merge passes 16×16=256 tokens to the language model. At 2 frames per second, one frame pair per second, that is 256 tokens per second of video. The 16,384-token training cap then holds 64 seconds at that resolution, which is why the resolution is lowered for longer videos.
The same merge explains a number in Qwen2-VL's paper, that a 224×224 image becomes 66 tokens. That is (224/28)2=64 merged tokens plus a start and an end marker. Qwen3-VL's encoder, SigLIP 2 (Tschannen et al. 2025), uses 16-pixel patches with the same depth of 2 and the same merge, so one of its language-model tokens covers 2×32×32 pixels.
The table collects the tokenization choices for a 16-frame clip at 224 pixels and a ViT-B encoder. TimeSformer is a video Transformer that tokenizes each frame on its own (Bertasius et al. 2021).
tubelets, then a 2×2 merge before the language model
392 to the language model
2 frames
361 in the encoder
Qwen2-VL, Qwen2.5-VL, Qwen3-VL (Qwen3-VL's 16-pixel patches give exactly this count)
The second table compares the position schemes, with the parameters they add for that clip at d=768.
positions
parameters
longer clips
relative in attention
used by
joint learned table
1,204,224
interpolate a 3D table
no
ViViT (initialized by repeating the image table)
separable learned tables
156,672
interpolate the 8 temporal vectors
no
MAE-ST
fixed 1D sines over the raster index
0
extend the table
no
VideoMAE
fixed 3D sines and cosines
0
extend each axis
no, but the table's dot products are
a fixed alternative to learned tables (MAE-ST found fixed sines work about as well as its learned ones)
3D RoPE, sectioned or interleaved
0
positions extrapolate, behavior may not
yes, by the theorem
Qwen2-VL, Qwen3-VL language models
time as absolute seconds or text timestamps
0
any duration and frame rate
yes
Qwen2.5-VL, Qwen3-VL
absolute seconds shared with audio tokens (TMRoPE)
0
any duration, coordinates grow by 25 per second
yes, and sound and picture align in time
Qwen2.5-Omni
relative time tokens, N bins over the duration
Nd new embedding rows, 76,800 at N=100
any duration, but a bin is ℓ/N seconds
not a position, time is carried by tokens
Vid2Seq, LITA
The same space-time patching reappears in two later places. Video generators built on diffusion Transformers cut a compressed latent video, rather than pixels, into space-time patches (§34.5). And vision language models merge and pool these tokens further before the language model reads them (§31.5).
In PyTorch
In PyTorch the tubelet embedding is nn.Conv3d with kernel equal to stride, on the layout (B,C,Tf,H,W). Its weight has shape (d,C,Pt,P,P), with the channels before time. So its flattening order differs from our channels-last math, and the reshape-plus-matmul version must permute the brick axes to (C,Pt,P,P) to match. The block checks the convolution against that version. Then it inflates a 2D patch embedding both ways and checks the boring-video fixed point on every time slice.
🔥 In PyTorchfor a machine with PyTorch
The Hugging Face implementation of VideoMAE shows the same choices in a released model. Its configuration names the temporal size tubelet_size, and its patch embedding is the Conv3d above. The snippet downloads weights, so it is shown, not run.
Read more
Arnab et al. (2021). ViViT: A Video Vision Transformer. Section 3.2 defines tubelet embedding, Section 3.4 the inflation and central-frame initializations and the repeated position table, and Table 1 compares them.
Bai et al. (2025b). Qwen3-VL Technical Report. Sections 2.1 and 2.3 explain interleaved MRoPE and text timestamps, and why absolute-time positions were dropped. The time-aligned M-RoPE they replace is in Bai et al. (2025a), Qwen2.5-VL Technical Report, and its audio-aligned form, TMRoPE with 2-second interleaving, in Section 2.2 of Xu et al. (2025), Qwen2.5-Omni Technical Report.
🎬 Lecture 18: Videos · Michigan Online · starts at 27:30Justin Johnson compares a 2D convolution over stacked frames with a true 3D convolution, the operation a tubelet embedding is, before moving on to two-stream networks and non-local blocks.🎬 Rotary Positional Embeddings: Combining Absolute and Relative · Efficient NLP · starts at 5:51A short visual refresher on the 1D rotation and its relative property, which this section extends to three axes.