diff --git a/LOW_MEMORY.md b/LOW_MEMORY.md new file mode 100644 index 0000000..f31ed40 --- /dev/null +++ b/LOW_MEMORY.md @@ -0,0 +1,126 @@ +# Low-memory TCP storage + +Build with the existing `with_low_memory` tag to select smaller TCP storage +and cache bounds. No runtime setter or additional feature tag is required. +Ordinary builds retain the existing values and behavior. + +| Storage policy | Ordinary build | `with_low_memory` | +| --- | ---: | ---: | +| Minimum first send allocation | 2 KiB | 1 KiB | +| Largest reusable acknowledged send chunk | 32 KiB | 16 KiB | +| Retained drained receive metadata | 64 slice slots | 32 slice slots | + +Allocation remains demand-driven. The first send allocation can exceed its +minimum when the application writes more data. Repeated moderate writes still +reuse one eligible acknowledged chunk; common-MTU receive payloads and short +metadata arrays remain reusable. Larger unused backing is released after it +drains. Unread or unacknowledged bytes are never truncated. + +An undersized small spare is not reused for a larger first write in the +low-memory build. For example, a one-byte write can leave a 1 KiB spare. A +subsequent 1,200-byte write gets one fitted allocation, instead of filling the +small spare and allocating another 16 KiB chunk for its tail. Later chunks +retain their existing minimum so packet scatter bounds remain valid. + +This changes storage retention, not TCP flow control. Default buffer limits, +automatic receive/send growth, advertised windows, retransmission handling, +and explicit socket options keep their existing behavior. The package adds +no pressure polling, timer, callback, forced GC, or per-connection state. +It does not provide a whole-stack or whole-process memory cap. + +## iOS motivation + +Hako already builds its iOS/tvOS core slices with `with_low_memory`; its macOS +slice does not use that tag. Hako's iOS extension targets a 50 MiB engineering +budget. Its gVisor TCP profile uses 32 KiB initial and 128 KiB maximum buffers +in each direction. The tests also express those byte limits as MIPS options; +this is a comparison workload, not a claim that the two stacks have identical +semantics. Smaller configured maxima alone do not release idle caches. + +## Validation + +Run the root test/race and vet suites with and without `with_low_memory`. +The gVisor interoperability tests are a separate module in `interop/gvisor`. +`TestLowMemory*` covers cache bounds, useful reuse, payload preservation, and +the tiny-write-to-larger-write allocation edge case. Existing transport tests +continue to exercise the normal flow-control and lifecycle paths. + +`TestTCPMemoryProfileBaseline` accounts for retained backing in a deterministic +1,000-connection fixture. It excludes live actors, connection structs, runtime +memory and physical footprint. Stream benchmarks transfer packets between two +in-process stacks; their throughput is not Internet speed or device energy. +Physical-device measurements must separately report their process type, +workload, compiler, memory, throughput, latency, CPU cost and limitations. + +### Latest host review + +On Go 1.26.5 darwin/arm64, the root race suite and vet passed for both +ordinary and low-memory builds. The full gVisor interoperability suite also +passed for both builds in serial, non-race runs with GOMAXPROCS=2. + +Running both complete gVisor race suites concurrently with GOMAXPROCS=4 +produced timeouts in IPv4 MTU-68 and some custom-congestion-control cases; +both suites eventually reached their three-minute timeout. This is an +unresolved validation limitation, not a passing race result. Similar MTU-68 +instability was previously observed on the unmodified baseline. These results +do not establish the cause or attribute it to this change, and this patch +does not claim to fix it. + +## Physical iPad comparison + +An iPad Pro (12.9-inch, 6th generation; iPad14,5) running iPadOS 26.6.2 ran +three fresh-process repetitions per build, with baseline/candidate order +alternated. Both builds used Go 1.26.5, `with_low_memory`, and GOMAXPROCS=2. +The baseline MIPS commit was `ba762df4c91d6f9bddf82062afb0d40aa1352687`, which +does not act on that tag. No runtime pressure API was present or called. + +The independent Debug App connected two MIPS stacks through an in-process +packet link, using 256 connection pairs and the Hako 32/128 KiB socket profile. +Each connection exchanged a one-byte message, two 1,200-byte messages, and +three 32 KiB messages. Idle measurements followed a 500 ms pause and explicit +GC in both builds. One connection then ran 1,000 small request/response cycles +and a fixed 1 GiB bidirectional payload transfer. Every connection was checked +again after the stream. All six runs completed without transfer errors. + +| Measurement (median of three runs) | Baseline | Low-memory candidate | +| --- | ---: | ---: | +| Live Go heap after small messages | 4.68 MiB | 4.31 MiB | +| Live Go heap after repeated 32 KiB messages | 16.19 MiB | 8.08 MiB | +| Process physical footprint at that latter boundary | 51.74 MiB | 40.19 MiB | +| Fixed-byte stream throughput, both directions combined | 2.61 Gbps | 2.71 Gbps | +| Stream process CPU cost | 2.324 CPU seconds/GB | 2.418 CPU seconds/GB | +| Stream allocation traffic per payload byte | 2.123 B/B | 2.207 B/B | +| Small-request p95 round-trip latency | 47.67 microseconds | 43.29 microseconds | +| Highest sampled process footprint | 66.53 MiB | 49.50 MiB | + +Throughput uses decimal Gbps (10^9 bits/s) and counts both echoed payload +directions. For this symmetric echo workload, each direction therefore +accounts for approximately 1.31 and 1.35 Gbps, respectively. These are not +separate upload/download saturation tests and cannot be compared directly +with one-way benchmark charts. + +The 32 KiB workload deliberately exercises backing that the ordinary build +retains and the low-memory build releases. Its roughly 8.11 MiB live-heap +saving across 512 TCP endpoints is workload-specific. Small messages saved +about 0.38 MiB; idle savings do not scale from connection count alone. +The stream showed no large throughput collapse in these runs, but its small +throughput/latency differences are not proof of a speed improvement. CPU cost +and allocation traffic per byte increased by about 4%. Applications dominated +by repeated medium-sized writes may pay more allocation cost and should test +that workload separately. + +These are standalone App process results, not NetworkExtension, PacketFlow, +Internet throughput, battery, or iOS termination-risk measurements. The probe +contains both TCP endpoints. Its footprint cannot be interpreted as Hako's +extension footprint or compared directly with the 50 MiB engineering budget. +The 100 ms footprint sampler may miss shorter peaks. The API does not force +GC; explicit GC is used only by the measurement harness. + +## Ordinary build boundary + +On Go 1.26.5 darwin/arm64, fifteen affected ordinary-build functions matched +baseline instructions after address relocation normalization, including the +send-buffer append/acknowledgement paths, TCP read path, established loop and +handshakes. No connection or stack fields are added. This check is specific +to that compiler and architecture; it is not whole-binary identity or a claim +that every platform has been benchmarked. diff --git a/README.md b/README.md index ad3ec63..98f16ce 100644 --- a/README.md +++ b/README.md @@ -1164,3 +1164,10 @@ would not benefit existing consumers. ## License MIPS is licensed under the Mozilla Public License 2.0. See `LICENSE`. + +### Low-memory builds + +The existing `with_low_memory` build tag selects smaller idle TCP cache and +initial allocation bounds while retaining automatic buffer growth and normal +buffer reuse. Ordinary builds keep the existing policy. See +[Low-memory TCP storage](LOW_MEMORY.md) for behavior and validation details. diff --git a/performance_test.go b/performance_test.go index 89852b6..646f302 100644 --- a/performance_test.go +++ b/performance_test.go @@ -13,19 +13,23 @@ import ( ) func benchmarkTCPControllerConnection(b *testing.B, algorithm string) (net.Conn, *Stack, *stackBridge) { + return benchmarkTCPProfileConnection(b, TCPSocketDefaults{CongestionControl: algorithm}) +} + +func benchmarkTCPProfileConnection(b *testing.B, defaults TCPSocketDefaults) (net.Conn, *Stack, *stackBridge) { b.Helper() clientAddress := netip.MustParseAddr("192.0.2.201") serverAddress := netip.MustParseAddr("192.0.2.202") client, err := New(Config{ LocalAddresses: []netip.Prefix{netip.PrefixFrom(clientAddress, 32)}, - TCP: TCPSocketDefaults{CongestionControl: algorithm}, + TCP: defaults, }) if err != nil { b.Fatal(err) } server, err := New(Config{ LocalAddresses: []netip.Prefix{netip.PrefixFrom(serverAddress, 32)}, - TCP: TCPSocketDefaults{CongestionControl: algorithm}, + TCP: defaults, }) if err != nil { b.Fatal(err) diff --git a/tcp.go b/tcp.go index 412a7f6..f1ae751 100644 --- a/tcp.go +++ b/tcp.go @@ -86,28 +86,16 @@ const ( tcpMaximumReceiveCapacity = 16 * 1024 * 1024 // tcpMaximumSendCapacity bounds automatic send-buffer growth under the same policy. tcpMaximumSendCapacity = 16 * 1024 * 1024 - // tcpReadChunkRetain keeps metadata for a modest receive burst after it - // drains without retaining the payload backing or a multi-megabyte array of - // slice headers on an idle connection. - tcpReadChunkRetain = 64 // tcpReusableReceivePayloadLimit keeps one common-MTU receive backing per // connection. Larger packets are released after Read so an occasional jumbo // segment cannot permanently raise the idle memory cost. tcpReusableReceivePayloadLimit = 2048 - // tcpSendChunkInitial bounds the unused backing retained by an idle - // connection after a small write. Later chunks in the same live send window - // use tcpSendChunkMinimum so packet construction remains scatter-bounded. - tcpSendChunkInitial = 2 * 1024 // tcpSendChunkMinimum packs later writes without moving bytes referenced by // retransmission metadata and keeps cross-chunk gathers uncommon on bulk // streams. tcpSendChunkMinimum = 16 * 1024 // tcpSendChunkMaximum bounds unused tail capacity in one send chunk. tcpSendChunkMaximum = tcpSendCapacity - // tcpReusableSendChunkLimit retains only a modest acknowledged send chunk. - // Larger chunks are released so a completed bulk transfer does not pin its - // former window. - tcpReusableSendChunkLimit = 32 * 1024 // tcpMetadataQueueInitial avoids charging every connection for a burst // before one occurs. tcpMetadataQueueInitial = 1 @@ -3473,11 +3461,15 @@ func (b *tcpSendBuffer) append(payload []byte) { var storage []byte // A small retained first chunk cannot be inserted behind a live // chunk: doing so would violate the scatter bound used by view. - if b.spare != nil && (len(b.chunks) == 0 || cap(b.spare) >= tcpSendChunkMinimum) { + if b.spare != nil && (len(b.chunks) == 0 || cap(b.spare) >= tcpSendChunkMinimum) && + (!tcpFitSmallSendSpare || cap(b.spare) >= tcpSendChunkMinimum || cap(b.spare) >= capacity) { storage = b.spare b.spare = nil } if cap(storage) == 0 { + if tcpFitSmallSendSpare { + b.spare = nil + } storage = make([]byte, capacity) if capacity > tcpSendChunkInitial && capacity <= tcpReusableSendChunkLimit && b.reusableState == tcpSendReusableReleased { b.reusableState = tcpSendReusableConfirmed diff --git a/tcp_low_memory_test.go b/tcp_low_memory_test.go new file mode 100644 index 0000000..a80a39b --- /dev/null +++ b/tcp_low_memory_test.go @@ -0,0 +1,99 @@ +//go:build with_low_memory + +package mipstack + +import ( + "bytes" + "testing" +) + +func TestLowMemorySmallWritesRetainReusableBacking(t *testing.T) { + var b tcpSendBuffer + payload := bytes.Repeat([]byte{0x57}, 1200) + for i := 0; i < 3; i++ { + b.append(payload) + b.acknowledge(len(payload)) + } + if n := cap(b.spare); n > 1200 { + t.Fatalf("small idle send backing=%d, want <=1200", n) + } + if len(b.spare) == 0 && cap(b.spare) == 0 { + t.Fatal("small reusable backing was discarded") + } + p := &b.spare[:cap(b.spare)][0] + b.append(payload) + if &b.chunks[0].storage[0] != p { + t.Fatal("small repeated write lost reuse") + } + var view tcpPayloadView + b.view(0, len(payload), &view) + got := make([]byte, len(payload)) + view.copyTo(got) + if !bytes.Equal(got, payload) { + t.Fatal("reused storage corrupted payload") + } +} + +func TestLowMemoryCapsIdleSendBacking(t *testing.T) { + for _, size := range []int{16 << 10, 32 << 10} { + var b tcpSendBuffer + payload := bytes.Repeat([]byte{0x6b}, size) + for i := 0; i < 3; i++ { + b.append(payload) + b.acknowledge(size) + } + if n := cap(b.spare); n > 16<<10 { + t.Fatalf("%d-byte bursts retained %d bytes; low-memory maximum is 16 KiB", size, n) + } + if size == 16<<10 && cap(b.spare) != size { + t.Fatal("common send chunk reuse was lost") + } + } +} + +func TestLowMemoryCapsDrainedReadMetadata(t *testing.T) { + b := tcpReadBuffer{chunks: make([][]byte, 0, 48)} + for i := 0; i < 48; i++ { + b.append([]byte{byte(i)}) + } + got := make([]byte, 48) + if b.read(got, len(got), nil) != len(got) { + t.Fatal("short read") + } + for i, v := range got { + if v != byte(i) { + t.Fatal("read data changed") + } + } + if n := cap(b.chunks); n > 32 { + t.Fatalf("drained metadata slots=%d, want <=32", n) + } + for i := 0; i < 8; i++ { + b.append([]byte{1}) + } + b.read(got, 8, nil) + if cap(b.chunks) == 0 { + t.Fatal("common read metadata reuse was lost") + } +} + +func TestLowMemoryGrowingSmallWriteAvoidsLargeTail(t *testing.T) { + var b tcpSendBuffer + b.append([]byte{1}) + b.acknowledge(1) + payload := bytes.Repeat([]byte{0x43}, 1200) + b.append(payload) + if len(b.chunks) != 1 { + t.Fatalf("small write used %d chunks after a tiny write; want one fitted allocation", len(b.chunks)) + } + if n := cap(b.chunks[0].storage); n > 1200 { + t.Fatalf("small write retained %d bytes of backing", n) + } + var view tcpPayloadView + b.view(0, len(payload), &view) + got := make([]byte, len(payload)) + view.copyTo(got) + if !bytes.Equal(got, payload) { + t.Fatal("fitted write lost data") + } +} diff --git a/tcp_memory_low.go b/tcp_memory_low.go new file mode 100644 index 0000000..7aaefb9 --- /dev/null +++ b/tcp_memory_low.go @@ -0,0 +1,19 @@ +//go:build with_low_memory + +package mipstack + +// Low-memory builds retain less unused backing while preserving on-demand +// allocation, active TCP windows, automatic growth and common buffer reuse. +// These are storage/cache bounds, not advertised TCP receive-window limits. +const ( + // Avoid spending a full later chunk on overflow from an undersized small spare. + tcpFitSmallSendSpare = true + // Keep ordinary short read bursts reusable, but release larger drained metadata. + tcpReadChunkRetain = 32 + // The first live send chunk can be small. Later chunks keep their normal + // minimum to preserve the bound on packet scatter/gather segments. + tcpSendChunkInitial = 1024 + // Repeated moderate writes can retain one 16 KiB chunk. Larger acknowledged + // chunks are released; unread and unacknowledged storage is never truncated. + tcpReusableSendChunkLimit = 16 * 1024 +) diff --git a/tcp_memory_profile_test.go b/tcp_memory_profile_test.go new file mode 100644 index 0000000..88a779b --- /dev/null +++ b/tcp_memory_profile_test.go @@ -0,0 +1,122 @@ +package mipstack + +import ( + "bytes" + "io" + "net/netip" + "testing" + "time" + "unsafe" +) + +// These profiles compare policies, not up-front allocation. Connection queues +// allocate on demand, so a smaller configured maximum is not itself a saving. +func memoryTestProfiles() []struct { + name string + tcp TCPSocketDefaults +} { + return []struct { + name string + tcp TCPSocketDefaults + }{ + {"default", TCPSocketDefaults{}}, + {"bounded", TCPSocketDefaults{ReceiveBuffer: 32 << 10, MaximumReceiveBuffer: 256 << 10, SendBuffer: 32 << 10, MaximumSendBuffer: 512 << 10}}, + // Hako's current iOS gVisor envelope, expressed as MIPS parameters. + // Equal byte limits do not imply identical TCP/window semantics. + {"hako-ios-envelope", TCPSocketDefaults{ReceiveBuffer: 32 << 10, MaximumReceiveBuffer: 128 << 10, SendBuffer: 32 << 10, MaximumSendBuffer: 128 << 10}}, + } +} + +func BenchmarkTCPMemoryProfileStream(b *testing.B) { + for _, profile := range memoryTestProfiles() { + b.Run(profile.name, func(b *testing.B) { benchmarkTCPMemoryStream(b, profile.tcp) }) + } +} + +func benchmarkTCPMemoryStream(b *testing.B, defaults TCPSocketDefaults) { + conn, _, _ := benchmarkTCPProfileConnection(b, defaults) + if err := conn.SetDeadline(time.Now().Add(time.Minute)); err != nil { + b.Fatal(err) + } + payload := bytes.Repeat([]byte{0x57}, 1<<20) + response := make([]byte, len(payload)) + b.SetBytes(int64(2 * len(payload))) + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + done := make(chan error, 1) + go func() { _, err := conn.Write(payload); done <- err }() + if _, err := io.ReadFull(conn, response); err != nil { + b.Fatal(err) + } + if err := <-done; err != nil { + b.Fatal(err) + } + if !bytes.Equal(payload, response) { + b.Fatal("payload mismatch") + } + } +} + +func memoryTestStack(t testing.TB, defaults TCPSocketDefaults) *Stack { + t.Helper() + s, err := New(Config{LocalAddresses: []netip.Prefix{netip.MustParsePrefix("192.0.2.1/32")}, TCP: defaults}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = s.Close() }) + return s +} + +// Exercise production queue operations without actors to make retained backing +// deterministic. This excludes live protocol state, runtime stacks and RSS. +func warmMemoryTestConn(c *TCPConn) { + payload := make([]byte, 1200) + for i := 0; i < 2; i++ { + c.sendBuffer.append(payload) + c.sendBuffer.acknowledge(len(payload)) + c.readBuffer.append(append([]byte(nil), payload...)) + c.readBuffer.read(payload, len(payload), c.inbound.recyclePayload) + c.inbound.enqueue(tcpSegment{}) + c.inbound.dequeue() + } +} + +func idleMemoryTestBacking(c *TCPConn) int { + c.mu.Lock() + defer c.mu.Unlock() + c.inbound.mu.Lock() + defer c.inbound.mu.Unlock() + return cap(c.sendBuffer.spare) + cap(c.readBuffer.chunks)*int(unsafe.Sizeof([]byte{})) + + cap(c.inbound.spare) + cap(c.inbound.segments)*int(unsafe.Sizeof(tcpSegment{})) +} + +func TestTCPMemoryProfileBaseline(t *testing.T) { + for _, profile := range memoryTestProfiles() { + t.Run(profile.name, func(t *testing.T) { + s := memoryTestStack(t, profile.tcp) + const count = 1000 + total := 0 + for i := 0; i < count; i++ { + c := newTCPConn(s, "tcp4", tcpKey{}, 1500, tcpSocketOptionSet{}) + warmMemoryTestConn(c) + total += idleMemoryTestBacking(c) + } + t.Logf("%d drained connections: retained backing=%d B (%d B/connection), excluding connection structs and runtime", count, total, total/count) + }) + } +} + +func BenchmarkTCPMemoryProfileChurn(b *testing.B) { + for _, profile := range memoryTestProfiles() { + b.Run(profile.name, func(b *testing.B) { + s := memoryTestStack(b, profile.tcp) + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + c := newTCPConn(s, "tcp4", tcpKey{}, 1500, tcpSocketOptionSet{}) + warmMemoryTestConn(c) + } + }) + } +} diff --git a/tcp_memory_standard.go b/tcp_memory_standard.go new file mode 100644 index 0000000..ea5c310 --- /dev/null +++ b/tcp_memory_standard.go @@ -0,0 +1,20 @@ +//go:build !with_low_memory + +package mipstack + +const ( + // Avoid spending a full later chunk on overflow from an undersized small spare. + tcpFitSmallSendSpare = false + // tcpReadChunkRetain keeps metadata for a modest receive burst after it + // drains without retaining the payload backing or a multi-megabyte array of + // slice headers on an idle connection. + tcpReadChunkRetain = 64 + // tcpSendChunkInitial bounds the unused backing retained by an idle + // connection after a small write. Later chunks in the same live send window + // use tcpSendChunkMinimum so packet construction remains scatter-bounded. + tcpSendChunkInitial = 2 * 1024 + // tcpReusableSendChunkLimit retains only a modest acknowledged send chunk. + // Larger chunks are released so a completed bulk transfer does not pin its + // former window. + tcpReusableSendChunkLimit = 32 * 1024 +)