Skip to content

Bound the bytes returned by a single TlsStream::poll_read - #209

Draft
rdettai-sk wants to merge 2 commits into
rustls:mainfrom
rdettai-sk:bound-poll-read
Draft

rdettai-sk wants to merge 2 commits into
rustls:mainfrom
rdettai-sk:bound-poll-read

Conversation

@rdettai-sk

@rdettai-sk rdettai-sk commented Oct 7, 2026 •

Copy link
Copy Markdown

Follow-up to #198, as discussed there.

#198 made TlsStream::poll_read keep calling poll_fill_buf until the caller's ReadBuf is full. That saves a lot of wake-ups. But each extra record costs a decryption, and the batch is only limited by how big the caller's buffer is. Hyper grows its read buffer up to ~400 KiB, and other users of tokio-rustls may pass even larger buffers. When many streams share a task, the longer polls add up.

This PR caps the batch: once a single poll_read has copied at least MAX_READ_PER_POLL (128 KiB) bytes, it stops pulling records and returns Ready with what it has. The rest stays buffered in rustls for the next call.

  • The first chunk is always returned as before (records are ≤16 KiB, well below the cap).
  • Records are not split to hit the cap exactly, so a call can overshoot by less than one record (< 144 KiB in total). Splitting would save no work: the last record is already decrypted by the time it is copied.
  • Client and server get the same change. The constant lives in common.

Choosing the value. 128 KiB is 8 full records. That keeps most of the gain from #198 (8x fewer polls than one record per poll), and it bounds per-poll work to about a third of hyper's largest buffer. I'm happy to change the value or make it configurable if you prefer.

🤖 Generated with Claude Code

rustls#198 made poll_read drain as many records as fit in the caller's buffer.
With a large buffer that turns one poll into an unbounded amount of
decryption work. Stop batching once 128 KiB have been copied.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@djc djc left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could/should we bound this by time instead of space?

Comment thread src/client.rs Outdated
Poll::Pending => break,
};
let len = Ord::min(data.len(), buf.remaining());
let len = Ord::min(data.len(), buf.remaining()).min(MAX_READ_PER_POLL - read);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: please use Ord::min() instead of the method variant.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this clamping is even worth it 😄

Comment thread src/server.rs Outdated
Poll::Pending => break,
};
let len = Ord::min(data.len(), buf.remaining());
let len = Ord::min(data.len(), buf.remaining()).min(MAX_READ_PER_POLL - read);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And here.

Clamping the last chunk saves no decryption: the record is already
decrypted by the time it is copied. Overshooting by less than one record
keeps the loop identical to the one from rustls#198 apart from its condition.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@rdettai-sk

Copy link
Copy Markdown
Author

Could/should we bound this by time instead of space?

You're call. This feels simple and easy to test, keeps the batching consistently while providing a safeguard.

@djc

djc commented Oct 7, 2026

Copy link
Copy Markdown
Member

Were you going to benchmark this in production? Could you benchmark both?

@rdettai-sk

Copy link
Copy Markdown
Author

I can benchmark this in a large scale setup!

@djc

djc commented Oct 7, 2026

Copy link
Copy Markdown
Member

Would be nice!

rdettai-sk added a commit to SekoiaLab/quickwit that referenced this pull request Oct 8, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@rdettai-sk

Copy link
Copy Markdown
Author

I didn't get a chance to run the bench on the time bound version (#210) yet. For this PR:

  • I ran a canary across 10 out of 25 nodes running a baseline of 1GB/s downloads with some spikes (queries) going up to 3GB/s. Baseline runs rustls 0.26.4, the canary runs this PR.
  • All nodes are running a 16 thread tokio runtime. 2.2 of these are used for the background S3 download, it sometimes spikes to 7 or even 8.
  • There are 7x fewer polls on the canary
  • I saw no significant CPU improvement on the canary. There seems to be a ~5% average improvement but its drown in noise.
  • The number of slow polls above 16ms is 1.35x higher on the canary, this this is much better than what I saw when running 0.26.6
  • The number of slow polls above 1ms is 3x higher on canary, also better than 0.26.6 but still not great.

From these results, I would rather run 0.26.4, as the slight hypothetical CPU gain is not really worth the stability loss on the Tokio runtime.

Next steps: if I find the time I would try yielding after 64KB and the time bound yield.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants