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.
- Module
- 6.6 · Big data for trading
- Duration
- 60 min
- Prerequisites
- 6.6.2 · Parquet, Arrow, Polars streaming and out-of-core DuckDB, 6.6.4 · Time series databases: TimescaleDB, QuestDB, ClickHouse, ArcticDB and kdb+
- Author
- Jesús Cuesta
- Updated
Level 6 lessons
6.1 · Classical and modern financial NLP
6.2 · LLMs for research and trading
6.3 · Financial RAG
6.4 · AI agents for trading
- 6.4.1 Tool-calling agents: tool calling, ReAct and planning
- 6.4.2 Agent frameworks and MCP
- 6.4.3 TradingAgents-style multi-agent architectures
- 6.4.4 Evidence and limits of agents
- 6.4.5 Guardrails for agents: the agent proposes, the engine decides
- 6.4.6 Research agents: hypotheses, alpha mining and review
6.5 · AI as the quant's copilot
6.6 · Big data for trading
- 6.6.1 When data becomes “big”: ticks, order books, options and universes
- 6.6.2 Parquet, Arrow, Polars streaming and out-of-core DuckDB
- 6.6.3 Dask, Spark and Ray: from one machine to a cluster
- 6.6.4 Time series databases: TimescaleDB, QuestDB, ClickHouse, ArcticDB and kdb+
- 6.6.5 Cloud storage, lakehouse (Delta, Iceberg) and GPU (RAPIDS, cuDF)
6.7 · Alternative data
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) andListObjectsV2: 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:ListObjectsV2returns 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
GETwith theRangeheader 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,
GETrequests cost 0.0004 USD per 1,000 andPUT,COPY,POSTandLIST, 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.
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.
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__)PyFileSystem on AlmacenObjetosReal 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:
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.
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")
escritura1,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.9Writing 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.
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)) 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.4The 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
GETrequests (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 2LISTrequests (2,000 keys, 1,000 per request), which on S3 cost more thanGETrequests. - For the full read of a column, small objects are a disaster: 4,061
GETrequests 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.
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.
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")version 1 | 20 live files | 1,999,891 rowsTwo 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.
# 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")
historia22 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 97775Every 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):
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:
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")deleted: 21 | remaining: 1
version 1 can no longer be read
1,997,833 rows in the latest versionIt 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:
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…):
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 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"}
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
| Need | Option | What it costs |
|---|---|---|
| Lake shared by a team, serverless | Parquet on S3 or GCS, partitioned by date | Requests and storage; watch out for small files |
| Concurrent writes, corrections, versions | Delta Lake or Iceberg on top of the lake | A log and, with Iceberg, a catalog; periodic compaction and vacuum |
| Per-symbol series with versions from Python | ArcticDB on S3 (lesson 6.6.4) | BSL license; its own data model |
| Repeated massive aggregations | GPU with cuDF or Polars on GPU | NVIDIA hardware, card memory, partial operation support |
| Solo research, data on one disk | Local Parquet, Polars and DuckDB (lesson 6.6.2) | Nothing new |
Exercises
- 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
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)}) USD_backtest USD_año
one file 0.0180 4.54
by day 0.0140 3.53
by day and symbol 0.0232 5.85Cents: 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.
- 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
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() 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 0Five 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.
import shutil
shutil.rmtree(RAIZ)
s3.rm(CUBO, recursive=True)
print(sorted(p.name for p in Path(".").glob("tabla_*")),
s3.exists(CUBO))[] False- 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
-
AWS, “What is Amazon S3?”, “Organizing objects using prefixes”, “Copying objects” (renaming),
GetObject(Range) andListObjectsV2: 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 -
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. ↩
-
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. ↩
-
AWS, “Conditional writes”: https://docs.aws.amazon.com/AmazonS3/latest/userguide/conditional-writes.html, accessed on October 23, 2026. ↩
-
Apache Iceberg, format specification (metadata, snapshots, manifests and optimistic concurrency): https://iceberg.apache.org/spec/, accessed on October 23, 2026. ↩ ↩2
-
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. ↩
-
NVIDIA RAPIDS,
cudf.pandasand installation guide: https://docs.rapids.ai/api/cudf/stable/cudf_pandas/ and https://docs.rapids.ai/install/, accessed on October 23, 2026. ↩ ↩2 -
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
Reading the whole
preciocolumn costs 64GETrequests 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
LISTreturns 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.
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 retriesThis is optimistic concurrency: nothing is locked while the data is written; only the final step, publishing the commit, is atomic. The second writer gets
FileExistsErrorand must check whether its change is still valid.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.
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 requestedAccording to the Polars documentation, the GPU engine (in open beta) transparently falls back to the CPU. Without the
cudf_polarspackage installed, however, the request fails withModuleNotFoundError, as in the lesson.- AThe whole query runs on the CPU with the normal engine, unless
What does the lesson's cost table count and what does it leave out?
- AThe entire cost of S3
- BOnly
GETandLISTrequests at S3 Standard prices; storage, data transfer and latency are left out - COnly storage
- DThe cost of the GPU
Show answer
Answer B. Only
GETandLISTrequests at S3 Standard prices; storage, data transfer and latency are left outThe 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 withengine="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.