Skip to content

readline: improve performance#64585

Open
mcollina wants to merge 2 commits into
nodejs:mainfrom
mcollina:readline-createinterface-perf
Open

readline: improve performance#64585
mcollina wants to merge 2 commits into
nodejs:mainfrom
mcollina:readline-createinterface-perf

Conversation

@mcollina

@mcollina mcollina commented Jul 18, 2026

Copy link
Copy Markdown
Member

Speed up readline.createInterface(), which is on the hot path of every CLI tool that reads lines from a stream.

A CPU profile of interface construction showed three avoidable costs:

  1. setupHistoryManager() accounted for ~43% of construction self time: every instance ran four ObjectDefineProperty calls, allocating six fresh closures and four descriptor objects each time. The accessors (history, historyIndex, historySize, isFlushing) are now defined from a module-level shared descriptor map with a single ObjectDefineProperties call. They remain own, enumerable, configurable properties, so observable semantics are unchanged.
  2. The constructor assigned size, history and removeHistoryDuplicates onto the user-provided input stream to forward them to the history manager. It now passes a plain options object instead, so the user's stream is no longer observably mutated and doesn't go through extra hidden-class transitions. The legacy positional-arguments path is unchanged.
  3. The Interface wrapper unconditionally read process.env.TERM (which goes through the env interceptor) to bind the dumb-terminal _ttyWrite. The check is now gated on this.terminal, since _ttyWrite is never invoked when the interface is not in terminal mode.

Results with the new benchmark (30 runs, interleaved via benchmark/compare.js):

                                                         confidence improvement accuracy (*)   (**)  (***)
readline/readline-createInterface.js terminal=0 n=100000        ***     63.81 %       ±1.94% ±2.59% ±3.38%
readline/readline-createInterface.js terminal=1 n=100000        ***     38.05 %       ±1.90% ±2.53% ±3.31%

Update: a second commit speeds up line throughput as well. [kNormalWrite] ran a regular-expression exec loop that allocated a match object and two index entries per line, then sliced each line out in JS. It now does a single split pass, and uses a plain string split on "\n" when the chunk contains none of the rare line endings (\r, , ), which takes V8's fast string-split path.

Results for readline/readline-iterable.js (20 runs, interleaved):

                                                   confidence improvement accuracy (*)    (**)   (***)
readline/readline-iterable.js type='new' n=10             ***      5.18 %       ±1.91%  ±2.57%  ±3.38%
readline/readline-iterable.js type='new' n=100              *     13.16 %      ±12.52% ±16.89% ±22.48%
readline/readline-iterable.js type='new' n=1000           ***     32.94 %      ±11.25% ±15.31% ±20.66%
readline/readline-iterable.js type='new' n=10000          ***     47.44 %       ±4.83%  ±6.53%  ±8.70%
readline/readline-iterable.js type='new' n=100000         ***     70.71 %       ±2.18%  ±2.94%  ±3.91%
readline/readline-iterable.js type='new' n=1000000        ***     80.62 %       ±1.51%  ±2.05%  ±2.75%
readline/readline-iterable.js type='old' n=10             ***      4.92 %       ±1.50%  ±2.01%  ±2.66%
readline/readline-iterable.js type='old' n=100                    -5.79 %      ±10.40% ±13.97% ±18.46%
readline/readline-iterable.js type='old' n=1000                    1.39 %       ±6.82%  ±9.14% ±12.03%
readline/readline-iterable.js type='old' n=10000          ***     18.40 %       ±3.62%  ±4.85%  ±6.38%
readline/readline-iterable.js type='old' n=100000         ***     26.02 %       ±2.91%  ±3.93%  ±5.21%
readline/readline-iterable.js type='old' n=1000000        ***     31.42 %       ±2.00%  ±2.67%  ±3.52%

Event-based consumption (rl.on('line')) goes from 8.1M to 13.3M lines/s (+65%) in a microbenchmark of 32-byte lines.

Speed up Interface construction:

- Hoist the history accessor property descriptors to module scope and
  define them with a single ObjectDefineProperties call, instead of
  allocating six closures and four descriptor objects per instance.
- Stop assigning the history options onto the input stream. This avoids
  hidden class transitions on the user provided stream and no longer
  mutates it observably.
- Only check process.env.TERM for a dumb terminal when the interface is
  in terminal mode. Reading process.env goes through the environment
  interceptor and is comparatively expensive, and _ttyWrite is never
  called when terminal is false.

Signed-off-by: Matteo Collina <hello@matteocollina.com>
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

Review requested:

  • @nodejs/performance

@nodejs-github-bot nodejs-github-bot added needs-ci PRs that need a full CI run. readline Issues and PRs related to the built-in readline module. labels Jul 18, 2026
Replace the per-line regular expression exec loop with a single split
pass, and use a plain string split on "\n" when the chunk contains none
of the rare line endings (\r, \u2028, \u2029). This avoids allocating a
match object and two index entries per line and lets the common case
use the fast string split path.

readline/readline-iterable.js type='new' improves by up to 80% and
the event based line consumption by up to 65%.

Signed-off-by: Matteo Collina <hello@matteocollina.com>
@mcollina mcollina changed the title readline: reduce createInterface overhead readline: improve performance Jul 18, 2026
@codecov

codecov Bot commented Jul 18, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.59155% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 90.12%. Comparing base (4f844f4) to head (0fd92a9).

Files with missing lines Patch % Lines
lib/internal/readline/interface.js 98.52% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #64585      +/-   ##
==========================================
- Coverage   90.13%   90.12%   -0.02%     
==========================================
  Files         741      741              
  Lines      241976   241994      +18     
  Branches    45543    45551       +8     
==========================================
- Hits       218116   218101      -15     
- Misses      15379    15410      +31     
- Partials     8481     8483       +2     
Files with missing lines Coverage Δ
lib/readline.js 95.80% <100.00%> (+0.01%) ⬆️
lib/internal/readline/interface.js 93.21% <98.52%> (+<0.01%) ⬆️

... and 30 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

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

Labels

needs-ci PRs that need a full CI run. readline Issues and PRs related to the built-in readline module.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants