Lesson 6.6.5 · 60 min

Cloud storage, lakehouse (Delta, Iceberg) and GPU (RAPIDS, cuDF)

In the cloud, every read is a request that is billed and takes time. This lesson stores two million ticks in three ways in a simulated bucket and counts the requests and their cost, builds a transaction log with JSON and Parquet to understand what Delta Lake and Iceberg add (atomic commits, time travel, compaction, vacuum) and places the GPU with RAPIDS, cuDF and Polars as background knowledge, with its requirements.

Fig. — S3 Standard request cost of one million queries over two million ticks, by lake layout (one object, one per day, or one per day and symbol)
Level 6 lessons
  1. 6.1 · Classical and modern financial NLP

    1. 6.1.1 Tokenization, embeddings, financial dictionaries and FinBERT
    2. 6.1.2 Text sources: EDGAR, CNMV, transcripts and social media
    3. 6.1.3 Event studies with text signals, timestamp alignment and topic modeling
  2. 6.2 · LLMs for research and trading

    1. 6.2.1 How an LLM works: from the next token to the API
    2. 6.2.2 Structured prompting: JSON you can validate
    3. 6.2.3 Guidance, KPIs and tone: what an LLM extracts well
    4. 6.2.4 Lightweight fine-tuning for financial tasks: LoRA and QLoRA
    5. 6.2.5 Look-ahead bias in LLMs: the model that already knows the future
  3. 6.3 · Financial RAG

    1. 6.3.1 Embeddings and vector databases: FAISS, Chroma, pgvector and Qdrant
    2. 6.3.2 Chunking financial documents: tables, footnotes and XBRL
    3. 6.3.3 RAG over reports, prospectuses and KIDs, and how to evaluate it
  4. 6.4 · AI agents for trading

    1. 6.4.1 Tool-calling agents: tool calling, ReAct and planning
    2. 6.4.2 Agent frameworks and MCP
    3. 6.4.3 TradingAgents-style multi-agent architectures
    4. 6.4.4 Evidence and limits of agents
    5. 6.4.5 Guardrails for agents: the agent proposes, the engine decides
    6. 6.4.6 Research agents: hypotheses, alpha mining and review
  5. 6.5 · AI as the quant's copilot

    1. 6.5.1 Coding assistants with a test-first workflow
    2. 6.5.2 Automated review of backtests and code migrations
    3. 6.5.3 Risks of AI-generated code
  6. 6.6 · Big data for trading

    1. 6.6.1 When data becomes “big”: ticks, order books, options and universes
    2. 6.6.2 Parquet, Arrow, Polars streaming and out-of-core DuckDB
    3. 6.6.3 Dask, Spark and Ray: from one machine to a cluster
    4. 6.6.4 Time series databases: TimescaleDB, QuestDB, ClickHouse, ArcticDB and kdb+
    5. 6.6.5 Cloud storage, lakehouse (Delta, Iceberg) and GPU (RAPIDS, cuDF)
  7. 6.7 · Alternative data

    1. 6.7.1 Overview and size of the alternative data market
    2. 6.7.2 Types of alternative data: what each one measures
    3. 6.7.3 Responsible web scraping
    4. 6.7.4 Alternative data vendors and due diligence
    5. 6.7.5 From alternative data to a trading signal
    6. 6.7.6 Satellite imagery, computer vision and supply chains

Object storage: S3 and GCS

