krlmlr opened a new issue, #952:
URL: https://github.com/apache/arrow-nanoarrow/issues/952

   `nanoarrow::convert_array()` returns an ALTREP character vector. Its `Elt` 
method builds the R string (a CHARSXP) from the Arrow buffer with 
`Rf_mkCharLenCE()` every time an element is read, and the `CHARSXP` is not held 
unless the whole vector is materialized.
   
   @paleolimbot: I wonder if nanoarrow should cache at the `CHARSXP` level. My 
use case is returning lazy-ish data frames in DBI from `dbFetchArrowChunk()` 
and friends. If this is out of scope, no worries, I can wrap this with a 
caching ALTREP class. The reprex below is AI generated but looks solid to me. 
Checked with CRAN and GitHub versions of nanoarrow. Please let me know how to 
further support this.
   
   ---
   
   Part 1 shows this for a single element, part 2 for bulk operations, part 3 
which operations materialize the vector in place.
   
   ``` r
   library(nanoarrow)
   packageVersion("nanoarrow")
   #> [1] '0.9.0'
   ```
   
   ## Setup
   
   One million distinct strings.
   The R vector the array is built from is dropped right away:
   while it is alive, `mkChar()` finds every string in R’s global string cache
   and returns that CHARSXP, which hides the re-creation completely.
   
   ``` r
   n <- 1e6
   array <- as_nanoarrow_array(sprintf("string_%07d", seq_len(n)))
   invisible(gc())
   
   x <- convert_array(array, character())
   .Internal(inspect(x))
   #> @561bd853e400 16 STRSXP g0c0 [REF(65535)] <nanoarrow::altrep_chr[1000000]>
   nanoarrow:::is_nanoarrow_altrep(x)
   #> [1] TRUE
   nanoarrow:::is_nanoarrow_altrep_materialized(x)
   #> [1] FALSE
   
   # Element access is cheap and does not materialize.
   x[1:3]
   #> [1] "string_0000001" "string_0000002" "string_0000003"
   system.time(nchar(x[1:1e5]))
   #>    user  system elapsed 
   #>   0.011   0.000   0.011
   nanoarrow:::is_nanoarrow_altrep_materialized(x)
   #> [1] FALSE
   ```
   
   ## 1. Repeated access to one element gives a new CHARSXP after `gc()`
   
   `charsxp_line()` extracts the CHARSXP line from `.Internal(inspect())`
   of a length-one character vector, `charsxp_addr()` just its address.
   Next to the address, the line shows the node’s GC generation and flags.
   A node without `MARK` in generation `g0` was allocated after the last 
collection.
   A collection sets `MARK` on every node it finds alive,
   and a full `gc()` moves such nodes to the older generation `g1`.
   `[cached]` only means the node is in R’s global string cache,
   which is true of every CHARSXP that `mkChar()` returns.
   
   ``` r
   charsxp_line <- function(s) {
     trimws(grep("CHARSXP", capture.output(.Internal(inspect(s))), value = 
TRUE))
   }
   charsxp_addr <- function(s) sub("^@(\\S+) .*$", "\\1", charsxp_line(s))
   i <- 12345L
   ```
   
   Two accesses with no collection in between return the same node:
   the first one is garbage, but it is still in the string cache,
   so `mkChar()` finds it.
   That is a cache hit.
   
   ``` r
   charsxp_addr(x[[i]]) == charsxp_addr(x[[i]])
   #> [1] TRUE
   ```
   
   With a `gc()` in between, nothing references the string any more,
   the collector drops it from the cache,
   and the next access allocates a new node: `g0` and no `MARK`.
   The addresses usually differ.
   They can coincide by chance when the allocator hands out the node
   that the previous access left behind,
   so the generation flags are the reliable evidence, not the address.
   
   ``` r
   writeLines(charsxp_line(x[[i]]))
   #> @561bd6f6c1e8 09 CHARSXP g0c2 [REF(5),gp=0x60] [ASCII] [cached] 
"string_0012345"
   invisible(gc())
   writeLines(charsxp_line(x[[i]]))
   #> @561bd9b7a948 09 CHARSXP g0c2 [REF(2),gp=0x60] [ASCII] [cached] 
"string_0012345"
   invisible(gc())
   writeLines(charsxp_line(x[[i]]))
   #> @561bd9b7a908 09 CHARSXP g0c2 [REF(2),gp=0x60] [ASCII] [cached] 
"string_0012345"
   ```
   
   Holding on to the first result keeps its CHARSXP alive,
   and therefore in the string cache: the next access gets the same node back,
   a cache hit that still hashes the bytes and walks the hash chain.
   The node now shows `MARK` and `g1` because the collection found it alive.
   
   ``` r
   held <- x[[i]]
   writeLines(charsxp_line(held))
   #> @561bd9b7a908 09 CHARSXP g0c2 [REF(3),gp=0x60] [ASCII] [cached] 
"string_0012345"
   invisible(gc())
   writeLines(charsxp_line(x[[i]]))
   #> @561bd9b7a908 09 CHARSXP g1c2 [MARK,REF(4),gp=0x60] [ASCII] [cached] 
"string_0012345"
   charsxp_addr(x[[i]]) == charsxp_addr(held)
   #> [1] TRUE
   ```
   
   Drop it, and the next access is a fresh allocation again:
   `g0` and no `MARK`, even if it lands at the same address.
   
   ``` r
   rm(held)
   invisible(gc())
   writeLines(charsxp_line(x[[i]]))
   #> @561bd9b7a908 09 CHARSXP g0c2 [REF(2),gp=0x60] [ASCII] [cached] 
"string_0012345"
   ```
   
   A materialized copy holds its CHARSXPs.
   The node of an element stays at one address across collections
   (possibly an address seen above, since freed nodes get reused)
   and carries `MARK` once a collection has seen it, instead of being 
re-created.
   
   ``` r
   y <- c(x)
   nanoarrow:::is_nanoarrow_altrep(y)
   #> [1] FALSE
   before <- charsxp_addr(y[[i]])
   writeLines(charsxp_line(y[[i]]))
   #> @561bd9b7a908 09 CHARSXP g0c2 [REF(5),gp=0x60] [ASCII] [cached] 
"string_0012345"
   invisible(gc())
   writeLines(charsxp_line(y[[i]]))
   #> @561bd9b7a908 09 CHARSXP g1c2 [MARK,REF(6),gp=0x60] [ASCII] [cached] 
"string_0012345"
   charsxp_addr(y[[i]]) == before
   #> [1] TRUE
   
   # The copy has to go again: while it is alive, its strings sit in the
   # string cache and every lazy access below would be a cache hit.
   rm(y)
   invisible(gc())
   ```
   
   ## 2. Bulk operations redo the work on every call
   
   Three passes over the lazy vector take about the same time each:
   every `==` creates one million CHARSXPs and throws them away.
   `nchar()` and even `c()` do the same.
   
   ``` r
   v <- "string_0000042"
   system.time(x == v)
   #>    user  system elapsed 
   #>   0.100   0.000   0.101
   system.time(x == v)
   #>    user  system elapsed 
   #>   0.097   0.000   0.096
   system.time(x == v)
   #>    user  system elapsed 
   #>   0.078   0.000   0.078
   system.time(nchar(x))
   #>    user  system elapsed 
   #>   0.126   0.000   0.126
   system.time(c(x))
   #>    user  system elapsed 
   #>   0.088   0.004   0.092
   nanoarrow:::is_nanoarrow_altrep_materialized(x)
   #> [1] FALSE
   ```
   
   `Rprofmem()` with a huge threshold records nothing but the allocation of
   new pages for small vectors, which is where CHARSXPs live.
   One `==` on the lazy vector allocates about one page per 125 strings.
   
   ``` r
   new_pages <- function(expr) {
     f <- tempfile()
     # R releases its free pages only on every other full collection,
     # so collect twice to start from a heap without spare pages.
     invisible(gc())
     invisible(gc())
     Rprofmem(f, threshold = 1e9)
     force(expr)
     Rprofmem(NULL)
     sum(grepl("new page", readLines(f)))
   }
   new_pages(x == v)
   #> [1] 7963
   ```
   
   On the materialized copy the same operations take a fraction of the time,
   for `==` more than ten times less, and allocate no page.
   
   ``` r
   y <- c(x)
   system.time(y == v)
   #>    user  system elapsed 
   #>   0.004   0.000   0.004
   system.time(nchar(y))
   #>    user  system elapsed 
   #>   0.035   0.000   0.034
   system.time(c(y))
   #>    user  system elapsed 
   #>   0.004   0.004   0.009
   new_pages(y == v)
   #> [1] 0
   ```
   
   While the copy is alive, the lazy vector’s accesses turn into cache hits:
   no allocation, but still one hash lookup per element,
   so the lazy pass stays an order of magnitude slower than the copy.
   
   ``` r
   system.time(x == v)
   #>    user  system elapsed 
   #>   0.057   0.000   0.058
   new_pages(x == v)
   #> [1] 0
   ```
   
   `gc()` shows who holds the strings.
   The lazy vector holds none.
   The copy holds one node per string (Ncells) and their bytes (Vcells).
   
   ``` r
   rm(y)
   invisible(gc())
   gc()[, "used"]
   #>  Ncells  Vcells 
   #>  633936 1624861
   y <- c(x)
   gc()[, "used"]
   #>  Ncells  Vcells 
   #> 1633943 4624851
   rm(y)
   invisible(gc())
   ```
   
   ## 3. Which operations materialize the vector in place?
   
   Each expression runs on a fresh lazy vector.
   `in_place` reports whether that vector was materialized by the call,
   `result` what came back, `seconds` how long it took.
   
   ``` r
   result_kind <- function(res, x) {
     addr <- function(s) sub("^@(\\S+) .*$", "\\1", 
capture.output(.Internal(inspect(s)))[1])
     if (identical(addr(res), addr(x))) {
       "x itself"
     } else if (grepl("wrapper", capture.output(.Internal(inspect(res)))[1])) {
       "R wrapper around x"
     } else if (is.character(res)) {
       "plain character vector"
     } else {
       "not a character vector"
     }
   }
   probe <- function(label, f) {
     x <- convert_array(array, character())
     seconds <- system.time(res <- f(x))[["elapsed"]]
     data.frame(
       expression = label,
       in_place = nanoarrow:::is_nanoarrow_altrep_materialized(x),
       result = result_kind(res, x),
       seconds = round(seconds, 3)
     )
   }
   rbind(
     probe("c(x)", function(x) c(x)),
     probe("as.character(x)", function(x) as.character(x)),
     probe("enc2utf8(x)", function(x) enc2utf8(x)),
     probe("structure(x, foo = 1)", function(x) structure(x, foo = 1)),
     probe("x == 'a'", function(x) x == "a"),
     probe("nchar(x)", function(x) nchar(x)),
     probe("unique(x)", function(x) unique(x)),
     probe("as_nanoarrow_array(x)", function(x) as_nanoarrow_array(x)),
     probe("x[]", function(x) x[]),
     probe("x %in% 'a'", function(x) x %in% "a"),
     probe("sort(x)", function(x) sort(x)),
     probe("z <- x; z[1] <- 'q'", function(x) { z <- x; z[1] <- "q"; z }),
     probe("force_materialize(x)", function(x) 
nanoarrow:::nanoarrow_altrep_force_materialize(x))
   )
   #>               expression in_place                 result seconds
   #> 1                   c(x)    FALSE plain character vector   0.104
   #> 2        as.character(x)    FALSE               x itself   0.000
   #> 3            enc2utf8(x)    FALSE               x itself   0.117
   #> 4  structure(x, foo = 1)    FALSE     R wrapper around x   0.000
   #> 5               x == 'a'    FALSE not a character vector   0.099
   #> 6               nchar(x)    FALSE not a character vector   0.109
   #> 7              unique(x)    FALSE plain character vector   0.398
   #> 8  as_nanoarrow_array(x)    FALSE not a character vector   0.155
   #> 9                    x[]     TRUE plain character vector   0.095
   #> 10            x %in% 'a'     TRUE not a character vector   0.100
   #> 11               sort(x)     TRUE plain character vector   0.342
   #> 12   z <- x; z[1] <- 'q'     TRUE plain character vector   0.084
   #> 13  force_materialize(x)     TRUE not a character vector   0.102
   ```
   
   Nothing nanoarrow exports materializes the vector in place,
   not even converting it back with `as_nanoarrow_array()`,
   which re-creates every string once more to copy its bytes.
   The package’s own way is the unexported 
