惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

人人都是产品经理
人人都是产品经理
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
月光博客
月光博客
雷峰网
雷峰网
Google DeepMind News
Google DeepMind News
Y
Y Combinator Blog
Microsoft Security Blog
Microsoft Security Blog
M
MIT News - Artificial intelligence
WordPress大学
WordPress大学
MongoDB | Blog
MongoDB | Blog
V
V2EX
博客园 - 【当耐特】
GbyAI
GbyAI
Stack Overflow Blog
Stack Overflow Blog
I
InfoQ
Martin Fowler
Martin Fowler
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
Hugging Face - Blog
Hugging Face - Blog
B
Blog
V
Visual Studio Blog
D
DataBreaches.Net
C
Check Point Blog
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
F
Fortinet All Blogs

Homepage on Yihui Xie | 谢益辉

Should I Ask for Sponsorship? - Yihui Xie | 谢益辉 A Review of **rmarkdown** Updates from 2024 to 2026 - Yihui Xie | 谢益辉 A Review of **knitr** Updates from 2024 to 2026 - Yihui Xie | 谢益辉 The lt Package: Lightweight HTML Tables for R (and Beyond) - Yihui Xie | 谢益辉 Bye, Stack Overflow - Yihui Xie | 谢益辉 Converting testthat Tests to testit - Yihui Xie | 谢益辉 Reflections on AI-assisted Programming - Yihui Xie | 谢益辉 Preliminary Support for Typst in knitr - Yihui Xie | 谢益辉 R.I.P., Tomas Kalibera - Yihui Xie | 谢益辉 An Introduction to xfun - Yihui Xie | 谢益辉 tinyimg: An R Package for Compressing Images - Yihui Xie | 谢益辉 A CDN-backed CTAN Mirror: `tlnet.yihui.org` - Yihui Xie | 谢益辉 Announcing TinyTeX Binaries for arm64 and musl-based Linux - Yihui Xie | 谢益辉 TinyTeX on macOS: No More Messing with `/usr/local/bin` - Yihui Xie | 谢益辉 R.I.P., John Fox - Yihui Xie | 谢益辉 R.I.P., Fritz Leisch - Yihui Xie | 谢益辉 Bye, Hex Stickers - Yihui Xie | 谢益辉 Navigating CRAN's Reverse Dependency Check Logs - Yihui Xie | 谢益辉 Viewing Nested Lists with `xfun::tabset()` - Yihui Xie | 谢益辉
The Surprising Slowness of `textConnection()` in R - Yihu...
Yihui Xie · 2026-03-29 · via Homepage on Yihui Xie | 谢益辉

Earlier this month, @idavydov filed an issue against Quarto reporting that it is about 100x slower than rmarkdown for documents with long output. The minimal reprex was striking:

cat(strrep("x\n", 100000))

Running quarto render on a document with that single chunk took 35 seconds. The equivalent rmarkdown::render() finished in under half a second. As a side note in the issue, the reporter pinged me that the same problem existed in litedown. litedown is independent of both Quarto and knitr; it executes R code through xfun::record(). That is where I started looking.

Profiling

I reproduced the issue in litedown and ran utils::Rprof() on xfun::record(). The first result looked clear: cat() was consuming 88% of runtime, with a call stack that went through one_string → paste → cat. My initial diagnosis was that xfun::record() was collapsing the output lines into a single string with paste() before writing, and the string concatenation was slow.

That turned out to be the wrong diagnosis.

I re-profiled, this time passing gc.profiling = TRUE to also capture garbage collection data. The new profile completely changed the picture: 56% of the total runtime was GC. Not cat(), not paste()—the garbage collector. GC consuming more than half your runtime is not a sign of slow code per se; it is a sign that something is creating an enormous number of short-lived objects that R has to keep reclaiming. The question shifted from “why is cat slow?” to “what is producing all this garbage?” What a relief to kittens! How sad garbage trucks are!

Finding the Cause

With GC as the real culprit, I examined handle_output() inside xfun::record()—the function that captures chunk output via sink()—with Claude’s help. The answer was sitting right at the top:

