|
| 1 | +# SQLite3 Benchmark |
| 2 | + |
| 3 | +A comprehensive benchmark suite comparing the performance and event loop utilization of different SQLite drivers for Node.js. |
| 4 | + |
| 5 | +Thanks to [better-sqlite3/benchmark](https://github.com/WiseLibs/better-sqlite3/tree/master/benchmark) for the initial work! |
| 6 | + |
| 7 | +This benchmark is using small tables with few columns and little data, therefore low I/O, so it's not reasonable to expect an asynchronous driver to perform in anyway better here. |
| 8 | +But it is strange, though, that a brief review also highlighted some other “tricks” designed to make the async driver look worse. |
| 9 | + |
| 10 | +- In general, prepared statements were not used for the async driver, but for all others. |
| 11 | + The performance improvements are significant, e.g 2.4 x for 'select-iterate', 1.5 x for 'insert' |
| 12 | +- The async driver had to open an additional database connection for each isolated transaction, even though this is a limitation of SQLite that affects all drivers equally. |
| 13 | + The performance improvements are significant, e.g 'transaction small' is now about 26x faster |
| 14 | + |
| 15 | +## Why Async Drivers are expected to be slower |
| 16 | + |
| 17 | +1. **Event Loop Integration**: Async drivers must integrate with Node.js's event loop, requiring context switches and queue management. |
| 18 | + |
| 19 | +2. **Thread Pool Usage**: Async SQLite operations are using libuv's thread pool, introducing thread scheduling overhead. |
| 20 | + |
| 21 | +Despite lower raw throughput, async drivers provide **Non-Blocking I/O**, by preventing the event loop from being blocked and provide **Concurrency**, by allowing other operations (network requests, file I/O, timers) to proceed while waiting for database operations to complete. |
| 22 | + |
| 23 | +## Supported Drivers |
| 24 | + |
| 25 | +| Driver | Type | Description | |
| 26 | +|--------|------|-------------| |
| 27 | +| `better-sqlite3` | Synchronous | High-performance synchronous SQLite bindings | |
| 28 | +| `@homeofthings/sqlite3` | Asynchronous | Promise-based SQLite bindings (fork of node-sqlite3) | |
| 29 | +| `node:sqlite` | Synchronous | Built-in Node.js SQLite (Node.js v22.6.0+) | |
| 30 | + |
| 31 | +## Requirements |
| 32 | + |
| 33 | +- **Node.js**: v20.17.0 or later (for N-API compatibility) |
| 34 | +- **For `node:sqlite`**: Node.js v22.6.0+ (experimental) or v22.12.0+ (stable) |
| 35 | + |
| 36 | +## Installation |
| 37 | + |
| 38 | +```bash |
| 39 | +npm install |
| 40 | +``` |
| 41 | + |
| 42 | +## Usage |
| 43 | + |
| 44 | +### Run Default Benchmarks |
| 45 | + |
| 46 | +```bash |
| 47 | +node index.js |
| 48 | +``` |
| 49 | + |
| 50 | +This runs a general-purpose benchmark suite |
| 51 | + |
| 52 | +### Run Specific Benchmarks |
| 53 | + |
| 54 | +```bash |
| 55 | +node index.js <search-terms...> |
| 56 | +``` |
| 57 | + |
| 58 | +Examples: |
| 59 | +```bash |
| 60 | +# Run only select benchmarks |
| 61 | +node index.js select |
| 62 | + |
| 63 | +# Run benchmarks for specific tables |
| 64 | +node index.js small |
| 65 | + |
| 66 | +# Run benchmarks for specific columns |
| 67 | +node index.js integer text |
| 68 | + |
| 69 | +# Combine search terms |
| 70 | +node index.js select small integer |
| 71 | +``` |
| 72 | +
|
| 73 | +### Using Local Development Version |
| 74 | +
|
| 75 | +To benchmark the local development version of `@homeofthings/sqlite3` instead of the npm package: |
| 76 | +
|
| 77 | +```bash |
| 78 | +node index.js --use-local |
| 79 | +``` |
| 80 | +
|
| 81 | +This is useful for testing performance changes before publishing. Requires the native addon to be built: |
| 82 | +
|
| 83 | +```bash |
| 84 | +# From project root |
| 85 | +npm run build |
| 86 | +``` |
| 87 | +
|
| 88 | +The `--use-local` flag can be combined with search terms: |
| 89 | +
|
| 90 | +```bash |
| 91 | +node index.js --use-local insert small |
| 92 | +``` |
| 93 | +
|
| 94 | +## Benchmark Types |
| 95 | +
|
| 96 | +| Type | Description | |
| 97 | +|------|-------------| |
| 98 | +| `select` | Reading single rows by primary key | |
| 99 | +| `select-all` | Reading 100 rows into an array | |
| 100 | +| `select-iterate` | Iterating over 100 rows | |
| 101 | +| `select-aggregate` | Aggregate functions (COUNT, SUM, AVG, MIN, MAX) with WHERE clause | |
| 102 | +| `insert` | Inserting single rows | |
| 103 | +| `update` | Updating single rows | |
| 104 | +| `transaction` | Inserting 100 rows in a single transaction | |
| 105 | +| `update-transaction` | Updating 100 rows in a single transaction | |
| 106 | +
|
| 107 | +## Output Format |
| 108 | +
|
| 109 | +Results are displayed as: |
| 110 | +``` |
| 111 | +driver-name x 471,255 ops/sec ±0.07% (event loop: 50%, 2.1μs/op) |
| 112 | +``` |
| 113 | +
|
| 114 | +- `x` - Separator (from original benchmark format) |
| 115 | +- `ops/sec` - Operations per second (higher is better) |
| 116 | +- `±X.XX%` - Relative margin of error |
| 117 | +- `event loop: X%, Yμs/op` - Utilization percentage and blocking time per operation (lower is better) |
| 118 | +
|
| 119 | +### Example Output |
| 120 | +
|
| 121 | +Running `node index.js select` produces output like: |
| 122 | +
|
| 123 | +``` |
| 124 | +select small (nul) |
| 125 | +better-sqlite3 x 638,075 ops/sec ±0.44% (event loop: 100%, 1.6μs/op) |
| 126 | +@homeofthings/sqlite3 x 88,459 ops/sec ±0.82% (event loop: 47%, 5.3μs/op) |
| 127 | +node:sqlite x 543,445 ops/sec ±0.53% (event loop: 100%, 1.8μs/op) |
| 128 | +``` |
| 129 | +
|
| 130 | +### Event Loop Metrics |
| 131 | +
|
| 132 | +The **event loop** metrics show how the driver affects the event loop (measured using Node.js native `performance.eventLoopUtilization()` API): |
| 133 | +
|
| 134 | +**Utilization Percentage:** How much of the benchmark time the event loop was busy (100% = completely blocked, 0% = completely free) |
| 135 | +
|
| 136 | +**Time per Operation:** `(1,000,000 μs/sec ÷ ops/sec) × utilization = μs blocked per operation` |
| 137 | +
|
| 138 | +| Driver | Utilization | Time per Op | Meaning | |
| 139 | +|-------------------------|-------------|-------------|-------------------------------------------------| |
| 140 | +| `better-sqlite3` | 100% | ~1.6μs/op | Blocks completely - all time is event loop time | |
| 141 | +| `@homeofthings/sqlite3` | ~47% | ~5.3μs/op | 3.3x more blocking than sync drivers | |
| 142 | +| `node:sqlite` | 100% | ~1.8μs/op | Blocks completely - all time is event loop time | |
| 143 | +
|
| 144 | +Such metric shows the real cost: dependend on the operation, async drivers may even block the event loop **longer in total** for the same amount of work, even though they don't block it **completely** for the whole operation, like the sync drivers do. However, async drivers also do not always block the event loop **longer in total**: |
| 145 | +
|
| 146 | +``` |
| 147 | +--- inserting rows individually --- |
| 148 | +better-sqlite3 x 139,898 ops/sec ±21.94% (event loop: 100%, 7.1μs/op) |
| 149 | +@homeofthings/sqlite3 x 47,619 ops/sec ±18.89% (event loop: 22%, 4.6μs/op) |
| 150 | +node:sqlite x 128,465 ops/sec ±22.25% (event loop: 100%, 7.8μs/op) |
| 151 | +``` |
| 152 | +
|
| 153 | +### Large Data Performance |
| 154 | +
|
| 155 | +For I/O-bound operations (large data reads), async drivers can actually **outperform** sync drivers: |
| 156 | +
|
| 157 | +``` |
| 158 | +--- reading large blobs (16MB each) --- |
| 159 | +better-sqlite3 x 83 ops/sec ±7.99% (event loop: 100%, 12.07ms/op) |
| 160 | +@homeofthings/sqlite3 x 94 ops/sec ±8.57% (event loop: 34%, 3.63ms/op) |
| 161 | +node:sqlite x 127 ops/sec ±10.75% (event loop: 100%, 7.88ms/op) |
| 162 | +``` |
| 163 | +
|
| 164 | +**Why async wins for large data:** |
| 165 | +
|
| 166 | +1. **Lower event loop blocking**: 3.63ms vs 12.07ms - async driver blocks 70% less |
| 167 | +2. **Higher throughput**: 94 vs 83 ops/sec - async driver is 13% faster |
| 168 | +3. **Event loop availability**: 66% free during async operations |
| 169 | +
|
| 170 | +For I/O-bound operations, the async driver's overhead becomes negligible compared to disk I/O wait time. The ability to interleave other work becomes an advantage - the event loop can process other tasks while waiting for data. |
| 171 | +
|
| 172 | +### Long Running Query Performance |
| 173 | +
|
| 174 | +With such a small amount of data we are currently using, it's not so easy to simulate longer running queries. That's why I tried it here using simple aggregation. |
| 175 | +
|
| 176 | +Aggregate functions (COUNT, SUM, AVG, MIN, MAX) with WHERE clauses show even more dramatic async advantages: |
| 177 | +
|
| 178 | +``` |
| 179 | +--- aggregate functions (COUNT, SUM, AVG, MIN, MAX) with WHERE clause --- |
| 180 | +better-sqlite3 x 11,246 ops/sec ±0.27% (event loop: 100%, 88.9μs/op) |
| 181 | +@homeofthings/sqlite3 x 68,779 ops/sec ±0.60% (event loop: 47%, 6.8μs/op) |
| 182 | +node:sqlite x 10,982 ops/sec ±0.40% (event loop: 100%, 91.1μs/op) |
| 183 | +``` |
| 184 | +
|
| 185 | +**Why async wins for aggregation:** |
| 186 | +
|
| 187 | +1. **6x higher throughput**: 68,779 vs 11,246 ops/sec |
| 188 | +2. **13x less event loop blocking**: 6.8μs/op vs 88.9μs/op |
| 189 | +3. **Same pattern as large data**: I/O-bound operations benefit from async |
| 190 | +
|
| 191 | +Aggregation queries scan 1000 rows per operation. The async driver's ability to yield during I/O makes it significantly more efficient for these multi-row operations. |
| 192 | +
|
| 193 | +## Project Structure |
| 194 | +
|
| 195 | +``` |
| 196 | +├── index.js # Main orchestrator |
| 197 | +├── benchmark.js # Benchmark runner (tinybench) |
| 198 | +├── drivers.js # SQLite driver configurations |
| 199 | +├── trials.js # Benchmark trial definitions |
| 200 | +├── seed.js # Database seeding |
| 201 | +├── types/ |
| 202 | +│ ├── insert.js # Insert benchmark |
| 203 | +│ ├── select.js # Single row select benchmark |
| 204 | +│ ├── select-all.js # Multi-row select benchmark |
| 205 | +│ ├── select-aggregate.js # Aggregate functions benchmark |
| 206 | +│ ├── select-iterate.js # Iteration benchmark |
| 207 | +│ └── transaction.js # Transaction benchmark |
| 208 | +└── temp/ # Temporary database files (auto-created) |
| 209 | +``` |
| 210 | +
|
| 211 | +## Architecture |
| 212 | +
|
| 213 | +Each benchmark runs in an isolated child process to ensure: |
| 214 | +- Clean state for each measurement |
| 215 | +- Memory isolation between runs |
| 216 | +- No interference between drivers |
| 217 | +
|
| 218 | +## Adding a New Driver |
| 219 | +
|
| 220 | +1. Add the driver to `package.json` dependencies |
| 221 | +2. Add a connection function to `drivers.js`: |
| 222 | +```javascript |
| 223 | +['driver-name', async (filename, pragma) => { |
| 224 | + const db = require('driver-package')(filename); |
| 225 | + // Apply PRAGMA settings |
| 226 | + for (const str of pragma) await db.exec(`PRAGMA ${str}`); |
| 227 | + return db; |
| 228 | +}] |
| 229 | +``` |
| 230 | +3. Add benchmark implementations in each `types/*.js` file: |
| 231 | +```javascript |
| 232 | +// Either return sync or async function |
| 233 | +
|
| 234 | +exports['driver-name'] = (db, ctx) => { |
| 235 | + return () => db.someOperation(); |
| 236 | +}; |
| 237 | +
|
| 238 | +exports['driver-name'] = async (db, ctx) => { |
| 239 | + return () => db.someOperation(); |
| 240 | +}; |
| 241 | +``` |
0 commit comments