Skip to content

Conversation

@zainkabani
Copy link
Contributor

@zainkabani zainkabani commented Oct 14, 2022

This PR leverages client and server buffers to read messages onto and flush messages from.

This reduces the number of allocations performed by pgcat as seen by these stats after pgbench run on the main branch compared to this branch.

pgbench with new client per query results in a ~37% reduction in allocations
pgbench -t 1000 -c 16 -j 2 --protocol extended -C

Before:

Stats: Stats {
    allocations: 5617437,
    deallocations: 5614634,
    reallocations: 130699,
    bytes_allocated: 673340233,
    bytes_deallocated: 672221777,
    bytes_reallocated: 3760681,
}

After:

Stats: Stats {
    allocations: 3505408,
    deallocations: 3502605,
    reallocations: 130702,
    bytes_allocated: 628430295,
    bytes_deallocated: 627311847,
    bytes_reallocated: 3760728,
}

pgbench with same client for pgbench session results in a ~53% reduction in allocations:
pgbench -t 1000 -c 16 -j 2 --protocol extended

Before:

Stats: Stats {
    allocations: 3874108,
    deallocations: 3871232,
    reallocations: 2880,
    bytes_allocated: 158461974,
    bytes_deallocated: 157277508,
    bytes_reallocated: 797417,
}

After:

Stats: Stats {
    allocations: 1792054,
    deallocations: 1789265,
    reallocations: 2804,
    bytes_allocated: 113265426,
    bytes_deallocated: 112172240,
    bytes_reallocated: 780508,
}

res.put_u8(b'I');

write_all_half(stream, res).await
write_all_half(stream, &res).await
Copy link
Contributor

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 changes anything. res is deallocated at the end of this block anyway, so borrowing it doesn't do anything, you might as well pass ownership to write_all_half(). Passing ownership != copying.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

if the function doesn't need the explicit object then it's better to have it accept the reference so it can stay more flexible

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Another reason we're using reference here is because we don't want to clone or pass ownership of the buffer to the send operation so we pass a reference instead.

