Repository navigation
[Perf][ROCm][Attention] Narrow the Triton prefill-attention KV tile on RDNA3/RDNA4 - #58225
Conversation
…n RDNA3/RDNA4 The Triton prefill attention kernel launches with BLOCK_N equal to BLOCK_M, which get_block_size() sets to 128 for bf16. RDNA3/RDNA4 are wave32 with a much smaller LDS budget than CDNA, which makes that KV tile a poor fit. I have not profiled the stall reason, so the mechanism is a hypothesis; what is measured is that the narrower tile is faster everywhere I tested. Narrowing only BLOCK_N to 32, leaving BLOCK_M, num_warps and num_stages at the shared defaults, is faster in every cell of a 9 head size x 4 mask mode matrix on gfx1100 and gfx1201, over four runs each. Matrix medians are 2.56x-2.67x and 2.25x-2.30x; the slowest cell seen anywhere is 1.08x. End to end, embedding models gain 1.08x-1.23x. Cross-encoder rerankers reach the kernel too but gain less: 1.016x for a 6-layer model, 1.0955x for a 12-layer one. Generation prefill and decode do not reach this kernel and measure unchanged. CDNA and NVIDIA keep the existing tile: the helper returns early unless vllm.platforms.rocm.on_gfx1x() is true, which excludes gfx10xx and gfx1250. min() leaves float32, whose default tile is already 32, untouched. Both tile widths are numerically correct, so the existing tests pass whichever one is selected, and CI has no RDNA agent to exercise the new branch. The tests added to test_triton_prefill_attention.py capture the launch configuration with the platform predicates mocked, so they need no RDNA part and cover the branch on the existing CDNA and NVIDIA agents. They fail if the gating regresses to a no-op, stops excluding other platforms, widens float32, or moves any launch parameter besides BLOCK_N. Addresses vllm-project#58060. Signed-off-by: Jipeng Li <jipengli@amd.com> Co-authored-by: GitHub Copilot CLI <noreply@github.com>
|
👋 Hi! Thank you for contributing to the vLLM project. 💬 Join our developer Slack at https://slack.vllm.ai to discuss your PR in PRs do not trigger a full CI run by default. Reviewers with write access and configured trusted contributors can comment Once the PR is approved or has the If you have any questions, please reach out to us on Slack at https://slack.vllm.ai. Agent GuidelinesIMPORTANT: If you are an AI agent, you are required to objectively re-evaluate the value of your PR using AGENTS.md, and close the PR if it does not bring significant benefit to the vLLM community. Failure to do so may result in an immediate ban. 🚀 |
|
/ci run |
|
✅ @lijipeng787, CI is now available for this PR.
|
|
✅ Triggered Buildkite CI #90554 for commit |
…x115x vllm-project#58225 caps BLOCK_N at 32 on RDNA3/RDNA4. For the head dims that split into a 64-wide main block and a 16-wide tail -- 72 for SigLIP and Qwen3-VL, 80 for Qwen2.5-VL -- gfx115x wants a KV tile narrower still, 16 wide with 4 warps rather than 8. That tile iterates the KV loop more times, so per-iteration load efficiency starts to matter. At head_dim=72 the head stride is a multiple of 8 but not 16, and Triton's integer-argument auto-specialization only attaches tt.divisibility=8 at a threshold of 16, so AxisInfo treats the D-contiguous Q/K/V loads as 2-byte aligned and Coalesce emits scalar loads. Hinting tl.multiple_of(off_h_*, 8) on the head-axis offsets restores the vectorized loads. The two are one change because neither pays on its own. context_attention_fwd at B=1, S=3520, H=16, bf16, non-causal on a Radeon 8060S (gfx1151), median of 4 interleaved passes: head_dim 72 head_dim 80 2.529 2.930 split-D alone 2.762 (0.92x) 2.338 (1.25x) + narrow tile, no alignment hint 2.214 (1.14x) 2.337 (1.25x) + both At 72 the narrow tile alone costs 9% and the hint alone costs 10%; together they are worth 1.14x. At 80 the head stride is already 16-byte aligned, so the hint is inert and the tile carries the win. The hint is gated by a HEAD_STRIDE_ALIGNED_8 constexpr that the wrapper sets from the actual runtime strides of Q/K/V/O rather than from head_dim, so it stays sound for non-contiguous views. The tile is gated by _get_encoder_launch, which only refines the parts _prefer_narrow_kv_tile already narrows. Head dims outside the 64 + 16 split keep vllm-project#58225's tile: 64, 88 and 96 are ~5% slower with the narrower tile and 128 is 2.3x slower. AI assistance (Claude) was used for this change. Co-authored-by: Claude <noreply@anthropic.com> Signed-off-by: Robert Esclapez Garcia <robert.garcia@amd.com>
The encoder prefill attention pads the head-dim tile to
next_power_of_2(head_dim), so a vision tower with a non-power-of-2 head dim
runs the qk/pv dots over padding lanes: SigLIP and Qwen3-VL have head_dim=72,
which contracts over 128 lanes when only 5 K-passes of 16 are needed.
Cover the head dim as a power-of-2 main block plus a power-of-2 tail block
(72 -> 64 + 16 = 80) behind a new constexpr BLOCK_DMODEL_TAIL. A tail of 0 is
dead code and reproduces the single-block kernel exactly, so every other
architecture and every power-of-2 head dim is byte-identical.
The split is a strict reduction in dot extent and ought to carry elsewhere,
but the extra dot and loads it costs have only been shown to pay for
themselves on gfx1151, so that is where it is enabled. It is also skipped
where it would not narrow the extent: 112 would split into 64 + 64, covering
the same 128 lanes for 12% more time.
context_attention_fwd at B=1, S=3520, H=16, bf16, non-causal on gfx1151,
median of 3 interleaved passes:
head_dim 72: 4.842 -> 2.524 ms/call (1.92x)
head_dim 80: 3.511 -> 2.938 ms/call (1.20x)
head_dim 96: 3.578 -> 3.222 ms/call (1.11x)
head_dim 64, 128 and 256 are flat to within 1%, as they must be.
End to end with a 1920x1080 image, TTFT: Gemma3-4B-w4a16 (SigLIP, head_dim
72) 442.2 -> 358.2 ms, Qwen2.5-VL-7B (head_dim 80) 1976.7 -> 1948.5 ms.
The existing correctness tests only covered head_dim 128; they now also run
64, 72, 80 and 96, which between them exercise both tail widths the kernel
can emit. The launch configuration is asserted through the capture harness
vllm-project#58225 added, so the gfx1151 branch is covered on CI agents that are not
gfx1151.
AI assistance (Claude) was used for this change.
Co-authored-by: Claude <noreply@anthropic.com>
Signed-off-by: Robert Esclapez Garcia <robert.garcia@amd.com>
vllm-project#58225 caps BLOCK_N at 32 on RDNA3/RDNA4. The head dims that split into a 64-wide main block and a 16-wide tail -- 72 for SigLIP and Qwen3-VL, 80 for Qwen2.5-VL -- want a KV tile narrower still, 16 wide with 4 warps rather than 8. Two things are needed for that, and only one of them is about the tile. First, the head-stride alignment hint. In the packed [seq, heads, dim] layout the head stride equals head_dim, and Triton's integer-argument auto-specialization only attaches tt.divisibility=8 when that is a multiple of 16. At head_dim=72 it is 8 mod 16, so AxisInfo treats the D-contiguous Q/K/V loads as 2-byte aligned and Coalesce emits scalar loads. tl.multiple_of(off_h_*, 8) on the head-axis offsets restores them. The hint is set from the real runtime strides of Q/K/V/O rather than from head_dim, so it stays sound for non-contiguous views, and it is not tied to the tile: at every head dim whose stride is already 16-byte aligned it is a tautology and measures exactly neutral. context_attention_fwd at B=1, S=3520, H=16, bf16, non-causal on gfx1151, each head dim on the tile it is given, median of 4 interleaved passes: head_dim 64: 1.814 -> 1.813 1.00x head_dim 72: 2.887 -> 2.302 1.25x head_dim 80: 2.322 -> 2.325 1.00x head_dim 96: 3.204 -> 3.203 1.00x head_dim 128: 3.871 -> 3.885 1.00x head_dim 256: 13.556 -> 13.563 1.00x Second, the tile itself, which does depend on the hint. With the loads vectorized, BLOCK_N=16 and 4 warps are worth 1.25x at both 72 and 80: head_dim 72: 2.895 -> 2.293 (1.26x) head_dim 80: 2.924 -> 2.331 (1.25x) Without it they are not: at head_dim=72 with scalar loads the narrow tile runs 2.676 ms against 2.449 for the tile vllm-project#58225 picks, 9% worse. So the narrow tile is taken only when the hint is active. A head dim of 72 in a view whose head stride is padded hits exactly that case. Head dims outside the 64 + 16 split keep vllm-project#58225's tile. Sweeping BLOCK_N x num_warps confirms it is the right one for them: 64 wants 32/4, and 96, 128 and 256 want 32/8, where BLOCK_N=16 is 22% to 45% slower even with the warp count it prefers. End to end with a 1920x1080 image, TTFT: Gemma3-4B-w4a16 362.2 -> 350.8 ms, Qwen2.5-VL-7B 1934.4 -> 1907.1 ms. AI assistance (Claude) was used for this change. Co-authored-by: Claude <noreply@anthropic.com> Signed-off-by: Robert Esclapez Garcia <robert.garcia@amd.com>
The encoder prefill attention pads the head-dim tile to
next_power_of_2(head_dim), so a vision tower with a non-power-of-2 head dim
runs the qk/pv dots over padding lanes: SigLIP and Qwen3-VL have head_dim=72,
which contracts over 128 lanes when only 5 K-passes of 16 are needed.
Cover the head dim as a power-of-2 main block plus a power-of-2 tail block
(72 -> 64 + 16 = 80) behind a new constexpr BLOCK_DMODEL_TAIL. A tail of 0 is
dead code and reproduces the single-block kernel exactly, so every other
architecture and every power-of-2 head dim is byte-identical.
The split is a strict reduction in dot extent and ought to carry elsewhere,
but the extra dot and loads it costs have only been shown to pay for
themselves on gfx1151, so that is where it is enabled. It is also skipped
where it would not narrow the extent: 112 would split into 64 + 64, covering
the same 128 lanes for 12% more time.
context_attention_fwd at B=1, S=3520, H=16, bf16, non-causal on gfx1151,
median of 3 interleaved passes:
head_dim 72: 4.842 -> 2.524 ms/call (1.92x)
head_dim 80: 3.511 -> 2.938 ms/call (1.20x)
head_dim 96: 3.578 -> 3.222 ms/call (1.11x)
head_dim 64, 128 and 256 are flat to within 1%, as they must be.
End to end with a 1920x1080 image, TTFT: Gemma3-4B-w4a16 (SigLIP, head_dim
72) 442.2 -> 358.2 ms, Qwen2.5-VL-7B (head_dim 80) 1976.7 -> 1948.5 ms.
The existing correctness tests only covered head_dim 128; they now also run
64, 72, 80 and 96, which between them exercise both tail widths the kernel
can emit. The launch configuration is asserted through the capture harness
vllm-project#58225 added, so the gfx1151 branch is covered on CI agents that are not
gfx1151.
AI assistance (Claude) was used for this change.
Co-authored-by: Claude <noreply@anthropic.com>
Signed-off-by: Robert Esclapez Garcia <robert.garcia@amd.com>
vllm-project#58225 caps BLOCK_N at 32 on RDNA3/RDNA4. The head dims that split into a 64-wide main block and a 16-wide tail -- 72 for SigLIP and Qwen3-VL, 80 for Qwen2.5-VL -- want a KV tile narrower still, 16 wide with 4 warps rather than 8. Two things are needed for that, and only one of them is about the tile. First, the head-stride alignment hint. In the packed [seq, heads, dim] layout the head stride equals head_dim, and Triton's integer-argument auto-specialization only attaches tt.divisibility=8 when that is a multiple of 16. At head_dim=72 it is 8 mod 16, so AxisInfo treats the D-contiguous Q/K/V loads as 2-byte aligned and Coalesce emits scalar loads. tl.multiple_of(off_h_*, 8) on the head-axis offsets restores them. The hint is set from the real runtime strides of Q/K/V/O rather than from head_dim, so it stays sound for non-contiguous views, and it is not tied to the tile: at every head dim whose stride is already 16-byte aligned it is a tautology and measures exactly neutral. context_attention_fwd at B=1, S=3520, H=16, bf16, non-causal on gfx1151, each head dim on the tile it is given, median of 4 interleaved passes: head_dim 64: 1.814 -> 1.813 1.00x head_dim 72: 2.887 -> 2.302 1.25x head_dim 80: 2.322 -> 2.325 1.00x head_dim 96: 3.204 -> 3.203 1.00x head_dim 128: 3.871 -> 3.885 1.00x head_dim 256: 13.556 -> 13.563 1.00x Second, the tile itself, which does depend on the hint. With the loads vectorized, BLOCK_N=16 and 4 warps are worth 1.25x at both 72 and 80: head_dim 72: 2.895 -> 2.293 (1.26x) head_dim 80: 2.924 -> 2.331 (1.25x) Without it they are not: at head_dim=72 with scalar loads the narrow tile runs 2.676 ms against 2.449 for the tile vllm-project#58225 picks, 9% worse. So the narrow tile is taken only when the hint is active. A head dim of 72 in a view whose head stride is padded hits exactly that case. Head dims outside the 64 + 16 split keep vllm-project#58225's tile. Sweeping BLOCK_N x num_warps confirms it is the right one for them: 64 wants 32/4, and 96, 128 and 256 want 32/8, where BLOCK_N=16 is 22% to 45% slower even with the warp count it prefers. End to end with a 1920x1080 image, TTFT: Gemma3-4B-w4a16 362.2 -> 350.8 ms, Qwen2.5-VL-7B 1934.4 -> 1907.1 ms. AI assistance (Claude) was used for this change. Co-authored-by: Claude <noreply@anthropic.com> Signed-off-by: Robert Esclapez Garcia <robert.garcia@amd.com>
The encoder prefill attention pads the head-dim tile to
next_power_of_2(head_dim), so a vision tower with a non-power-of-2 head dim
runs the qk/pv dots over padding lanes: SigLIP and Qwen3-VL have head_dim=72,
which contracts over 128 lanes when only 5 K-passes of 16 are needed.
Cover the head dim as a power-of-2 main block plus a power-of-2 tail block
(72 -> 64 + 16 = 80) behind a new constexpr BLOCK_DMODEL_TAIL. A tail of 0 is
dead code and reproduces the single-block kernel exactly, so every other
architecture and every power-of-2 head dim is byte-identical.
The split is a strict reduction in dot extent and ought to carry elsewhere,
but the extra dot and loads it costs have only been shown to pay for
themselves on gfx1151, so that is where it is enabled. It is also skipped
where it would not narrow the extent: 112 would split into 64 + 64, covering
the same 128 lanes for 12% more time.
context_attention_fwd at B=1, S=3520, H=16, bf16, non-causal on gfx1151,
median of 3 interleaved passes:
head_dim 72: 4.842 -> 2.524 ms/call (1.92x)
head_dim 80: 3.511 -> 2.938 ms/call (1.20x)
head_dim 96: 3.578 -> 3.222 ms/call (1.11x)
head_dim 64, 128 and 256 are flat to within 1%, as they must be.
End to end with a 1920x1080 image, TTFT: Gemma3-4B-w4a16 (SigLIP, head_dim
72) 442.2 -> 358.2 ms, Qwen2.5-VL-7B (head_dim 80) 1976.7 -> 1948.5 ms.
The existing correctness tests only covered head_dim 128; they now also run
64, 72, 80 and 96, which between them exercise both tail widths the kernel
can emit. The launch configuration is asserted through the capture harness
vllm-project#58225 added, so the gfx1151 branch is covered on CI agents that are not
gfx1151.
AI assistance (Claude) was used for this change.
Co-authored-by: Claude <noreply@anthropic.com>
Signed-off-by: Robert Esclapez Garcia <robert.garcia@amd.com>
vllm-project#58225 caps BLOCK_N at 32 on RDNA3/RDNA4. The head dims that split into a 64-wide main block and a 16-wide tail -- 72 for SigLIP and Qwen3-VL, 80 for Qwen2.5-VL -- want a KV tile narrower still, 16 wide with 4 warps rather than 8. Two things are needed for that, and only one of them is about the tile. First, the head-stride alignment hint. In the packed [seq, heads, dim] layout the head stride equals head_dim, and Triton's integer-argument auto-specialization only attaches tt.divisibility=8 when that is a multiple of 16. At head_dim=72 it is 8 mod 16, so AxisInfo treats the D-contiguous Q/K/V loads as 2-byte aligned and Coalesce emits scalar loads. tl.multiple_of(off_h_*, 8) on the head-axis offsets restores them. The hint is set from the real runtime strides of Q/K/V/O rather than from head_dim, so it stays sound for non-contiguous views, and it is not tied to the tile: at every head dim whose stride is already 16-byte aligned it is a tautology and measures exactly neutral. context_attention_fwd at B=1, S=3520, H=16, bf16, non-causal on gfx1151, each head dim on the tile it is given, median of 4 interleaved passes: head_dim 64: 1.814 -> 1.813 1.00x head_dim 72: 2.887 -> 2.302 1.25x head_dim 80: 2.322 -> 2.325 1.00x head_dim 96: 3.204 -> 3.203 1.00x head_dim 128: 3.871 -> 3.885 1.00x head_dim 256: 13.556 -> 13.563 1.00x Second, the tile itself, which does depend on the hint. With the loads vectorized, BLOCK_N=16 and 4 warps are worth 1.25x at both 72 and 80: head_dim 72: 2.895 -> 2.293 (1.26x) head_dim 80: 2.924 -> 2.331 (1.25x) Without it they are not: at head_dim=72 with scalar loads the narrow tile runs 2.676 ms against 2.449 for the tile vllm-project#58225 picks, 9% worse. So the narrow tile is taken only when the hint is active. A head dim of 72 in a view whose head stride is padded hits exactly that case. Head dims outside the 64 + 16 split keep vllm-project#58225's tile. Sweeping BLOCK_N x num_warps confirms it is the right one for them: 64 wants 32/4, and 96, 128 and 256 want 32/8, where BLOCK_N=16 is 22% to 45% slower even with the warp count it prefers. End to end with a 1920x1080 image, TTFT: Gemma3-4B-w4a16 362.2 -> 350.8 ms, Qwen2.5-VL-7B 1934.4 -> 1907.1 ms. AI assistance (Claude) was used for this change. Co-authored-by: Claude <noreply@anthropic.com> Signed-off-by: Robert Esclapez Garcia <robert.garcia@amd.com>
…n RDNA3/RDNA4 (vllm-project#58225) Signed-off-by: Jipeng Li <jipengli@amd.com> Co-authored-by: GitHub Copilot CLI <noreply@github.com> Co-authored-by: Andreas Karatzas <akaratza@amd.com>
The encoder prefill attention pads the head-dim tile to
next_power_of_2(head_dim), so a vision tower with a non-power-of-2 head dim
runs the qk/pv dots over padding lanes: SigLIP and Qwen3-VL have head_dim=72,
which contracts over 128 lanes when only 5 K-passes of 16 are needed.
Cover the head dim as a power-of-2 main block plus a power-of-2 tail block
(72 -> 64 + 16 = 80) behind a new constexpr BLOCK_DMODEL_TAIL. A tail of 0 is
dead code and reproduces the single-block kernel exactly, so every other
architecture and every power-of-2 head dim is byte-identical.
The split is a strict reduction in dot extent and ought to carry elsewhere,
but the extra dot and loads it costs have only been shown to pay for
themselves on gfx1151, so that is where it is enabled. It is also skipped
where it would not narrow the extent: 112 would split into 64 + 64, covering
the same 128 lanes for 12% more time.
context_attention_fwd at B=1, S=3520, H=16, bf16, non-causal on gfx1151,
median of 3 interleaved passes:
head_dim 72: 4.842 -> 2.524 ms/call (1.92x)
head_dim 80: 3.511 -> 2.938 ms/call (1.20x)
head_dim 96: 3.578 -> 3.222 ms/call (1.11x)
head_dim 64, 128 and 256 are flat to within 1%, as they must be.
End to end with a 1920x1080 image, TTFT: Gemma3-4B-w4a16 (SigLIP, head_dim
72) 442.2 -> 358.2 ms, Qwen2.5-VL-7B (head_dim 80) 1976.7 -> 1948.5 ms.
The existing correctness tests only covered head_dim 128; they now also run
64, 72, 80 and 96, which between them exercise both tail widths the kernel
can emit. The launch configuration is asserted through the capture harness
vllm-project#58225 added, so the gfx1151 branch is covered on CI agents that are not
gfx1151.
AI assistance (Claude) was used for this change.
Co-authored-by: Claude <noreply@anthropic.com>
Signed-off-by: Robert Esclapez Garcia <robert.garcia@amd.com>
vllm-project#58225 caps BLOCK_N at 32 on RDNA3/RDNA4. The head dims that split into a 64-wide main block and a 16-wide tail -- 72 for SigLIP and Qwen3-VL, 80 for Qwen2.5-VL -- want a KV tile narrower still, 16 wide with 4 warps rather than 8. Two things are needed for that, and only one of them is about the tile. First, the head-stride alignment hint. In the packed [seq, heads, dim] layout the head stride equals head_dim, and Triton's integer-argument auto-specialization only attaches tt.divisibility=8 when that is a multiple of 16. At head_dim=72 it is 8 mod 16, so AxisInfo treats the D-contiguous Q/K/V loads as 2-byte aligned and Coalesce emits scalar loads. tl.multiple_of(off_h_*, 8) on the head-axis offsets restores them. The hint is set from the real runtime strides of Q/K/V/O rather than from head_dim, so it stays sound for non-contiguous views, and it is not tied to the tile: at every head dim whose stride is already 16-byte aligned it is a tautology and measures exactly neutral. context_attention_fwd at B=1, S=3520, H=16, bf16, non-causal on gfx1151, each head dim on the tile it is given, median of 4 interleaved passes: head_dim 64: 1.814 -> 1.813 1.00x head_dim 72: 2.887 -> 2.302 1.25x head_dim 80: 2.322 -> 2.325 1.00x head_dim 96: 3.204 -> 3.203 1.00x head_dim 128: 3.871 -> 3.885 1.00x head_dim 256: 13.556 -> 13.563 1.00x Second, the tile itself, which does depend on the hint. With the loads vectorized, BLOCK_N=16 and 4 warps are worth 1.25x at both 72 and 80: head_dim 72: 2.895 -> 2.293 (1.26x) head_dim 80: 2.924 -> 2.331 (1.25x) Without it they are not: at head_dim=72 with scalar loads the narrow tile runs 2.676 ms against 2.449 for the tile vllm-project#58225 picks, 9% worse. So the narrow tile is taken only when the hint is active. A head dim of 72 in a view whose head stride is padded hits exactly that case. Head dims outside the 64 + 16 split keep vllm-project#58225's tile. Sweeping BLOCK_N x num_warps confirms it is the right one for them: 64 wants 32/4, and 96, 128 and 256 want 32/8, where BLOCK_N=16 is 22% to 45% slower even with the warp count it prefers. End to end with a 1920x1080 image, TTFT: Gemma3-4B-w4a16 362.2 -> 350.8 ms, Qwen2.5-VL-7B 1934.4 -> 1907.1 ms. AI assistance (Claude) was used for this change. Co-authored-by: Claude <noreply@anthropic.com> Signed-off-by: Robert Esclapez Garcia <robert.garcia@amd.com>
Purpose
Addresses #58060. On RDNA3/RDNA4,
context_attention_fwdinvllm/v1/attention/ops/triton_prefill_attention.pylaunches_fwd_kernelwith a 128x128 tile for bf16/fp16, which is slow on these GPUs. This PR narrows only the KV tile (BLOCK_N) to 32 whenon_gfx1x()is true.BLOCK_M,num_warpsandnum_stagesare unchanged, float32 is untouched (its tile is already 32), and CDNA, gfx1250 and NVIDIA keep the existing configuration. No other open PR addresses #58060.This kernel serves encoder / non-causal pooling, so embedding and reranking models benefit. Generation prefill and decode use different kernels and are unaffected.
Test Plan
W7900 (gfx1100) and R9700 (gfx1201), ROCm 10.0, bf16, TP=1, stock clocks.
pytest tests/kernels/attention/test_triton_prefill_attention.py, including 5 new cases that check the launch configuration with the platform predicates mocked, so CI covers the RDNA branch without an RDNA GPU.vllm bench serveon gte-Qwen2-1.5B embeddings at concurrency 1/4/16/64.Test Result
max_num_seqs, with no kernel change, moves it by +0.0011 and +0.0005. Recall@10 is identical.on_gfx1x()without being measured; the gate can be narrowed if preferred.Developed with AI assistance (GitHub Copilot CLI) for the benchmarking, analysis and this description. I reviewed every changed line and ran all measurements myself.