con = textConnection('out', 'w', local = TRUE)

A writable textConnection appends one element per line to a character vector. So for cat(strrep("x\n", 100000)), R is effectively doing this 100,000 times in a tight loop:

out = c(out, new_line)

Because R vectors are copy-on-modify, each c() call allocates a brand-new vector and copies all previous content into it before adding the new element. The growth pattern looks like:

NULL → [1 elem] → [2 elems] → [3 elems] → ... → [100,000 elems]

That is $O(n^2)$ in both allocations and data copies, and every discarded intermediate vector becomes garbage for R’s collector to clean up. The initial profile was not lying about cat—time really was being spent writing output—but the GC profile told the deeper truth. Without gc.profiling = TRUE, I would have chased the wrong thing.

Why rmarkdown Was Fast

rmarkdown itself uses knitr, which in turn uses the evaluate package to execute code. evaluate captures output by sinking into a file() connection, which delegates buffering to the operating system’s file I/O layer and sidesteps the R-level vector-growing trap entirely. It never accumulates a character vector one line at a time; it just writes bytes.

The Fix

The fix is to replace textConnection() with rawConnection(). A raw connection uses a dynamically-growing byte buffer internally—similar to realloc() with doubling in C—so appending is amortized $O(1)$ rather than $O(n^2)$. The change in xfun::record() was a few lines:

# before
out = NULL
con = textConnection('out', 'w', local = TRUE)
# ...
sink()
close(con)

# after
con = rawConnection(raw(0), 'w')
# ...
sink()
out = rawToChar(rawConnectionValue(con))
close(con)
out = strsplit(out, '\n', fixed = TRUE)[[1]]

Instead of reading out directly from the connection variable (which textConnection writes into by name), we retrieve the buffer at the end with rawConnectionValue(), convert it to a character string with rawToChar(), and split on newlines ourselves.

Another alternative I considered was sinking into a file('') connection opened in read/write mode, which would also avoid the quadratic growth. I went with rawConnection() instead because I wanted a pure in-memory solution with no involvement of the file system at all.

After the fix, the runtime (for cat(strrep("x\n", 50000)) instead of 100000) dropped from 5.58 seconds to 1.30 seconds—a 4.3× speedup—with cat and GC disappearing from the profile entirely.

What I Took Away

I have used textConnection() in R for a very long time and never thought to question it. It is documented, idiomatic, and used inside base R itself (see ?capture.output). For typical usage—capturing a few lines of output—it is perfectly fine. The quadratic behavior only bites you when the output is large, which is rare enough that it stayed hidden for years.

The lesson I keep relearning is that “idiomatic” and “efficient” are not the same thing. When something feels slow in a way that is hard to explain intuitively, profiling almost always surfaces something surprising. In this case, it was a base R function that I had mentally filed under “fast and boring” that turned out to have a hidden $O(n^2)$ trap.

Donate

As a freelancer (currently working as a contractor) and a dad of three kids, I truly appreciate your donation to support my writing and open-source software development! Your contribution helps me cope with financial uncertainty better, so I can spend more time on producing high-quality content and software. You can make a donation through methods below.

  • Venmo: @yihui_xie, or Zelle: [email protected]

  • Paypal

    • If you have a Paypal account, you can follow the link https://paypal.me/YihuiXie or find me on Paypal via my email [email protected]. Please choose the payment type as “Family and Friends” (instead of “Goods and Services”) to avoid extra fees.

    • If you don’t have Paypal, you may donate through this link via your debit or credit card. Paypal will charge a fee on my side.

  • Other ways:

    WeChat Pay (微信支付:谢益辉) Alipay (支付宝:谢益辉)
    WeChat Pay QR code Alipay QR code

When sending money, please be sure to add a note “gift” or “donation” if possible, so it won’t be treated as my taxable income but a genuine gift. Needless to say, donation is completely voluntary and I appreciate any amount you can give.

Please feel free to email me if you prefer a different way to give. Thank you very much!

I’ll give back a significant portion of the donations to the open-source community and charities. For the record, I received about $30,000 in total (before tax) in 2024-25, and gave back about $15,000 (after tax).