`nanoarrow_altrep_force_materialize()`.
   `c()` gives a plain copy and leaves `x` lazy.
   `as.character()` and `enc2utf8()` return `x` itself,
   `enc2utf8()` after visiting and discarding every string.
   Setting an attribute wraps `x` in R’s own ALTREP wrapper without touching it.
   But `x[]`, `%in%`, `sort()` and assignment into a copy that shares `x`
   do materialize `x` in place:
   R’s `duplicate()` (behind `x[]` and the assignment), `match()` and `sort()`
   reach the data through `DATAPTR()`,
   which nanoarrow implements by materializing everything into 
`R_altrep_data2()`.
   
   The main vector is still lazy after everything above.
   The unexported helper materializes it in place, and the same `==` is fast 
afterwards.
   
   ``` r
   .Internal(inspect(x))
   #> @561bd853e400 16 STRSXP g1c0 [MARK,REF(65535)] 
<nanoarrow::altrep_chr[1000000]>
   nanoarrow:::nanoarrow_altrep_force_materialize(x)
   .Internal(inspect(x))
   #> @561bd853e400 16 STRSXP g1c0 [MARK,REF(65535)] <materialized 
nanoarrow::altrep_chr[1000000]>
   nanoarrow:::is_nanoarrow_altrep_materialized(x)
   #> [1] TRUE
   system.time(x == v)
   #>    user  system elapsed 
   #>   0.010   0.000   0.011
   new_pages(x == v)
   #> [1] 0
   ```
   
   <sup>Created on 2026-09-26 with [reprex 
v2.1.1](https://reprex.tidyverse.org)</sup>
   
   <details style="margin-bottom:10px;">
   
   <summary>
   
   Session info
   </summary>
   
   ``` r
   sessioninfo::session_info()
   #> ─ Session info 
───────────────────────────────────────────────────────────────
   #>  setting  value
   #>  version  R version 4.5.3 (2026-03-11)
   #>  os       Ubuntu 24.04.4 LTS
   #>  system   x86_64, linux-gnu
   #>  ui       X11
   #>  language (EN)
   #>  collate  C.UTF-8
   #>  ctype    C.UTF-8
   #>  tz       Etc/UTC
   #>  date     2026-09-26
   #>  pandoc   3.9.0.2 @ /usr/local/bin/ (via rmarkdown)
   #>  quarto   1.9.38 @ /usr/local/bin/quarto
   #> 
   #> ─ Packages 
───────────────────────────────────────────────────────────────────
   #>  package     * version date (UTC) lib source
   #>  cli           3.6.6   2026-04-09 [1] RSPM (R 4.5.0)
   #>  digest        0.6.39  2025-11-19 [1] RSPM
   #>  evaluate      1.0.5   2025-08-27 [1] RSPM
   #>  fastmap       1.2.0   2024-05-15 [1] RSPM
   #>  fs            2.1.0   2026-04-18 [1] RSPM
   #>  glue          1.8.1   2026-04-17 [1] RSPM
   #>  htmltools     0.5.9   2025-12-04 [1] RSPM
   #>  knitr         1.52    2026-09-06 [1] RSPM (R 4.5.0)
   #>  lifecycle     1.0.5   2026-01-08 [1] RSPM
   #>  nanoarrow   * 0.9.0   2026-08-04 [1] RSPM (R 4.5.0)
   #>  otel          0.2.0   2025-08-29 [1] RSPM
   #>  reprex        2.1.1   2024-07-06 [1] RSPM
   #>  rlang         1.3.0   2026-07-05 [1] RSPM
   #>  rmarkdown     2.32    2026-09-01 [1] RSPM (R 4.5.0)
   #>  sessioninfo   1.2.4   2026-06-04 [1] RSPM
   #>  withr         3.0.3   2026-06-19 [1] RSPM
   #>  xfun          0.61    2026-09-16 [1] RSPM
   #>  yaml          2.3.12  2025-12-10 [1] RSPM
   #> 
   #>  [1] /root/R/x86_64-pc-linux-gnu-library/4.5
   #>  [2] /opt/R/4.5.3/lib/R/library
   #>  * ── Packages attached to the search path.
   #> 
   #> 
──────────────────────────────────────────────────────────────────────────────
   ```
   
   </details>
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to