Background
AggPose is a model released in 2022 for infant pose estimation.
I remain somewhat skeptical about the motivation behind designing a dedicated architecture for this task1. What exactly is the task-specific prior that makes infant pose estimation fundamentally different? Why not start from a strong general-purpose human pose estimation architecture and fine-tune it on infant data? And if this architecture were broadly competitive, it would be natural to ask why it was not evaluated more aggressively against SOTA methods for adult human pose estimation.
In Comparison of marker-less 2D image-based methods for infant pose estimation, the authors compare several pose estimation models on infant pose estimation, including AggPose. The evaluated methods include two infant-specific models and four general-purpose pose estimation models. The conclusion is that the general-purpose ViTPose performs the best. Even without fine-tuning, it already achieves the best results; after fine-tuning, it leads by a clear margin.
It is also worth noting that the paper reports inference speed on an NVIDIA GTX 2080 Ti: ViTPose-huge runs at 1 FPS, while AggPose runs at 2.7 FPS.
That said, given the actual project requirements, the practical question becomes how to optimize AggPose as much as possible. Our main goal is to run AggPose on hospital-side machines for offline GMA video processing, extract infant poses, and obtain de-identified infant pose data. Therefore, we focus on maximizing throughput rather than minimizing single-frame latency. We also assume that the hospital-side machines only have CPUs, so the optimization target is a single-CPU deployment scenario.
For now, we do not yet have the exact hardware specifications of the hospital-side machines. As a result, the current optimization work is mainly performed on the AMD Ryzen 7600X CPU available in our home lab. Once the target machine specifications are available, we can migrate and re-tune the optimization accordingly.
Model
TL;DR: SegFormer with an HRNet-style flavor.
The implementation is available in the PediaMedAI/AggPose2 repository. The authors released two checkpoints:
- AggPose-L_256x192_COCO2017.pth: trained on the COCO 17 keypoint dataset, with 17 keypoints.
- AggPose-L_256x192_infantpose2022.pth: presumably trained on the authors’ InfantPose dataset, with 21 keypoints.
Because we do not have access to the corresponding infant dataset, it is difficult to perform calibration and distillation on infant data. Therefore, the current optimization work is based on the COCO-17 checkpoint.
AggPose follows the top-down pose estimation paradigm. In other words, it must be used together with a person detector. In this optimization work, however, we do not include the detector.
The overall architecture is similar to HRNet: different branches correspond to different feature-map resolutions.
However, AggPose replaces each channel map branch with a modified AViT module, short for Aggregation Vision Transformer. Each AViT block is structured as follows:
Compared with the Transformer Encoder in ViT, AggPose replaces MHSA (Multi-Head Self-Attention) with Eff-Attention3.
Optimization
Experiment Setup
The experiments were run on an AMD Ryzen 7600X with 6 cores and 12 threads.
Accuracy evaluation is mainly based on COCO 17. We use throughput as the primary performance metric, while latency is reported only as a secondary reference.
One important caveat is that the reported throughput does not represent end-to-end throughput in a real deployment. It does not include the overhead of video decoding, preprocessing, the person detector, or postprocessing.
The optimization steps below are presented in a slightly rearranged order and do not necessarily reflect the exact chronological order of experimentation.
Baseline
We first rewrote the original implementation into a more parameterized version. The baseline implementations use PyTorch, including eager mode and torch.compile, ONNX Runtime, and OpenVINO. The OpenVINO baseline is not manually tuned but uses Async Infer. All baselines use FP32 inference.
| Backend | Mean ms | Median ms | P95 ms | Throughput img/s |
|---|---|---|---|---|
| Torch CUDA eager | 24.392 | 24.340 | 24.553 | 40.997 |
| Torch CPU eager | 140.252 | 138.999 | 144.944 | 7.130 |
| Torch CPU compile | 95.570 | 93.399 | 108.347 | 10.464 |
| ONNX Runtime CPU | 96.701 | 88.893 | 124.807 | 10.341 |
| OpenVINO CPU | 47.755 | 47.633 | 48.766 | 33.710 |
OpenVINO performs very well out of the box, which is why we chose it as the basis for further inference optimization.
Step 1: Quantization
We use NNCF, which is closely aligned with the OpenVINO ecosystem, for PTQ, or Post-Training Quantization. Since the CPU supports avx512_vnni, we quantize the model to int8.
The number of calibration samples needs to be reasonably large. I used 300 samples; using a much smaller calibration set caused severe accuracy degradation, with AP dropping to 0.
However, after quantization, throughput became worse rather than better. This issue is addressed later in Step 4.
Step 2: Static Batch
The model input is dynamic, but only along the batch_size dimension.
Specifically, the input shape is [B, 3, 256, 192], where B is the inference-time batch_size. For a given hardware platform, there should be an optimal batch_size that maximizes throughput.
First, we benchmarked the dynamic-shape model with different batch sizes:
| Batch | Throughput |
|---|---|
| 1 | 42.89 img/s |
| 2 | 52.39 img/s |
| 4 | 58.68 img/s |
| 8 | 58.68 img/s |
| 16 | 54.27 img/s |
| 32 | 43.86 img/s |
Then we converted the high-throughput dynamic-shape candidates, namely batch sizes 4, 8, and 16, into static-shape models and benchmarked them again:
| Batch | Throughput |
|---|---|
| 4 | 72.87 img/s |
| 8 | 72.69 img/s |
| 16 | 60.80 img/s |
The best batch size is therefore 4.
Step 3: Pruning + Distillation
We noticed that AggPose stacks 40 blocks in AViT_3,1, which seems excessive. Earlier depths are at most 6 blocks. We therefore tried reducing the depth of this stage.
We tested five variants: D32, D24, D16, D12, and D8. Throughput was measured with Torch CPU eager mode.
| Variant | Depths | AP (smoke) | CPU Median | Throughput | Improvement |
|---|---|---|---|---|---|
| D40 Baseline | (3,6,40,3) | 0.792 | 130.3 ms | 7.5 img/s | - |
| D32 | (3,6,32,3) | 0.781 | 121.4 ms | 8.0 img/s | +6.7% |
| D24 | (3,6,24,3) | 0.741 | 109.6 ms | 9.0 img/s | +20.0% |
| D16 | (3,6,16,3) | 0.700 | 103.4 ms | 9.6 img/s | +28.0% |
| D12 | (3,6,12,3) | 0.653 | 96.3 ms | 10.2 img/s | +36.0% |
| D8 | (3,6,8,3) | 0.616 | 89.1 ms | 11.0 img/s | +46.7% |
We selected the D24 variant and distilled it from the original D40 model to recover part of the lost accuracy. The distillation objective follows Knowledge Distillation:
where:
- denotes the teacher model’s predicted heatmap.
- denotes the student model’s predicted heatmap.
- is the distillation temperature, usually set to 2.
The training data is the full COCO-17 training set. We mainly trained for two epochs, although one epoch may already be sufficient:
- In the first epoch, only stage 2 is fine-tuned. In other words, stage 0 and stage 1 are frozen, namely
AViT 1,1,AViT 2,1, andAViT 1,2. - In the second epoch, the full model is fine-tuned.
The following table shows full validation results on the COCO-17 validation set for the teacher model and the distilled student model:
| Variant | AP (full) | AP50 | AR |
|---|---|---|---|
| D40 teacher | 0.7668 | 0.9324 | 0.7987 |
| D24 distilled | 0.7590 | 0.9338 | 0.7892 |
| D24 distilled INT8 | 0.7585 | 0.9338 | 0.7886 |
As shown above, the distilled student model recovers most of the original model’s accuracy.
Step 4: Replacing Linear with 1x1 Conv2d
As mentioned earlier, after int8 quantization, throughput became even worse than the fp32 version. In this step, we locate and address the underlying issue.
Profiling
Using benchmark_app to profile the performance hot spots, we found that FakeQuantize and Reorder had unusually high runtime. The breakdown is as follows:
| Layer Type | Time |
|---|---|
FullyConnected | 106.909 ms / 303 nodes |
Reorder | 96.124 ms / 223 nodes |
FakeQuantize | 66.284 ms / 74 nodes |
GroupConvolution | 63.428 ms / 74 nodes |
Convolution | 15.729 ms / 61 nodes |
Subgraph | 34.519 ms |
MVN | 10.853 ms |
Locating the Problem
A rough inspection showed that the Reorder and FakeQuantize operators mainly appear in the mlp module, corresponding to the Mlp module in AggPose. We then used benchmark_app for a more detailed profile.
Based on the report exported by benchmark_app in average_counters mode, namely benchmark_average_counters_report.csv, and comparing it against the model structure file .xml, we can inspect the actually executed layers.
Take __module.block_passes.0.levels.0.blocks.1.mlp as an example. After removing operators that were not executed, that is, entries whose execStatus is Status.NOT_RUN, the actual layers in the mlp module are:
| Layer Name | Layer Type |
|---|---|
fc1/aten::linear/MatMul | FullyConnected |
fc1/aten::linear/MatMul/ -> fc1/aten::linear/Add/ | Reorder |
fc1/aten::linear/Add/fq_output_0 | FakeQuantize |
fc1/aten::linear/Add/ -> dwconv/aten::transpose/Transpose | Reorder |
dwconv/aten::_convolution/GroupConvolution | GroupConvolution |
dwconv/aten::_convolution/ -> fc2/aten::linear/MatMul | Reorder |
fc2/aten::linear/MatMul | FullyConnected |
For readability, the layer names above have been simplified. They are not the exact layer names in the exported report.
The per-layer runtime is shown below:
| Layer Type | Exec Type | Real Time (ms) | CPU Time (ms) |
|---|---|---|---|
FullyConnected | brgemm_avx512_i8 | 1.479 | 1.479 |
Reorder | jit_uni_bf16 | 2.526 | 2.526 |
FakeQuantize | jit_avx512_f32 | 3.919 | 3.919 |
Reorder | jit_uni_f32 | 2.430 | 2.430 |
GroupConvolution | brgconv_avx512_dw_bf16 | 3.267 | 3.267 |
Reorder | jit_uni_bf16 | 0.790 | 0.790 |
FullyConnected | brgemm_avx512_I8 | 0.635 | 0.635 |
Reorder and FakeQuantize account for most of the runtime. It is also clear that the three Reorder nodes are introduced around GroupConvolution and FakeQuantize.
Reorder?
I checked the OpenVINO Operation Specifications and found no Reorder operator there. The documentation does not describe it either.
After looking through the source code, Reorder appears to be a runtime-level operator. When adjacent operators require incompatible tensor layouts, the graph optimizer automatically inserts Reorder between them. In practice, it may insert not only Reorder, but also Convert.
The insertion path seems to be GraphOptimizer::TailNodesPrecisionOptimize -> Graph::ResolveEdgeConflicts -> Graph::InsertReorder.
Source-Level Insight
Mapping the profile results back to the model source code, the issue is located in Mlp, which is the Mix-FFN module from SegFormer:
class Mlp(nn.Module):
def __init__(self, in_features, hidden_features=None, out_features=None, act_layer=nn.GELU, drop=0.):
super().__init__()
out_features = out_features or in_features
hidden_features = hidden_features or in_features
self.fc1 = nn.Linear(in_features, hidden_features)
self.dwconv = DWConv(hidden_features)
self.act = act_layer()
self.fc2 = nn.Linear(hidden_features, out_features)
self.drop = nn.Dropout(drop)
self.apply(self._init_weights)
def _init_weights(self, m):
...
def forward(self, x, H, W):
x = self.fc1(x)
x = self.dwconv(x, H, W)
x = self.act(x)
x = self.drop(x)
x = self.fc2(x)
x = self.drop(x)
return x
A depth-wise convolution operation, dwconv, is inserted after fc1. The implementation is in DWConv:
class DWConv(nn.Module):
def __init__(self, dim=768):
super(DWConv, self).__init__()
self.dwconv = nn.Conv2d(dim, dim, 3, 1, 1, bias=True, groups=dim)
def forward(self, x, H, W):
B, N, C = x.shape
x = x.transpose(1, 2).view(B, C, H, W)
x = self.dwconv(x)
x = x.flatten(2).transpose(1, 2)
return x
In other words, the Mlp module effectively performs the following sequence of operations. Since the dropout probability is actually 0, we remove the dropout layers here. For simplicity, bias terms are also omitted.
import torch.nn.functional as F
# H, W = 192, 256
# input = torch.empty(N, H * W, C)
# Step 1: fc1
# fc1_weight = nn.Parameter(torch.empty(hidden, C))
fc1_output = F.linear(input, fc1_weight)
# Step 2: dwconv
N, __, hidden = fc1_output.shape
# Layout conversion: [N, H*W, hidden] -> [N, hidden, H*W] -> [N, hidden, H, W]
fc1_output_transpose = fc1_output.transpose(1, 2).view(N, hidden, H, W)
# dwconv_weight = nn.Parameter(torch.empty([hidden, 1, 3, 3]))
dwconv_output = F.conv2d(
fc1_output_transpose,
dwconv_weight,
stride=1,
padding=1,
groups=C_hidden,
)
# Layout conversion: [N, hidden, H, W] -> [N, hidden, H*W] -> [N, H*W, hidden]
dwconv_output_transpose = dwconv_output.flatten(2).transpose(1, 2)
# Step 3: activation
act_output = F.gelu(dwconv_output_transpose)
# Step 4: fc2
# fc2_weight = nn.Parameter(torch.empty(hidden, out_features))
mlp_output = F.linear(act_output, fc2_weight)
The main issue is that the intermediate Conv2d forces layout conversions before and after the convolution. Under fp32, this overhead is not significant. Under int8, however, it accounts for a large share of runtime.
Improvement
To remove the layout-conversion overhead, we convert nn.Linear into nn.Conv2d with a kernel size of 1x1:
class MapMlp(nn.Module):
def __init__(
self,
in_features: int,
hidden_features: int,
out_features: int,
act_layer: type[nn.Module] = nn.GELU,
drop: float = 0.0,
):
super().__init__()
self.fc1 = nn.Conv2d(in_features, hidden_features, 1, bias=True)
self.dwconv = nn.Conv2d(hidden_features, hidden_features, 3, 1, 1, bias=True, groups=hidden_features)
self.act = act_layer()
self.fc2 = nn.Conv2d(hidden_features, out_features, 1, bias=True)
self.drop = nn.Dropout(drop)
def forward(self, x: torch.Tensor, height: int, width: int) -> torch.Tensor:
batch = x.shape[0]
x = x.reshape(batch, height, width, -1).permute(0, 3, 1, 2).contiguous()
x = self.fc1(x)
x = self.dwconv(x)
x = self.act(x)
x = self.drop(x)
x = self.fc2(x)
x = self.drop(x)
return x.permute(0, 2, 3, 1).reshape(batch, height * width, -1).contiguous()
Results
The rewritten model performs much better:
| Variant | Latency | Throughput | Node Count |
|---|---|---|---|
| non-MapMLP INT8 | 403.97 ms | 39.47 FPS | 1552 |
| MapMLP INT8 | 162.71 ms | 96.96 FPS | 1256 |
The per-operator runtime also changes significantly:
| Layer Type | non-MapMLP | MapMLP |
|---|---|---|
FullyConnected | 106.909 ms / 303 nodes | 18.737 ms / 157 nodes |
Reorder | 96.124 ms / 223 nodes | 0.155 ms / 2 nodes |
FakeQuantize | 66.284 ms / 74 nodes | 0 ms / 0 nodes |
GroupConvolution | 63.428 ms / 74 nodes | 30.564 ms / 74 nodes |
Convolution | 15.729 ms / 61 nodes | 74.081 ms / 209 nodes |
Subgraph | 34.519 ms | 27.544 ms |
MVN | 10.853 ms | 8.552 ms |
After the rewrite, the runtime of FakeQuantize and Reorder becomes almost negligible.
MVN-1 and MVN-6
Summary
After a series of optimizations, CPU throughput improved by nearly 3x.
| Step | Variant | Throughput | Relative Improvement | Absolute Speedup |
|---|---|---|---|---|
| 0 | D40 dynamic FP32 | 33.71 img/s | - | 1.00x |
| 1 | D40 static-b4 FP32 | 47.70 img/s | 1.42x | 1.42x |
| 2 | D40 static-b4 INT8 | 36.05 img/s | 0.76x | 1.07x |
| 3-0 | D24 static-b4 FP32 | 60.46 img/s | 1.68x | 1.79x |
| 3-1 | D24 static-b4 INT8 | 39.86 img/s | 0.66x | 1.18x |
| 4 | D24 static-b4 MapMLP INT8 | 96.79 img/s | 2.43x | 2.87x |

I had originally planned to further optimize the model with TVM, but the experience was far from plug-and-play. Following the official End-to-End Optimize Model tutorial, the resulting inference latency was 45 seconds. After applying Library Dispatch, with DNNL selected as the library5, the latency dropped from 45 seconds to 7.8 seconds. It appears that only conv2d was dispatched, and I did not have the bandwidth to continue digging further.
One final aside: the search experience in the official OpenVINO documentation is, frankly, not very helpful.
The model has been published on Hugging Face: xvyv99/aggpose-lite. The corresponding code is available on GitHub: xvyv99/aggpose-lite.
Footnotes
-
I have similar reservations about some other model architectures designed specifically for infant-related tasks. ↩
-
The implementation appears to be adapted from Simple Baselines for Human Pose Estimation and Tracking. ↩
-
This component comes from the MiT, or Mix Transformer encoder, proposed in SegFormer: Simple and Efficient Design for Semantic Segmentation with Transformers. ↩
-
See How to Read Opset Specification. The
-Nsuffix in an operator name means the operator was first introduced inopsetN; a larger number therefore indicates a newer operator version. ↩ -
It is now called oneDNN, but TVM still refers to it as DNNL and only supports the
2.*versions. ↩