A data lake in the cloud does not live on a disk, but in object storage: Amazon S3, Google Cloud Storage (GCS), Azure Blob Storage or their S3-compatible equivalents. From Python they look like folders (Polars, DuckDB and PyArrow read s3://cubo/lago/ just as they read lago/), but they work differently, and those differences explain half of the design decisions of a lake:

  • There are no folders. An S3 bucket is flat: every object has a key (lago/fecha=2025-03-14/parte-0.parquet) and the “folders” are shared prefixes that the console presents as such1Note 1 · AWS, “What is Amazon S3?”, “Organizing objects using prefixes”, “Copying objects” (renaming), GetObject (Range) and ListObjectsV2: https://docs.aws.amazon.com/AmazonS3/latest/userguide/Welcome.html and https://docs.aws.amazon.com/AmazonS3/latest/API/API_ListObjectsV2.html, accessed on October 23, 2026.. Listing means requesting the keys with a prefix: ListObjectsV2 returns at most 1,000 per request1.
  • The whole object is written. You cannot modify a piece or append at the end: you upload (PUT) a complete new object. Renaming means copying and deleting the original1.
  • It is read by ranges. A GET with the Range header downloads an interval of bytes1; that is how a Parquet reader reads the footer and then only the column chunks it needs (lesson 6.6.2).
  • Strong consistency. Since December 2020, S3 guarantees that a read after a write or a delete sees the result in all regions; GCS also offers strong global read-after-write consistency2Note 2 · AWS, announcement of December 1, 2020 on strong consistency in S3: https://aws.amazon.com/about-aws/whats-new/2020/12/amazon-s3-now-delivers-strong-read-after-write-consistency-automatically-for-all-applications/; Google Cloud, “Consistency”: https://cloud.google.com/storage/docs/consistency. Accessed on October 23, 2026..
  • Every request is billed. On the S3 pricing page, in S3 Standard, GET requests cost 0.0004 USD per 1,000 and PUT, COPY, POST and LIST, 0.005 USD per 1,000 (Northern Virginia, accessed on October 23, 2026)3Note 3 · AWS, “Amazon S3 pricing”: https://aws.amazon.com/s3/pricing/, accessed on October 23, 2026. Prices change and depend on the region and the storage class.. Storage and data transfer are charged separately.
LIST prefixGET Range: footerGET Range: columnsPUT whole objectPython clientS3 bucket
Mermaid source
flowchart LR
  C[Python client] -- LIST prefix --> B[(S3 bucket)]
  C -- GET Range: footer --> B
  C -- GET Range: columns --> B
  C -- PUT whole object --> B

The course environment has no s3fs or any library that imitates S3, and the lesson does not use the network. To measure anyway, the first cell defines a “bucket” that is a local folder seen through fsspec (the same layer s3fs uses for S3) and that counts every object written as a PUT and every read as a range GET. It does not reproduce S3's latency or prices, but it does reproduce the number of requests a reader would make, which is what costs money and time in the cloud.

IN [01]python
import math

import numpy as np
import pandas as pd
import pyarrow as pa
import pyarrow.compute as pc
import pyarrow.dataset as ds
import pyarrow.parquet as pq
import os

from fsspec.implementations.local import LocalFileSystem
from pyarrow.fs import FSSpecHandler, PyFileSystem

class AlmacenObjetos(LocalFileSystem):
    """Imitates an S3 bucket in a folder: counts PUT and GET."""
    def reiniciar(self):
        self.put = self.get = self.bytes_get = 0

    def _open(self, path, mode="rb", **kw):
        f = super()._open(path, mode, **kw)
        if "w" in mode:
            self.put += 1                  # one whole object per PUT
            return f
        leer = f.read
        def read(n=-1):
            datos = leer(n)
            self.get += 1                  # every read, one GET
            self.bytes_get += len(datos)
            return datos
        f.read = read
        return f

CUBO = os.path.abspath("cubo")         # the “bucket”: a folder
s3 = AlmacenObjetos()
s3.reiniciar()
cubo = PyFileSystem(FSSpecHandler(s3))
print(type(cubo).__name__, "on", type(s3).__name__)
OUT [01]A simulated object store that counts requests
PyFileSystem on AlmacenObjetos

Real code against S3 only changes the path and the credentials (which are not written in the code: they are taken from the environment or the AWS profile). With s3fs installed, the three tools from lesson 6.6.2 read like this:

IN [02]lago_en_s3.pypython
import duckdb
import polars as pl
import pyarrow.dataset as ds

ticks = pl.scan_parquet("s3://mi-cubo/lago/ticks/**/*.parquet",
                        hive_partitioning=True)  # credentials: environment
lago = ds.dataset("s3://mi-cubo/lago/ticks/", format="parquet",
                  partitioning="hive")
con = duckdb.connect()
con.sql("INSTALL httpfs; LOAD httpfs;")
con.sql("CREATE SECRET (TYPE s3, PROVIDER credential_chain)")
con.sql("SELECT count(*) FROM read_parquet("
        "'s3://mi-cubo/lago/ticks/**/*.parquet')")

The test data: two million synthetic ticks for 100 stocks over the 20 sessions of March 2025, stored in three ways: a single object, one object per day and one object per day and symbol.

IN [03]python
import exchange_calendars as xcals

tickers = sorted(pd.read_csv("panel5_activos.csv")["ticker"])[:100]
cal = xcals.get_calendar("XNYS")
sesiones = cal.sessions_in_range("2025-03-03", "2025-03-28")
rng = np.random.default_rng(665)
partes = []
for s in sesiones:
    n = rng.poisson(1000, len(tickers))
    abre = cal.session_open(s).value // 1000
    partes.append(pd.DataFrame({
        "fecha": s.date(),
        "simbolo": np.repeat(tickers, n),
        "ts": pd.to_datetime(
            rng.integers(abre, abre + 23_400_000_000, n.sum()),
            unit="us", utc=True),
        "precio": rng.lognormal(4, 0.5, n.sum()).round(2),
        "tamano": rng.geometric(0.02, n.sum()).astype("int32")}))
ticks = pd.concat(partes).sort_values(["fecha", "simbolo", "ts"])
tabla = pa.Table.from_pandas(ticks, preserve_index=False)

formato = ds.ParquetFileFormat()
opciones = formato.make_write_options(compression="zstd")
def escribir(ruta, particiones):
    s3.reiniciar()
    ds.write_dataset(tabla, ruta, filesystem=cubo, format=formato,
                     file_options=opciones, partitioning=particiones,
                     partitioning_flavor="hive" if particiones
                     else None,
                     basename_template="parte-{i}.parquet",
                     max_rows_per_group=131_072)
    claves = s3.find(ruta)
    return {"objetos": len(claves), "PUT": s3.put,
            "MB": round(sum(s3.size(k) for k in claves) / 1e6, 1)}

disenos = {"one file": (f"{CUBO}/uno", None),
           "by day": (f"{CUBO}/dia", ["fecha"]),
           "by day and symbol": (f"{CUBO}/dia_simbolo",
                                 ["fecha", "simbolo"])}
escritura = pd.DataFrame([escribir(*v) for v in disenos.values()],
                         index=list(disenos))
print(f"{len(ticks):,} ticks, {len(sesiones)} sessions,"
      f" {len(tickers)} symbols")
escritura
OUT [03]Two million ticks and three ways to store them
1,999,891 ticks, 20 sessions, 100 symbols
                   objetos   PUT    MB
one file                 1     1  22.1
by day                  20    20  22.3
by day and symbol     2000  2000  27.9

Writing costs one PUT per object: 1, 20 or 2,000. And the 2,000 small objects take up 26% more space (27.9 MB versus 22.1): each one carries its own footer and compresses worse, as seen in lesson 1.4.7. Now, the same pair of queries on the three designs: the BAGO ticks for one day and the whole precio column.

IN [04]python
GET_1000, PUT_LIST_1000 = 0.0004, 0.005   # USD, S3 Standard

def consultar(ruta, particiones, filtro, columnas):
    s3.reiniciar()
    esquema = pa.schema([tabla.schema.field(c)
                         for c in particiones or []])
    lago = ds.dataset(ruta, filesystem=cubo, format="parquet",
                      partitioning=ds.partitioning(
                          esquema, flavor="hive") if particiones
                      else None)
    claves = len(s3.find(ruta))
    for frag in lago.get_fragments(filtro):  # footers that are needed
        frag.ensure_complete_metadata()
    t = lago.to_table(                       # reads in a fixed order
        columns=columnas, filter=filtro, use_threads=False,
        fragment_scan_options=ds.ParquetFragmentScanOptions(
            pre_buffer=False))
    listados = math.ceil(claves / 1000)   # 1,000 keys per LIST
    return {"filas": t.num_rows, "LIST": listados, "GET": s3.get,
            "MB": round(s3.bytes_get / 1e6, 2),
            "USD_por_millón": round((s3.get * GET_1000 + listados
                                     * PUT_LIST_1000) * 1000, 2)}

un_dia = ((pc.field("fecha") == sesiones[9].date())
          & (pc.field("simbolo") == "BAGO"))
filas = {}
for k, (ruta, part) in disenos.items():
    filas[(k, "BAGO, one day")] = consultar(ruta, part, un_dia,
                                            ["ts", "precio"])
    filas[(k, "all, one column")] = consultar(ruta, part, None,
                                              ["precio"])
pd.DataFrame(list(filas.values()),
             index=pd.MultiIndex.from_tuples(filas))
OUT [04]The same query on the three designs: requests and cost
                                     filas  LIST   GET     MB  USD_por_millón
one file          BAGO, one day       1016     1    10   0.79             9.0
                  all, one column  1999891     1    64   5.75            30.6
by day            BAGO, one day       1016     1     5   0.31             7.0
                  all, one column  1999891     1   102   7.22            45.8
by day and symbol BAGO, one day       1016     2     4   0.04            11.6
                  all, one column  1999891     2  4061  36.90          1634.4

The figure at the top plots the cost column. What the numbers say:

  • For the point query, all three designs are cheap: between 4 and 10 requests. The single file needs more GET requests (10) because it has to read the footer and the chunks of several columns in the candidate row groups; the by-day-and-symbol design reads only 0.04 MB, but needs 2 LIST requests (2,000 keys, 1,000 per request), which on S3 cost more than GET requests.
  • For the full read of a column, small objects are a disaster: 4,061 GET requests and 36.90 MB, more than the whole lake (27.9 MB). With 14 KB objects, the first footer read (64 KB) already brings the whole object, and then the reader requests the column again. A million reads like that would cost about 1,634 USD in requests alone, versus 31 USD with a single object.
  • The by-day design is the reasonable compromise here: cheap on both queries. With real data of hundreds of gigabytes, the equivalent is partitioning by date with objects of hundreds of megabytes, not kilobytes.

Time follows the same logic, even more strongly: every request to S3 has a latency of tens of milliseconds, much higher than that of a local disk, so thousands of small sequential requests can take minutes even if they download only a few megabytes. Readers compensate by issuing requests in parallel, but the number of requests is still what you have to watch.

Lakehouse: a transaction log on top of the lake

A lake of files has three shortcomings that a database does not: there are no transactions (if a process rewrites ten files and fails on the sixth, the lake is left half done), there are no versions (correcting a day erases what was there) and two writers at the same time can overwrite each other. Lakehouse formats (Delta Lake, Apache Iceberg, Apache Hudi) add a transaction log on top of the Parquet files: an ordered list of commits, each one with the files it adds and the ones it removes. The table, at a given version, is the set of live files after applying the commits up to it.

_registro/000000.json: add x15000001.json: add x5000002.json: remove, add000003.json: compactdatos/*.parquet
Mermaid source
flowchart LR
  R[_registro/] --> V0[000000.json: add x15]
  R --> V1[000001.json: add x5]
  R --> V2[000002.json: remove, add]
  R --> V3[000003.json: compact]
  V3 --> D[datos/*.parquet]

Data files are never modified: correcting means writing a new file and removing the old one in the same commit (copy-on-write). And the commit is atomic: the file 000004.json either exists in full or does not exist. To understand the idea without depending on any library, the following cells build a minimal log with JSON and Parquet files. It is not Delta or Iceberg, but it has their pieces: numbered commits, writing with open(..., "x") (create only if it does not exist: the local equivalent of a conditional write), reading any version and cleaning up old files.

IN [05]python
import json
import os
from pathlib import Path

RAIZ = Path("tabla_ticks")
(RAIZ / "_registro").mkdir(parents=True)
(RAIZ / "datos").mkdir()

def confirmar(version, acciones):
    """Writes commit `version` only if it does not exist (put-if-absent)."""
    ruta = RAIZ / "_registro" / f"{version:06d}.json"
    with open(ruta, "x") as f:           # "x": fails if it already exists
        json.dump(acciones, f)

def ficheros_vivos(version):
    vivos = set()
    for v in range(version + 1):
        ruta = RAIZ / "_registro" / f"{v:06d}.json"
        for a in json.loads(ruta.read_text()):
            if a["op"] == "add":
                vivos.add(a["f"])
            elif a["op"] == "remove":
                vivos.discard(a["f"])
    return sorted(vivos)

def ultima_version():
    return len(list((RAIZ / "_registro").glob("*.json"))) - 1

def leer(version=None):
    v = ultima_version() if version is None else version
    return pa.concat_tables(
        pq.read_table(RAIZ / "datos" / f) for f in ficheros_vivos(v))

def escribir_fichero(df, nombre):
    pq.write_table(pa.Table.from_pandas(df, preserve_index=False),
                   RAIZ / "datos" / nombre, compression="zstd")
    return {"op": "add", "f": nombre, "filas": len(df)}

dias = [d for d, _ in ticks.groupby("fecha")]
# version 0: the first 15 sessions, one file per day
confirmar(0, [escribir_fichero(ticks[ticks["fecha"] == d],
                               f"{d}-a.parquet") for d in dias[:15]])
# version 1: the next 5 sessions arrive
confirmar(1, [escribir_fichero(ticks[ticks["fecha"] == d],
                               f"{d}-a.parquet") for d in dias[15:]])
print("version", ultima_version(), "|",
      len(ficheros_vivos(1)), "live files |",
      f"{leer().num_rows:,} rows")
OUT [05]A transaction log with files: atomic commits
version 1 | 20 live files | 1,999,891 rows

Two commits: the first adds the first 15 sessions (one file per day) and the second, the next 5. The table at version 1 has 20 live files and the 1,999,891 ticks. Now, two operations that would be risky in a lake of loose files: the vendor corrects March 14 (it cancels the single-share prints, which in this data are 2,058 rows) and then the 20 daily files are compacted into one.

IN [06]python
# version 2: the vendor corrects March 14 (copy-on-write)
d14 = ticks[ticks["fecha"] == dias[9]].copy()
d14 = d14[d14["tamano"] > 1]        # cancels the single-share prints
confirmar(2, [{"op": "remove", "f": f"{dias[9]}-a.parquet"},
              escribir_fichero(d14, f"{dias[9]}-b.parquet")])
# version 3: compact the 20 daily files into one
todo = leer().to_pandas()
confirmar(3, [{"op": "remove", "f": f} for f in ficheros_vivos(2)]
          + [escribir_fichero(todo, "compacto-0.parquet")])

historia = pd.DataFrame([
    {"versión": v, "ficheros_vivos": len(ficheros_vivos(v)),
     "filas": leer(v).num_rows,
     "filas_14_marzo": pc.sum(pc.equal(
         leer(v)["fecha"], pa.scalar(dias[9]))).as_py() or 0}
    for v in range(4)])
en_disco = len(list((RAIZ / "datos").iterdir()))
print(en_disco, "files on disk")
historia
OUT [06]Correction, compaction and time travel
22 files on disk
   versión  ficheros_vivos    filas  filas_14_marzo
0        0              15  1499553           99833
1        1              20  1999891           99833
2        2              20  1997833           97775
3        3               1  1997833           97775
Fig. — Data files on disk and live files at each version of the lesson's transaction log

Every version remains readable (time travel): version 0 has 15 files and 1,499,553 rows; version 1, 20 files and 1,999,891; version 2 replaces the March 14 file and drops to 97,775 rows that day; version 3 has a single file with the same 1,997,833 rows as version 2. A reader that started reading version 2 while the compaction was running sees nothing odd: its 20 files are still on disk, because compaction only adds a new one and records in the log that the old ones no longer count. On disk there are 22 files (the 20 originals, the correction and the compacted one) for 1 live file.

Atomicity also solves concurrent writers. If two processes read version 3 and both try to write version 4, only one can create 000004.json; the second gets an error and must reread the log, check whether its change is still valid and try again with version 5 (optimistic concurrency):

IN [07]python
version_leida = ultima_version()            # two writers read version 3
confirmar(version_leida + 1,
          [{"op": "nota", "texto": "writer A"}])
confirmar(version_leida + 1,                # the second one arrives late
          [{"op": "nota", "texto": "writer B"}])
FileExistsError: [Errno 17] File exists: 'tabla_ticks/_registro/000004.json'

On a local disk, open(..., "x") is atomic. In object storage you need its equivalent: S3 supports conditional writes with the If-None-Match header, which prevent overwriting an object that already exists4Note 4 · AWS, “Conditional writes”: https://docs.aws.amazon.com/AmazonS3/latest/userguide/conditional-writes.html, accessed on October 23, 2026., and Iceberg delegates the version change to a catalog that atomically swaps the pointer to the current metadata file5Note 5 · Apache Iceberg, format specification (metadata, snapshots, manifests and optimistic concurrency): https://iceberg.apache.org/spec/, accessed on October 23, 2026..

What nobody cleans up are the files that no version uses any longer. Vacuum deletes them, and with them the ability to travel to the versions that used them:

IN [08]python
def vacuum(desde_version):
    """Deletes the data that no version >= desde_version uses."""
    usados = set()
    for v in range(desde_version, ultima_version() + 1):
        usados |= set(ficheros_vivos(v))
    borrados = 0
    for f in (RAIZ / "datos").iterdir():
        if f.name not in usados:
            f.unlink()
            borrados += 1
    return borrados

print("deleted:", vacuum(desde_version=3),
      "| remaining:", len(list((RAIZ / "datos").iterdir())))
try:
    leer(1)
except FileNotFoundError:
    print("version 1 can no longer be read")
print(f"{leer().num_rows:,} rows in the latest version")
OUT [08]Vacuum: delete what no version references any longer
deleted: 21 | remaining: 1
version 1 can no longer be read
1,997,833 rows in the latest version

It deletes 21 files and keeps the compacted one. From that moment on, version 1 can no longer be read: the log describes it, but its data no longer exists. That is why real formats impose a retention period (for example, not deleting anything that stopped being used less than a week ago) and why it pays to decide beforehand how much history you need to be able to reproduce, as with ArcticDB versions in lesson 6.6.4.

Delta Lake and Iceberg

Delta Lake stores the log in _delta_log/: one JSON file per commit (00000000000000000042.json) with actions like those in the cell, plus periodic checkpoints in Parquet that summarize the state so that not all the JSON files have to be read from the beginning6Note 6 · Delta Lake, transaction protocol: https://github.com/delta-io/delta/blob/master/PROTOCOL.md; delta-rs: https://delta-io.github.io/delta-rs/latest/. Accessed on October 23, 2026.. Apache Iceberg uses a hierarchy of metadata: a table metadata file with its snapshots; each snapshot, a manifest list (since version 4 of the format, a root manifest) and each manifest, a list of data files with their statistics; a catalog stores the pointer to the current metadata file and each commit swaps it atomically5.

Neither of the two is installed in the course environment (deltalake and pyiceberg were not added), so the code is not run. With deltalake (delta-rs, the Rust implementation with a Python API), the operations from the previous section are:

IN [09]delta_ticks.pypython
from deltalake import DeltaTable, write_deltalake

write_deltalake("s3://mi-cubo/delta/ticks", ticks_v0,
                partition_by=["fecha"])                 # version 0
write_deltalake("s3://mi-cubo/delta/ticks", ticks_nuevos,
                mode="append")                           # version 1
dt = DeltaTable("s3://mi-cubo/delta/ticks")
dt.load_as_version(0)                    # time travel
v0 = dt.to_pyarrow_table()
dt = DeltaTable("s3://mi-cubo/delta/ticks")
dt.optimize.compact()                    # compact small files
dt.vacuum(retention_hours=168, dry_run=False)

And with PyIceberg, which needs a configured catalog (REST, Glue, SQL…):

IN [10]iceberg_ticks.pypython
from pyiceberg.catalog import load_catalog

catalogo = load_catalog("produccion")   # in .pyiceberg.yaml
tabla = catalogo.load_table("mercado.ticks")
bago = tabla.scan(row_filter="simbolo == 'BAGO'",
                  selected_fields=("ts", "precio")).to_arrow()
for s in tabla.snapshots():
    print(s.snapshot_id, s.timestamp_ms)
antes = tabla.scan(snapshot_id=tabla.snapshots()[0].snapshot_id)

The choice between the two is usually dictated by the platform and the engines already in use; the most visible design difference is that Iceberg makes the catalog explicit, while Delta keeps all the state next to the data. For a solo researcher with a Parquet lake, neither is essential; they start to pay off when several processes write to the same lake or you need to reproduce the state of the data as of a date.

GPU: RAPIDS, cuDF and Polars

GPUs, designed for graphics and today for training networks, also work for tables: thousands of cores apply the same operation to millions of rows at once. RAPIDS is NVIDIA's set of libraries for that, and cuDF its GPU DataFrame. Its cudf.pandas mode speeds up pandas code without changing it: the operations cuDF knows how to do go to the GPU and the rest automatically fall back to pandas on the CPU7Note 7 · NVIDIA RAPIDS, cudf.pandas and installation guide: https://docs.rapids.ai/api/cudf/stable/cudf_pandas/ and https://docs.rapids.ai/install/, accessed on October 23, 2026..

IN [11]cudf_pandas.pypython
# In Jupyter: %load_ext cudf.pandas before importing pandas
# As a script: python -m cudf.pandas barras_diarias.py
import pandas as pd

ticks = pd.read_parquet("ticks/")             # on the GPU if possible
diarias = ticks.groupby(["simbolo", "fecha"]).agg(
    volumen=("tamano", "sum"), maximo=("precio", "max"))

Polars has a GPU engine (in open beta, built on cuDF) that is requested with collect(engine="gpu"); if an operation is not supported, the whole query runs on the CPU, unless the opposite is requested with pl.GPUEngine(raise_on_fail=True)8Note 8 · Polars, “GPU Support”: https://docs.pola.rs/user-guide/gpu-support/, accessed on October 23, 2026.. The requirements, according to the RAPIDS and Polars documentation: an NVIDIA GPU with Volta architecture or later (compute capability 7.0 or higher), CUDA 12 or 13 and Linux or WSL278. Without any of that, Polars does not even attempt the query: :::cell{title="Polars with engine="gpu" without a GPU"}

IN [12]python
import polars as pl

consulta = pl.LazyFrame(ticks).group_by("simbolo").agg(
    pl.col("tamano").sum())
try:
    consulta.collect(engine="gpu")
except ModuleNotFoundError as e:
    print(str(e).splitlines()[0])
print(consulta.collect().height, "groups on CPU")
GPU engine requested, but required package 'cudf_polars' could not be imported.
100 groups on CPU

:::

Without the cudf_polars package the request fails with a clear message; the same query on the CPU returns the 100 groups. On a machine with a GPU and pip install polars[gpu], the code would not change.

When a GPU pays off for tabular data: aggregations, joins and sorts over tens or hundreds of millions of rows that fit in the card's memory, repeated many times (a feature sweep, a vectorized backtest over many assets). When it does not: data that does not fit in GPU memory (much smaller than a server's RAM), queries that read little and compute less (the bottleneck is the disk or the network, not computation) and code with many operations the GPU does not support, which go back and forth to the CPU copying data over the bus. As with the clusters in lesson 6.6.3, you first have to measure where the time goes.

Costs and decisions

NeedOptionWhat it costs
Lake shared by a team, serverlessParquet on S3 or GCS, partitioned by dateRequests and storage; watch out for small files
Concurrent writes, corrections, versionsDelta Lake or Iceberg on top of the lakeA log and, with Iceberg, a catalog; periodic compaction and vacuum
Per-symbol series with versions from PythonArcticDB on S3 (lesson 6.6.4)BSL license; its own data model
Repeated massive aggregationsGPU with cuDF or Polars on GPUNVIDIA hardware, card memory, partial operation support
Solo research, data on one diskLocal Parquet, Polars and DuckDB (lesson 6.6.2)Nothing new

Exercises

  1. A backtest requests the ticks for each of the 100 symbols in each of the 20 sessions (2,000 point queries like “BAGO, one day”). With the lesson's request table, how much does it cost in S3 requests with each design? And if it were run every night for a year?
Solution
IN [13]python
tabla_p = pd.DataFrame(list(filas.values()),
                       index=pd.MultiIndex.from_tuples(filas))
puntual = tabla_p.xs("BAGO, one day", level=1)
coste = (puntual["GET"] * GET_1000 + puntual["LIST"] * PUT_LIST_1000) \
    / 1000 * 2000
pd.DataFrame({"USD_backtest": coste.round(4),
              "USD_año": (coste * 252).round(2)})
OUT [13]The request cost of a backtest
                   USD_backtest  USD_año
one file                 0.0180     4.54
by day                   0.0140     3.53
by day and symbol        0.0232     5.85

Cents: between 0.014 and 0.023 USD per backtest and between 3.5 and 5.9 USD a year running it every session. For point queries, S3 requests are cheap with any reasonable design; LIST requests weigh more than GET requests, and that is why the 2,000-object design, which needs two per query, is the most expensive even though it downloads less. The real cost appears in full reads with small objects (the lesson's second query) and, outside this calculation, in data transfer and in time: 2,000 sequential queries with tens of milliseconds of latency per request add up to minutes. A backtest that reads the same data every night should also keep a local copy or read in batches.

  1. Write a function that, reading only the log (without opening any Parquet file), returns for each version how many files it adds and removes and how many rows it adds.
Solution
IN [14]python
def historial():
    filas_h = []
    for v in range(ultima_version() + 1):
        ruta = RAIZ / "_registro" / f"{v:06d}.json"
        acciones = json.loads(ruta.read_text())
        filas_h.append({
            "versión": v,
            "añade": sum(a["op"] == "add" for a in acciones),
            "retira": sum(a["op"] == "remove" for a in acciones),
            "filas_añadidas": sum(a.get("filas", 0) for a in acciones
                                  if a["op"] == "add")})
    return pd.DataFrame(filas_h)

historial()
OUT [14]The history from the log alone
   versión  añade  retira  filas_añadidas
0        0     15       0         1499553
1        1      5       0          500338
2        2      1       1           97775
3        3      1      20         1997833
4        4      0       0               0

Five versions: version 4 is writer A's note, which neither adds nor removes anything. The history comes entirely from the log, without opening any Parquet file; real formats store much more in each action (size, column statistics, number of rows) so that a reader can discard files without opening them, as the Parquet footer does inside a file. The last cell deletes the table and the simulated bucket.

IN [15]python
import shutil

shutil.rmtree(RAIZ)
s3.rm(CUBO, recursive=True)
print(sorted(p.name for p in Path(".").glob("tabla_*")),
      s3.exists(CUBO))
OUT [15]Cleanup
[] False
  1. A team processes 300 million rows of 1-minute bars (about 20 GB in memory) in pandas every night to compute 40 features. It takes two hours. Someone proposes buying a GPU. What would you check first?
Solution

First, where the two hours go: measure how much is reading, how much conversion and how much computation, and the peak memory of each step (lesson 6.6.1). If most of it is reading and converting data (CSV, object columns, poorly chosen types), the GPU does not help: what helps is well-partitioned Parquet and reading only the columns needed (lesson 6.6.2). Second, whether the computation can be vectorized: per-row Python loops or custom functions with apply do not go to the GPU, and in cudf.pandas they would fall back to the CPU copying data. Third, whether it fits: 20 GB in pandas can be quite a bit less in Arrow, but you have to check that it fits in the card's memory along with its intermediate structures. Fourth, the cheap alternative: the same computation in lazy Polars or DuckDB on the CPU usually speeds up a pandas process a lot without new hardware. Only if, after all that, the bottleneck is computation on data that fits on the GPU is it worth trying one, for example by renting one by the hour in the cloud before buying it, and measuring with the real data.

Footnotes

  1. AWS, “What is Amazon S3?”, “Organizing objects using prefixes”, “Copying objects” (renaming), GetObject (Range) and ListObjectsV2: https://docs.aws.amazon.com/AmazonS3/latest/userguide/Welcome.html and https://docs.aws.amazon.com/AmazonS3/latest/API/API_ListObjectsV2.html, accessed on October 23, 2026. ↩ ↩2 ↩3 ↩4

  2. AWS, announcement of December 1, 2020 on strong consistency in S3: https://aws.amazon.com/about-aws/whats-new/2020/12/amazon-s3-now-delivers-strong-read-after-write-consistency-automatically-for-all-applications/; Google Cloud, “Consistency”: https://cloud.google.com/storage/docs/consistency. Accessed on October 23, 2026. ↩

  3. AWS, “Amazon S3 pricing”: https://aws.amazon.com/s3/pricing/, accessed on October 23, 2026. Prices change and depend on the region and the storage class. ↩

  4. AWS, “Conditional writes”: https://docs.aws.amazon.com/AmazonS3/latest/userguide/conditional-writes.html, accessed on October 23, 2026. ↩

  5. Apache Iceberg, format specification (metadata, snapshots, manifests and optimistic concurrency): https://iceberg.apache.org/spec/, accessed on October 23, 2026. ↩ ↩2

  6. Delta Lake, transaction protocol: https://github.com/delta-io/delta/blob/master/PROTOCOL.md; delta-rs: https://delta-io.github.io/delta-rs/latest/. Accessed on October 23, 2026. ↩

  7. NVIDIA RAPIDS, cudf.pandas and installation guide: https://docs.rapids.ai/api/cudf/stable/cudf_pandas/ and https://docs.rapids.ai/install/, accessed on October 23, 2026. ↩ ↩2

  8. Polars, “GPU Support”: https://docs.pola.rs/user-guide/gpu-support/, accessed on October 23, 2026. ↩ ↩2

Each answer is folded under its question

Self-assessment

  1. Reading the whole precio column costs 64 GET requests with a single object and 4,061 with 2,000 objects of 14 KB, which also download 36.9 MB from a 27.9 MB lake. Why?

    • ABecause S3 compresses small objects worse
    • BBecause every object requires at least one read of the footer and another of the column, and with 14 KB objects the footer read (64 KB) already brings the whole object: many requests and repeated bytes
    • CBecause small objects have no statistics
    • DBecause LIST returns only 1,000 keys
    Show answer

    Answer B. Because every object requires at least one read of the footer and another of the column, and with 14 KB objects the footer read (64 KB) already brings the whole object: many requests and repeated bytes

    A million reads like that would cost about 1,634 USD in requests versus 31 USD with one object. This is the small-files problem, and the solution is to compact into objects of hundreds of megabytes.

  2. What guarantees that two writers that read version 3 of the table do not both publish a different version 4?

    • AThat each one writes its data files into different folders
    • BAn operating system lock for the whole write
    • CThat the reader picks the most recent version
    • DThat the commit is created only if it does not exist (open(..., "x") locally, a conditional write on S3, an atomic swap of the pointer in the Iceberg catalog): the second one gets an error, rereads and retries
    Show answer

    Answer D. That the commit is created only if it does not exist (open(..., "x") locally, a conditional write on S3, an atomic swap of the pointer in the Iceberg catalog): the second one gets an error, rereads and retries

    This is optimistic concurrency: nothing is locked while the data is written; only the final step, publishing the commit, is atomic. The second writer gets FileExistsError and must check whether its change is still valid.

  3. After compaction (version 3) there are 22 files on disk and 1 live. After vacuum(desde_version=3), what is lost?

    • ANothing: the log keeps the versions
    • BThe latest version
    • CThe ability to read versions 0 to 2: the log describes them, but their data files have been deleted
    • DThe compaction, which is undone
    Show answer

    Answer C. The ability to read versions 0 to 2: the log describes them, but their data files have been deleted

    Vacuum deletes the files that no retained version uses. That is why real formats impose a retention period: time travel only reaches as far back as the data does.

  4. A Polars query with collect(engine="gpu") includes an operation the GPU engine does not support. What happens by default?

    • AThe whole query runs on the CPU with the normal engine, unless GPUEngine(raise_on_fail=True) is requested
    • BIt always fails
    • CIt runs approximately on the GPU
    • DPolars installs cuDF automatically
    Show answer

    Answer A. The whole query runs on the CPU with the normal engine, unless GPUEngine(raise_on_fail=True) is requested

    According to the Polars documentation, the GPU engine (in open beta) transparently falls back to the CPU. Without the cudf_polars package installed, however, the request fails with ModuleNotFoundError, as in the lesson.

  5. What does the lesson's cost table count and what does it leave out?

    • AThe entire cost of S3
    • BOnly GET and LIST requests at S3 Standard prices; storage, data transfer and latency are left out
    • COnly storage
    • DThe cost of the GPU
    Show answer

    Answer B. Only GET and LIST requests at S3 Standard prices; storage, data transfer and latency are left out

    The S3 pricing page charges separately for storage, requests and transfer. The lesson measures requests because they are what changes with the lake design; the latency of each request also turns thousands of small requests into minutes.

Key points

  • Object storage is flat, is written in whole objects, is read by ranges, is strongly consistent (S3 since December 2020) and charges for every request: 0.0004 USD per 1,000 GET and 0.005 USD per 1,000 PUT or LIST in S3 Standard.
  • With the same two million ticks, reading a whole column costs 64 GET with one object, 102 with one per day and 4,061 with 2,000 small objects, which also download more bytes than the whole lake: partition by date with large objects and compact.
  • A lakehouse adds a transaction log on top of the Parquet files: atomic commits that add and remove files, copy-on-write for corrections, time travel, optimistic concurrency and vacuum, which deletes the files and with them the old versions.
  • Delta Lake stores one JSON per commit and checkpoints in _delta_log/; Iceberg, metadata, snapshots and manifests with a catalog that atomically swaps the pointer to the current version.
  • The GPU (RAPIDS, cudf.pandas, Polars with engine="gpu") speeds up massive aggregations that fit in its memory; it requires NVIDIA Volta or later, CUDA 12 or 13 and Linux or WSL2, and before buying one you have to measure whether the bottleneck is computation.

Resources