This post documents the process of fine-tuning a video model on the server that I worked on earlier.
We’re using Video-ORA. The task is to have the model watch a video, output the time intervals worth keeping, and then pass that information to a subsequent program for editing. I’m working on SFT, which involves supervised fine-tuning using the video and corresponding answers to train a small number of additional parameters using LoRA.
There are several RTX 3060 cards on the server, each with 12GB of VRAM. Initially, when I set the frame rate to just 8 frames per second on a single card, it used about 11.24 GiB of video memory, and increasing it any further easily caused an OOM (out of memory) error. Since the video needs to show the entire sequence of actions, I couldn’t continue working on it by constantly reducing the frame rate.
(Actually, I knew from the start that using just one card wouldn’t work, but I still wanted to give it a try.)
The last two cards completed the multi-step training involving 64 sample frames, and the weight updates, saving, and reloading for all four cards were also successful. The most time-consuming part was loading the weights, and there was another issue I didn’t notice at first: the video resolution limit wasn’t enforced as expected.
1. Where Exactly Is the Video Memory Used?
At that point, we had already implemented BF16, LoRA, and gradient checkpoints, and the visual encoder and multimodal connection layer had been frozen; each card processed only one sample at a time.
A two-card DDP setup with a small number of frames works, but the peak GPU memory usage is still close to 11.2 GiB. DDP maintains model copies on each card; since the issue here is that a single card cannot accommodate the entire model, we’ll continue testing DeepSpeed ZeRO-3 by sharding the parameters and other training states across different cards.
However, when troubleshooting, you must first determine at which step the OOM occurred.
During a single-card inspection at caching_allocator_warmup() The request for 8.35 GiB failed; it appeared that the model was too large, but it turned out that the validation script was called twice within the same process. get_model_processor()... and began loading the second model. Later, the reload check was moved to a separate process, leaving the training process responsible only for updating and saving.
There were also a few instances of erroneous stops during the “gather” phase when ZeRO-3 was loading weights—that is, when temporarily aggregating the shard parameters. Since the video hadn’t been fed into the system yet, reducing the frame rate obviously wouldn’t solve the problem.
Later, I broke down the investigation based on where the errors occurred: during the loading phase, I looked at the weights and temporary allocations; once I received the batch, I examined the video input and the training process. That way, at least I wouldn’t just reduce all the parameters as soon as I saw an OOM error.
2. The video budget hasn’t taken effect
This section is worth checking first, even more so than the Gaka section.
It was already set up. max_pixels...I thought the video resolution had already been limited. After reducing it and then examining the actual batch, the tensors did not shrink accordingly. The Qwen3.5 video processing branch I was using at the time did not limit the input based on this parameter.
The original 8-frame sample,video_grid_thw Yes [4,72,40], bringing the total number of tokens, including text, to 3,058. Later, we switched to a video token budget and set it before importing the relevant processing code:
export VIDEO_MAX_TOKEN_NUM=196
export VIDEO_MIN_TOKEN_NUM=128
After reprocessing the same sample, the difference is obvious:
| Input | video_grid_thw | Total input tokens (including text) |
|---|---|---|
| The original 8 frames | [4,72,40] | 3058 |
| The same 8-frame sample after limiting the video budget | [4,36,20] | 898 |
| Subsequent 16-second, 32-frame sample | [16,36,20] | Approximately 3,170 |
The number of visual tokens in the first two lines dropped from approximately 2,880 to 720. With this space saved, the original single-GPU setup was able to complete three consecutive updates in 16 seconds at 32 frames per second, with peak GPU memory usage of approximately 11.28 GiB.
In other words, the initial observation that “it hit the limit after 8 frames” merely indicates that the specific input configuration placed a heavy load on the video memory; it should not be taken as a fixed frame rate limit for this card. This time, we reduced the level of spatial detail per frame slightly, which allowed for more frames to be rendered; whether this loss of detail affects the task’s performance will require further evaluation.
This grid is also easy to misread.video_grid_thw The data records a grid consisting of time, height, and width. At that time, the time patch size was 2, so [16,36,20] This corresponds to 32 input frames, not 16 frames.
VIDEO_MAX_TOKEN_NUM=196 Nor does this mean that the entire video has a maximum of 196 tokens. In this processing path, it constrains the spatial budget for each frame; the visual tokens resulting from the merging of the 32 sample frames above still total:
16 × (36 / 2) × (20 / 2) = 2880
In subsequent logs, I recorded the actual number of sampled frames, the grid, and the input tokens. Regardless of what’s specified in the configuration file, the final result depends on how much data the model actually receives. The usage of these two environment variables corresponds to the processor used at the time; they’ll need to be rechecked if the model or processing library is changed.
3. ZeRO-3 Weight Loading Issue
The two cards initially stopped after a log entry like this:
Video-ORA ZeRO-3 loaded 250 parameters
Then, while reporting the missing key, I _initialize_missing_keys() An OOM error occurs nearby. After checking with a meta-device probe that only constructs the model structure without loading the actual weights, the checkpoint files and indexes were found to be aligned; the problem lies in the old custom loading logic.
One of the differences is the weight key name:
checkpoint: model.language_model.layers.0....
model state: language_model.layers.0....
Adding a prefix mapping alone isn’t enough; the lookups, copying, and initialization completion checks during recursive loading must all align. Otherwise, even if the weight file clearly exists, the framework will still treat the parameter as if it hadn’t been loaded and proceed with the initialization process for missing parameters.
Another pitfall is mixing the use of `Tensor` and `Parameter`. The loader starts from a regular state_dict() A value is retrieved, but the value obtained must be nn.Parameter. The first error was related to shared weights lm_head, After adding the special judgment, the bias in the standard visual layer reported the same error again.
This issue is evident in the CPU:
layer = torch.nn.Linear(2, 2)
type(layer.state_dict()["weight"])
# torch.Tensor
layer.state_dict(keep_vars=True)["weight"] is layer.weight
# True
During the investigation, we also encountered a situation where “the function had been changed, but the old function was still being executed”: the loader in the defining module had been replaced, but the function reference saved by another module during import remained unchanged. As compatibility patches piled up, it became increasingly difficult to verify them. Eventually, we moved the main development branch to an isolated environment to validate the upstream loader and disabled the old patches.
The environment at that time is documented below for future reference; this is not intended as a requirement for installing the standard version combination:
| Components | This Record |
|---|---|
| Python | 3.11 |
| PyTorch | 2.10.0+cu128 |
| ms-swift | 4.6.0 |
| DeepSpeed | 0.18.2 |
| Transformers | Original environment: 5.5.4; Isolated environment that passed testing: 5.19.0 |
| tokenizers | Isolated Environment 0.23.1 |
The isolated environment preserves the original environment, while the remaining dependencies are reused via the search path. Later, when I reran the script, the old error reappeared; upon checking the logs, I discovered that the startup command had reverted to the old interpreter and loader. Since then, I’ve included the actual interpreter, dependency versions, and script hash in the runtime logs as well.
There was also one false positive during weight verification: unaggregated parameters in ZeRO-3 may be displayed as [0]...If you directly compare this local shape with the complete checkpoint, of course they won’t match. When verifying, use the logical shape instead:
def logical_parameter_shape(parameter):
return list(getattr(parameter, "ds_shape", parameter.shape))
For numerical comparisons, weights from each rank are fed into the gather function in the same order and then compared element-by-element with the source weights, which have been converted to the same dtype. Ultimately, all 724 checkpoint entries matched, and only then was this loading step considered complete.
4. Training is paused at Step 0
Communication between the two cards was first tested using NCCL all-reduce: one on each card. 1.0, Both sides received it 2.0. The model has finished loading, but the training log shows:
global_step/max_steps: 0/1
train_loss: 0
train_steps_per_second: 0
The reason is that only one sample was prepared for the minimum test. The sampler also sorts the data by rank, so neither of the two cards received a usable batch. Turning off shuffle didn’t help either; changing the order won’t magically create an extra data point.
Change the single-sample check to world_size Copy the data so that each training process receives one batch; this flag is used only for smoke testing. A DataLoader check has also been added before training; if any rank fails to receive a batch, an error is reported in advance.
After switching to a different window to perform multi-step testing, we discovered that only two of the three samples were actually used. The sampler’s sharding path at that time followed total_samples // world_size During processing, one of the samples at the end of each of the two cards was discarded. The test was therefore modified to use four separate windows, gradually recording the actual samples read from each card and checking whether the number of sample rows was divisible by the number of cards.
Both times, the program started up without explicitly telling me that “not all the data was used.” Since I was only watching the progress bar and the loss value, it was easy to overlook this.
There was also a minor hiccup with the parameter update check: half of the LoRA tensors in the first step had all-zero gradients, but they all belonged to lora_A. At that time, A was initialized randomly, B was initialized to zero, and the increment was B × A, the gradients propagated backward to A for the first time are multiplied by a zero vector for B. B can still be updated, so we cannot use the criterion that “every parameter has a nonzero gradient in the first step” to determine whether training is proceeding normally.
5. Test Results for the Two-Card and Four-Card Configurations
The two-card, multi-step test used four different windows, the longest of which was 32 seconds and 64 frames; it was actually trained on rank 0:
video_grid_thw = [[32, 28, 28]]
input_tokens = 6770
supervised_tokens = 182
In three consecutive steps, we completed the forward pass, backward pass, optimizer update, and adapter saving, and all four windows appeared in the batch log. The peak values for each of the two processes are as follows:
| Process | Longest input actually trained on | Peak allocated | Peak reserved |
|---|---|---|---|
| rank 0 | 64 frames | 8.91 GiB | 9.37 GiB |
| rank 1 | 32 frames | 7.39 GiB | 7.90 GiB |
“allocated” refers to the GPU memory that PyTorch has already allocated to a tensor, while “reserved” also includes the space reserved in its cache pool. The values recorded here are PyTorch metrics and are not directly equivalent to nvidia-smi the total card space used.
This is a set of mixed-length tests; it should not be interpreted as both cards running 64 frames each at the same time. The 64-frame spatial grid used here for the two cards also differs from the single-card tests described earlier, so you cannot simply compare the two sets of numbers to calculate the percentage of savings achieved with multiple cards.
In addition to the video memory, I also checked whether the answer was actually included in the training: the supervision token corresponding to “assistant” was non-empty, the answer was not truncated, and the rest of the input retained the mask. The timestamps after windowing must also be aligned: subtract the window start time from the timestamp in the source video, and trim events that span multiple windows so they fall within the current window. Otherwise, if the video starts at a local 0 seconds but the answer still lists the original video timestamp, the training will run, but the system will be fed misaligned samples.
The ZeRO configuration used is shown below. The communication and prefetch buckets are set to small sizes, communication overlap is disabled, and neither CPU nor NVMe offload is used; this version focuses on addressing capacity issues and does not include throughput optimization.
{
"bf16": {"enabled": "auto"},
"zero_optimization": {
"stage": 3,
"overlap_comm": false,
"contiguous_gradients": true,
"reduce_bucket_size": 5000000,
"stage3_prefetch_bucket_size": 5000000,
"stage3_param_persistence_threshold": 10000,
"stage3_max_live_parameters": 100000000,
"stage3_max_reuse_distance": 100000000,
"stage3_gather_16bit_weights_on_model_save": true
},
"gradient_accumulation_steps": "auto",
"gradient_clipping": "auto",
"train_batch_size": "auto",
"train_micro_batch_size_per_gpu": "auto"
}
Subsequent four-card tests used the same loading and training entry points; each rank read a different window, and continuous updates, saves, and reloads were all successful. In that test, a 32-second window and 32-frame input were used, with each card peaking at approximately 5.76–5.82 GiB of reserved memory. Unlike the two-card test, the frame count here differs, so we will not use it for a linear scaling comparison. The previous three-card single-step test did not show any speed improvement either; the primary goal of this multi-card joint debugging was to determine whether the system could even fit the data.
6. Adapter Load Testing
The saved adapter is approximately 10 MB. After exiting the training process, when the base model and adapter are reloaded, the 256 tensors match the saved file element-by-element.
I then used the short-test adapter to conduct a generation test without the standard answers, and the following occurred: <argmax>11</argmax> and duplicates <1.0>...and the output is still unstable. We checked the training and inference wrappers, templates, and video budgets, but were unable to pinpoint the specific cause at the time. These are the results of the preliminary tests conducted in the previous steps; performance evaluations for the subsequent formal fine-tuning will be conducted separately.
7. THE END
Subsequent official tweaks continued to use this multi-card entry system. The biggest contributors to this setup: ds-4.1f (really fast) + 6astra (spot-on advice)