@zainkabani zainkabani marked this pull request as ready for review October 14, 2022 18:43
src/server.rs Outdated
/// in order to receive all data the server has to offer.
pub async fn recv(&mut self) -> Result<BytesMut, Error> {
// Our server response buffer. We buffer data before we give it to the client.
let mut message_buffer = BytesMut::with_capacity(8196);
Copy link
Contributor

Choose a reason for hiding this comment

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

This will allocate a new buffer every time we send a query, this will be much slower than it is now.

Copy link
Contributor Author

@zainkabani zainkabani Oct 14, 2022

Choose a reason for hiding this comment

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

which is more expensive, cloning or allocating?
cloning does both so if anything is is equivalent

Copy link
Contributor

Choose a reason for hiding this comment

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

Why not just pass self.buffer[..] here instead avoiding both problems? I just know that in network programming, the buffer is never reallocated and is re-used instead, so we should make sure to do that as well.

To answer your question, I am not sure. My gut feeling makes me thing allocating because it'll be allocated on the heap, but maybe cloning does the same thing in this case.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

the problem here is we're writing into the server's buffer not the client's send buffer.
also self.buffer[..] returns a different type. The problem is that the buffer isn't shared between the client and server so we can't really populate and flush. Also rust's ownership model makes it difficult too. I can imagine a re-architecture that solves this better but for now this change maintains parity if anything

src/client.rs Outdated
// Internal buffer, where we place messages until we have to flush
// them to the backend.
let mut message_buffer = BytesMut::with_capacity(8196);
let mut client_message_buffer = BytesMut::with_capacity(8196);
Copy link
Contributor

Choose a reason for hiding this comment

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

Both client and server have their own buffers already, both self.buffer. I unfortunately don't understand what you're trying to accomplish here.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This idea came from your earlier comment about how in networking we have a buffer that we usually don't allocate and deallocate frequently. When writing to the client the server needs to populate the buffer that the client uses to send messages, which is what the server_message_buffer is. We were previously using the server's own buffer and then cloning it, which is an allocation and data copy.

Copy link
Contributor

Choose a reason for hiding this comment

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

Yeah...this ends up being the same thing I think. Either we write to server.buffer or this new buffer, we have to move it out of the server buffer and clear it, and since we're clearing it, we need to clone whatever was in it in the first place.

I think to achieve what you want, we need to use slices in a very clever way. Basically when a new message comes in, we make read_message read that data directly into the clients buffer at the offset where we know there is no data and to ensure there is no empty bytes in between messages. We then pass the clients buffer around as a reference and whoever needs to parse the query / message inside it can do so without modifying the buffer. Finally we pass it to the server send function (borrow again) and write its contents directly to the server socket. Finally, we clear the buffer. No buffer copies, just one single copy when reading the clients message and one copy when writing it to the Postgres backend.

This approaches C-style programming honestly, and it's not straight forward. I think maybe the bytes crate has some handy methods for us to use? Worth a look, maybe.

src/client.rs Outdated
/// Internal buffer, where we place messages until we have to flush
/// them to the backend.
buffer: BytesMut,
client_message_buffer: BytesMut,
Copy link
Contributor

Choose a reason for hiding this comment

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

we should keep the name as buffer as its inside a Client struct

src/client.rs Outdated
// to when we get the S message
'P' | 'B' | 'D' | 'E' => {
self.buffer.put(&message[..]);
// client_message_buffer.put(&message[..]);
Copy link
Contributor

Choose a reason for hiding this comment

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

can we remove the commented code?

src/messages.rs Outdated
bytes.put_slice(&buf);
buffer.put_u8(code);
buffer.put_i32(len);
buffer.put_slice(&buf);
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
buffer.put_slice(&buf);
buffer.put_slice(&buffer);

i think this would allow us to get rid of the let mut buf allocation, cc @levkk correct me.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Removing the creation of this buffer to read the message body was a huge lift! Great idea :D

src/client.rs Outdated
// Admin clients ignore shutdown.
else {
read_message(&mut self.read).await?
read_message(&mut self.read, &mut self.client_message_buffer).await?
Copy link
Contributor

Choose a reason for hiding this comment

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

I think we should rename read_message to read_message_into_buffer now. the old implementation returned a buffer, and now it writes values into the buffer. i don't have a strong opinion on this, but @levkk what do you think?

Copy link
Contributor

Choose a reason for hiding this comment

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

It would be nice to keep both for different use cases, I agree.

let len = buf.get_i32() as usize;
let query = String::from_utf8_lossy(&buf[..len - 5]).to_string(); // Ignore the terminating NULL.
let _len = message_cursor.get_i32() as usize;
let query = message_cursor.read_string().unwrap();
Copy link
Contributor

Choose a reason for hiding this comment

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

can we keep query as a &str? do we need to convert it to an owned String

Copy link
Contributor

Choose a reason for hiding this comment

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

I don't recall, but in all likelihood, we can keep it as &str.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Can't easily return &str, open to suggestions on how to do that though

src/lib.rs Outdated
}

impl BytesMutReader for Cursor<&BytesMut> {
fn read_string(&mut self) -> Result<String, Error> {
Copy link
Contributor

Choose a reason for hiding this comment

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

What if the query contains a null byte? I think we need to be careful here and read the exact amount Postrgres is telling us is in the message.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah that's a fair call out, however this function should only be used to read strings explictly from things like the query packet, or parameters and the startupparameters packet. Postgres specifically doesn't allow null bytes because they're used as special characters in the protocol.

some info about that here, https://stackoverflow.com/questions/28813409/are-null-bytes-allowed-in-unicode-strings-in-postgresql-via-python#:~:text=Actually%2C%20this%20is%20only%20mentioned,types%20cannot%20store%20such%20bytes.


match stream
.read_exact(
&mut buffer[starting_point + mem::size_of::<u8>() + mem::size_of::<i32>()
Copy link
Contributor

Choose a reason for hiding this comment

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

You're sometimes hardcoding the length of the integer (4 bytes) and sometimes using these functions. Might be best to stick to one or the other.

// Query
'Q' => {
let query = String::from_utf8_lossy(&buf[..len - 5]).to_string();
let query = message_cursor.read_string().unwrap();
Copy link
Contributor

Choose a reason for hiding this comment

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

Here we should really read the exact amount we know is in the message instead of reading until a NULL byte.

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.

3 participants