Files
visor/pkg/tcpip/checksum/checksum_unsafe.go
Jamie LiuandgVisor bot 40a662086b netstack: further optimize checksumming
- Restructure calculateChecksumNoASM() to consistently handle misaligned
  inputs: special-case inputs less than 8 bytes in size, otherwise align the
  buffer address to 8 bytes before checksumming in bulk. This avoids
  significant performance degradation on misaligned inputs due to fallback to
  unrolledCalculateChecksum().

- Sum up to 64 bytes per loop iteration in calculateChecksumNoASM(), as in
  unrolledCalculateChecksum(). This significantly improves performance for
  large inputs.

- Don't plumb carry between blocks in calculateChecksumNoASM(). On x86, this is
  significantly faster because many instructions clobber the carry flag, so
  saving it requires extra overhead; on arm64, this has minimal effect on the
  generated code (the compiler inserts an extra useless copy from the zero
  register to a temporary).

- Replace the folding of uint64 accumulator to uint16 checksum with a
  branchless equivalent, which is ~2ns/checksum faster on average due to
  avoiding an unpredictable loop.

- Fix calculateChecksumNoASM() on big-endian architectures.

- Delete unrolledCalculateChecksum(), which is now unused.

Before these changes, calculateChecksumAMD64() is approximately twice as fast
as calculateChecksumNoASM(). After these changes, calculateChecksumNoASM() is
faster than calculateChecksumAMD64():

```
goos: linux
goarch: amd64
pkg: pkg/tcpip/checksum/checksum
cpu: Intel(R) Xeon(R) CPU @ 2.80GHz
                           │ checksum_baseline │         checksum_experiment         │
                           │      sec/op       │   sec/op     vs base                │
Checksum/checksum_64-48            10.50n ± 0%   10.10n ± 0%   -3.86% (p=0.000 n=10)
Checksum/checksum_128-48           13.72n ± 0%   12.16n ± 0%  -11.37% (p=0.000 n=10)
Checksum/checksum_256-48           22.80n ± 0%   16.31n ± 0%  -28.48% (p=0.000 n=10)
Checksum/checksum_512-48           39.92n ± 0%   24.96n ± 0%  -37.47% (p=0.000 n=10)
Checksum/checksum_1024-48          81.34n ± 0%   44.26n ± 2%  -45.58% (p=0.000 n=10)
Checksum/checksum_1500-48         119.70n ± 1%   65.05n ± 2%  -45.65% (p=0.000 n=10)
Checksum/checksum_2048-48         157.30n ± 0%   86.65n ± 0%  -44.91% (p=0.000 n=10)
Checksum/checksum_4096-48          309.5n ± 0%   172.4n ± 3%  -44.30% (p=0.000 n=10)
Checksum/checksum_8192-48          615.0n ± 0%   348.0n ± 0%  -43.42% (p=0.000 n=10)
Checksum/checksum_16384-48        1222.5n ± 0%   690.8n ± 0%  -43.49% (p=0.000 n=10)
Checksum/checksum_32767-48         2.447µ ± 0%   1.381µ ± 0%  -43.55% (p=0.000 n=10)
Checksum/checksum_32768-48         2.442µ ± 0%   1.381µ ± 0%  -43.48% (p=0.000 n=10)
Checksum/checksum_65535-48         4.883µ ± 0%   2.933µ ± 0%  -39.94% (p=0.000 n=10)
Checksum/checksum_65536-48         4.877µ ± 0%   2.935µ ± 0%  -39.82% (p=0.000 n=10)
geomean                            260.6n        161.9n       -37.88%
```

Thus, use calculateChecksumNoASM() on all architectures and rename it to
calculateChecksum().

---

Note: As of this writing, calculateChecksumNoASM() is still ~7% slower than an
equivalently-optimized x86 assembly implementation, for three main reasons:

1. The Go compiler emits separate MOV memory-to-register and ADD/ADC
   instructions rather than using memory operands for the latter.

2. For statements of the form `buf = buf[1:]`, updating the slice data pointer
   takes ~5 instructions instead of 1, because the compiler avoids incrementing
   the pointer if the resulting slice length is 0 (such that the pointer would
   point outside of the underlying array). This overhead can be avoided by
   using uintptr and runtime.KeepAlive(), but doing so worsens problem (1):
   replacing `*(*uint64(unsafe.Pointer(&buf[8])))` with
   `*(*uint64(unsafe.Pointer(addr+8)))` causes the compiler to split each MOV
   into LEA + MOV.

3. The faster form of reduce() described in that function is implementable in
   assembly.

Thus, we can add the optimized x86 assembly implementation back if desired.

---

`GOOS=linux GOARCH=mips64 go test -exec qemu-mips64 ./...` fails before this
CL, since the "bitslib" implementation, i.e. calculateChecksumNoASM(), is
broken on big-endian architectures. After this CL:
```
ok      gvisor.dev/gvisor/pkg/tcpip/checksum    125.970s
```

PiperOrigin-RevId: 574264329
2023-10-17 14:25:28 -07:00

183 lines
6.2 KiB
Go

// Copyright 2023 The gVisor Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package checksum
import (
"encoding/binary"
"math/bits"
"unsafe"
)
// Note: odd indicates whether initial is a partial checksum over an odd number
// of bytes.
func calculateChecksum(buf []byte, odd bool, initial uint16) (uint16, bool) {
// Use a larger-than-uint16 accumulator to benefit from parallel summation
// as described in RFC 1071 1.2.C.
acc := uint64(initial)
// Handle an odd number of previously-summed bytes, and get the return
// value for odd.
if odd {
acc += uint64(buf[0])
buf = buf[1:]
}
odd = len(buf)&1 != 0
// Aligning &buf[0] below is much simpler if len(buf) >= 8; special-case
// smaller bufs.
if len(buf) < 8 {
if len(buf) >= 4 {
acc += (uint64(buf[0]) << 8) + uint64(buf[1])
acc += (uint64(buf[2]) << 8) + uint64(buf[3])
buf = buf[4:]
}
if len(buf) >= 2 {
acc += (uint64(buf[0]) << 8) + uint64(buf[1])
buf = buf[2:]
}
if len(buf) >= 1 {
acc += uint64(buf[0]) << 8
// buf = buf[1:] is skipped because it's unused and nogo will
// complain.
}
return reduce(acc), odd
}
// On little-endian architectures, multi-byte loads from buf will load
// bytes in the wrong order. Rather than byte-swap after each load (slow),
// we byte-swap the accumulator before summing any bytes and byte-swap it
// back before returning, which still produces the correct result as
// described in RFC 1071 1.2.B "Byte Order Independence".
//
// acc is at most a uint16 + a uint8, so its upper 32 bits must be 0s. We
// preserve this property by byte-swapping only the lower 32 bits of acc,
// so that additions to acc performed during alignment can't overflow.
acc = uint64(bswapIfLittleEndian32(uint32(acc)))
// Align &buf[0] to an 8-byte boundary.
bswapped := false
if sliceAddr(buf)&1 != 0 {
// Compute the rest of the partial checksum with bytes swapped, and
// swap back before returning; see the last paragraph of
// RFC 1071 1.2.B.
acc = uint64(bits.ReverseBytes32(uint32(acc)))
bswapped = true
// No `<< 8` here due to the byte swap we just did.
acc += uint64(bswapIfLittleEndian16(uint16(buf[0])))
buf = buf[1:]
}
if sliceAddr(buf)&2 != 0 {
acc += uint64(*(*uint16)(unsafe.Pointer(&buf[0])))
buf = buf[2:]
}
if sliceAddr(buf)&4 != 0 {
acc += uint64(*(*uint32)(unsafe.Pointer(&buf[0])))
buf = buf[4:]
}
// Sum 64 bytes at a time. Beyond this point, additions to acc may
// overflow, so we have to handle carrying.
for len(buf) >= 64 {
var carry uint64
acc, carry = bits.Add64(acc, *(*uint64)(unsafe.Pointer(&buf[0])), 0)
acc, carry = bits.Add64(acc, *(*uint64)(unsafe.Pointer(&buf[8])), carry)
acc, carry = bits.Add64(acc, *(*uint64)(unsafe.Pointer(&buf[16])), carry)
acc, carry = bits.Add64(acc, *(*uint64)(unsafe.Pointer(&buf[24])), carry)
acc, carry = bits.Add64(acc, *(*uint64)(unsafe.Pointer(&buf[32])), carry)
acc, carry = bits.Add64(acc, *(*uint64)(unsafe.Pointer(&buf[40])), carry)
acc, carry = bits.Add64(acc, *(*uint64)(unsafe.Pointer(&buf[48])), carry)
acc, carry = bits.Add64(acc, *(*uint64)(unsafe.Pointer(&buf[56])), carry)
acc, _ = bits.Add64(acc, 0, carry)
buf = buf[64:]
}
// Sum the remaining 0-63 bytes.
if len(buf) >= 32 {
var carry uint64
acc, carry = bits.Add64(acc, *(*uint64)(unsafe.Pointer(&buf[0])), 0)
acc, carry = bits.Add64(acc, *(*uint64)(unsafe.Pointer(&buf[8])), carry)
acc, carry = bits.Add64(acc, *(*uint64)(unsafe.Pointer(&buf[16])), carry)
acc, carry = bits.Add64(acc, *(*uint64)(unsafe.Pointer(&buf[24])), carry)
acc, _ = bits.Add64(acc, 0, carry)
buf = buf[32:]
}
if len(buf) >= 16 {
var carry uint64
acc, carry = bits.Add64(acc, *(*uint64)(unsafe.Pointer(&buf[0])), 0)
acc, carry = bits.Add64(acc, *(*uint64)(unsafe.Pointer(&buf[8])), carry)
acc, _ = bits.Add64(acc, 0, carry)
buf = buf[16:]
}
if len(buf) >= 8 {
var carry uint64
acc, carry = bits.Add64(acc, *(*uint64)(unsafe.Pointer(&buf[0])), 0)
acc, _ = bits.Add64(acc, 0, carry)
buf = buf[8:]
}
if len(buf) >= 4 {
var carry uint64
acc, carry = bits.Add64(acc, uint64(*(*uint32)(unsafe.Pointer(&buf[0]))), 0)
acc, _ = bits.Add64(acc, 0, carry)
buf = buf[4:]
}
if len(buf) >= 2 {
var carry uint64
acc, carry = bits.Add64(acc, uint64(*(*uint16)(unsafe.Pointer(&buf[0]))), 0)
acc, _ = bits.Add64(acc, 0, carry)
buf = buf[2:]
}
if len(buf) >= 1 {
// bswapIfBigEndian16(buf[0]) == bswapIfLittleEndian16(buf[0]<<8).
var carry uint64
acc, carry = bits.Add64(acc, uint64(bswapIfBigEndian16(uint16(buf[0]))), 0)
acc, _ = bits.Add64(acc, 0, carry)
// buf = buf[1:] is skipped because it's unused and nogo will complain.
}
// Reduce the checksum to 16 bits and undo byte swaps before returning.
acc16 := bswapIfLittleEndian16(reduce(acc))
if bswapped {
acc16 = bits.ReverseBytes16(acc16)
}
return acc16, odd
}
func reduce(acc uint64) uint16 {
// Ideally we would do:
// return uint16(acc>>48) +' uint16(acc>>32) +' uint16(acc>>16) +' uint16(acc)
// for more instruction-level parallelism; however, there is no
// bits.Add16().
acc = (acc >> 32) + (acc & 0xffff_ffff) // at most 0x1_ffff_fffe
acc32 := uint32(acc>>32 + acc) // at most 0xffff_ffff
acc32 = (acc32 >> 16) + (acc32 & 0xffff) // at most 0x1_fffe
return uint16(acc32>>16 + acc32) // at most 0xffff
}
func bswapIfLittleEndian32(val uint32) uint32 {
return binary.BigEndian.Uint32((*[4]byte)(unsafe.Pointer(&val))[:])
}
func bswapIfLittleEndian16(val uint16) uint16 {
return binary.BigEndian.Uint16((*[2]byte)(unsafe.Pointer(&val))[:])
}
func bswapIfBigEndian16(val uint16) uint16 {
return binary.LittleEndian.Uint16((*[2]byte)(unsafe.Pointer(&val))[:])
}
func sliceAddr(buf []byte) uintptr {
return uintptr(unsafe.Pointer(unsafe.SliceData(buf)))
}