perf(pg-protocol): decode short ascii values without buffer.toString() - #3773
nigrosimone wants to merge 1 commit into
Conversation
|
oh hay! interested why you closed this? It actually seems like a pretty clever & nice performance improvement - particularly on things that are pretty common (short column values) in production workflows |
|
My benchmark was producing noisy results; I'm retrying with the CI one |
Benchmark:
|
| Scenario | Base q/s | Head q/s | Head / base | Rounds | Noise |
|---|---|---|---|---|---|
| select 1 row, all types, prepared | 7733 | 7709 | 1.000x (-0.0%) | 0.96 to 1.13 | ±7.0% |
| select 100 rows, all types, prepared 🏆 | 1141 | 1279 | 1.121x (+12.1%) | 1.09 to 1.13 | ±2.6% |
| select 500 rows, all types, prepared 🏆 | 276 | 313 | 1.136x (+13.6%) | 1.13 to 1.15 | ±2.3% |
| select 500 rows, simple types, prepared 🏆 | 1602 | 2131 | 1.335x (+33.5%) | 1.31 to 1.36 | ±6.5% |
| select 500 rows, simple types, array mode 🏆 | 1489 | 1980 | 1.329x (+32.9%) | 1.31 to 1.33 | ±4.1% |
| select 500 rows, simple types, binary 🏆 | 832 | 887 | 1.069x (+6.9%) | 1.06 to 1.08 | ±2.4% |
| select by id, parameterized | 5523 | 5310 | 0.975x (-2.5%) | 0.95 to 0.99 | ±6.7% |
| insert, all types, returning | 4162 | 4237 | 1.019x (+1.9%) | 0.98 to 1.05 | ±4.8% |
| update, parameterized | 6107 | 5922 | 0.971x (-2.9%) | 0.93 to 1.01 | ±4.6% |
| delete, parameterized | 7174 | 7237 | 1.001x (+0.1%) | 0.95 to 1.04 | ±6.9% |
| transaction, 3 queries | 11972 | 12085 | 0.976x (-2.4%) | 0.96 to 1.07 | ±7.0% |
| pool query, select 1 row | 6812 | 6902 | 1.024x (+2.4%) | 0.94 to 1.09 | ±8.5% |
| cursor, 500 rows in 5 reads 🏆 | 212 | 232 | 1.097x (+9.7%) | 1.08 to 1.13 | ±2.2% |
| query stream, 500 rows 🏆 | 213 | 236 | 1.105x (+10.5%) | 1.10 to 1.12 | ±2.4% |
| select 100 rows, all types, pipelined x10 🏆 | 1210 | 1341 | 1.107x (+10.7%) | 1.10 to 1.12 | ±4.9% |
4 rounds of 3s per scenario, each round measuring base, head and a second process of each one after the other, in an order that changes every round. "Head / base" is the median of the per-round ratios and "Rounds" their range. "Noise" is how far base/base and head/head, the same code on both sides, got from 1 in this same run: that is what the machine did, so a row is marked only when the median moved further than that, at least 2%, and every round moved the same way: 👀 slower, 🏆 faster. Only the ratio is comparable across runs, the absolute q/s depend on the runner.
Node v26.9.0, AMD EPYC 7763 64-Core Processor, 4 cores.
|
@brianc It actually seems like a good result! |
|
Results from the CI benchmark, rebased on master. Short values are common so the row heavy scenarios gain the most, the single row ones stay inside the noise. Benchmark:
|
| Scenario | Base q/s | Head q/s | Head / base | Rounds | Noise |
|---|---|---|---|---|---|
| select 1 row, all types, prepared | 7733 | 7709 | 1.000x (-0.0%) | 0.96 to 1.13 | ±7.0% |
| select 100 rows, all types, prepared 🏆 | 1141 | 1279 | 1.121x (+12.1%) | 1.09 to 1.13 | ±2.6% |
| select 500 rows, all types, prepared 🏆 | 276 | 313 | 1.136x (+13.6%) | 1.13 to 1.15 | ±2.3% |
| select 500 rows, simple types, prepared 🏆 | 1602 | 2131 | 1.335x (+33.5%) | 1.31 to 1.36 | ±6.5% |
| select 500 rows, simple types, array mode 🏆 | 1489 | 1980 | 1.329x (+32.9%) | 1.31 to 1.33 | ±4.1% |
| select 500 rows, simple types, binary 🏆 | 832 | 887 | 1.069x (+6.9%) | 1.06 to 1.08 | ±2.4% |
| select by id, parameterized | 5523 | 5310 | 0.975x (-2.5%) | 0.95 to 0.99 | ±6.7% |
| insert, all types, returning | 4162 | 4237 | 1.019x (+1.9%) | 0.98 to 1.05 | ±4.8% |
| update, parameterized | 6107 | 5922 | 0.971x (-2.9%) | 0.93 to 1.01 | ±4.6% |
| delete, parameterized | 7174 | 7237 | 1.001x (+0.1%) | 0.95 to 1.04 | ±6.9% |
| transaction, 3 queries | 11972 | 12085 | 0.976x (-2.4%) | 0.96 to 1.07 | ±7.0% |
| pool query, select 1 row | 6812 | 6902 | 1.024x (+2.4%) | 0.94 to 1.09 | ±8.5% |
| cursor, 500 rows in 5 reads 🏆 | 212 | 232 | 1.097x (+9.7%) | 1.08 to 1.13 | ±2.2% |
| query stream, 500 rows 🏆 | 213 | 236 | 1.105x (+10.5%) | 1.10 to 1.12 | ±2.4% |
| select 100 rows, all types, pipelined x10 🏆 | 1210 | 1341 | 1.107x (+10.7%) | 1.10 to 1.12 | ±4.9% |
4 rounds of 3s per scenario, each round measuring base, head and a second process of each one after the other, in an order that changes every round. "Head / base" is the median of the per-round ratios and "Rounds" their range. "Noise" is how far base/base and head/head, the same code on both sides, got from 1 in this same run: that is what the machine did, so a row is marked only when the median moved further than that, at least 2%, and every round moved the same way: 👀 slower, 🏆 faster. Only the ratio is comparable across runs, the absolute q/s depend on the runner.
Node v26.9.0, AMD EPYC 7763 64-Core Processor, 4 cores.
Every field of every row goes through
BufferReader.string(), which callsbuffer.toString('utf-8'). That call has a fixed cost in the C++ boundary that is bigger than the decoding itself when the value is short, and most values in a result set are short: ids, flags, small numbers, dates.So for values up to 16 bytes that are all ASCII the string is now built with
String.fromCharCode. Anything longer, or with a byte above 127, still goes tobuffer.toString().Parser only, 500 rows per message, median of 5 runs on node 26 (ms, lower is better):
End to end on localhost,
SELECTof 5000 rows, 5 short columns: 9.1ms -> 6.7ms per query.The test in
inbound-parser.test.tsparses a data row with a value of every length from 0 to 19 plus accented, chinese and emoji values, and fails if the ascii check is removed.