|
| 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 | +| `insert` | Inserting single rows | |
| 102 | +| `update` | Updating single rows | |
| 103 | +| `transaction` | Inserting 100 rows in a single transaction | |
| 104 | +| `update-transaction` | Updating 100 rows in a single transaction | |
| 105 | +
|
| 106 | +## Output Format |
| 107 | +
|
| 108 | +Results are displayed as: |
| 109 | +``` |
| 110 | +driver-name x 471,255 ops/sec ±0.07% (event loop: 50%, 2.1μs/op) |
| 111 | +``` |
| 112 | +
|
| 113 | +- `ops/sec` - Operations per second (higher is better) |
| 114 | +- `±X.XX%` - Relative margin of error |
| 115 | +- `event loop: X%, Yμs/op` - Utilization percentage and blocking time per operation (lower is better) |
| 116 | +
|
| 117 | +### Example Output |
| 118 | +
|
| 119 | +Running `node index.js select` produces output like: |
| 120 | +
|
| 121 | +``` |
| 122 | +select small (nul) |
| 123 | +better-sqlite3 x 638,075 ops/sec ±0.44% (event loop: 100%, 1.6μs/op) |
| 124 | +@homeofthings/sqlite3 x 88,459 ops/sec ±0.82% (event loop: 47%, 5.3μs/op) |
| 125 | +node:sqlite x 543,445 ops/sec ±0.53% (event loop: 100%, 1.8μs/op) |
| 126 | +``` |
| 127 | +
|
| 128 | +### Event Loop Metrics |
| 129 | +
|
| 130 | +The **event loop** metrics show how the driver affects the event loop (measured using Node.js native `performance.eventLoopUtilization()` API): |
| 131 | +
|
| 132 | +**Utilization Percentage:** How much of the benchmark time the event loop was busy (100% = completely blocked, 0% = completely free) |
| 133 | +
|
| 134 | +**Time per Operation:** `(1,000,000 μs/sec ÷ ops/sec) × utilization = μs blocked per operation` |
| 135 | +
|
| 136 | +| Driver | Utilization | Time per Op | Meaning | |
| 137 | +|-------------------------|-------------|-------------|-------------------------------------------------| |
| 138 | +| `better-sqlite3` | 100% | ~1.6μs/op | Blocks completely - all time is event loop time | |
| 139 | +| `@homeofthings/sqlite3` | ~47% | ~5.3μs/op | 3.3x more blocking than sync drivers | |
| 140 | +| `node:sqlite` | 100% | ~1.8μs/op | Blocks completely - all time is event loop time | |
| 141 | +
|
| 142 | +This 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. |
| 143 | +
|
| 144 | +### Large Data Performance |
| 145 | +
|
| 146 | +For I/O-bound operations (large data reads), async drivers can actually **outperform** sync drivers: |
| 147 | +
|
| 148 | +``` |
| 149 | +--- reading large blobs (16MB each) --- |
| 150 | +better-sqlite3 x 83 ops/sec ±7.99% (event loop: 100%, 12.07ms/op) |
| 151 | +@homeofthings/sqlite3 x 94 ops/sec ±8.57% (event loop: 34%, 3.63ms/op) |
| 152 | +node:sqlite x 127 ops/sec ±10.75% (event loop: 100%, 7.88ms/op) |
| 153 | +``` |
| 154 | +
|
| 155 | +**Why async wins for large data:** |
| 156 | +
|
| 157 | +1. **Lower event loop blocking**: 3.63ms vs 12.07ms - async driver blocks 70% less |
| 158 | +2. **Higher throughput**: 94 vs 83 ops/sec - async driver is 13% faster |
| 159 | +3. **Event loop availability**: 66% free during async operations |
| 160 | +
|
| 161 | +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. |
| 162 | +
|
| 163 | +## Project Structure |
| 164 | +
|
| 165 | +``` |
| 166 | +├── index.js # Main orchestrator |
| 167 | +├── benchmark.js # Benchmark runner (tinybench) |
| 168 | +├── drivers.js # SQLite driver configurations |
| 169 | +├── trials.js # Benchmark trial definitions |
| 170 | +├── seed.js # Database seeding |
| 171 | +├── types/ |
| 172 | +│ ├── insert.js # Insert benchmark |
| 173 | +│ ├── select.js # Single row select benchmark |
| 174 | +│ ├── select-all.js # Multi-row select benchmark |
| 175 | +│ ├── select-iterate.js # Iteration benchmark |
| 176 | +│ └── transaction.js # Transaction benchmark |
| 177 | +└── temp/ # Temporary database files (auto-created) |
| 178 | +``` |
| 179 | +
|
| 180 | +## Architecture |
| 181 | +
|
| 182 | +Each benchmark runs in an isolated child process to ensure: |
| 183 | +- Clean state for each measurement |
| 184 | +- Memory isolation between runs |
| 185 | +- No interference between drivers |
| 186 | +
|
| 187 | +## Adding a New Driver |
| 188 | +
|
| 189 | +1. Add the driver to `package.json` dependencies |
| 190 | +2. Add a connection function to `drivers.js`: |
| 191 | +```javascript |
| 192 | +['driver-name', async (filename, pragma) => { |
| 193 | + const db = require('driver-package')(filename); |
| 194 | + // Apply PRAGMA settings |
| 195 | + for (const str of pragma) await db.exec(`PRAGMA ${str}`); |
| 196 | + return db; |
| 197 | +}] |
| 198 | +``` |
| 199 | +3. Add benchmark implementations in each `types/*.js` file: |
| 200 | +```javascript |
| 201 | +// Either return sync or async function |
| 202 | +
|
| 203 | +exports['driver-name'] = (db, ctx) => { |
| 204 | + return () => db.someOperation(); |
| 205 | +}; |
| 206 | +
|
| 207 | +exports['driver-name'] = async (db, ctx) => { |
| 208 | + return () => db.someOperation(); |
| 209 | +}; |
| 210 | +``` |
0 commit comments