<?xml version="1.0" ?><entry xml:lang="en-US" xmlns="http://www.w3.org/2005/Atom" xmlns:planet="http://planet.intertwingly.net/"><id>http://blog.ezyang.com/?p=10354</id><link href="http://blog.ezyang.com/2024/10/tensor-programming-for-databases-with-first-class-dimensions/" rel="alternate" type="text/html"/><link href="http://blog.ezyang.com/2024/10/tensor-programming-for-databases-with-first-class-dimensions/#comments" rel="replies" type="text/html"/><link href="http://blog.ezyang.com/2024/10/tensor-programming-for-databases-with-first-class-dimensions/feed/atom/" rel="replies" type="application/atom+xml"/><title xml:lang="en-US">Tensor programming for databases, with first class dimensions</title><summary xml:lang="en-US">Tensor libraries like PyTorch and JAX have developed compact and accelerated APIs for manipulating n-dimensional arrays. N-dimensional arrays are kind of similar to tables in database, and this results in the logical question which is could you setup a Tensor-like API to do queries on databases that would be normally done with SQL? We have […]</summary><content type="xhtml" xml:lang="en-US"><div xmlns="http://www.w3.org/1999/xhtml"><div class="document">



<p>Tensor libraries like PyTorch and JAX have developed compact and accelerated APIs for manipulating n-dimensional arrays. N-dimensional arrays are kind of similar to tables in database, and this results in the logical question which is could you setup a Tensor-like API to do queries on databases that would be normally done with SQL? We have two challenges:</p>
<ul class="simple">
<li>Tensor computation is typically uniform and data-independent.  But SQL relational queries are almost entirely about filtering and joining data in a data-dependent way.</li>
<li>JOINs in SQL can be thought of as performing outer joins, which is not a very common operation in tensor computation.</li>
</ul>
<p>However, we have a secret weapon: <a class="reference external" href="https://github.com/facebookresearch/torchdim/blob/main/torchdim.ipynb">first class dimensions</a> were primarily designed to as a new frontend syntax that made it easy to express einsum, batching and tensor indexing expressions.  They might be good for SQL too.</p>
<p><strong>Representing the database.</strong> First, how do we represent a database? A simple model following columnar database is to have every column be a distinct 1D tensor, where all columns part of the same table have a consistent indexing scheme.  For simplicity, we'll assume that we support rich dtypes for the tensors (e.g., so I can have a tensor of strings).  So if we consider our classic customer database of <tt class="docutils literal">(id, name, email)</tt>, we would represent this as:</p>
<pre class="literal-block">customers_id: int64[C]
customers_name: str[C]
customers_email: str[C]
</pre>
<p>Where C is the number of the entries in the customer database.  Our tensor type is written as <tt class="docutils literal">dtype[DIM0, DIM1, <span class="pre">...]</span></tt>, where I reuse the name that I will use for the first class dimension that represents it.  Let's suppose that the index into C does <em>not</em> coincide with id (which is good, because if they did coincide, you would have a very bad time if you ever wanted to delete an entry from the database!)</p>
<p>This gives us an opportunity for baby's first query: let's implement this query:</p>
<pre class="literal-block">SELECT c.name, c.email FROM customers c WHERE c.id = 1000
</pre>
<p>Notice that the result of this operation is data-dependent: it may be zero or one depending on if the id is in the database.  Here is a naive implementation in standard PyTorch:</p>
<pre class="literal-block">mask = customers_id == 1000
return (customers_name[mask], customers_email[mask])
</pre>
<p>Here, we use boolean masking to perform the data-dependent filtering operation.  This implementation in eager is a bit inefficient; we materialize a full boolean mask that is then fed into the subsequent operations; you would prefer for a compiler to fuse the masking and indexing together.  First class dimensions don't really help with this example, but we need to introduce some new extensions to first class dimensions.  First, what we can do:</p>
<pre class="literal-block">C = dims(1)
c_id = customers_id[C]  # {C} =&gt; int64[]
c_name = customers_name[C]  # {C} =&gt; str[]
c_email = customers_email[C]  # {C} =&gt; str[]
c_mask = c_id == 1000  # {C} =&gt; bool[]
</pre>
<p>Here, a tensor with first class tensors has a more complicated type <tt class="docutils literal">{DIM0, DIM1, <span class="pre">...}</span> =&gt; dtype[DIM2, DIM3, <span class="pre">...]</span></tt>.  The first class dimensions are all reported in the curly braces to the left of the double arrow; curly braces are used to emphasize the fact that first class dimensions are unordered.</p>
<p>What next? The problem is that now we want to do something like <tt class="docutils literal">torch.where(c_mask, c_name, <span class="pre">???)</span></tt> but we are now in a bit of trouble, because we don't want anything in the false branch of where: we want to provide something like &quot;null&quot; and collapse the tensor to a smaller number of elements, much like how boolean masking did it without first class dimensions.  To express this, we'll introduce a binary version of torch.where that does exactly this, as well as returning the newly allocated FCD for the new, data-dependent dimension:</p>
<pre class="literal-block">C2, c2_name = torch.where(c_mask, c_name)  # {C2} =&gt; str[]
_C2, c2_email = torch.where(c_mask, c_email)  # {C2} =&gt; str[], n.b. C2 == _C2
return c2_name, c2_email
</pre>
<p>Notice that torch.where introduces a new first-class dimension. I've chosen that this FCD gets memoized with <tt class="docutils literal">c_mask</tt>, so whenever we do more <tt class="docutils literal">torch.where</tt> invocations we still get consistently the same new FCD.</p>
<p>Having to type out all the columns can be a bit tiresome.  If we assume all elements in a table have the same dtype (let's call it <tt class="docutils literal">dyn</tt>, short for dynamic type), we can more compactly represent the table as a 2D tensor, where the first dimension is the indexing as before, and the second dimension is the columns of the database.  For clarity, we'll support using the string name of the column as a shorthand for the numeric index of the column.  If the tensor is contiguous, this gives a more traditional row-wise database.  The new database can be conveniently manipulated with FCDs, as we can handle all of the columns at once instead of typing them out individually):</p>
<pre class="literal-block">customers:  dyn[C, C_ATTR]
C = dims(1)
c = customers[C]  # {C} =&gt; dyn[C_ATTR]
C2, c2 = torch.where(c[&quot;id&quot;] == 1000, c)  # {C2} =&gt; dyn[C_ATTR]
return c2[[&quot;name&quot;, &quot;email&quot;]].order(C2)  # dyn[C2, [&quot;name&quot;, &quot;email&quot;]]
</pre>
<p>We'll use this for the rest of the post, but the examples should be interconvertible.</p>
<p><strong>Aggregation.</strong>  What's the average age of all customers, grouped by the country they live in?</p>
<pre class="literal-block">SELECT AVG(c.age) FROM customers c GROUP BY c.country;
</pre>
<p>PyTorch doesn't natively support this grouping operation, but essentially what is desired here is a conversion into a <strong>nested tensor</strong>, where the jagged dimension is the country (each of which will have a varying number of countries).  Let's hallucinate a <tt class="docutils literal">torch.groupby</tt> analogous to its Pandas equivalent:</p>
<pre class="literal-block">customers: dyn[C, C_ATTR]
customers_by_country = torch.groupby(customers, &quot;country&quot;)  # dyn[COUNTRY, JC, C_ATTR]
COUNTRY, JC = dims(2)
c = customers_by_country[COUNTRY, JC]  # {COUNTRY, JC} =&gt; dyn[C_ATTR]
return c[&quot;age&quot;].mean(JC).order(COUNTRY)  # f32[COUNTRY]
</pre>
<p>Here, I gave the generic indexing dimension the name <tt class="docutils literal">JC</tt>, to emphasize that it is a <em>jagged</em> dimension.  But everything proceeds like we expect: after we've grouped the tensor and rebound its first class dimensions, we can take the field of interest and explicitly specify a reduction on the dimension we care about.</p>
<p>In SQL, aggregations have to operate over the entirety of groups specified by GROUP BY.  However, because FCDs explicitly specify what dimensions we are reducing over, we can potentially decompose a reduction into a series of successive reductions on different columns, without having to specify subqueries to progressively perform the reductions we are interested in.</p>
<p><strong>Joins.</strong> Given an order table, join it with the customer referenced by the customer id:</p>
<pre class="literal-block">SELECT o.id, c.name, c.email FROM orders o JOIN customers c ON o.customer_id = c.id
</pre>
<p>First class dimensions are great at doing outer products (although, like with filtering, it will expensively materialize the entire outer product naively!)</p>
<pre class="literal-block">customers: dyn[C, C_ATTR]
orders: dyn[O, O_ATTR]
C, O = dims(2)
c = customers[C]  # {C} =&gt; dyn[C_ATTR]
o = orders[O]  # {O} =&gt; dyn[O_ATTR]
mask = o[&quot;customer_id&quot;] == c[&quot;id&quot;]  # {C, O} =&gt; bool[]
outer_product = torch.cat(o[[&quot;id&quot;]], c[[&quot;name&quot;, &quot;email&quot;]])  # {C, O} =&gt; dyn[[&quot;id&quot;, &quot;name&quot;, &quot;email&quot;]]
CO, co = torch.where(mask, outer_product)  # {CO} =&gt; dyn[[&quot;id&quot;, &quot;name&quot;, &quot;email&quot;]]
return co.order(CO)  # dyn[C0, [&quot;id&quot;, &quot;name&quot;, &quot;email&quot;]]
</pre>
<p><strong>What's the point.</strong>  There are a few reasons why we might be interested in the correspondence here.  First, we might be interested in applying SQL ideas to the Tensor world: a lot of things people want to do in preprocessing are similar to what you do in traditional relational databases, and SQL can teach us what optimizations and what use cases we should think about.  Second, we might be interested in applying Tensor ideas to the SQL world: in particular, I think first class dimensions are a really intuitive frontend for SQL which can be implemented entirely embedded in Python without necessitating the creation of a dedicated DSL. Also, this might be the push needed to get <a class="reference external" href="https://github.com/pytorch/tensordict">TensorDict</a> into core.</p>
</div></div></content><updated planet:format="October 14, 2024 05:07 AM">2024-10-14T05:07:14Z</updated><published planet:format="October 14, 2024 05:07 AM">2024-10-14T05:07:14Z</published><category scheme="http://blog.ezyang.com" term="PyTorch"/><author><name>Edward Z. Yang</name><uri>http://ezyang.com</uri></author><source><id>http://blog.ezyang.com/feed/atom/</id><link href="http://blog.ezyang.com" rel="alternate" type="text/html"/><link href="http://blog.ezyang.com/feed/atom/" rel="self" type="application/atom+xml"/><subtitle xml:lang="en-US">the arc of software bends towards understanding</subtitle><title xml:lang="en-US">ezyang’s blog</title><updated planet:format="September 05, 2025 02:01 PM">2025-09-05T14:01:23Z</updated><planet:format>atom10</planet:format><planet:bozo>false</planet:bozo><planet:css-id>edward-z-yang</planet:css-id><planet:items_per_page>60</planet:items_per_page><planet:encoding>utf-8</planet:encoding><planet:name>Edward Z. Yang</planet:name><planet:days_per_page>0</planet:days_per_page><planet:http_last_modified>Sun, 19 Oct 2025 19:27:17 GMT</planet:http_last_modified><planet:http_status>200</planet:http_status></source></entry>