feat(map_encoder): add deformable cross-attention map BEV fusion mode - #184
Conversation
Signed-off-by: intisar <intisarcs@gmail.com>
The page said nothing about GPU memory, which is the wall two contributors have reported in autowarefoundation#168 and the reason both edited the code to shrink the BEV grid. Measured rather than inferred: on an RTX 3060 with 6 GB, train_il runs out of memory at batch_size 1 before completing an epoch, and expandable_segments -- which the traceback recommends and which reportedly helped on a 12 GB card -- does not close the gap on 6 GB. The KITScenes geometry pins the grid at 256x256 and train_il exposes no parameter to change it, so the workarounds available today are editing the code or the deformable fusion proposed in autowarefoundation#184. Also notes that CPU completes a smoke epoch without special handling, which is what this page's own end-to-end run used. Signed-off-by: Gabriela Cordova <100548769@alumnos.uc3m.es>
|
Regarding the changes in this PR, I believe the approach of using Deformable Cross Attention to reduce computational cost is appropriate for addressing the issue that dense cross-attention is impractical in production BEV. Therefore, I plan to resolve the conflicts separately and merge #184 itself. That said, I would like to request additional improvements regarding the handling of Route information. Currently, navigation_input = torch.cat([gated_map, gated_route], dim=1)
navigation_bev = self.NavigationEncoder(navigation_input)With this configuration, the sparse and thin Route features are mixed with the larger-area Map features at an early stage, which may result in the model not utilizing them sufficiently. Additionally, since the Deformable Cross Attention being added in this PR improves the fusion of As a follow-up change, could you consider a configuration that separates the encoding of Map and Route, and performs Route-gated fusion at a later stage? The concept would be a configuration like the following: For example, I think a per-channel gated residual like the following would be sufficient as an initial implementation: map_bev = self.MapEncoder(gated_map)
route_bev = self.RouteEncoder(gated_route)
route_gate = torch.sigmoid(self.route_gate).view(1, -1, 1, 1)
navigation_bev = map_bev + route_gate * route_bevThe RouteEncoder doesn't need to be a heavy backbone equivalent to the MapEncoder; a lightweight CNN would be fine. With this configuration, the following can be handled separately:
Additionally, I would like you to add a test that verifies that when only the Route is changed for the same camera/map input, the final trajectory changes appropriately. I want to validate that the Route is actually being used in the final output, not just that gradients flow through. This doesn't need to be included in the scope of #184; it's fine to handle it as a follow-up PR after merging. |
|
@riita10069 A clarifying note on my current baseline: I have been training the reactive branch with the route mask gated to zero on recently on purpose to test it, because I wanted to first establish a model conditioned only on the map input. so next logical step woudld be I will enable the route when testing the route-gated fusion. Planned configuration: with a per-channel learnable gate: map_bev = self.MapEncoder(gated_map) # swin
route_bev = self.RouteEncoder(gated_route) # lightweight CNN
route_gate = torch.sigmoid(self.route_gate).view(1, -1, 1, 1) # learnable, per-channel
navigation_bev = map_bev + route_gate * route_bevFor the RouteEncoder I plan a lightweight strided-CNN stack. Each stage is Conv2d -> BatchNorm -> GELU, downsampling by 2, then a 1x1 projection to embed_dim and an adaptive pool to the BEV size: class RouteEncoder(nn.Module):
def __init__(self, in_channels=2, embed_dim=256, out_h=450, out_w=300):
super().__init__()
self.encoder = nn.Sequential(
nn.Conv2d(in_channels, 32, 3, stride=2, padding=1), # 256 -> 128
nn.BatchNorm2d(32), nn.GELU(),
nn.Conv2d(32, 64, 3, stride=2, padding=1), # 128 -> 64
nn.BatchNorm2d(64), nn.GELU(),
nn.Conv2d(64, 128, 3, stride=2, padding=1), # 64 -> 32
nn.BatchNorm2d(128), nn.GELU(),
)
self.proj = nn.Conv2d(128, embed_dim, 1)
self.pool = nn.AdaptiveAvgPool2d((out_h, out_w))
def forward(self, route_mask):
return self.pool(self.proj(self.encoder(route_mask)))This keeps the encoder to a few hundred thousand parameters. For validation I will run an ablation of with-route vs without-route on a subset of the dataset, targeting ADE / FDE (and a route-corridor compliance metric such as mean path delta when the route is swapped). In addition to the numbers, I will visually verify by overlaying the predicted trajectory against the route map for a few samples, which makes it concrete whether the route changes the output path. This covers the point you wanted to vrify --> holding the camera and map fixed while changing only the route, then checking that the predicted trajectory changes appropriately, confirming the route influences the final output rather than only providing a gradient path. |
|
@intisar1020 / cc. @m-zain-khawaja Thanks again for the suggestion. Working at #186, the route reconstruction loss is now there, but Map and Route are still concatenated before the shared Could you try youridea on top of #186 as a follow-up? I think the combination of separate encoders + learnable route gate + the reconstruction auxiliary from #186 would be a cleaner way to make sure the route is preserved and actually used. It would be great if you could also validate on your side whether this improves the results compared with the current shared-encoder baseline, especially ADE/FDE and route-sensitive cases such as route swap / junction branch behavior. |
feat(map_encoder): add deformable cross-attention as a production-viable map BEV fusion mode
Motivation & Architectural Context
Map BEV fusion currently offers two modes (introduced in #55):
residual:image_bev + alpha * map_bevwith a per-channel gate initializedto zero. Cheap and stable at any BEV resolution, but spatially rigid -- every
pixel only ever sees its own map cell.
cross_attn: dense spatial cross-attention where each image BEV query attendsto every map BEV token. Spatially adaptive, but O(N^2): at the production BEV
grid (450x300 = 135K tokens) the score matrix is ~10^11 elements and OOMs,
which is why the module currently raises a guard above 4096 tokens.
This PR adds a third mode,
deformable, that gives attention-based map fusionthe spatial adaptivity of cross-attention at linear cost: each query attends to
only K learned-offset sample points in the map BEV (K=4 by default), dropping
the complexity from O(N^2) to O(N*K) and making it viable at production
resolution.
The design follows the deformable spatial cross-attention from BEVFormer
(Li et al., 2022): a query predicts sampling offsets relative to its own
reference position, features are sampled at those locations, and the K samples
are aggregated with per-head softmax weights. Since the reference plane here is
the map BEV itself, each query's reference point is simply its own pixel
position, and sampling uses
F.grid_sample(bilinear, border padding) -- nocustom CUDA kernels required.
Summary of Changes
1. New fusion module:
MapDeformableCrossAttentionFusionmodel_components/map_encoder/map_bev_fusion/deformable_cross_attention_fusion.pyoffset_projpredicts K 2D pixel displacements from the query feature;attn_projpredicts per-head softmax weights over the K sample points.sampling positions are reference + offset, with out-of-map positions clamped
by
padding_mode="border".F.grid_sampleyields(B, C, N, K); the channel dim is split per head andthe K samples are aggregated by weighted sum, followed by a pre-norm output
projection with residual, then the same FFN + residual block used by the
cross-attention fusion.
forward(image_bev, map_bev)with
(B, C, H, W)tensors in and out.Why this avoids OOM
Standard attention materializes an attention score matrix of shape
(B, num_heads, N, N)->(1, 8, 135000, 135000)at the production grid,which is ~145.8B elements -- roughly 583 GB in float32 -- and exactly why the
dense
cross_attnmode carries the >4096-token guard.Deformable attention never builds that matrix. Its largest intermediate tensor
is the sampled per-head features,
(B, N, num_heads, K, head_dim)->(1, 135000, 8, 4, 32)(~138M elements), which is only ~553 MB in float32(~276 MB under fp16 autocast) -- a ~1000x reduction that fits comfortably on a
12 GB GPU at production resolution.
2. Registry wiring
model_components/map_encoder/map_bev_fusion/__init__.pyregisters the newmode so it can be selected end-to-end via
map_fusion_mode="deformable"(AutoE2E -> ReactiveE2E ->
build_map_bev_fusion):Constructor options such as
num_sample_pointsare forwarded through theexisting
map_fusion_kwargsplumbing, so no model changes are required.3. Unit and integration tests
tests/test_map_encoder.pyadds:TestMapDeformableCrossAttentionFusion: output shape, map influence onoutput, gradient flow, all-parameters-receive-gradients, NaN safety with zero
inputs, non-square grids, and configurable K.
"deformable"key viabuild_map_bev_fusion.map_fusion_mode="deformable"forward pass andgradient flow through
MapBEVFusion(mock-backbone harness).Verification
tests/test_map_encoder.pypass, including the newdeformable unit and integration cases.
ruff checkandmypyare clean for the touched code.pytest Model/tests) passes.completes without OOM -- the exact failure mode that motivates this mode.
Training sanity check on a small subset of KITScenes tar files (loss goes
down over the first ~6-12 steps):