{"slug": "bugs-that-broke-driving-machine-learning-edition", "title": "Bugs that broke driving: Machine Learning edition", "summary": "Comma.ai published a blog post detailing machine learning bugs that degraded its self-driving model's training and inference, including a BF16 plan-output precision issue that limited speed representation to 0.125 m/s steps around 30 m/s, an FP16 ConvNeXt overflow where a residual multiplier near 54 at stages[2].blocks[29] exceeded FP16's 65,504 limit, and a desire-loss bug in which NaN targets were converted to class 0 by argmax. The company said the fixes promote the plan head and its inputs to FP32, divide the affected ConvNeXt stage's residual stream by four, and mask NaN targets before computing cross-entropy loss. Comma.ai framed the retrospective as a way to understand where its code tends to break and catch problems earlier.", "body_md": "# \n[Bugs that broke driving: Machine Learning edition](https://blog.comma.ai/ml-bugs/)\n\n“We have solved self driving, we just need to fix the bugs.”\n\nRumor has it that the Linux kernel averages around 0.5 bugs per 1,000 lines of code. Some bugs are easy to avoid or catch with tests and metrics. Others are very good at hiding.\n\nThis is a sequel to [Bugs that broke driving](https://blog.comma.ai/driving-bugs/) with more Machine Learning related bugs.\n\nSome of these bugs affected correctness, others slowed things down or used up extra memory. They all made training or inference worse.\n\nLooking back at these bugs helps us understand where our code tends to break and how to catch problems earlier.\n\n## Output layers need to be run in high precision[Permalink](#output-layers-need-to-be-run-in-high-precision)\n\n*September 2026.*\n\nThe world model is a diffusion transformer (DiT) which predicts the next frame and an “expert” driving plan.\n\nDuring inference, the model runs in mixed precision `bf16_fp8_nvfp4`:\n\n- Transformer MLP linears use NVFP4 (most FLOPs)\n- Attention projections, cached attention Q/K/V, and the KV cache use FP8 (saves VRAM)\n- Other layers use BF16 (not too many FLOPs)\n- Norm reductions, attention scores, and sinusoidal timestep features use FP32 (standard safe mixed precision)\n\nIn particular, the plan output layers ran in BF16. Around 30 m/s, BF16 can only represent speeds in steps of 0.125 m/s. The acceleration calculation from the plans subtracts nearby speed predictions and divides by a short time interval , magnifying the rounding errors:\n\nThe fix is to promote the plan head and its inputs to FP32 *before* running it, Casting the finished plan to FP32 just gives the staircase more decimal places.\n\n```\n+plan_head.float()\n-plan = plan_head(features).float()\n+plan = plan_head(features.float())\n```\n\n## ConvNeXt FP16[Permalink](#convnext-fp16)\n\n*August 2026.*\n\nThe driving model’s vision encoder is a ConvNeXt, with a learned multiplier on each residual branch: `x + gamma * f(x)`. At runtime, it runs in FP16.\n\nThe [pretrained checkpoint we used](https://huggingface.co/timm/convnext_xxlarge.clip_laion2b_soup) we pick had a value of\nalmost 54 at `stages[2].blocks[29]`. After fine-tuning, some inputs push the residual past FP16’s 65,504 limit leading to `inf`.\n\nThe annoying thing is that this only triggered in some seemingly random cases, and didn’t happen in the various checks we run during model exporting to FP16.\n\nThe workaround was to divide that stage’s residual stream by four and carry the scale through its biases, multipliers, and LayerNorm epsilons:\n\n```\n stage = encoder.stages[2]\n+stage.downsample[1].weight.div_(4)\n+stage.downsample[1].bias.div_(4)\n+for block in stage.blocks:\n+    block.conv_dw.bias.div_(4)\n+    block.gamma.div_(4)\n+    block.norm.eps /= 16\n+encoder.stages[3].downsample[0].eps /= 16\n```\n\n## Missing desire targets[Permalink](#missing-desire-targets)\n\n*July 2026.*\n\nThe driving model predicts “driving desire”, like turning or changing lanes.\n\nFor a while, some training data had no desire targets: we use NaNs to distinguish missing targets,\nand our loss helpers normally handle this by masking those targets out. But the desire loss took an `argmax` first, and did not have the NaN handling.\n\n``` python\n>>> import torch\n>>> import torch.nn.functional as F\n>>> target = torch.full((1, 8), torch.nan)\n>>> target.argmax(-1)\ntensor([0])\n>>> F.cross_entropy(torch.zeros(1, 8), target.argmax(-1))\ntensor(2.0794)\n```\n\nAll those NaNs became class `0`, “no desire”, with a perfectly normal loss.\n\nThe fix replaces NaNs with zeros while keeping a mask, then uses that mask to ignore missing targets:\n\n``` python\ndef num_from_nan(target):\n    valid = ~torch.isnan(target)\n    return target.masked_fill(~valid, 0).detach(), valid\n+target, valid = num_from_nan(target)\n loss = F.cross_entropy(logits, target.argmax(-1), reduction=\"none\")\n+loss = valid.all(-1) * loss\n```\n\n## Redundant biases drifted apart across distributed ranks[Permalink](#redundant-biases-drifted-apart-across-distributed-ranks)\n\n*April 2026.*\n\nThe driving model has many `nn.Linear` layers. During training, we use `torch.compile`,\nBF16 autocast and Distributed Data Parallel (DDP) as “acceleration” recipes.\n\nSometimes, harmless redundant biases are written in the architecture:\n\n```\na = nn.Linear(8, 8192)\nb = nn.Linear(4, 8192)\ny = a(x) + b(x[:, :4])\n```\n\nNote that the 8/4 is just illustrative, and keeps the weight gradients different while the bias gradients are identical.\n\n`a.bias` and `b.bias` will be merged by the compiler into one bias, so they get the same gradient. The compiler went one step further: it made both `.grad` tensors share the same memory.\n\nDDP synchronized them correctly, then `clip_grad_norm_` used its foreach implementation to scale that shared buffer from two CUDA blocks at once.\n\nDepending on timing, an entry could be multiplied by the clipping coefficient once or twice, giving each rank a different update.\n\nIn one recorded run, rank 0 clipped all 8,192 entries once; rank 1 clipped 384 of them twice. The drift can reach a magnitude of 0.0056 between ranks after only 50 SGD steps, while the weight matrices stayed identical.\n\nBoth biases receive the same faulty update within a rank, their sum drifts too, which can change the model’s outputs. DDP synchronizes gradients during training, so it doesn’t repair those parameter differences: the GPUs are now training different copies of the model.\n\nWe worked around it by removing the redundant biases.\n\nThis was fixed in [PyTorch 2.13.0](https://github.com/pytorch/pytorch/blob/v2.13.0/torch/_inductor/fx_passes/joint_graph.py#L801-L804) as a side effect of a [compiler optimization change](https://github.com/pytorch/pytorch/commit/6f94c6e16e1bf562f8f5514953cf9b8d47d07cdd).\n\nIt preserved `FP32 → BF16 → FP32` cast chains so smaller intermediate tensors could still be materialized: in our backward graph, that happened to keep the two bias gradients in separate FP32 buffers.\n\nNote: The foreach kernel itself still assumes the gradient buffers don’t overlap…\n\n## DDP random seed bug[Permalink](#ddp-random-seed-bug)\n\n*August 2026.*\n\n[Torchtitan](https://github.com/pytorch/torchtitan) uses the same seed across DDP (replicate), this is correct because weight initialization should match\nacross replicate ranks. But torchtitan then keeps that rank identical across during for training.\n\nWith ordinary tensors (not DTensors), different ranks could get identical dropout masks and diffusion noise.\n\nThe fix is to reseed each data-parallel rank after initialization (essentially):\n\n```\n torch.manual_seed(seed)\n model.init_weights()\n+torch.manual_seed(seed + dp_rank)\n train(model)\n```\n\nFlux [does this after initialization](https://github.com/pytorch/torchtitan/blob/68c97b0c54b0d853fefefab4ecf9e41e6a82a6b7/torchtitan/models/flux/trainer.py#L38-L50), setting `distinct_seed_mesh_dims=[\"cp\", \"dp_shard\", \"dp_replicate\"]`.\n\n## AllNorm[Permalink](#allnorm)\n\n*June 2021 to April 2026.*\n\nNormalization layers are widely used in Machine Learning. BatchNorm normalizes each channel using the mean and variance across the batch and spatial dimensions. During training, it uses the current batch statistics and updates running averages. During inference, it uses those running averages.\n\nThe driving models are multi-task learners, with some tasks being more sparse than others, for example: hard brake events.\n\nOne failure mode we encountered was when a channel specializes in a rare feature. Through batches without that feature, the channel stays quiet and its BatchNorm running variance shrinks.\n\nWhen validation activates that channel again, it gets divided by a stored standard deviation that is too small, blowing up the outputs. Training can still look fine because its statistics come from the current batch: when the feature returns, the denominator grows with it.\n\nLayerNorm fixes this, but needs per input statistics at inference. On device, we want normalization folded into convolutions. Recalibrating BatchNorm running statistics offline also fixes this, but adds overhead and another training pass. Same for lowering momentum which needed extra tuning.\n\nThe best fix we came up with was pooling the whole batch, channels, and spatial dimensions into one set of statistics. We called this AllNorm.\n\n``` python\nclass AllNorm(nn.Module):\n    def __init__(self):\n        super().__init__()\n        self.bn = nn.BatchNorm1d(1)\n\n    def forward(self, x):\n        return self.bn(x.reshape(-1, 1)).reshape_as(x)\n```\n\nAll channels share statistics, making it robust to this failure, and keeping the ability to fold the statistics into the convolutions.\n\nNote: With the extra compute that the [chestnut](https://blog.comma.ai/chestnut/) gives, we can now afford using LayerNorm and not deal with this!\n\n## Gigashuffle’s reusable CPU buffers[Permalink](#gigashuffles-reusable-cpu-buffers)\n\n*July 2026.*\n\n[Gigashuffle](https://github.com/commaai/gigashuffle) returns tensors backed by reusable CPU buffers.\nEach reader allocates these once and fills them again for every batch:\n\n```\n# Once per reader:\nreader_buffer[i][key] = torch.empty(shape, dtype=dtype).share_memory_()\n\n# For every batch:\ntorch.index_select(shuffle_buffer[i][key], 0, indices,\n                   out=reader_buffer[i][key])\n```\n\nNormally, each batch is copied to the GPU before the next read. Our gradient accumulation loop collected several microbatches first, so later reads overwrote batches already in the list.\n\nOne way to fix this is to clone both inputs and targets before fetching the next microbatch:\n\n```\n for _ in range(grad_accum):\n     inputs, targets = next(loader)\n+    inputs = {k: v.clone() for k, v in inputs.items()}\n+    targets = {k: v.clone() for k, v in targets.items()}\n     microbatches.append((inputs, targets))\n```\n\n## FSDP2 init bugs[Permalink](#fsdp2-init-bugs)\n\n*April 2025.*\n\nWith FSDP2, we construct the model on `meta`, shard it, then use `to_empty()` to allocate the weights.\nInitialization has to run after allocation: not defining an init function for a parameter initializes it\nto the arbitrary memory value it received during allocation.\n\n``` python\n>>> import torch\n>>> with torch.device(\"meta\"):\n...     model = torch.nn.Module()\n...     model.weight = torch.nn.Parameter(torch.ones(4))\n>>> _ = model.to_empty(device=\"cpu\")\n>>> model.weight.detach()\ntensor([ 3.7189e-38,  0.0000e+00, -6.3894e-37,  4.5160e-41])\n```\n\nNo clear fix here but to be paranoid about not forgetting init methods!\n\n## NCCL timeout increase didn’t do anything[Permalink](#nccl-timeout-increase-didnt-do-anything)\n\n*July 2026.*\n\nOur RL training runs collect rollouts from the cluster. If the cluster is busy, some ranks can wait for data\nlonger than PyTorch’s default NCCL timeout of [10 minutes](https://docs.pytorch.org/docs/2.14/distributed.html#torch.distributed.init_process_group).\n\nTo work around this, we added a barrier with a timeout of one hour before each training step, thinking we have fixed it. But that timeout was passed around, only to never be used:\n\n```\nopts = BarrierOptions()\nopts.timeout = timeout\nopts.asyncOp = async_op\nwork = group.barrier(opts=opts)\n```\n\nThe C++ dispatcher passed it along, but NCCL implemented the barrier as an all-reduce and only forwarded `asyncOp`:\n\n```\n// C++ dispatcher\nBarrierOptions opts;\nopts.timeout = std::chrono::milliseconds(timeout);\nopts.asyncOp = asyncOp;\nbackend->barrier(opts);\n\n// ProcessGroupNCCL::barrier\nAllreduceOptions arOpts;\narOpts.asyncOp = opts.asyncOp;\nauto work = allreduce_impl(barrierTensor, \"nccl:all_reduce_barrier\", arOpts);\n// opts.timeout goes nowhere.\n```\n\nThat all-reduce got its deadline from a different options object: the process group’s configuration.\nThe watchdog checked that deadline; our `barrier(timeout=...)` argument never entered the calculation:\n\n```\n// Creating the collective's work: options_ belongs to the process group.\nassignTimeoutToWork(work, options_);\n\n// Inside assignTimeoutToWork(work, option), omitting timeout extensions:\nwork->opTimeout_ = option->timeout;\n\n// Watchdog calls work.checkTimeout() with no timeout override:\nif (timeElapsed >= opTimeout_) {\n  // Report a collective timeout.\n}\n```\n\nThe fix was to give the wait for data its own Gloo group, with the timeout set on that group:\n\n```\n+# Once at startup, on every rank:\n+ready = dist.new_group(backend=\"gloo\", timeout=timedelta(hours=1))\n\n # Before each training step:\n-dist.barrier(timeout=timedelta(hours=1))\n+dist.barrier(group=ready)\n```\n\n## Memory leak with AdamW’s foreach updates on DTensors[Permalink](#memory-leak-with-adamws-foreach-updates-on-dtensors)\n\n*March 2026.*\n\nAdamW batches updates into calls like `torch._foreach_mul_(params, 1 - lr * weight_decay)`.\nFSDP2 exposes its parameters as DTensors, so those calls go through DTensor’s dispatcher to work out how the operation applies to the shards.\nFor foreach’s tensor lists, the dispatcher built a fresh `OpSchema` on every call, then constructed a cache key from the tensors’ shape and sharding metadata.\n\nIn the C++ key builder, `PyTuple_Pack` increments its arguments’ reference counts, but `.release()` stopped the C++ wrapper from decrementing its own reference afterward.\nThat left an extra reference to the argument tuple on every call, so the tuples and their metadata kept piling up in CPU RAM.\n\nThe [fix](https://github.com/pytorch/pytorch/pull/176010) was to simply not release:\n\n```\n // torch/csrc/autograd/python_variable.cpp\n // Inside DTensor_OpSchema_recompute_comparison_key_impl (simplified):\n py::tuple args_to_hash_tup(args_to_hash.size());\n // ... fill the tuple with argument metadata ...\n comparison_key = PyTuple_Pack(\n     2, op,\n-    args_to_hash_tup.release().ptr());\n+    args_to_hash_tup.ptr());\n```\n\nThe bug affected PyTorch 2.10.0 through 2.12.1; the same fix shipped via [a later PR](https://github.com/pytorch/pytorch/pull/181792) in 2.13.0.\n\n## CUDA cache allocation[Permalink](#cuda-cache-allocation)\n\n*February 2026.*\n\nWhen doing inference in our distributed cluster, we use an inference server with dynamic batching: [triton inference server](https://github.com/triton-inference-server).\nWe usually benchmark at the largest batch size in isolation, and assume this will be the max VRAM the model will use.\nBut dynamic batching reserved more VRAM, and we didn’t know why.\n\nWe traced it to how PyTorch caches freed GPU allocations, so the order of batch sizes matters. With its default native allocator, this takes just one tensor at a time:\n\n``` python\nimport torch\n\ndef reserved_after(batch_sizes):\n    torch.cuda.empty_cache()  # Reset between experiments.\n    for bs in batch_sizes:\n        x = torch.empty(bs, 1024, 1024, device=\"cuda\", dtype=torch.float32)\n        del x\n    return torch.cuda.memory_reserved() // 2**20\n\nprint(reserved_after([3, 4]))     # 28 MiB\nprint(reserved_after([4, 3, 4]))  # 16 MiB\n```\n\nBatch size 3 leaves a cached 12 MiB block, batch size 4 needs 16 MiB, so the allocator keeps both. Starting with 4 lets batch 3 use 12 MiB of the larger 16 MiB block.\n\nWe flipped the warmup order to start with the largest batch:\n\n```\n-for bs in range(1, max_batch_size + 1):\n+for bs in range(max_batch_size, 0, -1):\n     warmup(bs)\n```\n\n## CUDA graphs don’t capture Adam’s CPU work[Permalink](#cuda-graphs-dont-capture-adams-cpu-work)\n\n*June 2022.*\n\nWhen training with CUDA graphs, we used to capture the whole training step, optimizer step included. CUDA graphs replay only GPU work, so CPU code inside the capture runs just once!\n\nBy default, PyTorch’s eager Adam increments its step counter and computes its bias corrections, `1 - beta1**step` and `1 - beta2** step`, on the CPU:\n\n```\n# torch/optim/adam.py — Adam._init_group() (simplified)\nstate = self.state[p]\nif len(state) == 0:  # Initialize this parameter's optimizer state once.\n    state[\"step\"] = (\n        torch.zeros((), device=p.device)\n        if group[\"capturable\"] or group[\"fused\"]\n        else torch.tensor(0.0, device=\"cpu\") # cpu?!?!\n    )\n```\n\nBack then, replay kept updating the GPU moment estimates and weights with those frozen corrections, silently reusing the value of `step` from the capture-time value.\nWe avoided this by running the optimizer outside the graph.\n\nPyTorch 1.12.0 later added `Adam(..., capturable=True)`, putting the step counter and bias-correction math on the GPU so replay advances them too.\nToday, manual capture with `capturable=False` [raises an error](https://github.com/pytorch/pytorch/blob/v2.14.0/torch/optim/optimizer.py#L440-L449).\n\n## Looking back[Permalink](#looking-back)\n\nThe recurring themes were precision, leaked or reused memory, and distributed training. Some bugs were hard to spot but preventable with better code practices. Others became obvious once we visualized model outputs during inference or inspected model state during training.\n\nCome fix the bugs that are still hiding ([https://comma.ai/jobs)](<https://comma.ai/jobs)>).\n\nYassine.\n\n#### Share on\n\n[X](https://x.com/intent/tweet?via=comma_ai&text=Bugs+that+broke+driving%3A+Machine+Learning+edition%20https%3A%2F%2Fblog.comma.ai%2Fml-bugs%2F)\n\n[Bluesky](https://bsky.app/intent/compose?text=Bugs+that+broke+driving%3A+Machine+Learning+edition%20https%3A%2F%2Fblog.comma.ai%2Fml-bugs%2F)", "url": "https://wpnews.pro/news/bugs-that-broke-driving-machine-learning-edition", "canonical_source": "https://blog.comma.ai/ml-bugs/", "published_at": "2026-09-16 20:00:00+00:00", "updated_at": "2026-09-19 16:54:39.523747+00:00", "lang": "en", "topics": ["machine-learning", "autonomous-vehicles", "ai-research"], "entities": ["comma.ai", "ConvNeXt", "PyTorch", "Hugging Face"], "alternates": {"html": "https://wpnews.pro/news/bugs-that-broke-driving-machine-learning-edition", "markdown": "https://wpnews.pro/news/bugs-that-broke-driving-machine-learning-edition.md", "text": "https://wpnews.pro/news/bugs-that-broke-driving-machine-learning-edition.txt", "jsonld": "https://wpnews.pro/news/bugs-that-broke-driving-machine-learning-edition.jsonld"}}