Loading src/posix/udp.cpp +100 −94 Changes for src/posix/udp.cpp: 100 added lines, 94 removed lines. Original line number Diff line number Diff line Loading @@ -384,18 +384,27 @@ void udp::probeGSOGRO(uint16_t segment_size) { if (_Socket < 0) return; // Probe UDP_SEGMENT (GSO) — a setsockopt that succeeds means the // kernel supports generic segmentation offload for UDP. uint16_t seg = segment_size; #ifdef __linux__ // Probe UDP_SEGMENT (GSO) -- a setsockopt that succeeds means the // kernel supports generic segmentation offload for UDP. The option is // an int: udp_lib_setsockopt() rejects anything shorter with EINVAL, so // a uint16_t here silently disables GSO on every kernel. Set back to 0 // right away: a nonzero value is sticky and would apply to every later // send on this socket; sendBatch() asks per message via cmsg instead. int seg = segment_size; if (::setsockopt(_Socket, SOL_UDP, UDP_SEGMENT, &seg, sizeof(seg)) == 0) { seg = 0; ::setsockopt(_Socket, SOL_UDP, UDP_SEGMENT, &seg, sizeof(seg)); _gso_enabled = true; } // Probe UDP_GRO // Probe UDP_GRO. Linux only: the recvmmsg path is the one that reads // the segment size back and splits, the fallback loop does not. int gro_val = 1; if (::setsockopt(_Socket, SOL_UDP, UDP_GRO, &gro_val, sizeof(gro_val)) == 0) { _gro_enabled = true; } #endif } // The real wire length of a peer address, by family — NOT Loading @@ -420,6 +429,11 @@ socklen_t sockaddrLen(const sockaddr_storage& addr) { // UDP Batched Send: sendmmsg + GSO when available // ============================================================================ // UDP_MAX_SEGMENTS is 64 on older kernels (128 since 6.x), and one GSO send is // still a single IP datagram to the stack, capped at 65535 minus the headers. static constexpr size_t kGsoMaxSegments = 64; static constexpr size_t kGsoMaxPayload = 65000; ssize_t udp::sendBatch( const std::vector<std::pair<const uint8_t*, size_t>>& datagrams, sockaddr_storage* dest, socklen_t dest_len) Loading @@ -430,77 +444,6 @@ ssize_t udp::sendBatch( const size_t count = datagrams.size(); auto& perf = quicPerf(); // --- GSO path: coalesce uniform-sized datagrams into one sendmsg --- if (_gso_enabled && count > 1) { bool uniform = true; size_t seg_size = datagrams[0].second; // Last datagram may be shorter (tail segment) — GSO allows that. for (size_t i = 1; i < count - 1; ++i) { if (datagrams[i].second != seg_size) { uniform = false; break; } } if (uniform) { // F21: one iovec per datagram, pointing straight at each // caller-owned buffer — no intermediate "coalesced" heap // allocation + memcpy of the whole batch. sendmsg()'s own // copy_from_iter() into the outgoing skb already walks a // multi-entry iovec exactly like a single contiguous buffer // (that's what iovec/scatter-gather I/O is for), and UDP_SEGMENT // segments the *logical* byte stream described by the iovec // array as a whole — it does not require the source bytes to // already be contiguous in user space. This removes one full // copy of every batch's total bytes on the GSO path. // // iovec array itself: thread_local, reused across calls instead // of a fresh std::vector<iovec>(count) every flush — mirrors // recvBatchAddr()'s thread_local iovec/mmsghdr arrays below. // resize() never shrinks capacity, so after the first call at // the connection's steady-state batch size this is alloc-free. static thread_local std::vector<struct iovec> iov; if (iov.size() < count) iov.resize(count); for (size_t i = 0; i < count; ++i) { iov[i].iov_base = const_cast<uint8_t*>(datagrams[i].first); iov[i].iov_len = datagrams[i].second; } // cmsg carrying UDP_SEGMENT size union { char buf[CMSG_SPACE(sizeof(uint16_t))]; struct cmsghdr align; } cmsg_buf; struct msghdr msg{}; if (dest) { msg.msg_name = dest; msg.msg_namelen = dest_len; } msg.msg_iov = iov.data(); msg.msg_iovlen = count; msg.msg_control = cmsg_buf.buf; msg.msg_controllen = sizeof(cmsg_buf.buf); struct cmsghdr* cm = CMSG_FIRSTHDR(&msg); cm->cmsg_level = SOL_UDP; cm->cmsg_type = UDP_SEGMENT; cm->cmsg_len = CMSG_LEN(sizeof(uint16_t)); *reinterpret_cast<uint16_t*>(CMSG_DATA(cm)) = static_cast<uint16_t>(seg_size); ssize_t ret = ::sendmsg(_Socket, &msg, MSG_DONTWAIT); if (ret > 0) { perf.gso_sendmsg_calls.fetch_add(1, std::memory_order_relaxed); perf.gso_segments.fetch_add(count, std::memory_order_relaxed); perf.packets_sent.fetch_add(count, std::memory_order_relaxed); perf.bytes_sent.fetch_add(static_cast<uint64_t>(ret), std::memory_order_relaxed); return static_cast<ssize_t>(count); } if (errno == EAGAIN || errno == EWOULDBLOCK) perf.send_eagain.fetch_add(1, std::memory_order_relaxed); // GSO failed — fall through to sendmmsg } perf.gso_fallbacks.fetch_add(1, std::memory_order_relaxed); } // --- sendmmsg path --- #ifdef NETPLUS_NO_MMSG // Fallback: sendto loop for platforms without a trusted sendmmsg. Loading Loading @@ -529,40 +472,103 @@ ssize_t udp::sendBatch( } perf.single_send_calls.fetch_add(static_cast<uint64_t>(sent), std::memory_order_relaxed); perf.packets_sent.fetch_add(static_cast<uint64_t>(sent), std::memory_order_relaxed); return sent; #else // thread_local, reused across calls (same rationale as the GSO iovec // array above) — avoids two fresh heap allocations (iovec + mmsghdr // arrays) on every flushBatch() call. // thread_local, reused across calls -- avoids two fresh heap allocations // (iovec + mmsghdr arrays) on every flushBatch() call. // // One iovec per datagram, pointing straight at the caller-owned buffer. // With GSO, a run of equal-sized datagrams becomes one message with a // UDP_SEGMENT cmsg of its own instead of a whole batch either being // uniform or not: an ACK or a shorter tail in the middle of a batch only // ends the current run, and everything still goes out in one syscall. union CmsgBuf { char buf[CMSG_SPACE(sizeof(uint16_t))]; struct cmsghdr align; }; static thread_local std::vector<struct iovec> iovecs; static thread_local std::vector<struct mmsghdr> msgs; if (iovecs.size() < count) iovecs.resize(count); if (msgs.size() < count) msgs.resize(count); std::memset(msgs.data(), 0, sizeof(struct mmsghdr) * count); static thread_local std::vector<CmsgBuf> cmsgs; static thread_local std::vector<uint16_t> msg_counts; if (iovecs.size() < count) { iovecs.resize(count); msgs.resize(count); cmsgs.resize(count); msg_counts.resize(count); } for (size_t i = 0; i < count; ++i) { iovecs[i].iov_base = const_cast<uint8_t*>(datagrams[i].first); iovecs[i].iov_len = datagrams[i].second; msgs[i].msg_hdr.msg_iov = &iovecs[i]; msgs[i].msg_hdr.msg_iovlen = 1; } size_t nmsgs = 0; for (size_t i = 0; i < count; ) { const size_t seg_size = datagrams[i].second; size_t run = 1; if (_gso_enabled && seg_size > 0 && seg_size <= kGsoMaxPayload) { const size_t max_run = std::min(kGsoMaxSegments, kGsoMaxPayload / seg_size); while (i + run < count && run < max_run && datagrams[i + run].second == seg_size) ++run; // Only the last segment may be shorter, and it has to be nonempty. if (i + run < count && run < max_run && run > 0 && datagrams[i + run].second > 0 && datagrams[i + run].second < seg_size) ++run; } struct mmsghdr& m = msgs[nmsgs]; std::memset(&m, 0, sizeof(m)); m.msg_hdr.msg_iov = &iovecs[i]; m.msg_hdr.msg_iovlen = run; if (dest) { msgs[i].msg_hdr.msg_name = dest; msgs[i].msg_hdr.msg_namelen = dest_len; m.msg_hdr.msg_name = dest; m.msg_hdr.msg_namelen = dest_len; } if (run > 1) { m.msg_hdr.msg_control = cmsgs[nmsgs].buf; m.msg_hdr.msg_controllen = sizeof(cmsgs[nmsgs].buf); struct cmsghdr* cm = CMSG_FIRSTHDR(&m.msg_hdr); cm->cmsg_level = SOL_UDP; cm->cmsg_type = UDP_SEGMENT; cm->cmsg_len = CMSG_LEN(sizeof(uint16_t)); uint16_t seg = static_cast<uint16_t>(seg_size); std::memcpy(CMSG_DATA(cm), &seg, sizeof(seg)); } msg_counts[nmsgs] = static_cast<uint16_t>(run); ++nmsgs; i += run; } int sent = ::sendmmsg(_Socket, msgs.data(), static_cast<unsigned int>(count), MSG_DONTWAIT); int sent_msgs = ::sendmmsg(_Socket, msgs.data(), static_cast<unsigned int>(nmsgs), MSG_DONTWAIT); perf.sendmmsg_calls.fetch_add(1, std::memory_order_relaxed); if (sent < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) { if (sent_msgs <= 0) { if (errno == EAGAIN || errno == EWOULDBLOCK) { perf.send_eagain.fetch_add(1, std::memory_order_relaxed); } else if (sent > 0) { perf.packets_sent.fetch_add(static_cast<uint64_t>(sent), std::memory_order_relaxed); } else if (nmsgs < count && (errno == EIO || errno == EINVAL)) { // The route can't do it: EIO without checksum offload, EINVAL when // a segment doesn't fit the MTU. The probe can't see either. perf.gso_fallbacks.fetch_add(1, std::memory_order_relaxed); _gso_enabled = false; return sendBatch(datagrams, dest, dest_len); } return sent_msgs; } size_t sent = 0; uint64_t bytes = 0; for (int i = 0; i < sent; ++i) bytes += datagrams[static_cast<size_t>(i)].second; perf.bytes_sent.fetch_add(bytes, std::memory_order_relaxed); for (int m = 0; m < sent_msgs; ++m) { const size_t run = msg_counts[static_cast<size_t>(m)]; bytes += msgs[static_cast<size_t>(m)].msg_len; sent += run; if (run > 1) { perf.gso_sendmsg_calls.fetch_add(1, std::memory_order_relaxed); perf.gso_segments.fetch_add(run, std::memory_order_relaxed); } } perf.packets_sent.fetch_add(sent, std::memory_order_relaxed); perf.bytes_sent.fetch_add(bytes, std::memory_order_relaxed); return static_cast<ssize_t>(sent); #endif return sent; } // ============================================================================ Loading src/quic.cpp +89 −44 Changes for src/quic.cpp: 89 added lines, 44 removed lines. Original line number Diff line number Diff line Loading @@ -949,6 +949,12 @@ void quic::onPacketAckedCC(size_t packet_size, uint64_t pn) { return; } // RFC 9002 7.8: no growth while something else limits the sender (flow // control, the application, the CPU). Otherwise slow start keeps doubling // a window nothing has tested, and the first time the sender can fill it // that is one burst far beyond what the path takes. if (_bytes_in_flight * 2 < _cwnd) return; // If this packet was sent during recovery, don't grow cwnd if (_in_recovery && pn <= _recovery_start_pn) { return; Loading Loading @@ -4920,9 +4926,11 @@ size_t quic::recvStreamData(uint64_t stream_id, uint8_t* data, size_t max_len) { // Flow control: after consuming data, check if we should send // MAX_STREAM_DATA to open the receive window for the peer. // Trigger when >50% of the advertised window has been consumed. uint64_t consumed_from_committed = stream.recv_offset; if (consumed_from_committed > stream.max_data_local_committed / 2) { // Trigger when less than half a window of credit is left. Both sides of // this are absolute offsets, so comparing against committed / 2 instead // is only right for the first window: after that committed = offset + // window, the test becomes offset > window, and it fires on every packet. if (stream.recv_offset + stream.max_data_local / 2 > stream.max_data_local_committed) { uint64_t new_limit = stream.recv_offset + stream.max_data_local; if (new_limit > stream.max_data_local_committed) { stream.max_data_local_committed = new_limit; Loading @@ -4931,7 +4939,7 @@ size_t quic::recvStreamData(uint64_t stream_id, uint8_t* data, size_t max_len) { } // Connection-level flow control: update MAX_DATA after consumption if (_data_recv > _max_data_local_committed / 2) { if (_data_recv + _max_data_local / 2 > _max_data_local_committed) { uint64_t new_limit = _data_recv + _max_data_local; if (new_limit > _max_data_local_committed) { _max_data_local_committed = new_limit; Loading Loading @@ -5894,7 +5902,8 @@ void quic::processStreamFrame(const uint8_t* data, size_t len, size_t& offset, u // Update connection-level receive counter (only new unique bytes) _data_recv += new_unique_bytes; // Flow control: defer MAX_STREAM_DATA when >50% of window consumed. // Flow control: defer MAX_STREAM_DATA once less than half a window of // credit is left (see recvStreamData() on why not committed / 2). // recv_base_offset + recv_buffer.size() is the highest absolute offset // the buffer currently extends to — equivalent to the old plain // recv_buffer.size() when recv_base_offset is 0 (always true outside Loading @@ -5904,14 +5913,14 @@ void quic::processStreamFrame(const uint8_t* data, size_t len, size_t& offset, u // instead -- granting it on arrival is exactly what lets a fast peer outrun a slow reader. const bool pull_stream = _is_server && _stream_pull_dispatch && (stream_id % 4 == 0); uint64_t consumed = stream.recv_base_offset + stream.recv_buffer.size(); if (!pull_stream && consumed > stream.max_data_local_committed / 2) { if (!pull_stream && consumed + stream.max_data_local / 2 > stream.max_data_local_committed) { uint64_t new_limit = consumed + stream.max_data_local; stream.max_data_local_committed = new_limit; _pending_max_stream_data[stream_id] = new_limit; } // Flow control: defer MAX_DATA when >50% of connection window consumed if (_data_recv > _max_data_local_committed / 2) { // Flow control: same for MAX_DATA and the connection window if (_data_recv + _max_data_local / 2 > _max_data_local_committed) { uint64_t new_limit = _data_recv + _max_data_local; _max_data_local_committed = new_limit; _pending_max_data_value = new_limit; Loading Loading @@ -8093,16 +8102,41 @@ size_t quic::sendStreamData(uint64_t stream_id, const uint8_t* data, size_t len, stream.stream_id = stream_id; // Check flow control size_t send_len = len; if (_data_sent + send_len > _max_data_remote) { send_len = (_max_data_remote > _data_sent) ? auto clampToCredit = [&]() { size_t n = len; if (_data_sent + n > _max_data_remote) { n = (_max_data_remote > _data_sent) ? (_max_data_remote - _data_sent) : 0; } if (stream.send_offset + send_len > stream.max_data_remote) { send_len = (stream.max_data_remote > stream.send_offset) ? if (stream.send_offset + n > stream.max_data_remote) { n = (stream.max_data_remote > stream.send_offset) ? (stream.max_data_remote - stream.send_offset) : 0; } return n; }; size_t send_len = clampToCredit(); // Out of credit: the MAX_DATA/MAX_STREAM_DATA that ends this may already // be in the socket, and a caller retrying on 0 has nothing else to read // it. Without this, the only pumps were the ones on the cwnd path, so a // sender that runs out of credit before cwnd stalled forever. if (send_len == 0 && len > 0) { pumpIncomingLocked(); send_len = clampToCredit(); } // Still none: MAX_DATA/MAX_STREAM_DATA aren't retransmitted, so if ours // got lost only this gets the peer to resend it (0x14/0x15 in // processFrame()). Paced, a caller may be retrying this in a tight loop. if (send_len == 0 && len > 0) { auto now = std::chrono::steady_clock::now(); if (now - _last_blocked_sent >= std::chrono::duration<double>(std::max(_rto, 0.01))) { _last_blocked_sent = now; if (_data_sent >= _max_data_remote) sendDataBlocked(_max_data_remote); if (stream.send_offset >= stream.max_data_remote) sendStreamDataBlocked(stream_id, stream.max_data_remote); } } // A STREAM FIN fixes the final stream size permanently. Completion of a // flow-control-clamped prefix is therefore not completion of the caller's Loading Loading @@ -8162,6 +8196,31 @@ size_t quic::sendStreamData(uint64_t stream_id, const uint8_t* data, size_t len, pt_buf_base = pt_stack; } // Adaptive pump: while there's ample cwnd headroom, several GSO/sendmmsg // batches can go out before ACK/flow-control processing needs to run -- // the cwnd-blocked wait path below already forces a pump exactly when // headroom actually runs out, so skipping it here only removes redundant // recvmmsg syscalls + routing work in the common, not-blocked case, not // correctness. Still bounded by PUMP_ADAPTIVE_MAX_BATCHES so a connection // with a very large cwnd (BBR-lite ramped up, or a high-BDP path) doesn't // go arbitrarily long without processing ACKs/RTT samples/flow-control // updates. Returns false once the connection is going away. auto pumpAfterFlush = [&]() { ++_flushes_since_pump; bool cwnd_getting_tight = _handshake_complete && _cwnd > 0 && (_bytes_in_flight * 2 >= _cwnd); if (!cwnd_getting_tight && _flushes_since_pump < PUMP_ADAPTIVE_MAX_BATCHES) { quicPerf().pumps_skipped.fetch_add(1, std::memory_order_relaxed); return true; } // quic_mtx() is already held throughout this function (F31). pumpIncomingLocked(); _flushes_since_pump = 0; quicPerf().pumps_done.fetch_add(1, std::memory_order_relaxed); return _conn_state.load() != ConnectionState::Closed && _conn_state.load() != ConnectionState::Draining; }; size_t sent_total = 0; size_t batch_count = 0; // packets accumulated since last flush // Match BATCH_MAX so _send_batch's own auto-flush (batchPacket(), quic.cpp:300) Loading @@ -8178,6 +8237,12 @@ size_t quic::sendStreamData(uint64_t stream_id, const uint8_t* data, size_t len, // Congestion control: use estimated actual packet size, not max_datagram size_t est_pkt_size = header_overhead + frame_hdr_max + chunk; // Once stalled, wait for room for a real burst rather than one // packet. Resuming on the first ACK sends one packet per ACK, each // getting an ACK of its own: a stable state with no batching left // on either side, GSO and GRO doing nothing and a syscall per packet. size_t resume_bytes = std::max<size_t>(est_pkt_size, std::min<size_t>(est_pkt_size * CWND_RESUME_PACKETS, _cwnd / 4)); if (_handshake_complete && !cwndAllowsSend(est_pkt_size)) { quicPerf().congestion_stalls.fetch_add(1, std::memory_order_relaxed); // Flush any batched packets first so the peer can ACK them Loading Loading @@ -8205,7 +8270,7 @@ size_t quic::sendStreamData(uint64_t stream_id, const uint8_t* data, size_t len, pumpIncomingLocked(); _flushes_since_pump = 0; ++spin_count; if (cwndAllowsSend(est_pkt_size)) { unblocked = true; break; } if (cwndAllowsSend(resume_bytes)) { unblocked = true; break; } } quicPerf().cc_spin_iterations.fetch_add(static_cast<uint64_t>(spin_count), std::memory_order_relaxed); // If still blocked, wait with a real timeout instead of Loading Loading @@ -8263,11 +8328,11 @@ size_t quic::sendStreamData(uint64_t stream_id, const uint8_t* data, size_t len, pumpIncomingLocked(); _flushes_since_pump = 0; ++wait_count; if (cwndAllowsSend(est_pkt_size)) { unblocked = true; break; } if (cwndAllowsSend(resume_bytes)) { unblocked = true; break; } } quicPerf().cc_wait_iterations.fetch_add(static_cast<uint64_t>(wait_count), std::memory_order_relaxed); } if (!unblocked) break; if (!unblocked && !cwndAllowsSend(est_pkt_size)) break; } // === Build short header directly into pkt_buf === Loading Loading @@ -8437,38 +8502,18 @@ size_t quic::sendStreamData(uint64_t stream_id, const uint8_t* data, size_t len, if (batch_count >= BATCH_FLUSH) { flushBatch(); batch_count = 0; ++_flushes_since_pump; // Adaptive pump: while there's ample cwnd headroom, several // GSO/sendmmsg batches can go out before ACK/flow-control // processing needs to run — the cwnd-blocked wait path above // already forces a pump exactly when headroom actually runs // out, so skipping it here only removes redundant recvmmsg // syscalls + routing work in the common, not-blocked case, not // correctness. Still bounded by PUMP_ADAPTIVE_MAX_BATCHES so a // connection with a very large cwnd (BBR-lite ramped up, or a // high-BDP path) doesn't go arbitrarily long without processing // ACKs/RTT samples/flow-control updates. bool cwnd_getting_tight = _handshake_complete && _cwnd > 0 && (_bytes_in_flight * 2 >= _cwnd); if (cwnd_getting_tight || _flushes_since_pump >= PUMP_ADAPTIVE_MAX_BATCHES) { // quic_mtx() is already held throughout this function (F31). pumpIncomingLocked(); _flushes_since_pump = 0; quicPerf().pumps_done.fetch_add(1, std::memory_order_relaxed); if (_conn_state.load() == ConnectionState::Closed || _conn_state.load() == ConnectionState::Draining) { break; } } else { quicPerf().pumps_skipped.fetch_add(1, std::memory_order_relaxed); } if (!pumpAfterFlush()) break; } } // Flush remaining batched packets // Flush remaining batched packets. Counts towards the pump as well: an // application writing less than BATCH_FLUSH packets per call never gets // to the flush in the loop above, and without a pump here it reads // nothing at all until cwnd or flow control stalls it. By then ACKs and // MAX_DATA have piled up in the socket buffer and start getting dropped. if (batch_count > 0) { flushBatch(); pumpAfterFlush(); } // Only mark FIN if we actually sent all requested data (FIN is on the last chunk). Loading src/socket.h +10 −1 Changes for src/socket.h: 10 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -1809,6 +1809,8 @@ namespace netplus { void sendDataBlocked(uint64_t limit); /// Send a STREAM_DATA_BLOCKED frame void sendStreamDataBlocked(uint64_t stream_id, uint64_t limit); /// Paces the two above when a caller keeps retrying out of credit. std::chrono::steady_clock::time_point _last_blocked_sent{}; /// Send an ACK frame for 1-RTT packets void sendAppAck(); Loading Loading @@ -2262,7 +2264,11 @@ namespace netplus { // this file was added for. static constexpr double HYSTART_MIN_RTT_THRESH = 0.004; // 4ms static constexpr double HYSTART_MAX_RTT_THRESH = 0.016; // 16ms static constexpr int HYSTART_N_RTT_SAMPLE = 8; // RFC 9406 says 8, which assumes an ACK every other packet. A sample // here is one ACK, and with GSO/GRO that covers a whole receive // batch: a round rarely has 8 of them, and a round with too few is // never evaluated, so slow start would simply never exit. static constexpr int HYSTART_N_RTT_SAMPLE = 3; static constexpr int HYSTART_CSS_ROUNDS = 5; static constexpr uint64_t HYSTART_CSS_GROWTH_DIVISOR = 4; Loading Loading @@ -2369,6 +2375,9 @@ namespace netplus { // connection whose cwnd happens to be very large. size_t _flushes_since_pump = 0; static constexpr size_t PUMP_ADAPTIVE_MAX_BATCHES = 4; // How much cwnd room, in packets, sendStreamData() waits for after // stalling. Capped at cwnd / 4 there so small windows keep moving. static constexpr size_t CWND_RESUME_PACKETS = 16; // Scratch pointer/length pairs built from _send_batch/_send_arena on // every flushBatch() call, reused instead of a fresh // std::vector<std::pair<...>> per call — resize() never shrinks Loading test/quic_parallel_decrypt_test.cpp +19 −6 File changed.Preview size limit exceeded, changes collapsed. Show changes Loading
src/posix/udp.cpp +100 −94 Changes for src/posix/udp.cpp: 100 added lines, 94 removed lines. Original line number Diff line number Diff line Loading @@ -384,18 +384,27 @@ void udp::probeGSOGRO(uint16_t segment_size) { if (_Socket < 0) return; // Probe UDP_SEGMENT (GSO) — a setsockopt that succeeds means the // kernel supports generic segmentation offload for UDP. uint16_t seg = segment_size; #ifdef __linux__ // Probe UDP_SEGMENT (GSO) -- a setsockopt that succeeds means the // kernel supports generic segmentation offload for UDP. The option is // an int: udp_lib_setsockopt() rejects anything shorter with EINVAL, so // a uint16_t here silently disables GSO on every kernel. Set back to 0 // right away: a nonzero value is sticky and would apply to every later // send on this socket; sendBatch() asks per message via cmsg instead. int seg = segment_size; if (::setsockopt(_Socket, SOL_UDP, UDP_SEGMENT, &seg, sizeof(seg)) == 0) { seg = 0; ::setsockopt(_Socket, SOL_UDP, UDP_SEGMENT, &seg, sizeof(seg)); _gso_enabled = true; } // Probe UDP_GRO // Probe UDP_GRO. Linux only: the recvmmsg path is the one that reads // the segment size back and splits, the fallback loop does not. int gro_val = 1; if (::setsockopt(_Socket, SOL_UDP, UDP_GRO, &gro_val, sizeof(gro_val)) == 0) { _gro_enabled = true; } #endif } // The real wire length of a peer address, by family — NOT Loading @@ -420,6 +429,11 @@ socklen_t sockaddrLen(const sockaddr_storage& addr) { // UDP Batched Send: sendmmsg + GSO when available // ============================================================================ // UDP_MAX_SEGMENTS is 64 on older kernels (128 since 6.x), and one GSO send is // still a single IP datagram to the stack, capped at 65535 minus the headers. static constexpr size_t kGsoMaxSegments = 64; static constexpr size_t kGsoMaxPayload = 65000; ssize_t udp::sendBatch( const std::vector<std::pair<const uint8_t*, size_t>>& datagrams, sockaddr_storage* dest, socklen_t dest_len) Loading @@ -430,77 +444,6 @@ ssize_t udp::sendBatch( const size_t count = datagrams.size(); auto& perf = quicPerf(); // --- GSO path: coalesce uniform-sized datagrams into one sendmsg --- if (_gso_enabled && count > 1) { bool uniform = true; size_t seg_size = datagrams[0].second; // Last datagram may be shorter (tail segment) — GSO allows that. for (size_t i = 1; i < count - 1; ++i) { if (datagrams[i].second != seg_size) { uniform = false; break; } } if (uniform) { // F21: one iovec per datagram, pointing straight at each // caller-owned buffer — no intermediate "coalesced" heap // allocation + memcpy of the whole batch. sendmsg()'s own // copy_from_iter() into the outgoing skb already walks a // multi-entry iovec exactly like a single contiguous buffer // (that's what iovec/scatter-gather I/O is for), and UDP_SEGMENT // segments the *logical* byte stream described by the iovec // array as a whole — it does not require the source bytes to // already be contiguous in user space. This removes one full // copy of every batch's total bytes on the GSO path. // // iovec array itself: thread_local, reused across calls instead // of a fresh std::vector<iovec>(count) every flush — mirrors // recvBatchAddr()'s thread_local iovec/mmsghdr arrays below. // resize() never shrinks capacity, so after the first call at // the connection's steady-state batch size this is alloc-free. static thread_local std::vector<struct iovec> iov; if (iov.size() < count) iov.resize(count); for (size_t i = 0; i < count; ++i) { iov[i].iov_base = const_cast<uint8_t*>(datagrams[i].first); iov[i].iov_len = datagrams[i].second; } // cmsg carrying UDP_SEGMENT size union { char buf[CMSG_SPACE(sizeof(uint16_t))]; struct cmsghdr align; } cmsg_buf; struct msghdr msg{}; if (dest) { msg.msg_name = dest; msg.msg_namelen = dest_len; } msg.msg_iov = iov.data(); msg.msg_iovlen = count; msg.msg_control = cmsg_buf.buf; msg.msg_controllen = sizeof(cmsg_buf.buf); struct cmsghdr* cm = CMSG_FIRSTHDR(&msg); cm->cmsg_level = SOL_UDP; cm->cmsg_type = UDP_SEGMENT; cm->cmsg_len = CMSG_LEN(sizeof(uint16_t)); *reinterpret_cast<uint16_t*>(CMSG_DATA(cm)) = static_cast<uint16_t>(seg_size); ssize_t ret = ::sendmsg(_Socket, &msg, MSG_DONTWAIT); if (ret > 0) { perf.gso_sendmsg_calls.fetch_add(1, std::memory_order_relaxed); perf.gso_segments.fetch_add(count, std::memory_order_relaxed); perf.packets_sent.fetch_add(count, std::memory_order_relaxed); perf.bytes_sent.fetch_add(static_cast<uint64_t>(ret), std::memory_order_relaxed); return static_cast<ssize_t>(count); } if (errno == EAGAIN || errno == EWOULDBLOCK) perf.send_eagain.fetch_add(1, std::memory_order_relaxed); // GSO failed — fall through to sendmmsg } perf.gso_fallbacks.fetch_add(1, std::memory_order_relaxed); } // --- sendmmsg path --- #ifdef NETPLUS_NO_MMSG // Fallback: sendto loop for platforms without a trusted sendmmsg. Loading Loading @@ -529,40 +472,103 @@ ssize_t udp::sendBatch( } perf.single_send_calls.fetch_add(static_cast<uint64_t>(sent), std::memory_order_relaxed); perf.packets_sent.fetch_add(static_cast<uint64_t>(sent), std::memory_order_relaxed); return sent; #else // thread_local, reused across calls (same rationale as the GSO iovec // array above) — avoids two fresh heap allocations (iovec + mmsghdr // arrays) on every flushBatch() call. // thread_local, reused across calls -- avoids two fresh heap allocations // (iovec + mmsghdr arrays) on every flushBatch() call. // // One iovec per datagram, pointing straight at the caller-owned buffer. // With GSO, a run of equal-sized datagrams becomes one message with a // UDP_SEGMENT cmsg of its own instead of a whole batch either being // uniform or not: an ACK or a shorter tail in the middle of a batch only // ends the current run, and everything still goes out in one syscall. union CmsgBuf { char buf[CMSG_SPACE(sizeof(uint16_t))]; struct cmsghdr align; }; static thread_local std::vector<struct iovec> iovecs; static thread_local std::vector<struct mmsghdr> msgs; if (iovecs.size() < count) iovecs.resize(count); if (msgs.size() < count) msgs.resize(count); std::memset(msgs.data(), 0, sizeof(struct mmsghdr) * count); static thread_local std::vector<CmsgBuf> cmsgs; static thread_local std::vector<uint16_t> msg_counts; if (iovecs.size() < count) { iovecs.resize(count); msgs.resize(count); cmsgs.resize(count); msg_counts.resize(count); } for (size_t i = 0; i < count; ++i) { iovecs[i].iov_base = const_cast<uint8_t*>(datagrams[i].first); iovecs[i].iov_len = datagrams[i].second; msgs[i].msg_hdr.msg_iov = &iovecs[i]; msgs[i].msg_hdr.msg_iovlen = 1; } size_t nmsgs = 0; for (size_t i = 0; i < count; ) { const size_t seg_size = datagrams[i].second; size_t run = 1; if (_gso_enabled && seg_size > 0 && seg_size <= kGsoMaxPayload) { const size_t max_run = std::min(kGsoMaxSegments, kGsoMaxPayload / seg_size); while (i + run < count && run < max_run && datagrams[i + run].second == seg_size) ++run; // Only the last segment may be shorter, and it has to be nonempty. if (i + run < count && run < max_run && run > 0 && datagrams[i + run].second > 0 && datagrams[i + run].second < seg_size) ++run; } struct mmsghdr& m = msgs[nmsgs]; std::memset(&m, 0, sizeof(m)); m.msg_hdr.msg_iov = &iovecs[i]; m.msg_hdr.msg_iovlen = run; if (dest) { msgs[i].msg_hdr.msg_name = dest; msgs[i].msg_hdr.msg_namelen = dest_len; m.msg_hdr.msg_name = dest; m.msg_hdr.msg_namelen = dest_len; } if (run > 1) { m.msg_hdr.msg_control = cmsgs[nmsgs].buf; m.msg_hdr.msg_controllen = sizeof(cmsgs[nmsgs].buf); struct cmsghdr* cm = CMSG_FIRSTHDR(&m.msg_hdr); cm->cmsg_level = SOL_UDP; cm->cmsg_type = UDP_SEGMENT; cm->cmsg_len = CMSG_LEN(sizeof(uint16_t)); uint16_t seg = static_cast<uint16_t>(seg_size); std::memcpy(CMSG_DATA(cm), &seg, sizeof(seg)); } msg_counts[nmsgs] = static_cast<uint16_t>(run); ++nmsgs; i += run; } int sent = ::sendmmsg(_Socket, msgs.data(), static_cast<unsigned int>(count), MSG_DONTWAIT); int sent_msgs = ::sendmmsg(_Socket, msgs.data(), static_cast<unsigned int>(nmsgs), MSG_DONTWAIT); perf.sendmmsg_calls.fetch_add(1, std::memory_order_relaxed); if (sent < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) { if (sent_msgs <= 0) { if (errno == EAGAIN || errno == EWOULDBLOCK) { perf.send_eagain.fetch_add(1, std::memory_order_relaxed); } else if (sent > 0) { perf.packets_sent.fetch_add(static_cast<uint64_t>(sent), std::memory_order_relaxed); } else if (nmsgs < count && (errno == EIO || errno == EINVAL)) { // The route can't do it: EIO without checksum offload, EINVAL when // a segment doesn't fit the MTU. The probe can't see either. perf.gso_fallbacks.fetch_add(1, std::memory_order_relaxed); _gso_enabled = false; return sendBatch(datagrams, dest, dest_len); } return sent_msgs; } size_t sent = 0; uint64_t bytes = 0; for (int i = 0; i < sent; ++i) bytes += datagrams[static_cast<size_t>(i)].second; perf.bytes_sent.fetch_add(bytes, std::memory_order_relaxed); for (int m = 0; m < sent_msgs; ++m) { const size_t run = msg_counts[static_cast<size_t>(m)]; bytes += msgs[static_cast<size_t>(m)].msg_len; sent += run; if (run > 1) { perf.gso_sendmsg_calls.fetch_add(1, std::memory_order_relaxed); perf.gso_segments.fetch_add(run, std::memory_order_relaxed); } } perf.packets_sent.fetch_add(sent, std::memory_order_relaxed); perf.bytes_sent.fetch_add(bytes, std::memory_order_relaxed); return static_cast<ssize_t>(sent); #endif return sent; } // ============================================================================ Loading
src/quic.cpp +89 −44 Changes for src/quic.cpp: 89 added lines, 44 removed lines. Original line number Diff line number Diff line Loading @@ -949,6 +949,12 @@ void quic::onPacketAckedCC(size_t packet_size, uint64_t pn) { return; } // RFC 9002 7.8: no growth while something else limits the sender (flow // control, the application, the CPU). Otherwise slow start keeps doubling // a window nothing has tested, and the first time the sender can fill it // that is one burst far beyond what the path takes. if (_bytes_in_flight * 2 < _cwnd) return; // If this packet was sent during recovery, don't grow cwnd if (_in_recovery && pn <= _recovery_start_pn) { return; Loading Loading @@ -4920,9 +4926,11 @@ size_t quic::recvStreamData(uint64_t stream_id, uint8_t* data, size_t max_len) { // Flow control: after consuming data, check if we should send // MAX_STREAM_DATA to open the receive window for the peer. // Trigger when >50% of the advertised window has been consumed. uint64_t consumed_from_committed = stream.recv_offset; if (consumed_from_committed > stream.max_data_local_committed / 2) { // Trigger when less than half a window of credit is left. Both sides of // this are absolute offsets, so comparing against committed / 2 instead // is only right for the first window: after that committed = offset + // window, the test becomes offset > window, and it fires on every packet. if (stream.recv_offset + stream.max_data_local / 2 > stream.max_data_local_committed) { uint64_t new_limit = stream.recv_offset + stream.max_data_local; if (new_limit > stream.max_data_local_committed) { stream.max_data_local_committed = new_limit; Loading @@ -4931,7 +4939,7 @@ size_t quic::recvStreamData(uint64_t stream_id, uint8_t* data, size_t max_len) { } // Connection-level flow control: update MAX_DATA after consumption if (_data_recv > _max_data_local_committed / 2) { if (_data_recv + _max_data_local / 2 > _max_data_local_committed) { uint64_t new_limit = _data_recv + _max_data_local; if (new_limit > _max_data_local_committed) { _max_data_local_committed = new_limit; Loading Loading @@ -5894,7 +5902,8 @@ void quic::processStreamFrame(const uint8_t* data, size_t len, size_t& offset, u // Update connection-level receive counter (only new unique bytes) _data_recv += new_unique_bytes; // Flow control: defer MAX_STREAM_DATA when >50% of window consumed. // Flow control: defer MAX_STREAM_DATA once less than half a window of // credit is left (see recvStreamData() on why not committed / 2). // recv_base_offset + recv_buffer.size() is the highest absolute offset // the buffer currently extends to — equivalent to the old plain // recv_buffer.size() when recv_base_offset is 0 (always true outside Loading @@ -5904,14 +5913,14 @@ void quic::processStreamFrame(const uint8_t* data, size_t len, size_t& offset, u // instead -- granting it on arrival is exactly what lets a fast peer outrun a slow reader. const bool pull_stream = _is_server && _stream_pull_dispatch && (stream_id % 4 == 0); uint64_t consumed = stream.recv_base_offset + stream.recv_buffer.size(); if (!pull_stream && consumed > stream.max_data_local_committed / 2) { if (!pull_stream && consumed + stream.max_data_local / 2 > stream.max_data_local_committed) { uint64_t new_limit = consumed + stream.max_data_local; stream.max_data_local_committed = new_limit; _pending_max_stream_data[stream_id] = new_limit; } // Flow control: defer MAX_DATA when >50% of connection window consumed if (_data_recv > _max_data_local_committed / 2) { // Flow control: same for MAX_DATA and the connection window if (_data_recv + _max_data_local / 2 > _max_data_local_committed) { uint64_t new_limit = _data_recv + _max_data_local; _max_data_local_committed = new_limit; _pending_max_data_value = new_limit; Loading Loading @@ -8093,16 +8102,41 @@ size_t quic::sendStreamData(uint64_t stream_id, const uint8_t* data, size_t len, stream.stream_id = stream_id; // Check flow control size_t send_len = len; if (_data_sent + send_len > _max_data_remote) { send_len = (_max_data_remote > _data_sent) ? auto clampToCredit = [&]() { size_t n = len; if (_data_sent + n > _max_data_remote) { n = (_max_data_remote > _data_sent) ? (_max_data_remote - _data_sent) : 0; } if (stream.send_offset + send_len > stream.max_data_remote) { send_len = (stream.max_data_remote > stream.send_offset) ? if (stream.send_offset + n > stream.max_data_remote) { n = (stream.max_data_remote > stream.send_offset) ? (stream.max_data_remote - stream.send_offset) : 0; } return n; }; size_t send_len = clampToCredit(); // Out of credit: the MAX_DATA/MAX_STREAM_DATA that ends this may already // be in the socket, and a caller retrying on 0 has nothing else to read // it. Without this, the only pumps were the ones on the cwnd path, so a // sender that runs out of credit before cwnd stalled forever. if (send_len == 0 && len > 0) { pumpIncomingLocked(); send_len = clampToCredit(); } // Still none: MAX_DATA/MAX_STREAM_DATA aren't retransmitted, so if ours // got lost only this gets the peer to resend it (0x14/0x15 in // processFrame()). Paced, a caller may be retrying this in a tight loop. if (send_len == 0 && len > 0) { auto now = std::chrono::steady_clock::now(); if (now - _last_blocked_sent >= std::chrono::duration<double>(std::max(_rto, 0.01))) { _last_blocked_sent = now; if (_data_sent >= _max_data_remote) sendDataBlocked(_max_data_remote); if (stream.send_offset >= stream.max_data_remote) sendStreamDataBlocked(stream_id, stream.max_data_remote); } } // A STREAM FIN fixes the final stream size permanently. Completion of a // flow-control-clamped prefix is therefore not completion of the caller's Loading Loading @@ -8162,6 +8196,31 @@ size_t quic::sendStreamData(uint64_t stream_id, const uint8_t* data, size_t len, pt_buf_base = pt_stack; } // Adaptive pump: while there's ample cwnd headroom, several GSO/sendmmsg // batches can go out before ACK/flow-control processing needs to run -- // the cwnd-blocked wait path below already forces a pump exactly when // headroom actually runs out, so skipping it here only removes redundant // recvmmsg syscalls + routing work in the common, not-blocked case, not // correctness. Still bounded by PUMP_ADAPTIVE_MAX_BATCHES so a connection // with a very large cwnd (BBR-lite ramped up, or a high-BDP path) doesn't // go arbitrarily long without processing ACKs/RTT samples/flow-control // updates. Returns false once the connection is going away. auto pumpAfterFlush = [&]() { ++_flushes_since_pump; bool cwnd_getting_tight = _handshake_complete && _cwnd > 0 && (_bytes_in_flight * 2 >= _cwnd); if (!cwnd_getting_tight && _flushes_since_pump < PUMP_ADAPTIVE_MAX_BATCHES) { quicPerf().pumps_skipped.fetch_add(1, std::memory_order_relaxed); return true; } // quic_mtx() is already held throughout this function (F31). pumpIncomingLocked(); _flushes_since_pump = 0; quicPerf().pumps_done.fetch_add(1, std::memory_order_relaxed); return _conn_state.load() != ConnectionState::Closed && _conn_state.load() != ConnectionState::Draining; }; size_t sent_total = 0; size_t batch_count = 0; // packets accumulated since last flush // Match BATCH_MAX so _send_batch's own auto-flush (batchPacket(), quic.cpp:300) Loading @@ -8178,6 +8237,12 @@ size_t quic::sendStreamData(uint64_t stream_id, const uint8_t* data, size_t len, // Congestion control: use estimated actual packet size, not max_datagram size_t est_pkt_size = header_overhead + frame_hdr_max + chunk; // Once stalled, wait for room for a real burst rather than one // packet. Resuming on the first ACK sends one packet per ACK, each // getting an ACK of its own: a stable state with no batching left // on either side, GSO and GRO doing nothing and a syscall per packet. size_t resume_bytes = std::max<size_t>(est_pkt_size, std::min<size_t>(est_pkt_size * CWND_RESUME_PACKETS, _cwnd / 4)); if (_handshake_complete && !cwndAllowsSend(est_pkt_size)) { quicPerf().congestion_stalls.fetch_add(1, std::memory_order_relaxed); // Flush any batched packets first so the peer can ACK them Loading Loading @@ -8205,7 +8270,7 @@ size_t quic::sendStreamData(uint64_t stream_id, const uint8_t* data, size_t len, pumpIncomingLocked(); _flushes_since_pump = 0; ++spin_count; if (cwndAllowsSend(est_pkt_size)) { unblocked = true; break; } if (cwndAllowsSend(resume_bytes)) { unblocked = true; break; } } quicPerf().cc_spin_iterations.fetch_add(static_cast<uint64_t>(spin_count), std::memory_order_relaxed); // If still blocked, wait with a real timeout instead of Loading Loading @@ -8263,11 +8328,11 @@ size_t quic::sendStreamData(uint64_t stream_id, const uint8_t* data, size_t len, pumpIncomingLocked(); _flushes_since_pump = 0; ++wait_count; if (cwndAllowsSend(est_pkt_size)) { unblocked = true; break; } if (cwndAllowsSend(resume_bytes)) { unblocked = true; break; } } quicPerf().cc_wait_iterations.fetch_add(static_cast<uint64_t>(wait_count), std::memory_order_relaxed); } if (!unblocked) break; if (!unblocked && !cwndAllowsSend(est_pkt_size)) break; } // === Build short header directly into pkt_buf === Loading Loading @@ -8437,38 +8502,18 @@ size_t quic::sendStreamData(uint64_t stream_id, const uint8_t* data, size_t len, if (batch_count >= BATCH_FLUSH) { flushBatch(); batch_count = 0; ++_flushes_since_pump; // Adaptive pump: while there's ample cwnd headroom, several // GSO/sendmmsg batches can go out before ACK/flow-control // processing needs to run — the cwnd-blocked wait path above // already forces a pump exactly when headroom actually runs // out, so skipping it here only removes redundant recvmmsg // syscalls + routing work in the common, not-blocked case, not // correctness. Still bounded by PUMP_ADAPTIVE_MAX_BATCHES so a // connection with a very large cwnd (BBR-lite ramped up, or a // high-BDP path) doesn't go arbitrarily long without processing // ACKs/RTT samples/flow-control updates. bool cwnd_getting_tight = _handshake_complete && _cwnd > 0 && (_bytes_in_flight * 2 >= _cwnd); if (cwnd_getting_tight || _flushes_since_pump >= PUMP_ADAPTIVE_MAX_BATCHES) { // quic_mtx() is already held throughout this function (F31). pumpIncomingLocked(); _flushes_since_pump = 0; quicPerf().pumps_done.fetch_add(1, std::memory_order_relaxed); if (_conn_state.load() == ConnectionState::Closed || _conn_state.load() == ConnectionState::Draining) { break; } } else { quicPerf().pumps_skipped.fetch_add(1, std::memory_order_relaxed); } if (!pumpAfterFlush()) break; } } // Flush remaining batched packets // Flush remaining batched packets. Counts towards the pump as well: an // application writing less than BATCH_FLUSH packets per call never gets // to the flush in the loop above, and without a pump here it reads // nothing at all until cwnd or flow control stalls it. By then ACKs and // MAX_DATA have piled up in the socket buffer and start getting dropped. if (batch_count > 0) { flushBatch(); pumpAfterFlush(); } // Only mark FIN if we actually sent all requested data (FIN is on the last chunk). Loading
src/socket.h +10 −1 Changes for src/socket.h: 10 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -1809,6 +1809,8 @@ namespace netplus { void sendDataBlocked(uint64_t limit); /// Send a STREAM_DATA_BLOCKED frame void sendStreamDataBlocked(uint64_t stream_id, uint64_t limit); /// Paces the two above when a caller keeps retrying out of credit. std::chrono::steady_clock::time_point _last_blocked_sent{}; /// Send an ACK frame for 1-RTT packets void sendAppAck(); Loading Loading @@ -2262,7 +2264,11 @@ namespace netplus { // this file was added for. static constexpr double HYSTART_MIN_RTT_THRESH = 0.004; // 4ms static constexpr double HYSTART_MAX_RTT_THRESH = 0.016; // 16ms static constexpr int HYSTART_N_RTT_SAMPLE = 8; // RFC 9406 says 8, which assumes an ACK every other packet. A sample // here is one ACK, and with GSO/GRO that covers a whole receive // batch: a round rarely has 8 of them, and a round with too few is // never evaluated, so slow start would simply never exit. static constexpr int HYSTART_N_RTT_SAMPLE = 3; static constexpr int HYSTART_CSS_ROUNDS = 5; static constexpr uint64_t HYSTART_CSS_GROWTH_DIVISOR = 4; Loading Loading @@ -2369,6 +2375,9 @@ namespace netplus { // connection whose cwnd happens to be very large. size_t _flushes_since_pump = 0; static constexpr size_t PUMP_ADAPTIVE_MAX_BATCHES = 4; // How much cwnd room, in packets, sendStreamData() waits for after // stalling. Capped at cwnd / 4 there so small windows keep moving. static constexpr size_t CWND_RESUME_PACKETS = 16; // Scratch pointer/length pairs built from _send_batch/_send_arena on // every flushBatch() call, reused instead of a fresh // std::vector<std::pair<...>> per call — resize() never shrinks Loading
test/quic_parallel_decrypt_test.cpp +19 −6 File changed.Preview size limit exceeded, changes collapsed. Show changes