<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:cc="http://cyber.law.harvard.edu/rss/creativeCommonsRssModule.html">
    <channel>
        <title><![CDATA[Stories by Pinterest Engineering on Medium]]></title>
        <description><![CDATA[Stories by Pinterest Engineering on Medium]]></description>
        <link>https://medium.com/@Pinterest_Engineering?source=rss-ef81ef829bcb------2</link>
        <image>
            <url>https://cdn-images-1.medium.com/fit/c/150/150/1*iAV-apeVpCJ1h6Znt1AzCg.jpeg</url>
            <title>Stories by Pinterest Engineering on Medium</title>
            <link>https://medium.com/@Pinterest_Engineering?source=rss-ef81ef829bcb------2</link>
        </image>
        <generator>Medium</generator>
        <lastBuildDate>Tue, 10 Nov 2026 05:45:23 GMT</lastBuildDate>
        <atom:link href="https://medium.com/@Pinterest_Engineering/feed" rel="self" type="application/rss+xml"/>
        <webMaster><![CDATA[yourfriends@medium.com]]></webMaster>
        <atom:link href="http://medium.superfeedr.com" rel="hub"/>
        <item>
            <title><![CDATA[Safe and Efficient Multi-Epoch Embedding Training]]></title>
            <link>https://medium.com/pinterest-engineering/safe-and-efficient-multi-epoch-embedding-training-103bec2abc83?source=rss-ef81ef829bcb------2</link>
            <guid isPermaLink="false">https://medium.com/p/103bec2abc83</guid>
            <category><![CDATA[engineering]]></category>
            <category><![CDATA[infrastructure]]></category>
            <category><![CDATA[recommendation-system]]></category>
            <category><![CDATA[machine-learning]]></category>
            <category><![CDATA[pinterest]]></category>
            <dc:creator><![CDATA[Pinterest Engineering]]></dc:creator>
            <pubDate>Fri, 06 Nov 2026 16:01:03 GMT</pubDate>
            <atom:updated>2026-11-06T16:01:03.199Z</atom:updated>
            <content:encoded><![CDATA[<p><strong>Authors:</strong> Shruthi Hariharan | Machine Learning Engineer II; Konish Dutta | Machine Learning Engineer II; Abhinav Naikawadi | Sr. Machine Learning Engineer; Zhihua Zhang | Machine Learning Engineer II; Rakshita Nagalla | Sr. Machine Learning Engineer; Kousik Rajesh* | Machine Learning Engineer II; Matt Lawhon | Staff Machine Learning Engineer<br><strong>Tags:</strong> Machine Learning, Infrastructure, Recommendation Systems<br><strong>Content Track:</strong> Technical Discussion / Architecture of big components</p><p>* == work done while at Pinterest</p><h3>Introduction</h3><p>Large ID embedding tables underpin some of the largest modeling advances at Pinterest, including teacher-model scaling and cross-domain pretraining for ranking and retrieval. These tables are first pre-trained to learn broadly useful Pin representations and then fine-tuned as part of the downstream surface-specific ranking model. Here, we’ll focus on the Homefeed ranking model.</p><p>The strength of the embedding tables comes from their enormous capacity to absorb information about billions of Pins and learn rich representations. That power, however, is coupled with fragile training dynamics that have historically constrained fine-tuning to one epoch.</p><p>That compromise has become increasingly dissatisfactory; single-epoch fine-tuning prevents us from effectively optimizing our models over small, fresh datasets in retraining, and disproportionately harms effective representation learning of certain components of our ranking model which benefit from longer training, like Vision Language Models (VLMs). A one-epoch ceiling forces every component of our model to learn on the schedule of its most training-sensitive component.</p><p>When trained longer, two problems appear:</p><ol><li><strong>Model quality degrades sharply. </strong>In Homefeed ranking, we saw nearly a <strong>6% decline</strong> in our offline save metric when extending training from one epoch to two epochs.</li><li><strong>GPU waste from communication-bound training accumulates. </strong>Distributed embedding lookup and synchronization overhead created large idle periods in the training loop, leaving GPUs waiting on communication rather than performing useful computation.</li></ol><h4>Background</h4><p>The “one-epoch” phenomenon of large ID embedding tables is well documented in recommendation systems literature. Their enormous capacity, combined with sparse and long-tailed updates, makes it easy for the model to “memorize” individual IDs associated with repeated examples rather than learn representations that generalize.</p><p>Prior work at Pinterest had already shown that multi-epoch ID embedding training does not fail in every setting. <a href="https://arxiv.org/abs/2508.18700"><em>Taming the One-Epoch Phenomenon in Online Recommendation System by Two-stage Contrastive ID Pre-training</em></a> demonstrated that embeddings themselves could be pretrained for multiple epochs using a contrastive-only pretraining objective before being transferred to downstream models for finetuning. We were interested in <strong>multi-epoch post-training</strong>: once the pretrained embeddings are part of the Homefeed ranking model, could we fine-tune them for multiple epochs without catastrophic overfitting?</p><p><a href="https://arxiv.org/abs/2505.05605">Related work from Pinterest Ads</a> explored strategies to train longer in post-training, including frequency-adaptive updates and larger learning rates; however, the ID embedding tables in this setting did not exhibit the same catastrophic multi-epoch degradation we observed in Homefeed.</p><h4>Making multi-epoch training safe</h4><p><strong>Freezing as a method to identify the problem<br></strong>To answer why we saw a nearly 6% decline in the offline save metric when extending training to two epochs, we isolated each component to determine whether the whole model was overfitting or whether the regression was concentrated in a particular module. The embedding table emerged as the dominant source of the regression: when we froze the table, the multi-epoch regression disappeared and the result beat the single-epoch trained model.</p><p>Over three epochs, we saw a <strong>+1.25% gain</strong> over the one-epoch model. Although freezing the embedding table immediately unlocked multi-epoch training, it prevented task-specific adaptation in the embedding table. Was task-specific adaptation possible given the fragility of embedding tables? Or were we constrained to only allowing tables to train during pretraining?</p><p><strong>Isolating different gradient paths<br></strong>We attempted to control gradient paths to prevent overfitting. The model uses three types of training objectives:</p><ol><li><strong>Head losses</strong>, which optimize per-action predictions such as save and click.</li><li><strong>Pairwise losses</strong>, which optimize relative ranking between positive and negative items.</li><li><strong>Contrastive loss</strong>, which trains next-item prediction as an auxiliary sequence-modeling objective. Next-item prediction is used only during training and is not an output of the production ranking model.</li></ol><p>All three objectives can update the shared embedding table, but their gradients reach it through different paths:</p><p>The <strong>contrastive objective</strong> provides one gradient path to the table through its sequence-oriented next-prediction task. Separately, for the candidate Pin being scored, the <strong>head </strong>and <strong>pairwise objectives </strong>can send gradients to the table through two candidate-side paths:</p><ol><li><strong>Transformer path:</strong> The candidate embedding passes through cross-attention and subsequent transformer layers, creating a deep gradient path from the head and pairwise objectives back to the embedding table.</li><li><strong>Feature cross path</strong>: The candidate embedding feeds directly into the MLP or DCN feature tower, creating a shorter path from the head and pairwise objectives back to the table.</li></ol><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*9yZ-TcOTdLpOVXAkYxOogw.png" /></figure><p>We tested a series of gradient isolation strategies:</p><ul><li><strong>Contrastive-only updates: </strong>We blocked gradients from the head and pairwise objectives from reaching the embedding table, while keeping the contrastive path active. The table learned only from its auxiliary next-item objective.</li><li><strong>Selective candidate gradients: </strong>We selectively re-enabled candidate-side gradients through the transformer path, the feature cross path, or both.</li></ul><p>Below, we compare full co-training, frozen-embedding training, and the gradient isolation strategies vs. a one-epoch full trained model.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*h_SpTY-BRaiYzp8Paf3wmw.png" /></figure><p>To summarize the results</p><ul><li><strong>Full co-training</strong> allows the embedding table to train alongside the rest of the ranking model and leads to catastrophic overfitting. <strong>Contrastive + both</strong> is equivalent to full co-training.</li><li><strong>Frozen embedding table</strong> freezes the embedding table and allows the rest of the model to train throughout the training cycle; this resulted in the best gains.</li><li><strong>Contrastive-only</strong> training only allows gradients from contrastive loss to reach the embedding table and allows co-training; this also solved the overfitting issue but the results were worse than fully frozen embedding tables.</li><li><strong>Contrastive + transformer</strong> allows gradients from the contrastive loss and transformer route gradients from pairwise and head losses. This caused mild overfitting.</li><li><strong>Contrastive + feature</strong> cross allows gradients from contrastive loss and feature cross route gradients from pairwise and head losses. This resulted in catastrophic overfitting. One possible explanation is that the transformer’s additional structure and depth act as a form of implicit regularization.</li></ul><p>Although gradient isolation prevented overfitting, we could not beat the model trained with a fully frozen embedding table through this technique.</p><h3>Decoupling embedding optimization</h3><p>We further explored decoupling embedding optimization in two settings with different practical goals:</p><ol><li>The first asked whether multiple passes over a smaller training-data window could replace a single pass over a longer window.</li><li>The second asked whether we could give a newly added vision-language model more optimization time without allowing the embedding tables to overfit.</li></ol><p>Because these investigations used different modeling and data configurations, we report their results separately. However, both pointed to the same pattern: freezing the embedding table early and adapting it later made multi-epoch training effective.</p><p><strong>Reusing a smaller training-data window<br></strong>The current Homefeed ranking model runs on a 3-week single-epoch training setup, fine tuning a pretrained foundation model backbone that is trained on data across 5 product surfaces including Homefeed. We explored adopting a shorter fine-tune window with multi-epoch training to trade in fresher fine-tune data for the ability to use a fresher pretrained backbone. Reducing the fine-tune window would also enable us to experiment faster on other workstreams that require feature logging.</p><p>The first investigation treated a 20-day, one-epoch full co-training run on the Homefeed ranking model as its control. Below, we compare the save offline metric lift of different training data windows, training horizons, and update strategies.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*r3KRPE9P0Kj7TmAyq6B9VQ.png" /></figure><p>Initial experiments on doing full co-training saw regressions despite the fresher pretrained backbone, because of the embedding table overfitting. Freezing the embedding table prevented that sharp degradation. With a 10-day training-data window, the fully frozen configuration remained near the baseline after two epochs and slightly improved after three.</p><p>We then tested out freezing the embedding table for different training windows, and found the <strong>timing</strong> of embedding adaptation mattered. Freezing the table up to the last epoch produced a 0.49% improvement; on the other hand, co-training the table during the first epoch and freezing it for the rest of training produced a 0.80% regression. This delayed update strategy remained positive as we expanded the training window, but gains saturated around three epochs. The same pattern held with a 20-day training window, leading to a 1.16% lift. On three-epoch runs, we observed that freezing the embedding tables up to the last epoch performed much better than freezing them before.</p><p>We hypothesized that update timing mattered because the pretrained embedding table could adapt too quickly to repeated training examples. If the embeddings became trainable from the beginning, they could absorb the learning signal before the rest of the network had learned how to use the pretrained representations effectively. Keeping the table frozen initially might allow the surrounding network to learn more generalizable patterns, after which embeddings could safely adapt.</p><p>To test this hypothesis, we held the training-data window, training horizon, and duration of embedding updates fixed, while progressively shifting the one-epoch update window later within a two-epoch run:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*-C6eEr4kYPmgNTFj49_ILw.png" /></figure><p>The results showed a roughly linear, monotonic relationship between update timing and model performance that is consistent with our hypothesis. Based on this finding, we adopted the strategy of keeping the table frozen for one epoch and allowing co-training in the second epoch over a small training data window. The smoothness of this trend suggests that update timing may be a tunable optimization dimension.</p><p><strong>Giving the VLM more optimization time</strong></p><p>A parallel investigation was motivated by giving a newly added VLM more time to optimize. The VLM adapted more slowly than the established ranking components, but as with the one-epoch model, extending unrestricted full co-training caused overfitting. Similar to the above results, the best update strategy for the VLM-based model was the delayed scheme. The below results are compared against a one-epoch VLM model where the model and embedding table co-trained.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*f2NxoxtERFLI3SW09ONLXQ.png" /></figure><p>Hence, we discovered a recipe to allow task-specific adaptation in post-training for embedding tables that not only beat the one-epoch model, but also the fully frozen multi-epoch model.</p><h3>Making longer training efficient</h3><p>To make extended training practical, we had to reduce the amount of time spent waiting on sparse communication, synchronization, compilation overhead, and data delivery. The largest initial bottleneck was distributed embedding lookup.</p><h4>Semi-synchronous training</h4><p>Our embedding tables are sharded across GPUs. Looking up sparse IDs requires the system to redistribute IDs to the devices that own the corresponding table shards, perform the lookup, and return the resulting embeddings. This introduces cross-GPU all-to-all communication, whose cost grows as our embedding tables, batches, and context lengths scale.</p><p>For the production Homefeed ranking model, distributed embedding lookup alone could consume more than 15% of total training time. More importantly, much of that time appeared as a bubble in the step loop: GPUs waited while data moved between devices instead of executing model kernels.</p><p>We addressed this with a semi-synchronous training pipeline that overlaps embedding communication for the next batch with computation for the current batch. The pipeline has three main components:</p><ul><li>a lookahead dataloader that prepares the next batch’s sparse IDs early</li><li>a prefetching forward wrapper that launches the next lookup immediately after the current forward pass; and</li><li>an embedding prefetcher that coordinates work across CUDA streams and events.</li></ul><p>Conceptually, the schedule is:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*pmskF4DWuM62SRyq1Ccnlw.png" /></figure><ol><li>Run the forward pass for batch B − 1.</li><li>Launch ID redistribution and embedding lookup for batch B on a separate prefetch stream.</li><li>Continue the current step’s useful computation while that communication runs in the background.</li><li>Synchronize only when batch B needs its embeddings. In steady state, the lookup has already completed by that point.</li></ol><p>TorchRec’s pipelining utilities did not integrate cleanly with our custom trainers and modules; a custom implementation gave us the flexibility we needed without forcing a larger rewrite of the training stack.</p><h4>Bounded embedding staleness</h4><p>The pipeline is semi-synchronous because the embeddings prefetched for batch B are read before the backward pass for batch B − 1 finishes. As a result, batch B uses embeddings that were last updated by batch B − 2: a bounded delay of one embedding update.</p><p>Empirically, we observed no model-quality degradation. Two factors likely explain why:</p><ul><li>Consecutive batches have little overlap in their sparse IDs, so most prefetched rows would not have been modified by the immediately preceding batch anyway.</li><li>The update schedule is approximately analogous to gradient accumulation with a step size of two for the embedding table.</li></ul><p>To preserve correctness and performance, we had to ensure that:</p><ul><li>Prefetch reads did not race with backward writes on another CUDA stream</li><li>TorchRec autograd continued to update the intended sparse rows</li><li>Embedding collectives did not collide with backward all-reduce operations, because NCCL calls could not be overlapped safely in our execution path</li><li>The pipelined loop remained compatible with torch.compile rather than introducing new graph breaks</li></ul><h4>Embedding lookup optimizations</h4><p>Overlapping communication hides latency, but reducing the amount of communication and kernel work is even better. We made several additional changes to the embedding path.</p><ul><li><strong>Fused training-ID lookup:</strong> The prefetch path previously issued multiple sharded lookups per step, such as candidate signatures and deduplicated user-sequence signatures. Each lookup paid the fixed cost of collective setup, including split exchange and payload all-to-all operations. We now concatenate these IDs into one flat request, perform a single sharded lookup, and partition the returned embeddings afterward, reducing lookups by up to a factor of three.</li><li><strong>Specialized singleton-row lookup:</strong> Many embedding-bag operations were degenerate bags containing exactly one ID per row. The generic ragged-bag path still performed offset processing and reduction work that these inputs did not need. We added a specialized cuEmbed operation that uses a direct gather with a custom backward implementation. Registering a fake implementation also allows torch.compile to trace the operation instead of breaking the graph. Unit tests confirmed exact gradient parity with the generic path.</li><li><strong>Optimized small embedding table lookups:</strong> In addition to our large ID embedding tables, we also leverage smaller lookup tables in the model, which contain only tens of rows but can receive millions of indexed lookups per step. The default backward kernel used an atomic scatter-add, creating heavy contention on a small number of rows. We replaced it with an equivalent dense formulation: one_hot(indices)ᵀ @ output_grad. This turns the weight-gradient computation into a GEMM, which the GPU executes much more efficiently. The forward pass remains a standard gather, and tests verified bit-equivalent results.</li></ul><p>Together, these changes reduced both collective latency and the number of kernels required for sparse training.</p><h4>Amortizing optimizer communication</h4><p>After the lookup and normalization bottlenecks moved, optimizer communication became a larger share of the step. Sharded optimizer state still required expensive broadcasts of updated parameters, leaving the GPUs idle while parameters were exchanged.</p><p>We amortized that cost with gradient accumulation, performing optimizer updates less frequently across multiple microbatches. TorchRec’s backward-fused embedding optimizer does not natively follow the same accumulation schedule, so we scaled the embedding learning rate to approximate the corresponding accumulated update.</p><h3>Conclusion</h3><p>The modeling and system insights allowed us to enable multi-epoch training in production, leading to +1.09% online saves. The best schedule we’ve discovered thus far allows the rest of the network to train first before enabling embedding adaptation. Future work could explore table-specific learning-rate schedules or treat embedding adaptation as a limited optimization budget, alternating between trainable and frozen phases to capture task-specific gains while controlling overfitting.</p><p>More broadly, these findings suggest that a single optimization schedule may not be appropriate for every model component, particularly when components differ in capacity, initialization, and convergence rate. The embedding table provided the clearest evidence of this. Whether a broader decoupling could produce additional benefits remains an open question.</p><h3>Acknowledgements</h3><p>Thanks to the teammates and reviewers who helped with the profiling, gradient-path investigations, experiment reviews, and repeated iterations that made both the multi-epoch and systems work possible.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=103bec2abc83" width="1" height="1" alt=""><hr><p><a href="https://medium.com/pinterest-engineering/safe-and-efficient-multi-epoch-embedding-training-103bec2abc83">Safe and Efficient Multi-Epoch Embedding Training</a> was originally published in <a href="https://medium.com/pinterest-engineering">Pinterest Engineering Blog</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Scaling Model Evaluation at Pinterest with Ray (Part 4)]]></title>
            <link>https://medium.com/pinterest-engineering/scaling-model-evaluation-at-pinterest-with-ray-part-4-4a96cd12cc11?source=rss-ef81ef829bcb------2</link>
            <guid isPermaLink="false">https://medium.com/p/4a96cd12cc11</guid>
            <category><![CDATA[open-source]]></category>
            <category><![CDATA[pinterest]]></category>
            <category><![CDATA[machine-learning]]></category>
            <category><![CDATA[engineering]]></category>
            <category><![CDATA[distributed-computing]]></category>
            <dc:creator><![CDATA[Pinterest Engineering]]></dc:creator>
            <pubDate>Wed, 04 Nov 2026 16:01:04 GMT</pubDate>
            <atom:updated>2026-11-04T16:01:04.588Z</atom:updated>
            <content:encoded><![CDATA[<p>Vishnu Arun | Tech Lead, Feature Importance Platform; Anderson Lam | Sr Software Engineer; Andrew Qiu | Machine Learning Engineer I; Helena Xu | Data Scientist I; Tianyou Wang | Data Scientist II</p><h3>Introduction</h3><p>In our multi-part blog series, we have discussed <a href="https://medium.com/pinterest-engineering/last-mile-data-processing-with-ray-629affbf34ff">why we chose Ray</a> as a last mile data processing framework, how we <a href="https://medium.com/pinterest-engineering/ray-infrastructure-at-pinterest-0248efe4fd52">integrated Ray </a>into our existing ML Training Infrastructure, and how we enabled efficient Batch Inference at scale. In this blog post, we will discuss how we were able to apply Ray to another critical part of our ML Infrastructure stack: <strong>Model Evaluation</strong>.</p><p>In the <a href="https://medium.com/pinterest-engineering/ray-batch-inference-at-pinterest-part-3-4faeb652e385">Ray Batch Inference Blog Post</a>, we ended on a section on accumulators (counters that would allow us to calculate metrics like ROC-AUC and cross entropy). This post is where those accumulators pay off: a model evaluation platform that combines the strengths of Ray and Apache Spark to compute metrics efficiently at scale.</p><p>Model evaluation may appear to be a relatively straightforward step after inference or training. But as ML workflows scale, evaluation quickly becomes an infrastructure challenge. Systems that run many experiments in parallel, such as feature importance, must efficiently compute and aggregate metrics over large volumes of inference data, as shown in Diagram 1.</p><p>Later in this post, we’ll show how we amortize the metrics compute step, overlapping it with inference for minimal added runtime cost, cutting end-to-end evaluation time by up to 32% while scaling linearly as we run more experiments.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*IjS1hNUJHyV0NzVGLIiAOA.png" /><figcaption><strong>Diagram 1</strong>: Depicts a feature importance system’s architecture. We illustrate a multi-head model with h heads and metrics calculated per head. In this type of workflow, metrics are calculated for each experiment and model head. <em>Note: We omit a batched view of this data found in large scale production ML systems for simplicity.</em></figcaption></figure><p><strong>Terminology</strong></p><ul><li><strong>Feature Importance (FI):</strong> A technique for quantifying how much each input feature contributes to a model’s predictions. In this post, we will reference a perturbation-based method where we perturb one or more columns, run inference, and compare the results against an unperturbed dataset.</li><li><strong>Experiment / baseline:</strong> One perturbed run vs. the unperturbed run.</li><li><strong>Model head: </strong>One output of a multi-task model.</li><li><strong>Batch:</strong> an arbitrary, compute-sized group of rows. This is the unit of data that one Ray task processes.</li><li><strong>Bucket:</strong> a batch that is group-preserving. It contains one or more complete segments, and no segment may span buckets.</li><li><strong>Segment:</strong> The semantic unit over which a group-wise metric is defined, such as a request.</li><li><strong>Serde [serialization/ deserialization]:</strong> The process and cost of writing data out and reading it back.</li></ul><h3>Background</h3><p>Traditionally, evaluation and inference are two separate stacks stitched together. Diagram 2 below contrasts that unfused pipeline against the fused pipeline we’ll build up over the rest of this post.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*dWX431vtwsoZ952WxlAgbw.png" /><figcaption><strong>Diagram 2</strong>: Unfused (top) vs. fused (bottom) inference-and-metrics compute. In the unfused lane, inference and metrics run as separate jobs connected by a serialize/persist/deserialize handoff; in the fused lane, metrics are computed per batch inside the Ray inference job via the shared object store. Red denotes the cross-stack handoff removed by fusion.</figcaption></figure><p>Concretely, this evaluation step is typically implemented as a Apache Spark job that computes metrics across one or more inference experiments. As shown in the diagram above, we previously used Ray inference to write predictions to S3, then have a follow-up compute job to reread all this data and compute the metrics.</p><p>For a FI run, this approach introduces two sources of overhead:</p><ul><li><strong>Cluster startup</strong>: Acquiring and warming up a cluster for the metric job.</li><li><strong>Data movement:</strong> Writing predictions to S3 and reading them back to compute metrics.</li></ul><p>Because inference is done in batches, and metrics typically depend on all of the inference results, the unfused approach can be used to calculate any metric. To save the overheads, we need to fuse the stages, but this also implies that we need to be able to calculate metrics on batches of rows. Therefore, we need a metric platform that treats decomposability as a first principle.</p><p>Metrics fall into three groups:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*SCUPm7Prbvl_JGK-ydqfSg.png" /></figure><h4>Metric Contract</h4><p>We define a common metric contract that captures each metric’s compute layout and provides a consistent interface across execution frameworks. We express this interface concretely as an Abstract Base Class.</p><pre>from abc import ABC, abstractmethod<br><br>class ReducibleMetric(ABC):<br>    @abstractmethod<br>    def dataset_preprocessor(self, ds):<br>        &quot;&quot;&quot;Prepare the dataset to satisfy the metric&#39;s data layout requirements.&quot;&quot;&quot;<br>        pass<br><br>    @abstractmethod<br>    def compute_partials(self, batch):<br>        &quot;&quot;&quot;Compute a partial result from one batch&quot;&quot;&quot;<br>        pass<br><br>    @abstractmethod<br>    def reduce_partials(self, partials):<br>        &quot;&quot;&quot;Combine partial results into the final result.&quot;&quot;&quot;<br>        pass</pre><p>Decomposable and holistic metrics can calculate partials from arbitrary batches because they do not require related rows to be colocated.</p><p>Group-wise metrics introduce an additional constraint. Before handing data to Ray, we organize complete segments into buckets. A bucket is therefore a group-preserving batch: it contains one or more complete segments, and no segment crosses a bucket boundary. This lets us independently tune bucket size for efficient execution without changing the semantic unit of the metric.</p><h3>Common Metrics found in Recommender Systems</h3><p>We will walk through three metrics, <a href="https://developers.google.com/machine-learning/glossary/metrics#mean-absolute-error-mae">MAE</a>, <a href="https://developers.google.com/machine-learning/crash-course/classification/roc-and-auc">ROC-AUC</a>, and <a href="https://developers.google.com/machine-learning/glossary/metrics#precision-at-k-precisionk">Precision@K</a>; one from each group above. We will use the following dataset to compute each metric.</p><p>For the examples below:</p><ul><li>model_output contains the predictions, and label_continuous contains the target values.</li><li>label_binarized is derived as 1 if label_continuous ≥ 0.5 and 0 otherwise.</li><li>Batch A contains ReqA, and Batch B contains ReqB. This split lets us demonstrate batch-level computation and aggregation.</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Wmr5Idz3Mm8qy0OzLQdE0Q.png" /></figure><p><strong>MAE (Decomposable)<br></strong>Mean Absolute Error measures the average absolute difference between predictions and their actual values.</p><p>MAE_b = (1/n_b) · Σ_{i ∈ batch b} |pred_i − label_continuous_i|</p><p>Unlike MSE/RMSE, MAE does not penalize large errors as heavily.</p><p>Per-row absolute errors:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*nRqgyneqYBEdvcTDQZB4fQ.png" /></figure><p><strong>Global:</strong> Σ = 1.45, N = 8 → MAE = 0.18125.</p><p><strong>Batch decomposition:<br></strong>Batch A partial (Σ|err| = 0.65, n = 4)<br>Batch B partial (Σ|err| = 0.80, n = 4)<br>Merge (0.65 + 0.80) / (4 + 4) = 0.18125</p><pre>class MAE(ReducibleMetric):<br>    def dataset_preprocessor(self, ds): <br>  # No dataset preparation needed. Rows are independent.<br> return ds<br><br>    def compute_partials(self, batch):<br>  # Emit summed absolute error and row counts from each batch.<br>        error = np.abs(batch[&quot;model_output&quot;] - batch[&quot;label_continuous&quot;])<br>        return error.sum(), len(error)<br><br>    def reduce_partials(self, partials):<br>  # Sum partials and divide once globally. <br>        error, count = np.sum(partials, axis=0)<br>        return error / count</pre><p><strong>ROC-AUC (Holistic)<br></strong>ROC-AUC is a binary classification metric which measures the ability of a model to distinguish between classes across different decision thresholds.</p><h4>Method 1 — Global Sort (what <a href="https://scikit-learn.org/stable/modules/generated/sklearn.metrics.auc.html">sklearn</a> does)</h4><p><strong>Global Sort</strong></p><p>We sort the rows by model_output in descending order, keeping each prediction paired with its corresponding binary label.</p><p><strong>Cumulative Sum</strong></p><p>After sorting, we calculate cumulative true-positive (TP) and false-positive (FP) counts and prepend 0 to both vectors.</p><p><strong>Calculate the TPR and FPR</strong></p><p>We calculate the TPR at each threshold by dividing the TP vector by the number of positive labels.</p><p>We calculate the FPR at each threshold by dividing the FP vector by the number of negative labels.</p><p><strong>Calculate the Trapezoidal area under TPR/FPR</strong></p><p>We calculate the AUC of TPR/FPR to get the final ROC_AUC value.</p><p>Sorting our eight rows by score:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*PJ5_Wx307xXePy8yIfkr0w.png" /></figure><p>With 4 positives and 4 negatives, the area works out to <strong>AUC = 0.9375</strong> (or 15/16 positive/negative pairs are ranked correctly).</p><p>We observe that ROC_AUC using this method is calculated over the entire dataset due to a global sort. We could break the dataset into batches, calculate ROC-AUC for each batch, and average the results. However, this <strong>does not</strong> produce the same result as a global ranking.</p><p>Consider the following calculation:</p><ul><li>Batch A (ReqA) = 1.00</li><li>Batch B (ReqB) = 0.75</li><li>Average = 0.875, not 0.9375</li></ul><p>Averaging a ranking number across batches is wrong, because AUC depends on how rows rank against <em>all</em> other rows, not just those in their batch.</p><p>We could force a real global sort in Apache Spark, but doing that across all metrics, model heads, and experiments is exactly the cross-stack cost we’re trying to avoid.</p><h4>Method 2 — Confusion Matrix</h4><p>Consider the standard <a href="https://developers.google.com/machine-learning/crash-course/classification/roc-and-auc">method</a> to approximate ROC_AUC.</p><p><strong>Define thresholds</strong></p><p>Pick a fixed set of thresholds in [0, 1].</p><p><strong>Calculate Confusion Matrix</strong></p><p>Calculate the confusion matrix (TP, FP, TN, FN) for each threshold</p><p><strong>Calculate the final metric</strong></p><p>Calculate the TPR/FPR and finally derive the AUC.</p><p>This method can be calculated batch-wise because the global confusion matrix can be calculated from the batch confusion matrices.</p><p>We can create the following confusion matrices per batch and then sum:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*8OqzT4-lnDst_sa2rzMVrA.png" /></figure><p>From this approximate example, when we derive the area under the curve, we get 0.875. If we use more thresholds, for example [0.00, 1.00] with step size of 0.01, we would get 0.9375. <strong>Note: </strong>This does not mean that threshold-based aggregation generally produces the exact global AUC. In this toy example, a step size of 0.01 recovers the exact value because the prediction scores align with the threshold grid.</p><pre>class ROCAUC(ReducibleMetric):<br>    def dataset_preprocessor(self, ds): <br> # No dataset preparation needed. Rows are independent.<br>return ds<br><br>    def compute_partials(self, batch):<br>  # Emit confusion matrix across thresholds. <br>        pred = batch[&quot;model_output&quot;]<br> truth = batch[&quot;label_binarized&quot;].astype(bool)<br><br>        positive = pred[None, :] &gt;= self.thresholds[:, None]<br>        return np.stack([(positive &amp; truth).sum(1), (positive &amp; ~truth).sum(1),<br>                       (~positive &amp; ~truth).sum(1), (~positive &amp; truth).sum(1)],1)<br><br>    def reduce_partials(self, partials):<br>  # Sum confusion matrices and add ROC endpoints.<br> # Integrate in ascending FPR order. <br>        tp, fp, tn, fn = np.sum(partials, 0).T<br> tpr, fpr = tp / (tp + fn), fp / (fp + tn)<br>        return np.trapz(np.r_[0, tpr[::-1], 1], np.r_[0, fpr[::-1], 1])</pre><p><strong>Precision@K (Group-wise)<br></strong>Precision@K is a common metric used for ranking systems. It measures the fraction of the top K ranked items that are relevant.</p><p>This metric is calculated within an individual segment of data, such as a session or request. A segment may be identified by a single key or a composite key.</p><p>The procedure for calculating Precision@K is as follows.</p><p><strong>Group/Rank Dataset<br></strong>Group the dataset by segment and sort by predicted score.</p><p><strong>Window and Count<br></strong>Select the top K items and count the relevant items within that window.</p><p><strong>Calculate Metric<br></strong>Divide the number of relevant items by K to calculate Precision@K for each segment. Average the per-segment scores to obtain the final result.</p><p>On our data with K = 2:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*dtCI5DIvd5RNlQ7sPsfIqA.png" /></figure><p>Average = (1.00 + 0.50) / 2 = 0.75.</p><p>We observe this metric has a similar decomposition pattern. We can break it down into subproblems where we look at each segment, calculate the metric, then average final scores of all subproblems.</p><p>We have two problems when trying to scale this approach:</p><ol><li>The subproblem (batch_size) is tied to the size of each segment. This is inefficient as each executor may receive a small and variably sized amount of data.</li><li>If we combine segments together, we must ensure that no segments span a bucket boundary.</li></ol><p>Our goal is to keep every row for a given segment in the same bucket while ensuring that each bucket contains enough segments to saturate the compute resources.</p><p>More succinctly, we want all rows within each segment to be colocated and each bucket to be sufficiently large. Since we are operating at the global level, we can define this as an Apache Spark procedure. It keeps all rows for each segment together, then packs complete segments into buckets large enough to use each Ray worker efficiently without splitting a segment across buckets.</p><pre>class PrecisionAtK(ReducibleMetric):<br>    def dataset_preprocessor(self, ds):<br>  # Colocate each segment and pack into buckets.<br>        return bucket_complete_segments(<br>            ds,<br>            by=&quot;request_id&quot;,<br>        )<br><br>    def compute_partials(self, bucket):<br>  # Rank within each segment and calculate top-K score.<br>        scores = []<br>        for segment in group_by(bucket, &quot;request_id&quot;):<br>            ranking = np.argsort(<br>                -segment[&quot;model_output&quot;]<br>            )<br>      # Assume each segment contains at least K items.<br>            top_k = ranking[:self.k]<br>            relevant = segment[&quot;label_binarized&quot;][top_k].sum()<br>            scores.append(relevant / self.k)<br><br>        return sum(scores), len(scores)<br><br>    def reduce_partials(self, partials):<br>  # Sum segment scores and divide by segment count.<br>        score, count = np.sum(<br>            partials,<br>            axis=0,<br>        )<br>        return score / count</pre><h4>Fusing Metrics with Inference</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*OMUSPm2j8BSisD6mPlilsQ.png" /><figcaption><strong>Diagram 3:</strong> Top Sequential Inference and Metric Compute. Bottom Fused Inference and Metrics Compute. Each cell is one unit of work indexed by bucket, head, and experiment: <strong>B = bucket</strong>, a group of rows that keeps each user/request segment colocated for group-wise metrics such as Hits@K; <strong>H = model head; E = perturbation experiment</strong>, with 0 denoting baseline. The sequential layout runs all inference before any metrics; the fused layout interleaves metric compute per bucket as inference completes. Internal inference batches are not shown.</figcaption></figure><p>In the upper figure in diagram 3, we show a typical Sequential Inference-Metrics multi-experiment pipeline. We run model inference over all the experiments, serialize, and persist those results along with the carryover labels. Note this inference dataset scales with the input data and the number of experiments. Next, we deserialize this data and compute metrics. GPU inference is typically more resource intensive than metrics computation. This design causes the CPU workers to sit idle during inference time.</p><p>The lower figure in diagram 3 illustrates a fused approach where we pipeline the metric computation once inference completes for each bucket. The shared object store minimizes serde overhead. This enables us to effectively overlap metric computation with inference, reducing total runtime.</p><h4>Results</h4><p>We benchmarked two setups against a Pin Ranking Model, sweeping across three dataset sizes (Small ≈ 30K, Medium ≈ 300K, Large ≈ 3M rows) and several experiment counts:</p><ul><li><strong>Unfused:</strong> Ray inference, then a <strong>separate</strong> Ray metric job.</li><li><strong>Fused:</strong> Ray inference <strong>with metrics in the same pass.</strong></li></ul><p>When we gather metrics during inference, we do not see any meaningful runtime increases. The time measured here includes startup time (acquiring the cluster) and runtime (actual computation).</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*H7f3gpwFikQMfV-pd7YRDg.png" /><figcaption><strong>Diagram 4</strong>: End-to-end wall-clock time (startup + runtime) for the unfused and fused pipelines across three dataset sizes at E = 100 experiments. Gray = unfused, indigo = fused, green = time saved. At E = 100, fusion cuts wall-clock time 25–32% at every size. This comes partly from removing a whole job and its fixed cluster-acquisition cost, and the saving holds as input grows.</figcaption></figure><p>Furthermore, we can see that runtime grows linearly with experiment count while startup stays flat.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*rgTfLzc5bjkYdNbjPkNaEg.png" /><figcaption><strong>Diagram 5</strong>: Unfused versus fused pipeline runtime as experiment count scales on a Large dataset (3M rows). The unfused runtime includes the metrics stage immediately after inference; both runtimes exclude cluster-acquisition time.</figcaption></figure><p>As the number of experiments increases from 1 to 300 on the Large dataset, fused runtime grows approximately linearly (~43 min fixed + ~41s per experiment), while startup time remains roughly constant. The separate metric stage (~11s per experiment) is work that fusion absorbs through overlap with inference, keeping the marginal cost per experiment small and predictable.</p><h3>What’s next</h3><p>Other upcoming topics include:</p><ul><li>Ray Tune at Pinterest</li><li>Improving the data loader performance in Ray</li><li>Fault-tolerant Ray Batch Inference and Training</li><li>Feature Importance on Ray</li></ul><h3>Acknowledgments</h3><p>Related Pins: Shivin Thukral, Zilin Ren<br>AI Platform: Rubin Fergerson IV, Qingxian Lai<br>Data Platform Sciences: Darren Reger, Adam Obeng<br>Agent Platform: Tan Wang</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=4a96cd12cc11" width="1" height="1" alt=""><hr><p><a href="https://medium.com/pinterest-engineering/scaling-model-evaluation-at-pinterest-with-ray-part-4-4a96cd12cc11">Scaling Model Evaluation at Pinterest with Ray (Part 4)</a> was originally published in <a href="https://medium.com/pinterest-engineering">Pinterest Engineering Blog</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Introducing ModuleLens: Zooming into PyTorch Modules for Human and Agentic Engineering]]></title>
            <link>https://medium.com/pinterest-engineering/introducing-modulelens-zooming-into-pytorch-modules-for-human-and-agentic-engineering-b7e9be9d29f8?source=rss-ef81ef829bcb------2</link>
            <guid isPermaLink="false">https://medium.com/p/b7e9be9d29f8</guid>
            <category><![CDATA[pinterest]]></category>
            <category><![CDATA[performance]]></category>
            <category><![CDATA[engineering]]></category>
            <category><![CDATA[machine-learning]]></category>
            <category><![CDATA[agents]]></category>
            <dc:creator><![CDATA[Pinterest Engineering]]></dc:creator>
            <pubDate>Mon, 19 Oct 2026 15:01:04 GMT</pubDate>
            <atom:updated>2026-10-19T15:01:04.745Z</atom:updated>
            <content:encoded><![CDATA[<p>Lida Li, Sr. Staff Software Engineer, Product ML Infra</p><h3>Introduction</h3><p>Modern recommendation systems, including those used for ads ranking and content discovery, have grown substantially in scale and complexity. This growth stems from both increasingly large model architectures. The resulting memory footprint often exceeds the capacity of a single GPU, making it necessary to shard production models across multiple GPUs using distributed training techniques such as Distributed Model Parallelism (DMP).</p><p>This evolution brings challenges to the everyday engineer work on testing and debugging the model code. The bottleneck includes:</p><ul><li><strong><em>Local Machine Hardware Limits</em></strong>: Because production models require multi-GPU setups, initializing a full model locally on a single-gpu workstation is virtually impossible.</li><li><strong><em>Input Data Dependencies</em></strong>: When an engineer modifies code inside a specific downstream module, getting realistic input data is a major hurdle. Traditionally, obtaining that input requires initializing the entire multi-GPU pipeline from scratch and executing every preceding module in the network — wasting immense amounts of time and computation resources.</li><li><strong><em>Slow Iteration Feedback Loops</em>:</strong> Engineers face prolonged iteration loops when attempting to verify sub-module changes, as every code edit requires re-executing or re-initializing heavy distributed trainer setups.</li></ul><p>To eliminate these bottlenecks, we build <strong>ModuleLens</strong>, which pivots from a legacy “Model-First” paradigm to an isolated “Module-First” engineering framework. The following table contrasts these two operational paradigms across key engineering metrics:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*BzwFhaH5R5hIUmjx1mEKow.png" /></figure><p>Under the module-first paradigm, engineers can isolate any given module, avoid multi-GPU model initialization entirely, and reconstruct and profile it locally within seconds. In addition to accelerating developer workflows, this modular foundation offers a fast, structured execution environment that <strong>empowers autonomous AI agents</strong> to analyze, refactor, and optimize PyTorch code independently.</p><h3>Architecture</h3><p>The architecture of ModuleLens is organized into three primary components: ModuleTracer for capturing runtime data, Module Repo for central storage, and a Consumption Layer serving human interfaces and AI-based tooling. The following diagram depicts this structure.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*M6YXooY-Qb6VW37l_48a-A.png" /></figure><h4>ModuleTracer</h4><p>ModuleTracer is designed to capture and record all essential data — including input and output tensors, model parameter weights, and initialization arguments. This comprehensive tracking allows any target PyTorch module to be reconstructed seamlessly within the consumption layer, enabling offline execution with captured inputs and validation against expected outputs.</p><p>To carry out trace capture, ModuleTracer relies on several key mechanisms:</p><ul><li><strong>Non-Intrusive PyTorch Forward Hooks: </strong>Attaches listeners directly to PyTorch modules to seamlessly log parameter weights (state_dict), input and output tensors, and execution metadata during runtime.</li><li><strong>In-Memory Module Init Tracking: </strong>Ensures target modules can be reconstructed by dynamically tracking initialization arguments (__init__ args and kwargs). It achieves this in memory by inspecting imported classes and patching Python’s __import__ function.</li><li><strong>Resource-Efficient Tracing Loops: </strong>Reduces GPU compute overhead by registering early_stop_func and post_train_step_func callbacks. These wait for model warmup, capture data across a single training step, and terminate early.</li><li><strong>Optimized Handling of Large Tensors: </strong>To efficiently profile large tensors, ModuleTracer automatically enables shape-only tracing (log_shapes), logging solely tensor dimensions so that synthetic inputs can be reconstructed during restoration. Additionally, if an input tensor is shared across multiple modules, ModuleTracer retains only a single copy and uses reference links for all other instances.</li></ul><h4>ModuleRepo</h4><p>Extracted module state is persisted within a queryable backend built on top of the Weights &amp; Biases (WandB) Registry. All captured modules are stored into one registry and organized around Collection and Versioned Artifacts:</p><p><strong>Collection Indexing:</strong> Target collections are indexed using an MD5 hash of the model and module names: hash(model_name, module_name). Additionally, top-level trace executions log overall run history to special collections structured as registered_model_xxx, indexed directly by model name. The registry supports both attribute-based and tag-based search filtering.</p><p><strong>Versioned Artifact Payload</strong>: Every captured Module produces a distinct version entry containing two standardized artifacts:</p><ul><li><em>JSON Metadata File</em>: Contains runtime execution metadata including the model and module metainfo, and the autocast info.</li><li><em>Binary State Payload</em>: Serialized via torch.save, containing the precise input tensors, baseline output tensors, and module parameter state_dict.</li></ul><h4>Consume Layer</h4><p>ModuleLens exposes its capabilities through two tailored surfaces to consume the traced data:</p><p><strong>Human Engineers:</strong> Interactive Jupyter Extension and IPython CLI for hands-on debugging, exploration, and manual profiling.</p><p><strong>AI Agents</strong>: Agent Skills that allow LLM agents (e.g., Claude Code, Cursor) to autonomously discover, rebuild, benchmark, and profile modules without human intervention.</p><p>The consume layer provides the following capabilities:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*cynWsOoDlPtSMa_hwj4d_w.png" /></figure><p>The screenshot below illustrates the command-line interface available to users.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*4DTZz3E36xkHXiAVF7z7Mg.png" /></figure><h3>Use Cases</h3><p>Ever since its deployment across our Ads and Core models, ModuleLens has also been adopted for various internal tools built by our engineering teams.</p><h4>Training Analyzer</h4><p>Evaluates module-level metrics across different architectures. Through hierarchical visual representations of latency, FLOPs, and memory usage across the module tree, engineers can rapidly identify training bottlenecks.</p><h4>KernelAgent</h4><p>An LLM-driven agent designed to generate high-performance GPU kernels. It leverages captured ModuleLens data to benchmark, profile, test, and validate the accuracy and speed of generated kernels.</p><h4>Scaling Law ROI Tools</h4><p>Evaluates the return on investment (ROI) for model scaling. By utilizing ModuleLens to track training and serving FLOPS, activation and parameter memory usage, and execution throughput, it helps assess infrastructure costs against model scaling gains.</p><h3>Looking Ahead</h3><p>Our immediate priority is stabilizing the core system and fostering adoption across machine learning teams. Concurrently, we are enriching our Agent Skills repository to furnish AI coding agents with advanced optimization capabilities, including automated Triton/CUDA kernel synthesis as well as autonomous training and serving optimization workflows. By building reliable, execution-friendly interfaces for both developers and autonomous agents, ModuleLens optimizes human engineering velocity while laying the groundwork for scalable, fully automated performance optimization.</p><p>Beyond optimization, ModuleLens can serve as an execution substrate for model autoresearch and RSI (recursive self-improvement). By giving agents reproducible access to isolated modules, captured inputs and outputs, and trustworthy performance signals, it enables them to formulate experiments, modify model components, benchmark results, and retain successful changes in a tight feedback loop. Over time, this foundation can support systems that continuously improve both model quality and the tools and strategies used to optimize the model.</p><h3>Acknowledgement</h3><p>This project benefited from the valuable contributions of many individuals. We express our gratitude to Archer Liu, leader of Product ML Infra (PMLI), for his leadership and support, Chen Yang (ATG) for insightful design discussions and feedback; Abhinav Naikawadi (HomeFeed) and Paulo Soares (Ads ranking) for leading testing, adoption, and advocacy efforts; and Jiasong Sun, Xinyi Zhang, Diane Li and Hongze Zhao (Product ML Infra) for their contributions to code development and review.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=b7e9be9d29f8" width="1" height="1" alt=""><hr><p><a href="https://medium.com/pinterest-engineering/introducing-modulelens-zooming-into-pytorch-modules-for-human-and-agentic-engineering-b7e9be9d29f8">Introducing ModuleLens: Zooming into PyTorch Modules for Human and Agentic Engineering</a> was originally published in <a href="https://medium.com/pinterest-engineering">Pinterest Engineering Blog</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Open Source Profiler on an Internet Scale Fleet: Pinterest and Intel Partnership on gProfiler]]></title>
            <link>https://medium.com/@Pinterest_Engineering/open-source-profiler-on-an-internet-scale-fleet-pinterest-and-intel-partnership-on-gprofiler-8fcb387d899c?source=rss-ef81ef829bcb------2</link>
            <guid isPermaLink="false">https://medium.com/p/8fcb387d899c</guid>
            <category><![CDATA[open-source]]></category>
            <category><![CDATA[pinterest]]></category>
            <category><![CDATA[engineering]]></category>
            <category><![CDATA[observability]]></category>
            <category><![CDATA[intel-partnership]]></category>
            <dc:creator><![CDATA[Pinterest Engineering]]></dc:creator>
            <pubDate>Fri, 16 Oct 2026 15:01:03 GMT</pubDate>
            <atom:updated>2026-10-16T15:01:03.190Z</atom:updated>
            <content:encoded><![CDATA[<p><em>Ambud Sharma | Principal Engineer, Pinterest; Prashant Patel | Staff Engineer, Pinterest; David Westbrook | Sr. Engineering Manager, Pinterest; Rogerio Gelamo | Solutions Architect, Intel; Min Yeol Lim | SW Engineer, Intel ; Keith Sundberg | Solutions Architect, Intel ; Danil Korlovskiy | SW Engineer, Intel; Jason M Harper | SW Engineer, Intel; Kim McLaughlin | Account Executive, Intel</em></p><p>Pinterest serves <a href="https://s204.q4cdn.com/369458543/files/doc_earnings/2026/q2/presentation/Q2-2026-Pinterest-Earnings-Presentation.pdf">640 million</a> monthly active users worldwide, and our backend services span hundreds of thousands of instances running code written in Java, Python, C++, Go, Node.js.</p><p>Developing observability on bottlenecks aka Profiling is a key step to driving data driving infrastructure efficiency optimization efforts for Pinterest. At our scale, even a 1% efficiency gain translates to a meaningful impact to our profit margins.</p><p>This post describes how Pinterest partnered with Intel to drive an open source first approach to developing profiling i.e. <a href="https://github.com/intel/gprofiler"><strong>gProfiler</strong></a> and adopt, harden, extend it by contributing — (1) <strong>Dynamic Profiling</strong>, a heartbeat-based remote profiling layer that lets teams trigger profiling on-demand; (2) <strong>on-demand Intel PerfSpect collection</strong> for hardware telemetry like cache behavior, memory bandwidth, and CPU stalls; (3) <strong>wall time profiling</strong> to capture latency from threads blocked on I/O, locks, or RPCs that CPU flame graphs miss; (4) <strong>PMC flame graphs</strong> that tie specific functions to hardware events such as L3 misses and branch mispredictions; and (5) <strong>production-hardening fixes</strong> that keep the agent efficient at fleet scale.</p><h3>The Profiling Challenge at Scale</h3><p>Profiling at scale requires many features like continuous profiling, aggregation, multi-host profiling, Kubernetes integration, and industry-level support. Meanwhile, engineers were using disparate tools — perf, async-profiler, py-spy_ — each requiring manual setup and producing siloed results.</p><p>We needed a unified profiler that could:</p><ul><li>Support all our language runtimes under a single user interface</li><li>Scale from a single instance to a fleet of hundreds of thousands of instances</li><li>Provide both continuous and on-demand profiling</li><li>Integrate hardware performance counters for deep bottleneck analysis</li><li>Remain fully open source to avoid vendor lock-in and limit collaboration</li></ul><h3>Why gProfiler</h3><p>gProfiler is a continuous profiler originally developed by Granulate (a former Intel subsidiary) and open sourced under Apache 2.0 as an outcome of collaboration between Pinterest and Intel. Unlike single-runtime profilers, gProfiler orchestrates multiple language-specific profilers and analyzer — perf, async-profiler, pyPerf, py-spy, rbspy, phpspy, and others — to produce unified <a href="https://github.com/brendangregg/flamegraph">Flamegraphs</a> that capture CPU activity across all processes on a host, regardless of language or runtime. The table below shows the profilers gProfiler orchestrates for each supported runtime:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*U3nWcSiZtWnzBy8IjYGwAQ.png" /></figure><p>By merging runtime-specific stacks (e.g., Java method names from async-profiler) with system-wide native stacks from perf, gProfiler produces flame graphs that show the complete picture — from application code down through native libraries and into kernel routines.</p><p>Critically, gProfiler is fully open source, aligned with Pinterest’s strategy of building on and contributing to open source infrastructure. The entire pipeline — from agent to storage to visualization — is free of proprietary dependencies, and Pinterest can fork, patch, or extend any component.</p><h3>The story of gProfiler open source</h3><p>Pinterest originally tried to develop an internal profiling tool called ProfilerX to consolidate CPU profiling across our fleet. It quickly became clear that the engineering effort needed to build all of the capabilities we would want from scratch would be considerable. The first and foremost capability we were looking for was continuous / always on profiling. This capability was critical to our journey of multi-year infrastructure efficiency.</p><p>We had a clear decision to make Build or Buy or Partner. Trying to solve problems that are shared across multiple companies made it clearer that building one more closed source profiler project was not the answer. We should partner rather than pursue just a Build or Buy strategy. The level of profiling insights that a leading hardware vendor like Intel could provide was significantly ahead of what we could build on our own as was evident from the decades of capabilities Intel developed in Intel vTune™. Understanding and interpreting architecture specific counters anyways requires talking to the creators.</p><p>Beyond hardware Intel has demonstrated decades of clear leadership in software optimizations and open source contributions, therefore we decided to partner with Intel on this. This first and obvious choice was vTune™ but there were several gaps, first vTune was never developed for a cloud first environment and it had a significant customer base that Intel needed to continue to support therefore opensourcing / rebuilding it would been very disruptive to existing users and didn’t make sense for us to pursue.</p><p>Intel’s acquisition of Granulate presented a cleaner opportunity to start something more purpose built for the cloud. We really liked the ability for Granulate to continuously profile workloads. After months of discussion, in late 2023 Pinterest requested Intel to release gProfiler agent and gProfiler Performance Studio backend under a permissive Apache 2.0 license — enabling any organization, including Pinterest, to self-host and extend the full profiling stack. Intel generously agreed, recognizing the broader benefit to the customer base. The vision was to make this profiler tooling easily accessible so we get down to solving optimizations together. Intel with its ability to optimize vertically from silicon to application runtime allowed us to get one step closer to our optimization and efficiency goals.</p><p>Once Intel open sourced gProfiler and gProfiler Performance Studio on <a href="https://www.intel.com/content/www/us/en/newsroom/news/intel-releases-continuous-profiler-for-cpu-performance.html">March 11, 2024</a>, Pinterest began deploying gProfiler agents to our fleet and standing up the backend infrastructure internally.</p><p>To deploy gProfiler at scale we addressed critical reliability issues: Pinterest and Intel collaborated closely to harden gProfiler for production use at fleet scale. Together, we addressed critical reliability issues spanning agent resource consumption, disk usage, multi-terabyte ingestion, and build pipeline efficiency — and contributed the resulting fixes back upstream, improving gProfiler’s reliability for the broader community. We cover this reliability journey in detail later in this post.</p><p>Once the agent was running reliably across our fleet, Pinterest contributed the Dynamic Profiling feature to upstream gProfiler — system that allows developers to request on-demand profiles at fleet scale.</p><p>This capability also unlocked additional features — wall-clock time profiling, PMU hardware event-based flame graphs (intel/gprofiler#1014), and Intel PerfSpect integration (intel/gprofiler#959) — all of which can be dynamically configured to provide deeper profiling insights when needed.</p><h3>gProfiler Open Source Architecture</h3><p>To deep dive into Pinterest’s dynamic profiling contributions, let’s first review gProfiler’s architecture. The system consists of two components: the gProfiler agent, which runs on each node where profiling data is collected, and gProfiler Performance Studio, a backend that aggregates, stores, and visualizes profiling data across the fleet.</p><h4>gProfiler Agent</h4><p>The gProfiler agent is an orchestrator that coordinates multiple language-specific profilers on a single host. When it starts, it follows a well-defined profiling cycle:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*8fK8YjUSFgGTwdjUiFBDIw.png" /><figcaption><em>Figure 1: gProfiler Agent profiling cycle — from process discovery through stack trace merge and upload.</em></figcaption></figure><ol><li><strong>Initiation</strong>: The gProfiler orchestrator initiates all configured profilers — perf for system-wide native profiling, plus runtime-specific profilers like async-profiler (Java), PyPerf (Python eBPF profiler), py-spy (Python fallback profiler), rbspy (Ruby), dotnet-trace (.NET), and phpspy (PHP).</li><li><strong>Process discovery</strong>: Each profiler discovers eligible processes by scanning /proc/*/maps for language-specific library signatures (e.g., libjvm.so for Java, libpython*.so for Python) or by matching executable names (e.g., node for Node.js). This detection runs every profiling cycle and takes approximately 50ms to scan thousands of processes.</li><li><strong>Profiling session</strong>: Each profiler attaches to its target processes using the appropriate mechanism — JVMTI for Java, ptrace for Python/Ruby/PHP, eBPF for PyPerf, EventPipe for .NET — and samples stack traces at the configured frequency (default: 11 Hz).</li><li><strong>Stack trace output</strong>: Each profiler writes its collected stack traces to output files, with language-specific frame suffixes (_[j] for Java JIT, _[p] for Python, _[rb] for Ruby, _[k] for kernel, etc.).</li><li><strong>Merge</strong>: The orchestrator parses all stack trace outputs and merges them. Runtime-specific stacks are scaled to match the perf sample counts for each process, then substituted in place of the native stacks that perf collected for those same processes. Processes without a runtime profiler (Go, C++, Rust) retain their perf stacks as-is.</li><li><strong>Upload</strong>: The merged, collapsed stack trace file is uploaded to the backend for indexing and visualization.</li></ol><p>This design means that a single gProfiler agent produces a unified view of CPU activity across all languages running on a host, without requiring any code changes to the profiled applications.</p><h4>gProfiler Performance Studio (Backend)</h4><p>The <a href="https://github.com/intel/gprofiler-performance-studio">gProfiler Performance Studio</a> is the backend infrastructure that receives, stores, and serves profiling data. It is structured as a set of microservices:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ytMxVUKkxNWBPbEHGprCaw.png" /><figcaption><em>Figure 2: gProfiler Performance Studio backend — microservice architecture for profile ingestion, storage, and visualization.</em></figcaption></figure><p><em>Legend : Solid arrows are the continuous data plane (profile upload → AWS S3 → AWS SQS → indexer → ClickHouse → flame graphs). Dashed arrows are the dynamic control plane (UI creates a profiling request; agent fetches start / stop profiling requests via heartbeat).</em></p><ul><li><strong>Backend (Webapp)</strong>: The central web application that exposes all APIs to the frontend and API users. It receives profiling data uploads from agents, stores raw profile files in S3, enqueues indexing tasks in Amazon Simple Queue Service (SQS), and reads/writes service metadata in PostgreSQL.</li><li><strong>Frontend UI</strong>: A React-based interface for exploring flame graphs, filtering by service/host/container/time range, and comparing profiles across time periods.</li><li><strong>Indexer</strong>: A Go service that consumes events from SQS, downloads raw profiling data from S3, and bulk-inserts it into ClickHouse. It handles the transformation from collapsed stack format into the columnar schema optimized for flame graph queries.</li><li><strong>FlameDB REST</strong>: A Go query service that sits between the frontend and ClickHouse. It constructs flame graph data by querying the appropriate resolution tier (raw, 1-minute, 1-hour, or 1-day aggregates) based on the requested time range, and performs frame-level language identification using pattern matching on stack frame names.</li><li><strong>Logging Service</strong>: Collects and centralizes logs from agents for debugging and operational monitoring.</li></ul><p>The backend uses <strong>S3</strong> for durable profile storage, <strong>SQS</strong> for decoupling ingestion from indexing, <strong>PostgreSQL</strong> for service metadata and configuration, and <strong>ClickHouse</strong> as the columnar analytical store for fast flame graph queries across billions of stack trace samples.</p><p>This open source architecture gave Pinterest a strong foundation to build upon. Our contributions focused on extending it with dynamic profiling capabilities, hardening it for production scale, and integrating hardware-level insights — all contributed back upstream.</p><h3>Pinterest’s Open Source Contributions with Intel</h3><h4>Dynamic Profiling: Single-Click Managed Profiling at Fleet Scale</h4><p>The original gProfiler agent only supported a statically configured profiling mode, without the ability to dynamically reconfigure the agent in a given session.</p><p>Pinterest operates a large fleet of nodes across AWS EC2 and Kubernetes (<a href="https://medium.com/pinterest-engineering/pincompute-a-kubernetes-backed-general-purpose-compute-platform-for-pinterest-8ad408df2d6f">Pincompute</a>). At that scale, a static agent posed real challenges. There was no way to target profiling to specific services, no way to adjust the configuration on the fly, and no way to control profiling remotely without SSH access to each node.</p><p>The <strong>Dynamic Profiling</strong> feature we contributed to the open source gProfiler project solves exactly this problem. It offers zero-setup, single-click managed RBAC controlled profiling designed for scalability and ease of management:</p><ul><li><strong>Service owners</strong> gain full control to start and stop profiling on demand, enabling targeted detection of service-specific bottlenecks, regressions, and optimization opportunities — all without shell access.</li><li><strong>Performance experts</strong> can fine-tune arguments to run various profilers, investigate common bottlenecks across services or processes, and dive deep into hardware-level insights using Intel PerfSpect — also without shell access.</li></ul><p>This streamlined, accessible profiling capability has made efficiency and cost optimization a core part of Pinterest’s engineering culture.</p><p>Under the hood, we needed the ability to:</p><ul><li>Install the agent on host in a dormant state</li><li>Activate profiling remotely on specific hosts, services, or clusters</li><li>Change profiling parameters (frequency, duration, profiler selection) without redeploying</li><li>Stop profiling instantly if resource pressure is detected</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*YEu9SrXMUsd3mA1PX3YwNQ.png" /><figcaption><em>Figure 3: dynamic profiling architecture — agents communicate via heartbeats, and profiling requests are delivered as heartbeat responses from the studio.</em></figcaption></figure><p><strong>How it works:</strong></p><ol><li><strong>gProfiler agents</strong> are installed on all hosts by default in dormant mode (less than 1% CPU, less than 100MB memory when idle). They send periodic heartbeats to the gProfiler Performance Studio every 60 seconds.</li><li>When an engineer submits a profiling request through the UI or API — specifying a service, host list, duration, frequency, and profiler configuration — the <strong>Profiling Service</strong> converts it into requests and queues them into the database</li><li>On the next heartbeat from a targeted host, the <strong>Heartbeat Handler</strong> responds with the pending profiling request. The agent validates, enqueues, and executes it.</li><li>Request execution is <strong>idempotent</strong> — agents track received and executed request IDs to prevent duplicate execution, and report completion status back through subsequent heartbeats.</li></ol><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*IbJubImgjVQVFDxxcdLO1g.png" /><figcaption><em>Figure 4: The Dynamic Profiling UI — configure and control profiling on demand, no SSH.</em></figcaption></figure><p>he full dynamic profiling system — agent-side profiling request management, heartbeat client, and backend profiling-orchestration APIs — has been merged upstream into the <a href="https://github.com/intel/gprofiler">Intel gProfiler repository</a> and the <a href="https://github.com/intel/gprofiler-performance-studio">Intel gProfiler Performance Studio repository</a>, available for any organization to use.</p><h3>PerfSpect: Deeper Insights for Infrastructure Decisions</h3><p>Flamegraphs show where software spends time, but not how hardware is stalled or saturated. <a href="https://github.com/intel/PerfSpect">Intel PerfSpect</a> adds CPU stalls, memory bandwidth, CPI, cache, and pipeline telemetry backed by hardware Performance Monitoring Counters (PMCs) — and Pinterest extended its gProfiler integration for on-demand collection of PerfSpect via the Dynamic Profiling UI, with metrics stored in the same backend for correlation with software hotspots. A standalone build pipeline bundles PerfSpect independently of Intel’s release cycle and runs on both x86 and ARM, covering the majority of Pinterest’s fleet including Graviton. The result: engineers pick optimal AWS instance types based on real workload behavior, not vendor specs.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*53mUrjh2FJxoq3JP4gRLSQ.png" /><figcaption>Figure 5: PerfSpect TMAM dashboard — surfaces hardware-level bottlenecks like cache-miss-driven stalls directly in the gProfiler UI.</figcaption></figure><h3>Wall Time Profiling</h3><p>Default CPU flamegraphs miss a huge class of microservice latency — threads blocked on I/O, locks, DB queries, or RPC calls never appear. Pinterest contributed <strong>wall time profiling</strong> (<a href="https://github.com/intel/gprofiler/issues/1012">intel/gprofiler#1012</a>), which enables sampling stacks by elapsed wall-clock time to capture both running and waiting states. Engineers toggle CPU and wall time modes on the fly through the same UI — no agent redeploys — and compare them side-by-side in Performance Studio.</p><h3>PMC Counter Flamegraphs</h3><p>CPU profiling shows where time goes and PerfSpect shows system-wide hardware behavior, but neither pinpoints <em>which functions</em> cause cache misses or branch mispredictions. Intel and Pinterest contributed <strong>custom PMC flamegraphs</strong> (<a href="https://github.com/intel/gprofiler/issues/1013">intel/gprofiler#1013</a>) that sample on hardware events (L3 misses, TLB misses, branch mispredicts, memory stalls) so each frame’s width reflects event frequency, not CPU time. Agents advertise supported events, the backend validates requests, and results render alongside CPU and wall time profiles — letting engineers tie specific Java or C++ functions to hardware bottlenecks.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*K-cdDCP92djy-uNyGtTb-A.png" /><figcaption>Figure 6: A PMU counter flame graph sampled on cache-miss events — each frame’s width reflects cache-miss frequency rather than CPU time, pinpointing the functions driving memory stalls.</figcaption></figure><h3>Reliability Journey</h3><p>Taking gProfiler from prototype to production-grade infrastructure at Pinterest scale required solving several reliability challenges, many of which resulted in fixes contributed back to the open source project(PRs <a href="https://github.com/intel/gprofiler/pull/987">#987</a>, <a href="https://github.com/intel/gprofiler/pull/988">#988</a>, <a href="https://github.com/intel/gprofiler/pull/993">#993</a>, <a href="https://github.com/intel/gprofiler/pull/995">#995</a>, <a href="https://github.com/intel/gprofiler/pull/997">#997</a>, <a href="https://github.com/intel/gprofiler/pull/998">#998</a>, <a href="https://github.com/intel/gprofiler/pull/1002">#1002</a>) :</p><p><strong>Memory optimization</strong>: The original agent consumed 2.8 GB at steady state on typical hosts and spiked to 4–5 GB+ on high-process-count hosts (500+ processes) — frequently triggering OOM kills. The root cause was 3,000+ leaked pipe file descriptors from subprocesses, compounded by leaks in the grpcio library and the profile collection path. Through comprehensive subprocess cleanup (98% FD reduction), grpcio leak fixes, and collection-path optimizations, we brought the agent to under 400 MB stable across both scenarios — critical for running as a DaemonSet alongside production workloads.</p><p><strong>Core dump elimination</strong>: The async-profiler component occasionally triggered core dumps consuming over 100GB/day of disk on storage-optimized instances. We disabled core dumps at the child process level and added zombie process checks, eliminating this entirely.</p><p><strong>Staticx temp file accumulation</strong>: Each agent restart left behind extracted binary directories in /tmp, accumulating hundreds of gigabytes over time. We addressed this through proper cleanup lifecycle management.</p><p><strong>ClickHouse “too many parts”</strong>: During aggressive backfill after an outage, high indexer concurrency with small batch sizes created more parts than the merge scheduler could compact, blocking inserts. We tuned batch sizes, added parts-limit monitoring, and implemented the <a href="https://github.com/pinterest/orion">Orion</a> shard manager for automated cluster operations.</p><p><strong>Build time reduction</strong>: gProfiler builds originally took over 30 minutes. Through build pipeline optimization — migrating from Jenkins to Buildkite, parallelizing architecture-specific builds, and caching intermediate artifacts — we achieved a 70% reduction in build time.</p><h3>Surfacing recurring efficiency opportunities</h3><p>Pinterest has started using gProfiler data to drive optimization across top CPU-bound services. The effort is still a prototype — engineers eyeball flamegraphs and track findings in a spreadsheet — but it has already paid off: we were able to identify common patterns across multiple services that lead to meaningful improvements e.g</p><ol><li>Frequent Pattern.compile calls delivered <strong>2–3% CPU savings</strong> in the affected sub-service.</li><li><strong>compression tuning</strong> across Zstd/Zlibng/LZ4/Snappy</li><li><strong>Serialization optimization</strong> with SIMD-accelerated alternatives like SimdJSON alongside Kryo, Jackson, Protobuf, and MessagePack.</li></ol><p>We’re working to formalize this into a structured recommendations framework in alignment with our original vision of what we hope the service would deliver.</p><h3>Results and Impact</h3><p>Since deploying gProfiler across Pinterest’s infrastructure:</p><ul><li><strong>Coverage</strong>: gProfiler agents are installed provide profiling observability across our fleet</li><li><strong>Productivity</strong>: Self-service dynamic profiling UI enables any engineer to start profiling their service in minutes, without needing to call the performance engineers or lookup documentation for profiling</li><li><strong>Engagement: </strong>Since its launch we see at least ~140 users across (engineers, efficiency leads, experts) using gProfiler on a monthly basis</li><li><strong>Efficiency</strong>: The profiling platform has detected several bottlenecks, regressions and optimizations with vetted recommendations translating to measurable savings</li><li><strong>Lightweight footprint</strong>: Agents stay nimble even at fleet scale — 0.15% CPU and ~200 MB memory in silent/dormant mode, and under 2% CPU and 2 GB memory while actively profiling</li><li><strong>Reliability</strong>: The infrastructure operates with 99.99% heartbeat success rate service level objective (SLO), sub-5-minute P99 indexing latency, and sub-second P95 flamegraph query latency</li><li><strong>Open source</strong>: Dynamic profiling, PerfSpect integration, memory optimizations, core dump fixes, and build improvements have all been contributed upstream</li></ul><h3>What’s Next</h3><p>We’re continuing to invest in several areas:</p><ul><li><strong>Latency profiling: </strong>Knowing what is blocking a service from running at higher utilizations<br><strong>Spark profiling</strong>: Extending gProfiler to support Spark job-level profiling, with optimization opportunities through JIT analysis and thread-name-aware flame graphs</li><li><strong>Workload-level profiling</strong>: Granular profiling by Kubernetes namespace, pod, and individual process — moving beyond host-level to workload-level visibility</li><li><strong>Structured optimization framework</strong>: Evolving today’s prototype recommendations effort into a formalized system with precision and accuracy KPIs, automated vetting, and hardware-informed instance selection using PerfSpect data (cache miss patterns, memory bandwidth saturation) to guide right-sizing decisions</li><li><strong>Regression detection</strong>: Automated detection of performance regressions across deployments using profiling data differentials</li><li><strong>GPU profiling</strong>: Extending the platform to cover GPU-bound workloads, integrating tools like NVIDIA Nsight Systems and Nsight Compute for kernel-level insights, alongside dynolog and DCGM for fleet-wide GPU telemetry</li><li><strong>LLM-powered analysis</strong>: Augmenting the optimization framework with large language models speed up RCA and analysis to provide fix suggestions with source pointers</li></ul><h3>Conclusion</h3><p>Maintaining software performance at fleet scale is a fundamentally different challenge than occasionally profiling a single service manually. It requires a platform that spans languages, operates across deployment models, and empowers every engineer — not just performance specialists to make things efficient. By partnering with Intel on gProfiler, Pinterest helped build that platform on a fully open source foundation: dynamic profiling for remote, on-demand analysis across hundreds of thousands of hosts; PerfSpect integration for hardware-level insights; wall time profiling and PMU counter flame graphs to answer questions that CPU-only profiling can’t; and a reliability-hardened pipeline that ingests hundreds of terabytes of profiling data monthly. Every major capability we built has been contributed upstream, so any organization can deploy the same infrastructure. If you’re looking for a unified, vendor-free profiling platform that scales from a single host to an entire fleet, gProfiler is ready.</p><h3>Resources to get started with gProfiler</h3><p>gProfiler is open source and ready to deploy:</p><ul><li><strong>gProfiler Agent</strong>: <a href="https://github.com/intel/gprofiler">github.com/intel/gprofiler</a></li><li><strong>gProfiler Performance Studio</strong>: <a href="https://github.com/intel/gprofiler-performance-studio">github.com/intel/gprofiler-performance-studio</a></li><li><strong>Intel PerfSpect</strong>: <a href="https://github.com/intel/PerfSpect">github.com/intel/PerfSpect</a></li></ul><p>Whether you’re running a handful of services or a fleet spanning hundreds of thousands of hosts, gProfiler’s dynamic profiling infrastructure — contributed by Pinterest — gives you the control to profile what you need, when you need it, without the overhead of profiling everything all the time.</p><p><em>All performance metrics and infrastructure statistics cited in this post are based on Pinterest internal data, Q1 2026.</em></p><h3>Acknowledgements</h3><p><em>This work would not have been possible without the collaboration between Pinterest and Intel’s open source teams.</em></p><p><em>We’d like to thank the current Intel team for their continued partnership on gProfiler and PerfSpect Min Yeol Lim, Keith Sundberg, Rogerio Gelamo, and Kim McLaughlin through ongoing support, development and maintenance of gProfiler, and Jason Harper for perfspect. This wouldn’t have been possible without Intel business and engineering leadership by Kelly Hammond and Bill Pearson.</em></p><p><em>We also want to recognize leaders who were part of laying the original foundation of this open source project: Asaf Ezra, Brendan Greg, Dave Burgess, Greg Lavender, Greg Ernst, Harshad Sane, Peggy Irelan and Steve Beckis. Additionally all members of the original Granulate team supported the original project.</em></p><p><em>From Pinterest, we’d like to thank the following current and past members who contributed to and helped maintain gProfiler: Ambud Sharma for Pinterest Software Performance Vision, Prashant Patel for Technical design and Infrastructure scale out. We are especially grateful to engineering contractors who helped bring this work to life : Artur Sarlo, Ashok Chatharajupalli, Marc Antonio Vieira De Queiroz and Renato Gagliardi Sabo. We’d also like to recognize Eric Lopez, Vibhav Garg, and Vaibhav Shankar for their partnership, and Nasser Manesh, David Westbrook, and Molly Junck for their leadership support.</em></p><p><em>To learn more about engineering at Pinterest, check out the rest of our </em><a href="https://medium.com/pinterest-engineering"><em>Engineering Blog</em></a><em> and visit our </em><a href="https://labs.pinterest.com/"><em>Pinterest Labs</em></a><em> site. To view and contribute to open source projects at Pinterest, visit our </em><a href="https://github.com/pinterest"><em>GitHub</em></a><em>.</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=8fcb387d899c" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[From Activity to Intent: Generating User Journeys with LLMs]]></title>
            <link>https://medium.com/pinterest-engineering/from-activity-to-intent-generating-user-journeys-with-llms-9652b9417626?source=rss-ef81ef829bcb------2</link>
            <guid isPermaLink="false">https://medium.com/p/9652b9417626</guid>
            <category><![CDATA[user-experience]]></category>
            <category><![CDATA[machine-learning]]></category>
            <category><![CDATA[pinterest]]></category>
            <category><![CDATA[infrastructure]]></category>
            <category><![CDATA[engineering]]></category>
            <dc:creator><![CDATA[Pinterest Engineering]]></dc:creator>
            <pubDate>Fri, 09 Oct 2026 15:01:02 GMT</pubDate>
            <atom:updated>2026-10-16T23:01:38.400Z</atom:updated>
            <content:encoded><![CDATA[<p>Lin Zhu | Sr. Staff Machine Learning Engineer; Manan Kalra | Machine Learning Engineer II; Byung Eun Jeon | Sr. Machine Learning Engineer; Ye Liu | Staff Machine Learning Engineer; Xiangyi Chen | Sr. Machine Learning Engineer; Jaewon Yang | Principal Machine Learning Engineer; Jinwen Xu | Manager II, Machine Learning Engineering; Tingting Zhu | Sr. Manager, Engineering; Sudarshan Lamkhede | Director, Machine Learning Engineering</p><p>Pinterest is built to get inspired and then turn the inspiration into realization — a dinner, a renovation, a wedding, a new skill. That only works if we understand more than the Pin a user just clicked. The useful unit of personalization isn’t the thing someone just clicked, it’s what they’re trying to accomplish. Such goals unfold over weeks and months, across dozens of sessions, through hundreds of saves.</p><p>We call those goals <strong>journeys</strong>: the intersection of a user’s interests, intent, and context at a point in time. A journey is a sequence of interactions, often spanning sessions, centered on one interest and a clear intent. For example, “Summer dresses” are an interest. “Figuring out what’s in style for a summer wedding I’m attending in August” is a journey. Someone might be interested in five at once: a kitchen remodel, a vegetable garden, learning sourdough and the mix changes as life does.</p><p>If we can name those journeys, recommendations and notifications can help someone finish a project instead of bouncing between unrelated interests. This post is about how we infer them in production: a fine-tuned 4-billion-parameter large language model (LLM) that reads a chronological activity log and emits a ranked list of journey names, in the user’s locale.</p><p>We didn’t start there. The <a href="https://medium.com/pinterest-engineering/identify-user-journeys-at-pinterest-b517f6275b42">initial user journey system</a> clustered keywords and attached attributes with separate machine learning models. This post is the follow-up which covers how we formulate journey inference as generation, how we distill frontier-model quality into a 4B student we can actually serve, how the serving stack holds up at notification scale, and what we’re building next. The recipe is domain-agnostic: any platform with sequential user activity and structured intent targets (e-commerce, streaming, search) can adopt the same activity-to-intent formulation, collapsing classical multi-stage recommendation pipelines into a single generative model, making generative journey inference a general recipe for personalization beyond Pinterest.</p><h3>Why interests aren’t enough</h3><p>Most recommenders optimize the next item. However, journeys on Pinterest are longer-horizon. Planning a kitchen is not the same as a one-off click on a cabinet Pin, and it isn’t well described by a durable interest like “Home Decor”. The cabinet Pin, the backsplash search, and the garden-bed save might be one project, two projects, or noise. Getting that distinction right changes what we retrieve, what we write in a notification, and when we should stay quiet.</p><p>So the output we want is not a taxonomy label. It’s a short, specific, ranked list of a user’s active journeys, with specific names they’d recognize.</p><h3>The legacy clustering system</h3><p>We had almost no labeled journeys when we started, so we stayed lean. We reused pretrained text embeddings, started from a few hundred human examples, and built a multi-stage pipeline:</p><ol><li><strong>Extract keywords</strong> from search queries, annotations and interests on engaged Pins, and Pins on owned boards.</li><li><strong>Embed and cluster</strong> those keywords hierarchically. Each cluster is a journey candidate.</li><li><strong>Name</strong> the cluster by ranking its keywords and taking the top one.</li><li><strong>Rank and diversify</strong> the clusters with a pointwise model, then a similarity penalty so the top of the list isn’t five near-duplicates.</li><li><strong>Classify stage</strong> with heuristics: evergreen vs. situational (roughly, does engagement persist past a few weeks?), and for situational journeys, ongoing vs. ended (time since last engagement).</li></ol><p>That pipeline powered the first journey-aware notifications. Against interest-based notifications, it lifted email click rate by 88%, push open rate by 32%, and positive survey feedback by 23%.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*rPutHFYtqfPA2vcx50nd4A.png" /><figcaption>Figure 1: User journey inference via the legacy clustering system</figcaption></figure><p>It also compounded its own errors. Every stage was locally reasonable; together they fragmented coherent goals.</p><ul><li><strong>Fragmentation:</strong> Cabinets, countertops, and backsplash are one kitchen, not three journeys fighting for the same notification slot.</li><li><strong>Generic buckets:</strong> When a cluster was messy, the naming model fell back to the most frequent keywords — “Art”, “Recetas”, “Shopping”. Taxonomically true, useless for personalization.</li><li><strong>Incidental noise:</strong> A handful of meme Pins or celebrity clicks could occupy a top slot. Clustering doesn’t know which activity is a goal and which is a passing click.</li></ul><h3>Journey inference as generation</h3><p>To solve these problems, we stopped treating journey inference as a chain of transformations and started treating it as one act of generation: read the activity, write the intent<strong>.</strong> The model gets a single input prompt containing a semi-static profile plus a chronological activity log, and returns strict JSON — a ranked list of journey names:</p><pre>User Profile:<br>gender: female; language: en-US; country: US; age: 30-35<br>User Owned Boards: Kitchen Reno 2026; Weeknight Dinners; Garden Plans<br>User Activities:<br>2026-06-14 search shaker cabinets.<br>2026-06-14 save subway tile backsplash, kitchen backsplash; quartz countertop, white kitchen.<br>2026-06-12 click raised garden bed, vegetable garden; tomato trellis.<br>2026-06-09 search sourdough starter discard.<br>2026-05-30 save cast iron skillet cornbread, dutch oven recipes.<br>...</pre><pre>{&quot;journeys&quot;: [&quot;Modern Farmhouse Kitchen&quot;, &quot;Raised Bed Vegetable Garden&quot;,<br>              &quot;Sourdough Bread Making&quot;, &quot;Cast Iron Recipes&quot;]}</pre><p>Notice what’s gone: no keyword extraction step, no embeddings, no clusters, no separate naming model. One model reads the log and writes the answer.</p><p>Some deliberate choices in the prompt:</p><ul><li><strong>Actions collapse into a tiny vocabulary</strong>: search, save, like, click, comment, dislike. The model doesn’t need to distinguish a repin from a save-to-device; it needs to know both are strong positives. The prompt states the ordering explicitly: search/save &gt; comment &gt; like &gt; click &gt; dislike.</li><li><strong>Events are grouped by (date, action)</strong>, with a semi-colon (;) separating Pins and a comma (,) separating tags within a Pin. That’s token economy, and it also gives the model a cleaner view of same-session bursts.</li><li><strong>Dislike is deliberately included in the input, as hides are evidence.</strong> The prompt says to suppress a journey whose support is predominantly dislikes unless there is evidence of significant related positive engagements.</li><li><strong>Temperature 0, reasoning tokens off, max 300 output tokens.</strong> We want a deterministic, replayable read of the same log, and with a fixed output schema for decoding purposes. Reasoning is turned off to reduce online inference cost.</li><li><strong>A 360-day lookback, up to 150 events.</strong> Journeys are long-horizon by definition, we experimented with different lookback windows and # of events to balance cost and relevance.</li></ul><p>The output contract is deliberately simple: drop non-strings, dedupe case-insensitively, and the first occurrence wins. The output is parsed defensively because a generative system that occasionally emits prose where you expected JSON is a system that occasionally drops a user’s entire personalization payload.</p><p>The other thing worth saying out loud is that once you collapse the pipeline, the prompt becomes the ranking policy and the safety policy. Everything the staged system encoded in model weights and thresholds now has to be clearly stated. In production it asks for up to about twelve distinct journeys, each 1–5 words, in the user’s locale, ranked by:</p><ol><li>Engagement strength (search/save over click)</li><li>Repeated support</li><li>Recency</li><li>Coherence</li><li>Specificity (“Mid-Century Coffee Table”, not “Furniture”)</li></ol><p>It also encodes the failure modes we observed with the legacy journey clustering system: don’t merge unrelated topics; drop one-off outliers; treat auto-generated tags as noisy; lightly diversify the top of the list only among journeys that are already well supported. Empty output is allowed when the activity doesn’t support anything but inventing unsupported journeys is not.</p><p>These instructions aren’t just decorative. The diversity pass replaced a similarity-penalty diversifier. The dislike rule replaced a hand-tuned negative-signal filter. Few tag-noise instructions exist because auto-generated annotations are genuinely noisy, and the model will happily build a journey out of a classification artifact if you let it. Prompts like this deserve the same review discipline as model code, so we treat changes to them as launches.</p><p>Clustering was the right cold-start system, and it still exists as a fallback. Production inference is now LLM-first: one model, one decode, structured output.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*yk5WWtOdEk00T9WnYnJVWA.png" /><figcaption>Figure 2: User journey inference system with feedback</figcaption></figure><h3>Distilling a frontier teacher into a 4B student</h3><p>Frontier models can infer coherent journeys without task-specific training, but they are too expensive to run for hundreds of millions of people on Pinterest regularly. Instead, we use them as teachers to generate synthetic training examples: each activity history is paired with a high-quality target journey. We also found that teacher outputs improved when the model was allowed to use additional reasoning tokens before producing its final structured response.</p><p>We fine-tuned open-weight <a href="https://arxiv.org/abs/2505.09388">Qwen3</a> students with 0.6B, 1.7B, 4B and 8B parameters. Supervised fine-tuning (SFT) teaches the model from the teacher-generated examples, while <a href="https://arxiv.org/abs/2106.09685">low-rank adaptation (LoRA)</a> lets us update small adapters rather than every model parameter.</p><p>Quality improved rapidly during the first few thousand examples, then largely stopped increasing. Adding more similar examples provided little benefit; selecting diverse, high-quality examples — especially difficult consolidation and multilingual cases — was more valuable.</p><p>Model size presented a separate trade-off. The 0.6B and 1.7B students were faster, but produced less relevant and consistent journeys on the harder cases. Among the sizes we tested, the 4B model offered the best balance between output quality and serving throughput, so it became our production student.</p><p>Table 1: Same-user comparison of the staged clustering pipeline against the fine-tuned LLM. Fragments consolidated (Cabinets + Countertops → Modern Farmhouse Kitchen). Generic buckets got specific (Art → Penguin Art Drawing). Dropped intents came back (Cast Iron Recipes). Noise disappeared (Celebrity News, Memes). And user B got journeys in Spanish — something the keyword pipeline structurally couldn’t do, because it named journeys out of an English-annotation vocabulary.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*GGtffWBh1hxKMaQhai-Xqg.png" /></figure><h3>Serving at notification scale</h3><p>Scoring hundreds of millions of people on Pinterest is a batch problem with online-shaped latency. We run a full refresh when the algorithm changes, and a daily incremental pass over recently active users, so journeys move when behavior does.</p><p>We benchmarked the offline workflow against serving the same 4B model behind an online inference stack. Online serving won on throughput. The reason is a set of now-mature decode optimizations that a naive offline loop typically doesn’t get for free: PagedAttention, so the key-value cache isn’t wasted on padding; continuous batching, so a finished sequence immediately yields its GPU slot to the next user instead of waiting for the slowest prompt in a fixed batch. The serving stack is <a href="https://github.com/ai-dynamo/dynamo">NVIDIA Dynamo</a> in front of <a href="https://arxiv.org/abs/2309.06180">vLLM</a> decode workers on L40S GPUs. Frontend and decode workers scale independently, and the frontend routes with key-value (KV) cache awareness so requests sharing prompt structure land on workers that already hold the relevant prefix, which matters because every request shares a long, stable system prompt. We enable prefix caching and chunked prefill and cap concurrent sequences per worker.</p><p>In production, a cluster on the order of 100 GPUs sustains about 775 requests per second end-to-end, with a median around 1.2 seconds and p95 around 3.0 seconds. The batch side is where the cheaper wins live. Inference runs as a daily fan-out over journey-eligible users, sharded into small per-job batches with bounded concurrency inside each batch, so a slow model response back-pressures one shard instead of the pipeline. The single most effective throughput lever we added wasn’t a GPU at all: we track when each user was last inferred and skip anyone processed within the last couple of days. Journeys don’t change hour to hour, and paying to re-derive an unchanged journey is the most expensive kind of waste — the kind that looks like progress on a dashboard.</p><p>If a decode fails, we retry that user rather than storing a partial list. An empty but valid JSON list is a successful inference; some users don’t have a supportable journey that day, and we’d rather send nothing journey-specific than a generic “Art”. We suppress inference of sensitive traits at multiple levels before anything is stored:</p><ul><li>Input keywords pass our notification content filter before reaching the prompt, so the model can’t build a journey on a keyword we wouldn’t have sent anyway.</li><li>Output journey names pass that filter and our generative content safety filters, so names that are unsafe in a journey-inference sense are dropped even if they would pass a generic keyword list.</li><li>The model is instructed never to infer or target topics related to health conditions, politics, religion, sexual orientation, gender identity, race, etc.</li><li>Profile inputs are minimized, with ages bucketed at the low end and grouped, because the model needs a rough life stage rather than a birthday. Under-18 users in the EEA, Switzerland, and the UK aren’t eligible yet due to local laws.</li></ul><p>Inference writes a ranked list of journeys per user into a store that notification serving reads at send time, and each journey name then becomes a retrieval query. We built embedding-based retrieval models to pull Pins for each user journey which are further ranked by our Pin ranking models. Journeys the user was recently notified about are fatigued, and journeys they’ve given explicit negative feedback on are suppressed outright. Only then does the notification framework decide whether to send at all, and on which channel.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*xmJDorBUBL7Cxc7OP-d7Zg.png" /><figcaption>Figure 3: Notification surfaces for User Journeys</figcaption></figure><h3>How we know it’s better</h3><p>There is no ground-truth label for a user’s latent goal. We evaluate at three levels, from inspection to a causal test.</p><ul><li><strong>Same-user qualitative comparison:</strong> For journey-eligible users, we put clustering output and LLM output side by side on the same user activity. We keep both the strong cases and the honest misses: over-generalization (“Home Cooking” when the log is clearly “Spam Kimchi Fried Rice”) and under-generation (dropping a well-supported side interest). This is slow, but it’s the only way to see fragmentation and language quality that metrics flatten.</li><li><strong>Evidence-grounded diagnosis:</strong> A separate language model scores a sample of emitted journeys on axes we care about in production: relevance to the visible context, support from the profile and activity log (is this name faithful to what we showed the model?), diversity / non-redundancy across the list, and granularity. The support axis is the important one. A fluent, on-brand name that isn’t in the log is a failure, even if a user might have clicked it.</li><li><strong>Online experiment:</strong> Initial experiment ramped to the US and Canada users, with the clustering production system as control, showed lifts of 1.1% on email click-through and 1.3% on push opens, plus multiple statistically significant sitewide engagement and retention gains. A subsequent multilingual international rollout replicated the direction, and shipping both experiments increased the daily, weekly, and monthly active users on the platform.</li></ul><h3>What we’re building now</h3><p>The fine-tuned Qwen-4B model is the production default. Everything in this section is under experiment — trained or rolling out, not what every user gets today.</p><h4>Semantic IDs, so Pins aren’t a bag of tags</h4><p>Tags mean every Pin costs a handful of tokens and drags along a dependency on Pin-to-text mappings. <a href="https://arxiv.org/pdf/2508.10478">Semantic IDs</a> (SIDs) are the alternative: each Pin is compressed by a residual-quantized variational autoencoder (<a href="https://arxiv.org/abs/2203.01941">RQ-VAE</a>) into five levels of 11 bits each, which we pack into a single 64-bit integer and unpack at prompt-build time into special tokens:</p><pre>&lt;sid_begin&gt;&lt;sid1_1263&gt;&lt;sid2_973&gt;&lt;sid3_725&gt;&lt;sid4_793&gt;&lt;sid5_1727&gt;&lt;sid_end&gt;</pre><p>A general-purpose model has no idea what &lt;sid3_725&gt; means, so we bridge the gap with continued pre-training (CPT): extend the tokenizer with the SID vocabulary, then run next-token prediction over interleaved SID sequences, Pin titles, and tags until the model has mapped its world knowledge onto our catalog. SFT goes on top, so that given a SID sequence the model writes journeys.</p><p>SIDs only work better for users with dense engagement, so production is a fallback ladder: fewer than ten SID-carrying events and we fall back to tags for the whole prompt; a single event missing a SID falls back to tags for that event; and search queries always keep their literal text, because a typed query is the highest-fidelity signal we have. The bet is fewer input tokens, no dependency on annotations, and a shared item language between recommenders and LLMs. The cost is interpretability — a tag-based prompt can be read by a human mid-debugging session. A SID prompt cannot.</p><h4>Offsite conversions as a complementary signal</h4><p>Onsite search and saves remain the strongest intent signals, but they can be sparse for dormant or low-activity users. We therefore use recent offsite actions — such as page visits, add-to-carts, sign-ups, and checkouts — as a complementary signal, adding a compact, safety-filtered summary to capture timely shopping or planning intent beyond Pinterest.</p><p>Offsite evidence remains secondary: onsite outweighs offsite, and checkout is stronger than add-to-cart or a page visit. We only add it when onsite activity is sparse, cap its prompt footprint, and avoid ranking offsite-only journeys at the top. We are testing whether better prompting is enough or whether a fine-tuned offsite-aware model is needed.</p><h4>One model, several products</h4><p>Once inference is generative, adding an output is a prompt-and-fine-tune change rather than a new service.</p><ul><li><strong>Multi-granularity journeys:</strong> Rather than a flat list of names, have one call emit each journey together with deeper cuts of it and adjacent topics that broaden discovery, so “Sourdough Bread Making” comes back with a specific next step into the interest and a sibling topic a user might jump to. That gives downstream retrieval a ladder of intent per user instead of a single rung.</li><li><strong>Copy titles</strong>: A good journey name isn’t always good notification copy, and we had engagement data showing which names underperform as titles (“Photography”, “Hairstyles”, “Quotes” — the generic ones again). One model now produces both, with a hard ordering constraint: finalize the journey names first, then write copy, and never reshape a name to make its copy sound better.</li></ul><h4>Closing the loop with User feedback</h4><p>Distillation got us strong journeys, but it has a ceiling. The student is optimized to match the teacher, so its quality is bounded by the teacher’s sense of what users want. To go further, we need to optimize directly toward user preferences, using real feedback from users themselves.</p><p>Much of that signal already exists. Journey emails include a module asking “Are you interested in &lt;journey&gt;?” with Yes and No buttons. Each answer is logged, and a No immediately suppresses that journey for that user. Beyond these explicit answers, engagement inside journey-aware notification sessions gives us a much larger implicit signal: saves, clicks, and hides on the Pins we retrieved for a journey.</p><p>This feedback comes with a caveat: it is pointwise. Each response is a verdict on a single journey, while the model generates a ranked list. To bridge that gap, we split training into two stages. First, a mid-training stage applies preference optimization to single journeys across a large volume of feedback. Given a user’s activity, the model learns to make a journey that users confirm or engage with more likely, and a journey they reject less likely. Then, we formulated feedback-as-edit: the chosen response is the model’s list with the Yes journeys kept or the No journeys removed, and the rejected response applies the reverse edit. Starting from the mid-training checkpoint, fine-tuning restores list-level behavior: ranking, diversity, and the output format described earlier.</p><p>Improving the LLM with preference optimization and real user feedback is still an active area of experimentation. We are exploring how to weight explicit versus implicit feedback, and how to formulate a loss that is amenable to scaling, one whose gains follow a scaling law as feedback volume grows. Offline results are promising, and online A/B tests are now running. We hope to share more details in a follow-up blog post or publication.</p><h3>Acknowledgements</h3><p>We’d like to thank Justin Tran, Darien Boyd, Sarah Schwager, Shipeng Yu, Randy Tumalle, James Hernandez, Mengtian Hu, Dimitra Tsiaousi, Yaxin Li, Ying Wei, Sam Owens, Gloria Lee, Justin Pangilinan, Ye Tian, Rui Liu, Anya Trivedi, Binghui Gong, Tianqi Wang, Fangzheng Tian, Eric Tam, Mengying Yang, Lauren Fratamico, Andrew von Rosenbach, Ignacy Debicki, Zhenyu Tan, Jiajing Xu, and other contributors from Notifications, ATG Applied Science, ML Platform, GenAI Serving, and Content Safety teams for their insights and support!</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=9652b9417626" width="1" height="1" alt=""><hr><p><a href="https://medium.com/pinterest-engineering/from-activity-to-intent-generating-user-journeys-with-llms-9652b9417626">From Activity to Intent: Generating User Journeys with LLMs</a> was originally published in <a href="https://medium.com/pinterest-engineering">Pinterest Engineering Blog</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Metrics Board: Building an Agent-ready Metrics Layer]]></title>
            <link>https://medium.com/pinterest-engineering/metrics-board-building-an-agent-ready-metrics-layer-2c8fefe68756?source=rss-ef81ef829bcb------2</link>
            <guid isPermaLink="false">https://medium.com/p/2c8fefe68756</guid>
            <category><![CDATA[metrics]]></category>
            <category><![CDATA[agents]]></category>
            <category><![CDATA[pinterest]]></category>
            <category><![CDATA[data-governance]]></category>
            <category><![CDATA[engineering]]></category>
            <dc:creator><![CDATA[Pinterest Engineering]]></dc:creator>
            <pubDate>Tue, 06 Oct 2026 16:01:02 GMT</pubDate>
            <atom:updated>2026-10-06T16:01:02.147Z</atom:updated>
            <content:encoded><![CDATA[<p>Michele Ceccacci; Software Engineer I | Jason Coffman; Sr. Software Engineer | Colm O’Shaughnessy; Software Engineer II | Laura Palmer; Staff Product Manager | Adam Podraza; Manager, Engineering | Surya Karri; Manager, Engineering</p><p>At Pinterest, reliable and trustworthy<strong> </strong>metrics are behind every decision: from measuring company-wide business performance to evaluating each feature experiment results. Hundreds of data producers — analysts, data scientists, and engineers across Pinterest — create thousands of metrics from our petabyte scale data lake. A metrics ecosystem this size can’t be ad hoc; it has to be organized, scalable, and resilient. So we built <em>Metrics Board</em>: an end-to-end platform that automates metric creation, enforces quality and enables agent-driven analytics.</p><p>In this post we walk through the product experience, architecture, integrations and explain some of the design choices we made to make metrics work at planet scale.</p><h3>Why Metrics Matter at Pinterest</h3><p>Most organizations rely on a familiar set of metrics — revenue, growth, engagement — to steer the business.</p><p>Metrics are how we understand whether Pinterest is delivering value to users and advertisers. Measures related to areas such as revenue, growth, and engagement turn the complexity of user behavior and Pinterest performance into a shared view of what is working, where we are falling short, and what matters most. These metrics drive the decisions our product, finance, and executive leaders make every day, from what to build to how we forecast and steer company strategy.</p><p>Metrics also power Pinterest’s product experimentation practice, which runs at massive scale — thousands of experiments are active at any given time. Those experiments are used as gates for each new feature launch. For results to be fair, comparable, and statistically significant, metrics must be clearly defined, reliable, and reusable. Beyond common core metrics, individual experiments often need bespoke metrics scoped to a single module, feature, or device. For example, a feature to add a new form of interaction with a Pin might need its own measure to evaluate engagement with the feature — even if core product engagement metrics are flat, the experiment might be successful if it creates a new form of user-engagement that a new metric would need to measure.</p><p>As much as key decision-makers need metrics, the way they get answers from data has changed too. Increasingly, the question isn’t typed as a SQL query or a dashboard filter — it’s asked in natural language to an agent. But agents are only as good as the trusted data they can access. For agentic analytics to work, metrics have to be discoverable, quality-checked and machine-readable.</p><h3>Before <em>Metrics Board</em></h3><p>In the past, users were defining their metrics in bespoke data pipelines and wiring them by hand into our reporting and experimentation products. That approach had several problems:</p><ul><li>Time spent building similar data pipelines by hand was slow</li><li>Inconsistent monitoring and availability were inconsistent, so breakages often only surfaced downstream</li><li>Low awareness of existing metrics, leading to redundancy and trust erosion</li><li>Metric metadata drifted from one place to the next with no source of truth</li></ul><p>Put together, these problems slowed teams down, held experiments back, and left us with more metrics than we could trust. We needed a solution that would:</p><ul><li>Unify metrics across the organization</li><li>Give us transparent definitions and code versioning</li><li>Automate pipeline creation, orchestration and operations as much as possible</li><li>Require and auto-generate documentation and metadata</li><li>Automatically validate and monitor metrics for accuracy, reliability, and anomalies</li><li>Give every metric and dashboard a clear, shared signal of quality and trust</li></ul><p>Our goal was to make metrics easy for everyone who touches them — the people who produce them, the people who consume them, the applications that serve them, and, increasingly, the agents that reason over them.</p><h3>The Solution</h3><p><em>Define a metric once, Metrics Board handles the rest.</em></p><p>We built <em>Metrics Board</em> as a thin semantic layer for metrics on top of the data tools Pinterest already runs: a metric is defined once, and that definition drives everything downstream. It’s built around five pillars: <strong>inventory, compute, quality, publishing, and serving &amp; discovery.</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*GTyQEs8yUPE3Pr2GsE2GgQ.png" /></figure><h4>1. Inventory</h4><p>The inventory pillar is our single source of truth for metric definitions. Every metric lives as a YAML spec in a dedicated Git repo, and is treated like any other production code, with version control, code review, and change history built in.</p><pre>version: 1<br>metric_id: mb__metrics<br>display_name: Metrics Board metrics count<br>description: |-<br>  Count of metrics defined in MB. Test metrics aren&#39;t included.<br>domain: tech_data<br>metric_sql: |-<br>  with mb as (<br>    SELECT urn<br>    FROM query_analytics.pincat_db_latest_state_v2<br>    WHERE dt = &#39;{end_date}&#39;<br>    AND aspect = &#39;structuredProperties&#39;<br>    AND is_active<br>  )<br>  SELECT <br>    COUNT(mb.urn) as mb_metrics,<br>    &#39;{end_date}&#39; as dt,<br>  FROM mb<br>temporal_columns:<br>  - column_name: dt<br>    format: YYYY-MM-DD<br>    granularity: DAY<br>integrations:<br>  superset:<br>    enabled: true<br>  starrocks:<br>    enabled: false<br>ownership:<br>  nimbus_project: ii<br>compute:<br>  schedule: 0 9 * * *</pre><p>One important consideration at the time of design was how much should <em>Metrics Board</em> be a true semantic layer. Do we enforce a model on top of existing datasets and have metrics defined as expressions leveraging the model? Or do we accept any SQL query and ask users to provide modelling information (temporal columns, metric or dimensions definitions) for the resulting data? We decided on the latter, driven by two main considerations: user feedback (“Put Pinners First” is one of our company values) and the sheer variety of tables which are used for metrics definitions — enforcing a semantic layer for each was simply not realistic.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*NEqwg2CaIyxEYURbe_yiMw.png" /></figure><p>We don’t ask teams to craft definitions by hand. The <em>Metrics Board</em> UI is a dedicated interface: users define metrics through guided forms, and the system generates the corresponding YAML and opens a pull request on their behalf. The result is one place to manage ownership, governance and metadata, that still integrates cleanly with the rest of the data stack.</p><h4>2. Compute</h4><p>To compute metrics, <em>Metrics Board</em> turns each definition into its own scheduled Airflow DAG. A definition carries all the metadata we need — schedule, SQL, validations, integrations — to generate a dedicated workflow. Isolating each metric in its own DAG lets us tune schedules, retries, and resources individually, and prevents a failure in one cascading to others.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*K22Ozm8haDY4XYtn431kFw.png" /></figure><p>At execution time, <em>Metrics Board</em> compiles each metric’s SQL into SparkSQL or PrestoSQL depending on the engine named in the definition. It then analyzes the SQL code to automatically create dependency tasks which wait on upstream data to be ready (we rely on <a href="https://sqlglot.com/sqlglot.html">sqlglot</a> for parsing). It also automatically generates standard data quality checks and lets users define additional ones too (more on that below). Backfills are managed through Airflow, so owners can recompute history easily. <em>Metrics Board</em> also provides a layer for automated operations. For example if upstream data is late, <em>Metrics Board</em> automatically detects when it lands and resumes the metric computation DAG.</p><p>Taken together, the above listed features make metrics creation and management easy even for non-data teams. What used to take days or weeks to build can now be achieved in 1–2 hours — a significant boost for users’ development and experiment velocity.</p><h4>3. Quality</h4><p>A metric nobody trusts is worse than no metric. Every definition can declare validations that run as part of the metric’s own pipeline, backed by two engines:</p><ul><li><strong>Rule-based checks</strong> for explicit expectations: day-over-day, week-over-week, month-over-month, and year-over-year thresholds, custom threshold rules, and comparisons against a parent metric — each applied to either a single point in time or a trend across time.</li><li><strong>ML-based anomaly detection</strong> that models a metric’s historical patterns — seasonality, trend, and normal variation — and surfaces deviations which a static rule would miss (you can read more about <a href="https://medium.com/pinterest-engineering/warden-real-time-anomaly-detection-at-pinterest-210c122f6afa">Warden here</a>).</li></ul><p>When a check fails, the right people hear about it in real time. Alerts fire as soon as validation runs, route to the owning team, and can be snoozed or acknowledged so the signal stays meaningful and doesn’t turn into noise.</p><p>All of this rolls up into a <strong>quality report</strong> per metric — per-check pass rates, a time series of results, gaps where validation didn’t run, and annotations (comments or linked tickets) — available via API and as a PDF export. Because the validations live in the same spec as the metric, quality is versioned and reviewed alongside the definition itself.</p><h4>4. Publishing</h4><p>A metric that lives only in its own pipeline isn’t useful yet — it has to reach the products people actually work in. Publishing does that automatically, driven entirely by the definition. Each metric declares its integrations, and turning a surface on is as simple as a flag: enable it, and <em>Metrics Board</em> fans the metric out to that surface with no per-surface wiring by hand. Today a single definition can publish to:</p><ul><li><strong>PinCat (based on DataHub)</strong>, our data catalog and metadata hub, so the metric is discoverable and its lineage is tracked</li><li><strong>Helium</strong>, our experimentation platform, so the metric is available to experiments the moment it’s defined</li><li><strong>Superset</strong>, our BI layer, where <em>Metrics Board</em> creates or updates the dataset and can stand up a monitoring dashboard automatically</li><li><strong>StarRocks</strong>, our analytics warehouse, for fast interactive querying</li></ul><p>Publishing happens in two phases:</p><ol><li><strong>Metadata-level integrations (DataHub, Helium)</strong> publish at commit time as soon as the definition merges. The metric is catalogued and available to experiments before it has even run.</li><li><strong>Data-level integrations (Superset, StarRocks)</strong> publish at compute time after each run when there’s fresh data to export.</li></ol><p>Either way, the outputs (dataset IDs, the dashboard link, the output table, the pipeline URL) are written back to DataHub, so the catalog stays the single place to find a metric and everything attached to it.</p><h4>5. Serving &amp; Discovery</h4><p><strong>Discovery &amp; governance.</strong> <em>Metrics Board</em> is where the org finds and evaluates metrics — but consumers have to be able to find a metric before they can evaluate it. Metrics Board centralizes definitions into a single source of truth, capturing rich metadata alongside them: ownership, description, certification grade, and quality signals.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*dnTGdHiAsp4nh3KLx_Jvow.png" /></figure><p>That metadata is what turns centralization into discovery. Every metric automatically flows into PinCat, so users can discover them in the same place they already go for datasets. With metrics easy to find, the next question is what to trust. Two signals do that work, and they operate at two levels.</p><p><em>At the metric level</em>, every metric carries a <strong>grade</strong> — a certification level that climbs from <strong>Unknown</strong> through <strong>C</strong>, <strong>B</strong>, and <strong>A</strong> up to <strong>A+</strong> (fully certified and manually verified by a Data Quality Steward), with each rung demanding more quality work. It tells consumers what to trust and shows owners exactly how to level up. Furthermore, a metric’s key metadata — grade, certification state, and owner — is surfaced directly within its Superset chart.</p><p><em>At the reporting level</em>, dashboards in Superset are classified into their own three <strong>tiers</strong> that reflect how critical and durable they are, and a dashboard’s tier drives three concrete things: how long it’s <strong>retained</strong> when unused, which Presto cluster its queries run on (and therefore its <strong>performance</strong>), and what <strong>documentation</strong> it must carry.</p><ul><li><strong>Tier 1</strong> is a small, curated set of company-critical dashboards. They carry the fullest documentation and are formally certified, and in return get the strongest guarantees — priority query performance and protection from automatic cleanup — with certification renewed periodically to keep them trustworthy.</li><li><strong>Tier 2</strong> is for team-level dashboards others rely on. They require basic ownership and documentation and are retained as long as they see regular use.</li><li><strong>Tier 3</strong> is the default for exploratory or test dashboards. They need no extra metadata and are cleaned up automatically once they go unused.</li></ul><p>Together, metric grade and dashboard tier give consumers a trust signal at every layer of the stack, while retention and query-lane routing fall out of the dashboard tier automatically.</p><p><strong>Agent enablement.</strong> The same structured metadata that powers discovery makes <em>Metrics Board</em> a foundational platform for analytics agents. We expose an MCP server so any LLM-based tools can find metrics, read their definitions, and reason about them. The clearest example is our <a href="https://medium.com/pinterest-engineering/unified-context-intent-embeddings-for-scalable-text-to-sql-793635e60aac">Analytics Agent</a>, which can answer natural-language questions about metrics. By referencing golden queries and certified metric definitions, the agent ensures its responses are grounded in trusted, governed data. As it turns out, the work we did to make metrics trustworthy for humans is what makes them usable by agents.</p><h3>Impact</h3><p>Adoption is the primary success measure we track, and we’ve exceeded adoption goals quarter after quarter — both in metrics created and in active users. Today nearly <strong>150 new metrics</strong> are created each month (we also built out a lifecycle policy and engine to garbage-collect metrics no longer in use). Over 98% of metrics created for experimentation go through the Metrics Board today.</p><p>That adoption rides on dramatically improved velocity. On one recent high-profile project revamping key engagement metrics, <em>Metrics Board</em> saved roughly <strong>two engineering-months</strong> versus the old approach; metric additions that once took weeks now take days. At scale those savings translate to thousands of hours saved each month.</p><p>And velocity isn’t the whole story: the governance, quality, and cataloging we built along the way are quickly becoming the foundation for the LLM and agentic use cases we’re now shipping.</p><h3>Acknowledgments</h3><p>Analytics Platform: Krishna Gopal<br>Workflow Platform: Dinghang Yu, Martin Yau<br>Metrics Quality: Dennis Marcu, Raj Phull<br>Experiment Platform: Lu Yang, Xinyue Cao<br>Real-time Analytics: Charles Wu</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=2c8fefe68756" width="1" height="1" alt=""><hr><p><a href="https://medium.com/pinterest-engineering/metrics-board-building-an-agent-ready-metrics-layer-2c8fefe68756">Metrics Board: Building an Agent-ready Metrics Layer</a> was originally published in <a href="https://medium.com/pinterest-engineering">Pinterest Engineering Blog</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Partition Finalization in Pinterest’s Next-Generation DB Ingestion Framework]]></title>
            <link>https://medium.com/pinterest-engineering/partition-finalization-in-pinterests-next-generation-db-ingestion-framework-4c7da6e4cc8f?source=rss-ef81ef829bcb------2</link>
            <guid isPermaLink="false">https://medium.com/p/4c7da6e4cc8f</guid>
            <category><![CDATA[pinterest]]></category>
            <category><![CDATA[icebergs]]></category>
            <category><![CDATA[engineering]]></category>
            <category><![CDATA[spark]]></category>
            <category><![CDATA[flink]]></category>
            <dc:creator><![CDATA[Pinterest Engineering]]></dc:creator>
            <pubDate>Fri, 25 Sep 2026 15:01:03 GMT</pubDate>
            <atom:updated>2026-09-25T15:01:03.108Z</atom:updated>
            <content:encoded><![CDATA[<p>Qianrui Zhang | Sr Software Engineer, Logging Platform<br>Kanchi Masalia | Software Engineer II, Stream Processing Platform<br>Liang Mou | Sr Staff Software Engineer, Logging Platform<br>Yi Pan | Principal Engineer, Agent Platform</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*RYnr5fSTAfCGM8R9wcHr_w.png" /></figure><h3>Introduction</h3><p>This is the third post in our series on Pinterest’s next-generation database ingestion framework. <a href="https://medium.com/pinterest-engineering/next-generation-db-ingestion-at-pinterest-66844b7153b7">Part 1</a> introduced the DB ingestion framework built on Kafka, Flink, Spark, and Iceberg, and <a href="https://medium.com/pinterest-engineering/automated-schema-evolution-in-pinterests-next-generation-db-ingestion-framework-36c5c07070de">Part 2</a> covered automated schema evolution. This post tackles another challenge in migrating downstream customers to the new ingestion framework: knowing when data is <strong>complete</strong> enough to read.</p><p>We’ll walk through that data completeness challenge and how we solve it: what “partition finalization” means, why it matters to downstream customers, how we built a unified mechanism to generate partition finalization markers, and how those markers are consumed. We’ll also look at how this mechanism generalizes beyond DB ingestion.</p><h3>Background &amp; Motivation</h3><p>Before explaining partition finalization, it helps to define what a partition is and why a downstream consumer cares when one partition is “done.”</p><h4>What is a Partition</h4><p>A time partition is a subset of a table whose rows are grouped by a time column, e.g. all rows whose timestamp falls within a given hour. Grouping data this way lets a consumer read just the slice it needs, such as the 1 AM hour, instead of scanning the whole table.</p><p>How that grouping is physically represented depends on the table format:</p><ul><li><strong>Hive</strong> uses explicit, directory-based partitioning: each partition maps to a physical directory in S3.</li><li><strong>Iceberg</strong> uses hidden partitioning: the partitioning is defined in the metadata layer rather than by directory layout, so consumers don’t need to know the physical file layout to query a partition.</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*1iXUPa7YgtrHOjGb9VSS3g.png" /></figure><h4>What Finalization Means</h4><p>Now consider a typical consumer of these tables: a downstream batch job that processes a single hour’s partition and runs only once for that hour. The consumer faces the question: when should my job run?</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*qEll5LdpLTmZZKZj3EV8gw.png" /></figure><p>The answer is straightforward: Because the job runs only once, it should run only after the target partition is ‘finalized’, i.e. data in that partition is less likely to change. If it runs too early and the partition is still being updated after the run, it will miss later updates in that partition. And to know when a partition is finalized, it relies on the producer to put some signals on the data. For example, the producer can emit a lightweight marker in the table location when it thinks the partition is finalized, and the consumer will use a sensor to check that marker.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*0vt-ThgywkTnELreCWlSBw.png" /></figure><h4>Challenge: Finalization in the Stream-based DB Ingestion Framework</h4><p>In the previous batch-based system, finalization was trivial. The data was produced by a single batch job that read the whole source DB and wrote it in one shot, so once that job finished and there is data in a partition, we can consider that as finalized and downstream jobs could start consuming.</p><p>The new framework is stream-based. A Flink job writes change events continuously, committing many small batches into a partition over time. Because a record’s event time (when the change happened in the source) can lag when we process it, a partition can keep receiving data after its wall-clock hour has passed and no single moment marks it “done”, and the presence of data in a partition no longer means the partition is complete.</p><p>So finalization is no longer a byproduct of a job finishing. We have to infer it from the stream itself uniformly across the thousands of pipelines the framework supports, and a new mechanism is needed to support this.</p><h3>Our Solution: EventTime Based Partition Finalization</h3><p>We introduced a partition finalization mechanism into the streaming layer of the pipeline. The same Flink job that writes the CDC table also measures how far its event time has progressed and publishes a finalization marker into the destination Iceberg table, where downstream jobs can wait on it via a sensor before they start consuming.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*pCiTbP6fNdAN7slAtu_FWg.png" /></figure><h3>Architecture Overview</h3><p>There is no change in the underlying data flow as in <a href="https://medium.com/pinterest-engineering/next-generation-db-ingestion-at-pinterest-66844b7153b7">Part 1</a> (see below figure, blue components are added for partition finalization): Kafka carries CDC events, Flink writes them into the CDC Iceberg table, and Spark upserts into the base table. The partition finalization logic mainly happens inside the Flink-to-Iceberg sink, plus one piece of published metadata that can be carried over in the Iceberg table.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*D2YjOOgWRVK8SJQTvw380w.png" /></figure><h3>Partition Finalization Logic</h3><p>We’ll start with how a Flink-to-Iceberg pipeline works in general. As Flink processes the event stream, it first writes incoming records into data files. Those files aren’t visible to readers right away; they become visible during Flink’s <strong>checkpoint</strong> process, which runs every few minutes (configurable) to persist the job’s progress for fault tolerance. Every checkpoint produces a new snapshot in the Iceberg table: an atomic commit that also carries a small summary of metadata describing it. It works much like a Git commit, with each snapshot recording a new visible state of the table on top of the last.</p><p>With this process in mind, our partition finalization logic executes in the following procedures:</p><p><strong>Event time extraction</strong></p><p>Everything starts from a single value per record: the CDC event’s <strong>event time</strong>. Each pipeline configures which column carries it, and an extractor reads that column and normalizes it to a timestamp. Because the extractor is pluggable, the same machinery works for any table by simply pointing it at that table’s event-time column.</p><p><strong>Fact collection: event time statistics</strong></p><p>Between checkpoints, we collect the event time of every record processed within that window and track statistics over them. The core facts are the minimum and maximum event time seen in that window. On top of those, we also capture percentiles, e.g. the 99th and 95th percentile of event time, so we retain the shape of the distribution rather than just its extremes.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ezKGoAR8KDcdR_zevdDrRA.png" /></figure><p><strong>Fact storage: Iceberg snapshot summary</strong></p><p>When a checkpoint commits, these statistics are written into the Iceberg snapshot’s summary, the per-snapshot metadata Iceberg already maintains for each commit. Every commit therefore carries its own self-describing record of what event times it contained, with no external store to keep in sync.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*42pFWz149Zu7BYc-8oRmWw.png" /></figure><p>To keep the footprint small, we store the percentiles in a <a href="https://datasketches.apache.org/docs/tdigest/tdigest.html">t-digest</a>, a compact sketch that approximates a distribution in a small, fixed amount of space. The summary stays around a few hundred bytes whether a checkpoint saw a thousand records or millions, which is what makes it affordable to attach to every commit. The sketch is also mergeable, so partial statistics computed in parallel combine into a single summary at checkpoint time.</p><p><strong>From fact to opinion: partition finalization watermark</strong></p><p>After each commit, the framework turns those facts (the per-commit event-time statistics, such as the minimum event time seen in each checkpoint) into an opinion: how far the partition is finalized. It runs an algorithm over the statistics from recent commits and produces a watermark that marks the point up to which the partition is considered complete. By default, it takes the minimum of the per-commit minimum event times across the last X commits, then rounds down to the hour boundary.</p><p>The watermark is kept monotonically increasing. If late-arriving data would pull it backward, the value holds instead of regressing, so a finalized hour stays finalized. When that happens, the application can also send an alert to the data consumer, flagging that late data arrived after the partition was finalized.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Whlx3fTum9f-so8vGzghCw.png" /></figure><p><strong>Opinion storage: Iceberg table property</strong></p><p>Unlike the facts which live on individual snapshots, the opinion is a single table-level value, so we store the partition finalization watermark as an Iceberg table property. Downstream jobs read it with an ordinary metadata lookup and gate their processing on it, without the need of any side channel or separate service.</p><p><strong>Finalization Metadata Propagation</strong></p><p>Since the statistics and finalization watermarks all live in Iceberg metadata, they are easy to propagate. Finalization is computed on the CDC table, but many consumers read the base table produced by the Spark upsert job, which carries the data but not the signal.</p><p>We close that gap in the upsert job: on each run it reads the finalization watermark from the CDC table and writes it onto the base table in the same commit. The signal follows the data to where consumers read it, and because these are ordinary Iceberg properties, propagation is just copying metadata during a commit the pipeline already makes without the need of side channel or services. Downstream jobs can then query those table metadata via sensors.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Jjz3FhhZyV_nWA7tdhp_Ig.png" /></figure><h3>Underlying Implementation: Extensible Iceberg-Flink Sink</h3><p>The above partition finalization logic is built entirely on top of the Flink-to-Iceberg sink. We needed two capabilities the standard sink did not have: (1) a way to collect per-record metrics inside the writer and merge them at commit time, and (2) a way to run custom logic immediately after every successful Iceberg commit. Rather than hard-coding partition finalization into the sink, we introduced two generic extension points — <strong>Accumulators</strong> and <strong>CommitProcessor</strong> — that any Flink-to-Iceberg pipeline can use.</p><h4>Operator Topology</h4><p>The standard sink has two Flink operators in sequence: a writer (one instance per parallelism unit) that flushes records to data files, and a committer (single instance) that makes those files visible by appending a new snapshot to the Iceberg table. The extensible sink keeps exactly the same topology and adds no extra operators.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*hjDlYZF2Q8dRm07volrSLA.png" /><figcaption>Zero overhead when no extension points configured — falls back to standar operators.</figcaption></figure><p>When either extension point is configured, the writer and committer swap in extensible variants that carry accumulator and commit processor logic alongside. When neither is configured, the pipeline falls back to the standard operators with zero overhead.</p><h4>Accumulators: collecting facts per record</h4><p>An accumulator is a lightweight aggregator that runs inside each writer subtask and builds up a summary of the records it has seen since the last checkpoint. Between checkpoints it records and aggregates each record, extracting a field value and updating running statistics such as a count, a min/max, or a custom metric. At checkpoint time, each subtask takes a snapshot of its accumulator’s current state and resets it for the next window.</p><p>Those per-subtask snapshots travel to the committer alongside the data-file manifests. The committer, which sees results from every subtask, combines the individual snapshots into a single merged result. Because the subtasks may complete in any order, the merge operation must be order-independent: two snapshots combined must produce the same result regardless of which is folded into which.</p><p>Once merged, the accumulator’s value is written into the Iceberg snapshot summary under a namespaced key, sitting alongside the other metadata Iceberg already records per commit.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*BnwLwqvMhLXPp6AMNEkNag.png" /><figcaption>Each writer subtask produces an accumulator snapshot per checkpoint. The committer merges them into a single result and writes it to the Iceberg snapshot summary.</figcaption></figure><h4>Commit processor: acting after each commit</h4><p>A commit processor adds two hooks around the Iceberg commit: one that runs just before the snapshot is written, and one that runs just after it is durable. The pre-commit hook can set additional snapshot properties or abort the commit by throwing, which causes Flink to retry the checkpoint. The post-commit hook is where partition finalization lives: it reads the statistics just written to the snapshot summary, runs the watermark algorithm, and updates the table property if the watermark advances.</p><p>Both hooks receive the same context: a handle to the live table, the checkpoint identifier, and the merged accumulators from all writer subtasks.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*wQhBdJ11GFPSPAWOnx7h0w.png" /><figcaption>The commit processor wraps every Iceberg commit with two hooks — before and after. Both receive the same CommitContext, including the merged accumulators from all writer substacks.</figcaption></figure><h4>Putting it together</h4><p>The event-time accumulator collects the minimum, maximum, and a compact percentile sketch of event times across all records in a checkpoint window. At commit time those per-subtask observations are merged into a single global summary and written into the snapshot. The commit processor then reads that summary, together with recent snapshot history, runs the non-regressing watermark algorithm, and writes the result back to the table, all within the commit cycle without any external service.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*avfczrlEaOPPZ6o4i-3E8Q.png" /><figcaption>Records flow through the writer where event-time statistics are accumulated per checkpoint window. The commit processor merges and acts on them at commit time — no external service required.</figcaption></figure><h3>Configurable Finalization Policies</h3><p>Because each summary keeps the full distribution rather than a single number, the field the commit processor reads becomes a knob each table can turn. This is where the completeness-versus-latency trade-off is handled.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*E_-DBdO9x2-tqSmIZYSEHQ.png" /></figure><p>Most tables use the conservative default. Tables that value freshness and can accept a rare late record opt into a percentile instead, accepting that a small tail of records may arrive after their hour was marked safe to read.</p><h3>Generalizing Beyond DB Ingestion</h3><p>Nothing above is specific to database ingestion. The accumulator and commit processor only need a way to read an event time from each record; everything else, e.g. the sketch, the windowed lookback, the non-regressing watermark are generic. Any Flink-to-Iceberg pipeline that needs a completeness signal can adopt partition finalization by plugging in its own event-time extractor. And we are actively working on adopting this partition finalization mechanism into Pinterest’s next-generation Stream Ingestion framework.</p><h3>Conclusion &amp; What’s Next</h3><p>Moving to streaming ingestion gives us fresh data in minutes, but it costs us the implicit completeness that batch loads provided for free. Partition finalization mechanism restores that guarantee: a cheap, mergeable event-time sketch on every commit, a non-regressing “safe to read” watermark derived from it, and a policy knob to trade-off completeness against freshness, and all of those are reusable beyond our DB ingestion framework.</p><p>In the following post, we will cover the incremental processing logic built on top of this ingestion framework and how we use it to support downstream use cases efficiently. Stay tuned for the future post: Incremental Processing on CDC Pipeline.</p><h3>Acknowledgments</h3><p>Huge thanks to my teammates Yisheng Zhou, Vi Nguyen, and Artem Tetenkin for building the Next Generation DB Ingestion at Pinterest together.</p><p>This project would not have been possible without the significant contributions and support of the following partners:</p><ul><li>Storage Services: Leonardo Marques Maciel Silva, Yuan Gao, Liqi Yi</li><li>Storage Foundations: Tailin Lyu, Yu Su, Istvan Podor, John Grass</li><li>Streaming Processing Platform: Kevin Browne</li><li>Batch Processing Platform: Carlos Benavides</li><li>Big Data Storage: Pucheng Yang, Mian Luo, Jenny Wang</li><li>Data Warehouse: Bryant Xiao, Anita Narra, Pritam Pan</li><li>Logging: Vahid Hashemian, Jeff Xiang, Jesus Zuniga</li></ul><p>Special gratitude goes to Shardul Jewalikar, Ang Zhang, and Roger Wang for their continuous guidance, feedback, and support throughout the project.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=4c7da6e4cc8f" width="1" height="1" alt=""><hr><p><a href="https://medium.com/pinterest-engineering/partition-finalization-in-pinterests-next-generation-db-ingestion-framework-4c7da6e4cc8f">Partition Finalization in Pinterest’s Next-Generation DB Ingestion Framework</a> was originally published in <a href="https://medium.com/pinterest-engineering">Pinterest Engineering Blog</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Beyond Two Towers: Launching the 3-Tower Engagement Co-Train Model (Part 2)]]></title>
            <link>https://medium.com/pinterest-engineering/beyond-two-towers-launching-the-3-tower-engagement-co-train-model-part-2-0b96167d2c14?source=rss-ef81ef829bcb------2</link>
            <guid isPermaLink="false">https://medium.com/p/0b96167d2c14</guid>
            <category><![CDATA[machine-learning]]></category>
            <category><![CDATA[pinterest]]></category>
            <category><![CDATA[monetization]]></category>
            <category><![CDATA[engineering]]></category>
            <category><![CDATA[recommendation-system]]></category>
            <dc:creator><![CDATA[Pinterest Engineering]]></dc:creator>
            <pubDate>Thu, 17 Sep 2026 15:01:05 GMT</pubDate>
            <atom:updated>2026-09-17T15:01:05.274Z</atom:updated>
            <content:encoded><![CDATA[<p>Authors: Longyu Zhao (Staff Machine Learning Engineer), Gwendolyn Zhao (Staff Machine Learning Engineer), Peng Yan (Senior Machine Learning Engineer), Yuanlu Bai (Senior Machine Learning Engineer), Yuan Wang (Senior Machine Learning Engineer), Yao Cheng (Staff Machine Learning Engineer), Ang Xu (Principal Machine Learning Engineer), Zhaohong Han (Manager II, Ads Lightweight Ranking)</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*H3SwMoNL3rjVC9o6-7p_1Q.png" /></figure><h3>Introduction</h3><p>Previously¹, we launched the next-generation serving stack for standard ads, which we call Nexus. Nexus decoupled candidate generation from scoring and moved us beyond the classic two-tower-only world, enabling richer model architectures while still meeting stringent latency and cost constraints.</p><p>Building on this system, we set out to design the first ads lightweight ranking model that goes beyond two towers. It jointly predicts three probabilities for each candidate ad: pCTR, the probability of a click; pGCTR30, the probability of a good click that lasts at least 30 seconds; and pOCTR, the probability of an outbound click to the advertiser’s destination. To support these objectives efficiently, we partition the query and Pin embeddings into task-specific CTR, gCTR30, and oCTR segments. For the CTR task, the fast two-tower prediction uses the first 64 dimensions of the CTR segment, while the three-tower prediction uses the full CTR segment together with richer cross features. For gCTR30 and oCTR tasks, full embeddings are shared between two-tower and three-tower predictions. This lets each task learn dedicated representations while sharing the overall model. In principle, Nexus places very few hard constraints on the architecture we can serve: cross-attention, sequence modeling, and more expressive interaction modules are all on the table.</p><p>However, in practice we quickly ran into the fundamental reality of ads lightweight ranking at Pinterest scale: for a typical request, we need to score on the order of hundreds of thousands of candidates (P99 post-targeting candidate counts can exceed 200K on some surfaces). We cannot simply keep increasing model complexity and expect to stay within our latency and cost budgets.</p><p>To strike a balance between latency and performance, we landed on a 3-tower co-train model design.</p><p>This design has a few key properties:</p><ul><li>We keep the query tower and Pin tower from the existing two-tower model, which lets us cache Pin embeddings offline and still obtain fast dot-product predictions for all candidates.</li><li>We extend the architecture with a third cross tower that performs cross-attention between user sequences and candidate (Pin) features, plus an inter module that further mixes query, Pin, and cross embeddings.</li><li>We co-train two-tower and three-tower predictions in a single model, giving us both fast but less accurate scores and slower but more accurate scores that we can deploy in different stages of the serving flow.</li></ul><p>Put simply, the same model produces a fast two-tower score for every candidate and a richer three-tower score for a selected subset, so we can spend additional compute where it has the greatest impact.</p><p>Later in this blog, we will walk through the model architecture (cross tower and inter module), serving performance optimizations, and the two-stage scoring flow that leverages both two-tower and three-tower predictions.</p><p>By combining these changes, we maintained two-tower prediction quality while achieving around 30% reduction in offline loss for three-tower predictions across our engagement tasks, compared to the existing production model (details see below Offline Performance section). These offline gains translated into online lifts in CTR and gCTR30, reductions in cost per click (CPC), with a modest increase in infrastructure cost.</p><h3>Model architecture</h3><p>Our starting point was the existing two-tower engagement model, with separated query and Pin towers whose dot product feeds into task-specific heads. On top of this, we introduced two major components:</p><ul><li>A new cross tower that uses reduced-query cross-attention between user sequences and candidate features to capture high-order interactions.</li><li>An inter module that jointly processes the query, Pin, and cross embeddings and produces a shared representation for all engagement tasks.</li></ul><p>Below we describe the main design choices and trade-offs in each part.</p><h4>Cross tower</h4><p>The cross tower is responsible for modeling rich interactions between a user’s recent activity and a candidate ad. We use three on-site user sequence features (organic engagement, ads engagement, and search history) and four candidate features (advertiser ID, campaign ID, GraphSAGE embeddings, and Pin PinnerSAGE embeddings).</p><p>A natural first idea would be to build increasingly complex attention modules over these sequences and candidates. In practice, we explored several options:</p><ul><li>Merging full user sequences (across surfaces) and then running cross-attention with candidate features.</li><li>Using shorter, truncated sequences to reduce compute and memory.</li><li>Replacing attention with simpler interaction functions such as DIN-style pooling or average pooling.</li><li>Crossing each sequence attribute with its corresponding candidate attribute individually, rather than merging first.</li></ul><p>These variants exposed a clear trade-off: more expressive attention patterns (longer sequences, more attributes, per-attribute crossing) tended to improve offline loss but also increased latency, especially at high candidate counts. For example, using more complex cross architectures could reduce loss by several additional percentage points, but at the cost of tens of milliseconds of extra latency per request at 100K candidates.</p><p>We ultimately converged on an architecture that uses candidate side features to generate query tokens, which then interacts with user sequences to calculate attention. In this way, we can pick the most important candidate features and control the cost of transformer computation. This design gives us:</p><ul><li>Strong offline performance improvements versus production.</li><li>A predictable compute profile that is easier to optimize and scale.</li><li>A good balance between modeling capacity and serving latency.</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*_EWlTdTVi_2FNlcnh780dg.png" /></figure><p>The output of this cross tower is a cross embedding that summarizes how a user’s recent behavior interacts with a particular candidate ad.</p><h4>Inter module</h4><p>The inter module takes three inputs: the query embedding, the Pin embedding, and the cross embedding from the cross tower. Its goal is to produce a compact, shared representation that works well for all three engagement tasks (CTR, gCTR30, and oCTR), while keeping parameter count and serving latency under control.</p><p>Here as well, we evaluated multiple architectures:</p><ul><li>A deep MLP with DCN (Deep &amp; Cross Network) layers.</li><li>A standard MMoE (mixture-of-experts) with DCN.</li><li>A top-K MMoE with DCN.</li><li>A shared-bottom MLP with additive task-specific biases.</li></ul><p>More complex structures such as MMoE with DCN achieved stronger loss reductions but also introduced noticeably higher latency compared to production. The shared-bottom MLP design provided a sweet spot: it delivered most of the performance gains while adding only modest latency, and it is architecturally simpler to optimize further.</p><p>In the final design, the inter module:</p><ul><li>Learns a shared logit for each task from the concatenated query, Pin, and cross embeddings.</li><li>Adds task-specific biases computed from query and Pin embeddings for gCTR30 and oCTR.</li><li>Outputs task logits that are then passed through sigmoid functions to produce probabilities.</li></ul><p>This structure allows us to capture shared patterns across tasks while preserving enough task-specific flexibility.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*1bu3hCJ1Oi3YgjqsboiUdg.png" /></figure><h4>Loss function</h4><p>We train the model using a multi-task loss that combines main losses for the three-tower predictions with auxiliary losses for the two-tower co-train task.</p><p>The final loss takes the form of a weighted sum:</p><ul><li>Main three-tower losses for CTR, gCTR30, and oCTR.</li><li>Co-train two-tower losses for CTR, gCTR30, and oCTR, computed from query–Pin dot products. For CTR, this fast auxiliary prediction uses the first 64 dimensions of the CTR embedding.</li></ul><p>We tuned the task weights to balance learning stability and final performance, and landed on the following weighting scheme:</p><ul><li>Strong emphasis on main CTR loss.</li><li>Moderate weight on main gCTR30 loss.</li><li>Lower weight on main oCTR loss.</li><li>Non-trivial but smaller weights on each of the co-train losses.</li></ul><p>This configuration made the three-tower predictions the primary optimization target, while keeping the two-tower co-train task healthy enough to match or slightly improve on the production two-tower model.</p><p>The query tower produces a 192-dimensional task embedding: 144 dimensions for CTR, 32 for gCTR30, and 16 for oCTR. The Pin tower produces the corresponding task embedding and appends a 256-dimensional candidate-feature projection for the cross tower, producing a 448-dimensional Pin representation. For fast two-tower CTR scoring, we use only the first 64 dimensions of the 144-dimensional CTR segment. For three-tower CTR scoring, the inter module uses the full CTR segment together with the cross embedding. The additional Pin projection is computed in the Pin tower and cached offline, so the three-tower path can use these candidate features without per-candidate preprocessing at serving time.</p><p>Formally, our final loss is:</p><p>L = (100 * L_main_ctr + 5 * L_main_gctr30 + 1 * L_main_octr) + (10 * L_cotrain_ctr + 5 * L_cotrain_gctr30 + 1 * L_cotrain_octr)</p><h4>Offline performance</h4><p>We evaluated offline performance on held-out standard-ads data across three tasks (CTR, gCTR30, and oCTR), reporting relative loss reduction versus the production two-tower engagement model for both main (three-tower) and co-train (two-tower) predictions. The table reports ranges because we evaluated the model across multiple log sources; each endpoint is the result observed for a different source. For gCTR30, we observed a small degradation on the co-train loss which we deemed acceptable given the main task gains.</p><p>Across tasks, we observed:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*G3NhvaIs5IQHrz48NIvZVg.png" /></figure><p>Taken together, these results show that the co-train task maintains performance comparable to the existing production two-tower model, while the three-tower predictions deliver substantial improvements. This is important operationally: we can deprecate the standalone production two-tower engagement model and rely on the co-train head for fast scoring, without sacrificing quality.</p><h3>Serving optimization</h3><p>Serving a three-tower model over hundreds of thousands of candidates per request is expensive. To make the launch feasible, we invested heavily in model-level latency optimizations. Below are several techniques that had meaningful impact. Together, these changes reduced P99 model inference from over 200 ms to about 30 ms, leaving the final model only 1–2 ms slower than production.</p><h4>Optimization 1: Move Pin pre-processing into the Pin tower</h4><p>To perform cross-attention, sequence and candidate features must share the same dimensionality. In an initial design, we handled this with on-the-fly MLPs in the cross module, which added per-request compute proportional to the number of candidates.</p><p>Instead, we moved this preprocessing into the Pin tower. We append the processed Pin features to the original Pin embedding, increasing its dimension by an additional 256, and cache the resulting embedding offline. At serving time, the cross module can directly consume these enriched Pin embeddings with no additional per-request MLPs.</p><p>This change saved roughly 2 ms of latency at 100K candidates in our benchmarks.</p><h4>Optimization 2: Lower precision</h4><p>We also explored reduced-precision inference. By switching from FP32 to BF16 in the three-tower path, we significantly reduced model inference time while keeping model quality neutral.</p><p>On one representative benchmark, we observed:</p><ul><li>At 50K candidates, latency dropped from around 103 ms in FP32 to about 56 ms in BF16.</li><li>At 20K candidates, latency dropped from around 45 ms to about 26 ms.</li></ul><p>These gains played a key role in making the three-tower path practical at high candidate volumes.</p><h4>Optimization 3: Late expansion of user features</h4><p>In the three-tower engagement model, we compute predictions between one user and tens of thousands of candidate ads at once. User features are computed once and then expanded to match the batch size of candidates.</p><p>Earlier, this expansion happened just before the cross module to avoid duplicated computation. We realized we could delay expansion even further: instead of expanding before building the attention keys and values, we expand inside the cross module right before attention is computed.</p><p>This avoids redundant computation on large tensors and yields latency savings of around 15 ms at 100K candidates in our benchmarks.</p><h4>Optimization 4: Pre-layer normalization in attention</h4><p>Finally, we revisited how we apply LayerNorm inside the cross-attention module. Previously, we normalized the larger output sequence after attention, which has a batch size proportional to the number of candidates. We switched to normalizing the input sequence before attention instead; during serving this input has batch size 1, so the normalization work is much lower and independent of how many candidates we score.</p><p>During training, both options behave similarly. During serving, however, pre-layer normalization dramatically reduces the amount of work we do at large candidate counts. Flipping pre_lnorm from False to True reduced latency by about 3 ms at 100K candidates in our benchmarks.</p><h3><strong>Rethinking the serving flow</strong></h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*UyFNvFuMbAjo0Knq9fCkag.png" /></figure><p>Even with model-level optimizations, running the full three-tower model on every candidate would still be too expensive. Post-targeting candidate counts can exceed 100K at P90 and reach up to over 200K at P99 on some surfaces. In early experiments where we scored all candidates with the three-tower model, model inference P99 latency exceeded 70 ms which is our timeout cutoff.</p><p>To tackle this, we redesigned the serving flow as a two-stage scoring pipeline that leverages both two-tower and three-tower predictions.</p><h4>Stage 1: Fast scoring for all candidates</h4><p>In the first stage, we use the two-tower head from the co-train model to score all candidates. These scores are combined into an initial utility: the overall ranking score that estimates a candidate ad’s value for the request and determines which candidates survive for later selection. This follows the existing production setup, but is powered by the new co-train model.</p><p>This stage is fast and inexpensive enough to run on the full candidate set.</p><h4>Stage 2: Focused refinement with three-tower scoring</h4><p>In the second stage, we identify the top-K candidates by utility and rescore only this subset with the three-tower model. Candidates outside this utility topK bypass the three-tower path and retain their two-tower scores.</p><p>We introduced a new hyperparameter, utility topK, which controls how many candidates the three-tower model sees. We tested several choices of the utility topK and measured both latency and downstream metrics such as clickthrough and web conversion impressions.</p><p>The trade-offs we observed:</p><ul><li>Smaller utility topK values reduce latency but can hurt web conversion impressions, because the final top-K selection stage needs a sufficiently large pool to satisfy different campaign groups and deduplication constraints.</li><li>Very large utility topK values allow more candidates into the three-tower stage but directly increase latency, so we needed to pick a value that balanced candidate coverage and serving cost.</li><li>Retaining candidates outside utility topK for later stages mitigates mixshifts with only a small additional latency cost.</li></ul><p>We ultimately chose a utility topK of 40K. This threshold roughly corresponds to the 70th percentile for Home Feed and Related Pins and the 90th percentile for Search, which means the three-tower model scores the majority of candidates on most requests while keeping P99 latency within budget.</p><h4>Final selection and bias considerations</h4><p>After we obtain updated utility scores from the three-tower model for the utility topK subset, we blend them with the two-tower dot-product utilities. The final topK selection step then runs on this blended set of scores, selects pre-defined quotas from several candidate sources and performs deduplication to select the final set of ads shown to the user.</p><p>We made two design choices which may create concerns:</p><ol><li>Heuristic selection of the utility topK subset for three-tower scoring.</li><li>Blending two-tower and three-tower utilities before final top-K selection.</li></ol><p>Both heuristics are designed to favor high-utility candidates. In principle, they could bias the system toward items that already scored well in the two-tower stage, especially if three-tower predictions further amplify those scores.</p><p>To monitor this, we looked at calibration and mixshift. Encouragingly, we observed that the three-tower model actually reduces over-calibration for auction candidates, moving predicted CTR closer to realized CTR. We also did not observe a drop in standard web conversion impressions when using the blending option.</p><h3>Online results and cost</h3><p>In online A/B experiments on standard ads, the three-tower co-train model delivered around 1% gains in CTR, gCTR30, and oCTR, along with nearly 1% reductions in CPC, with only a modest increase in GPU spend.</p><p>Taken together, this represents a strong trade-off: meaningful engagement and efficiency gains for advertisers and users, with a small and well-understood increase in infra cost.</p><h3>Conclusion</h3><p>In this post, we walked through how we took Nexus beyond two towers by introducing a three-tower engagement co-train model for standard ads. On the modeling side, the cross tower and inter module allow us to capture richer interactions between user behavior and candidate ads. On the systems side, a combination of model-level optimizations and a two-stage scoring flow let us deploy this more powerful architecture while staying within tight latency and cost constraints.</p><p>Looking ahead, this work opens up several promising directions:</p><ul><li>Extending similar three-tower and co-train ideas to other objectives beyond engagement.</li><li>Exploring even richer sequence modeling and attention patterns now that we have a scalable framework for late-stage scoring.</li><li>Further tightening the feedback loop between offline architecture exploration, online performance, and infra-aware serving design.</li></ul><p>Most importantly, it demonstrates that with the right system abstractions, we can continue to innovate on model architectures without losing sight of real-world constraints.</p><h3>Acknowledgements</h3><p>We thank Qingyu Zhou, Yuchen Shen, Li-Chien Lee, Qingmengting Wang, Zhixuan Shao, Tristan Nee, Sihan Wang, Lida Li, and Nuo Dou for their contributions to this project, and Renjun Zheng and Jamieson Kerns for their leadership support.</p><h3>References</h3><p>¹Yang, Xiao, et al. “<a href="https://medium.com/pinterest-engineering/beyond-two-towers-re-architecting-the-serving-stack-for-next-gen-ads-lightweight-ranking-models-1992f2b76cbb">Beyond Two Towers: Re-architecting the Serving Stack for Next-Gen Ads Lightweight Ranking Models (Part 1)</a>.” Pinterest Engineering Blog</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=0b96167d2c14" width="1" height="1" alt=""><hr><p><a href="https://medium.com/pinterest-engineering/beyond-two-towers-launching-the-3-tower-engagement-co-train-model-part-2-0b96167d2c14">Beyond Two Towers: Launching the 3-Tower Engagement Co-Train Model (Part 2)</a> was originally published in <a href="https://medium.com/pinterest-engineering">Pinterest Engineering Blog</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Evolving Pinterest’s Embedding Retrieval Platform]]></title>
            <link>https://medium.com/pinterest-engineering/evolving-pinterests-embedding-retrieval-platform-aede4e831e01?source=rss-ef81ef829bcb------2</link>
            <guid isPermaLink="false">https://medium.com/p/aede4e831e01</guid>
            <category><![CDATA[retrieval]]></category>
            <category><![CDATA[infrastructure]]></category>
            <category><![CDATA[ann]]></category>
            <category><![CDATA[pinterest]]></category>
            <category><![CDATA[engineering]]></category>
            <dc:creator><![CDATA[Pinterest Engineering]]></dc:creator>
            <pubDate>Fri, 11 Sep 2026 15:01:03 GMT</pubDate>
            <atom:updated>2026-09-11T15:01:03.828Z</atom:updated>
            <content:encoded><![CDATA[<p>Authors: Bowen Zhou | Staff Software Engineer; Shan Gao | Senior Software Engineer; Jingwen Hu | Software Engineer II; Wenjiang Chu | Staff Software Engineer</p><h3>The Billion-Embedding Challenge</h3><p>At Pinterest, the “signal” is our lifeblood. Whether it’s a home decor enthusiast finding the perfect rug or a fashion seeker discovering a new aesthetic, our discovery engine relies on understanding deep semantic relationships to help our users find inspirations. Over the last few years, the explosive growth of embedding-based retrieval has fundamentally transformed how we surface these signals — and at the heart of that transformation is Manas, Pinterest’s in-house distributed search platform.</p><p>Embedding Retrieval is one of the core capabilities of Manas, supporting multiple approximate nearest neighbor search algorithms, hybrid queries with both token and embedding clauses, as well as real-time updates to ensure fresh contents become searchable within seconds. Deployed on over 80 clusters and serving billions of embeddings, Manas embedding retrieval powers all major product surfaces at Pinterest including Home Feed, Search, Related Pins, Ads, and Notifications.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*9mEwgCdovwyH5yrcF-oJHw.png" /></figure><p>However, as our corpus scales toward tens of billions of embeddings and our models capture increasingly complex interactions, we face mounting challenges around cost efficiency, scalability, and flexibility. On the infrastructure side, traditional ANN algorithms like HNSW are notoriously memory-hungry — they require the entire index to reside in RAM to maintain low query latency, making cost grow linearly with corpus size. On the modeling side, the classic two-tower retrieval paradigm is too restrictive: it reduces each candidate to a single embedding and scores relevance through a simple dot product, leaving little room to express richer, context-dependent notions of similarity.</p><p>To tackle these challenges, our team has been evolving Manas’s embedding retrieval stack across three fronts:</p><ul><li><strong>Quantization.</strong> We reduce the memory footprint of embedding indices by compressing vectors into lower-bit representations with fewer effective dimensions. Quantization has been rolled out to all major use cases, delivering over 50% memory reduction in embedding indices and 20–30% cost savings in serving infrastructure.</li><li><strong>SSD-based Serving.</strong> Rather than holding entire indices in RAM, we serve ANN queries directly from SSD by carefully bounding the I/O per request — sustaining high throughput with low tail latencies at a fraction of the memory cost. Early experiments demonstrate a 10x reduction in memory usage and 40% CPU savings compared to in-memory serving</li><li><strong>Multi-embedding Retrieval.</strong> We move beyond the single-vector-per-candidate constraint of the two-tower model by supporting richer scoring functions that consider multiple embeddings per candidate. This unlocks more expressive ranking at the retrieval stage. We are currently partnering with a product team to launch a pilot use case.</li></ul><p>In this blog post, we will delve into the technical details and results of each initiative, and discuss what’s next for embedding retrieval in Manas.</p><h3>Quantization: Redefining the Footprint of High-Recall Search</h3><p>For a long time, serving embeddings in 16-bit or 32-bit float precision was considered the common practice. But at Pinterest’s scale, raw precision is a luxury that often provides diminishing returns. We discovered that quantization — transforming these high-dimensional float vectors into compact integer representations — is one of our most effective ways to better cost efficiency.</p><h4>Evaluating the Trade-offs: SQ vs. PQ</h4><p>In the Manas stack, we focused our implementation on two primary quantization algorithms: <strong>Scalar Quantization (SQ)</strong> and <strong>Product Quantization (PQ)</strong>. The core of these methodologies lies in partitioning the vector space into disjoint subspaces and mapping each vector into an integer representation: SQ applies uniform discretization from float to integer per dimension, while PQ performs a K-Means clustering in each subspace and maps a subvector to the ID of the closest cluster centroid.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*jPxZ0_hUgusRuRNamxyWig.png" /></figure><p>Benchmarks on a 100-million-embedding GraphSage dataset confirmed this intuition. We evaluated both SQ and PQ across two ANN algorithms (HNSW and IVF) and observed a clear trade-off: PQ achieves higher compression but with a significant recall decrease, while SQ delivers strong compression with minimal loss on recall.</p><ul><li>PQ reduces the HNSW index by 74% and the IVF index by 93%, with a recall in the range of 70–80%</li><li>SQ reduces the HNSW index by 59% and the IVF index by 75%, with a recall over 90% consistently</li></ul><p>Given the trade-off demonstrated by the offline benchmarking exercise, we ran online A/B experiments in production to select the best performing quantizer for each use case, and ensure negligible impact on user engagement metrics when enabling quantization. We launched SQ and PQ across major product use cases, reducing the total memory allocation significantly and realizing 20–30% cost savings for serving.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*O9Hv6yg0M7oaPSbXsmIo-A.png" /></figure><h4>Technical Deep Dive: SIMD and Linear Scaling</h4><p>Shifting to 8-bit or 4-bit representations isn’t just a memory win; it’s a compute challenge. Usually, SQ requires a decoding step before distance computation, which can become a CPU bottleneck. To solve this, we implemented Linear Scaling SQ<strong>,</strong> which quantizes a vector by scaling only, and thus eliminates the decoding step before distance computation. The key enabler here is SIMD intrinsics, which allows the CPU to perform multiple 8-bit integer operations with each instruction, and hence reduces the total computing resources needed per query by 10–15% in our use cases.</p><h3>SSD Serving: Moving Beyond the Constraints of RAM</h3><p>Our journey of improving embedding retrieval cost efficiency led us to exploring alternative ANN algorithms that take advantage of recent NVMe SSD performance advancements. The current generation of SSD devices are roughly an order of magnitude cheaper per gigabyte, but with the latency increased from nanoseconds to microseconds, which can slow down queries if I/O operations are not carefully managed. To bridge this gap, we experimented with I/O-aware ANN algorithms that were designed to minimize the number of random reads issued per query while retaining a recall score as good as memory-based algorithms.</p><h4>DiskANN vs. SPANN</h4><p>In our experiments, we benchmarked two disk-based ANN algorithms, DiskANN and SPANN, with a 100M-embedding corpus collected from a Pinterest Search use case. While DiskANN is a robust graph-based approach, SPANN emerged as the better option<strong> </strong>for the Manas use cases.</p><p>Our team’s key observation was applying PQ quantization to the on-disk embedding store while retaining the full precision centroids helped the search accuracy and throughput significantly — a slight tweak from the original SPANN paper. This makes SPANN+PQ 4.5x faster than plain SPANN and achieves over 3x the QPS of DiskANN with 1/3 the latency, with a slight 5% recall drop.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*HJv8egiSIKvxNxo3nysrsA.png" /></figure><h4>Implementing SPANN in Manas</h4><p>We implemented the SPANN algorithm in Manas, which stores the centroid index in the memory and the large posting lists in the disk, and guarantees both disk-access efficiency (low latency) and high recall by effectively reducing the disk access number per request. In the index-building stage, we adopt the hierarchical balanced clustering algorithm from SPANN for selecting the centroids, which ensures evenly distributed cluster sizes, and hence similar lengths of posting lists to keep the tail latency low. We build the centroid index using HNSW, which is well-suited for in-memory search over a relatively small set of centroids. In a preliminary evaluation with a Pin recommendation use case that indexes over 5 billion embeddings, our SPANN implementation saves over 40% of CPU time for production queries when compared with HNSW, with a &lt;5% recall drop. Our next step is to adopt SPANN across all major use cases.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Q2WJpn5O7vzUZGTYRkE8Sg.png" /></figure><h3>Multi-Embedding Retrieval: The Shift Toward Late Interaction</h3><p>As we improve cost efficiency, we are also evolving the expressivity of the Manas embedding retrieval stack. The traditional “Two-Tower” model, while efficient, collapses an entire Pin or query into a single vector, often losing the nuanced, token-level semantics that define high-quality discovery.</p><h4>Contrast in Paradigms: Sum of MaxSim</h4><p>We are now moving toward <strong>Late Interaction models</strong> , such as ColBERT. Unlike the single dot product of two-tower models, late interaction represents documents and queries as lists of vectors. We use the <strong>“Sum of MaxSim”</strong> scoring logic to capture the maximum similarity between each query token and the document’s tokens.</p><p>Integrating this into Manas required comprehensive changes in our serving stack. We integrated the multi-embedding retrieval as a new query type and updated Manas to handle multiple query embeddings rather than a single vector per query. This required updating the Manas query parser to understand these complex queries, as well as running multiple ANN searches from a multi-embedding query simultaneously. Currently, we are working with a client team to launch a pilot use case for the multi-embedding query support in Manas. This represents the next frontier of Pinterest search: moving from “two tower” to true model-based retrieval.</p><h3>Looking Ahead</h3><p>The future of vector search at Pinterest lies at the intersection of infrastructure efficiency and retrieval model innovation. Over the next five years, our central goal is to evolve Manas embedding retrieval into an architecture that is scalable, cost-efficient, and open to new retrieval paradigms. We are pursuing this along three directions: adopting SPANN and SPFresh to push CPU and memory costs even lower for billion-scale indices; building first-class support for multi-embedding retrieval models like ColBERT that enable richer, interaction-based scoring at the retrieval stage; and embracing GPU-based retrieval systems like SilverTorch and TIGER to unlock new model capabilities.</p><h3>Acknowledgements</h3><p>Many people from Core and Ads Delivery Infra teams contributed to the projects discussed in this blog post. Special thanks to Ellie Madsen, Jennifer Kong, and Jiawei Kuang for working on various Manas embedding retrieval projects. Thanks to our collaborators from client teams, including Bowen Deng, Jiaxing Qu, Ryan Hou, Minhazul Islam SK, Wei-Ting Lin, J.J. Hu, Konik Kothari, Yujiao Guo, Hanlin Lu, Bella Huang, Ai Zhang, Janvi Palan. Last but not least, many thanks to Van Lam, Tao Yang, Deeksha Sharma, Kartik Paramasivam, Abhishek Tayal, Zheng Liu for leadership support.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=aede4e831e01" width="1" height="1" alt=""><hr><p><a href="https://medium.com/pinterest-engineering/evolving-pinterests-embedding-retrieval-platform-aede4e831e01">Evolving Pinterest’s Embedding Retrieval Platform</a> was originally published in <a href="https://medium.com/pinterest-engineering">Pinterest Engineering Blog</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Building Pinterest’s VLM Serving Stack on NVIDIA Dynamo]]></title>
            <link>https://medium.com/pinterest-engineering/building-pinterests-vlm-serving-stack-on-nvidia-dynamo-0dce6e93d0f3?source=rss-ef81ef829bcb------2</link>
            <guid isPermaLink="false">https://medium.com/p/0dce6e93d0f3</guid>
            <category><![CDATA[engineering]]></category>
            <category><![CDATA[vlm-serving]]></category>
            <category><![CDATA[multimodal-ai]]></category>
            <category><![CDATA[pinterest]]></category>
            <category><![CDATA[nvidia]]></category>
            <dc:creator><![CDATA[Pinterest Engineering]]></dc:creator>
            <pubDate>Thu, 10 Sep 2026 23:08:16 GMT</pubDate>
            <atom:updated>2026-09-10T23:08:16.104Z</atom:updated>
            <content:encoded><![CDATA[<p>Lei Pan | Senior Software Engineer; Salina Wu | Senior Software Engineer; Cristian Lopez | Software Engineer I; Guangtong Bai | Staff Software Engineer; Soam Acharya | Principal Engineer; Saurabh Vishwas Joshi | Principal Engineer; Chia-Wei Chen | Staff Software Engineer; Ambud Sharma | Principal Engineer</p><h3>Why VLM Serving Matters at Pinterest</h3><p>Pinterest is a visual search and discovery platform, so its AI systems must reason over both language and visual content. Vision-language models (VLMs), which can interpret images, compare visual candidates, and respond naturally to user intent, are becoming the foundation for the next generation of Pinterest experiences: Pinterest Assistant, hybrid search, multimodal reranking, content understanding, signal generation, content safety, and more.</p><p>This direction also reflects <a href="https://venturebeat.com/orchestration/pinterest-cut-ai-costs-90-by-gutting-a-frontier-models-vision-layer">Pinterest’s broader strategy</a> to customize open-source models to meet its product &amp; scale needs. Pinterest Assistant is a standout example. This multi-turn conversational experience covers both user language and visual content. Serving it requires low-latency VLM inference over rich multimodal context as well as reworking Qwen3-VL with proprietary multimodal embeddings to cut runtime cost while improving performance.</p><p>Serving VLMs, however, introduces more challenges compared to text-only LLM workloads. Requests may carry multiple images, require extra vision encoder computation, incur larger and more variable prefill cost, and create higher KV cache pressure. To support this new class of models &amp; product experiences, we built Pinterest’s VLM serving stack on top of NVIDIA Blackwell GPUs and NVIDIA Dynamo. Blackwell GPUs incorporate many architectural innovations that are uniquely positioned for today’s most demanding AI workloads — including higher BF16/FP8 compute throughput, increased memory bandwidth, and larger HBM memory capacity — that enable dramatically higher performance for inference. Dynamo provides a distributed inference orchestration layer that gives us the flexibility to optimize multimodal workloads across the full serving path, including disaggregated encoder/prefill/decode serving, multimodal support in the Dynamo frontend and vLLM, multimodal KV-aware routing with custom payloads, and KV cache offloading.</p><h3>The Serving Challenge</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*N6GoYjNObJWG6oqCTqBxKg.png" /><figcaption><strong>Figure 1: Architecture of Dynamo-based serving system</strong></figcaption></figure><p>As mentioned above, serving VLM for Pinterest use cases present several challenges:</p><p><strong>Request payloads and preprocessing</strong>: For text-only serving, the request payload is just text (or tokens), so preprocessing is mostly tokenization plus applying a standard chat template; there are no external assets to fetch and no image-specific constraints. For VLM serving, the payload includes both text and images (URLs, base64, or precomputed embeddings), so the serving stack must download or load images, run image preprocessing (resize, enforce min/max pixels, normalization), map them into the model’s multimodal input format, and build prompts that mix both text and image content while inserting any required vision tokens or projectors, otherwise images get dropped or misinterpreted. Complexity is increased as requests include more images — Pinterest use cases sometimes require sending thousands of images per request.</p><p><strong>Expensive prefill</strong>: In text-only serving, the expensive part is typically decode, with relatively modest and uniform prompt lengths, so KV cache pressure is more predictable. In VLM serving, requests often include many images or content items per query, which makes prefill dominant (encoding visual context is costly), drives much larger and more irregular KV caches, and requires our serving stack to support KV-aware routing, cache offloading, as well as careful prompt design to stay within latency and memory SLOs.</p><p><strong>Multi-turn workloads</strong>: In text-only serving, multi-turn chat mainly increases prompt length and token costs but stays within a uniform text interface, so the serving logic is mostly about truncation and history management. In VLM serving, multi-turn workloads combine long dialog history with repeated or evolving visual context (e.g., revisiting or adding images/boards across turns), which makes prefill much heavier, complicates how visual state is represented across turns, and requires benchmarks and SLOs that reflect realistic multi-turn multimodal interaction patterns rather than single-shot prompts.</p><p><strong>KV cache memory pressure</strong>: For text-only models, KV cache growth is driven by text token counts and is relatively predictable per request and per turn, so standard cache sizing and eviction often suffice. For VLM models, large visual contexts and long conversations can produce far bigger KV states per request, so serving must treat KV as a first-class constraint — using KV-aware routing, offloading, and disaggregated Encoder/Prefill/Decode designs — to avoid frequent evictions and maintain throughput under multimodal, prefill-heavy traffic.</p><p><strong>Custom model and payload support</strong>: Text-only serving can often treat models as interchangeable behind a standard chat/completions API with generic JSON payloads and minimal per-model customization. VLM serving, by contrast, typically requires model-specific support for image fields, multimodal content arrays, projector layers (e.g., custom embedding projections), and custom routing or headers; the serving stack has to understand these payload shapes and model capabilities explicitly, and deployment artifacts and routers must be able to encode and route these richer, non-uniform multimodal requests correctly.</p><p>All of these VLM serving challenges required us to build a robust, flexible system that can meet our multimodal requirements. To build our serving stack, we relied on NVIDIA Dynamo’s multimodal serving capabilities.</p><h3>Scaling with Blackwell and Building on NVIDIA Dynamo</h3><p>Pinterest has been an early industry pioneer in adopting NVIDIA GPUs for online model serving at internet scale, starting with recommendation systems and expanding into LLM and VLM serving. Token costs and performance matters significantly for the viability of these products. Building on that foundation, we standardized on NVIDIA Blackwell GPUs B200 due to their market leading TCO for LLM/VLM inference. Blackwell also makes our LLM/VLM hardware stack future proof as our use cases and models continue to evolve. This cutting edge hardware enables Pinterest to be able to continue to get better TCO over time as we explore quantizations, improved kernels and multi-node inference.</p><p>Our in-house Gen AI Serving Solution is an end-to-end customizable stack centered around NVIDIA Dynamo as the serving orchestration framework. We use the OpenAI Chat Completions API, model-based Envoy routing and a model-aware gateway, Model Router, to provide a centralized way for all customers to call our system, ensuring a smooth client experience. Under the hood, we use vLLM as our inference engine and Weights and Biases for model management. Our compute infrastructure, PinCompute, is built on Pinterest’s internal centralized platform infrastructure that leverages AWS Elastic Kubernetes Service (EKS) along with NVIDIA GPUs. With the help of the Infra org, we manage dedicated EKS clusters that host all Gen AI Serving use cases. Notably, this is one of the first PinCompute EKS (PEKS) use cases at Pinterest. The Dynamo operator and components are installed through Helm charts, and our Dynamo workloads use the DynamoGraphDeployment CRD deployed as K8s manifests. To tailor the deployments to Pinterest’s requirements we inject additional sidecars and add custom containers to support functionality like Envoy (service mesh), model loading, and metrics scraping.</p><p>Our journey to Dynamo started with evaluating several Kubernetes-native frameworks that we found easy to start with but lacked flexibility in traffic management or forced reliance on a single inference ecosystem. We ultimately selected Dynamo as it is Kubernetes native, compatible with Pinterest Kubernetes and service discovery solution, inference engine agnostic, provides a flexible traffic solution, and uses a performant Rust-based router. During this process we developed a close relationship with the NVIDIA Dynamo team who have provided us with exceptional support. Pinterest utilizes many key features of Dynamo that provide flexibility and performance optimizations when powering our Gen AI Serving Stack.</p><p><strong>P/D disaggregated serving</strong></p><p>Our platform uses disaggregated prefilling and decoding inference to tailor serving to specific latency requirements (Time-to-First-Token (TTFT) or Inter-Token Latency (ITL)), optimizing hardware allocation by separating the distinct computational phases of LLM requests. This architecture is particularly effective for unblocking product launches with high traffic volume and tight latency requirements, especially for TTFT. Dynamo provides an easy to use solution to orchestrate distributed, disaggregated inference that allows us to explore the Pareto curve between the ratio of encoder (E), prefill (P), and decode (D) workers.</p><p><strong>KV cache offloading</strong></p><p>We leverage KV cache offloading, specifically via LMCache, for multi-tier offloading to CPU memory and disk, which is critical in high QPS, multi-turn scenarios. LMCache serves as a sophisticated extension for the inference engine, providing tiered storage across GPU, CPU DRAM, and local disk (NVMe) to preserve generation latency while managing high GPU memory pressure. This tiered approach includes asynchronous prefetching and compression, contributing to lower TTFT and increased throughput by effectively managing long-context scenarios where visual tokens would otherwise overwhelm available VRAM. LMCache fits seamlessly into Dynamo as one of the many KV cache integration option for KV cache offloading</p><p><strong>Multimodal support in Dynamo frontend/vLLM</strong></p><p>Pinterest’s image-based products have specific multi-modal serving requirements. We worked closely with the NVIDIA Dynamo team to develop corresponding multimodality features, including a frontend image decoder, multi-modal disaggregated serving, and multimodal KV router support to reduce recomputation for VLMs. These enhancements enable the serving stack to handle complex multimodal payloads, such as base64 encoded images or image URLs, and perform necessary image preprocessing directly in the Dynamo frontend. Furthermore, the implementation of multimodal KV-aware routing allows the system to track prefix cache overlap for visual content, which is essential for maintaining production latency in multi-turn interactions with high visual token counts.</p><p><strong>Custom modality support: Projection Embeddings</strong></p><p>Pinterest Assistant workloads often need to reason over large visual contexts: Pins, boards, products, and other image-heavy inputs that may appear across multi-turn interactions. Sending all of that context as raw image pixels is expensive for VLM serving because each request may require image loading, decoding, preprocessing, and online vision encoder computation before the language model can use the visual information. To reduce that cost, we added support for projection embeddings using PinCLIP, Pinterest’s internal image encoder, for generating Pin embeddings. Instead of sending raw images through the serving path, Assistant requests can send precomputed PinCLIP embeddings. Dynamo and the underlying inference engine then run a projector that maps those precomputed embeddings into the target VLM’s native visual token space, letting us reuse visual representations that already exist for many Pinterest entities while avoiding the most expensive parts of pixel-based image serving.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*lyJhuNkfUWpki8GGoUZmKw.png" /><figcaption>Figure 2. Comparison between a vanilla VLM and projection embedding enabled VLM</figcaption></figure><p>The performance impact of this approach has been significant. Compared with pixel-based image inputs in Dynamo, incorporating projection embeddings into Dynamo have yielded results that are substantially faster across our benchmarks: average gains are roughly 85x faster TTFT, 7.3x faster end-to-end latency, and 1.1x faster TPOT. Peak gains are even larger, reaching approximately 369x faster TTFT, 44x faster end-to-end latency, and 2.6x faster TPOT. Just as importantly, this makes much larger visual contexts practical: requests with 250 images represented as PinCLIP visual tokens reached latencies comparable to pixel-based requests with roughly 10 images, while carrying 25x more visual context. Even at that scale, the serving profile remained reasonable and production-ready.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*6lFOKJb4DSF0Pzo9-Q5SJg.png" /><figcaption>Figure 3. Mean Time-to-First-Token (TTFT) latency speedup by request rate for 100-output-token requests.</figcaption></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*k2QLPXXxcwRCIK750poRIA.png" /><figcaption>Figure 4. Mean End-to-End (E2E) latency speedup by request rate for 100-output-token requests.</figcaption></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*alUmsb_i8fNN3G49hP10EQ.png" /><figcaption>Figure 5. End-to-End architecture of precomputed projection embedding enabled client/server</figcaption></figure><p>Supporting this required changes across the API, artifact, serving, and engine layers. We introduced an updated ChatCompletions request format for projection embeddings, defined a model artifact contract so training and serving teams could package projector weights, model weights, and configs together into a single model artifact, added the new modality path in vLLM alongside image and video to decode embeddings, validate types and shapes, invoke the correct projector, and insert projected visual tokens into the model input sequence, and updated Dynamo to accept and route the new request format while preserving the multimodal contract. Adding multimodal KV-aware routing support for our modality delivered meaningful tail-latency gains: the strongest result improved TTFT p99 by 5.92x, and across the full benchmark matrix Multimodal(MM) KV-aware routing delivered about 1.42x average speedup on TTFT p99. Additionally, because embedding payloads are larger than ordinary text inputs, vLLM frontend processing latency became a bottleneck in some cases; Dynamo’s Rust frontend helps by providing an efficient path for receiving, parsing, and forwarding larger multimodal payloads.</p><p><strong>Tool calling<br></strong>Lastly, Dynamo fully supports tool calling with custom chat templates and multi-modal inputs, which is essential for providing flexibility for Pinterest’s agentic AI systems. This capability allows our agents to interact with internal tools and APIs in the Pinterest ecosystem, enabling more complex workflows that go beyond simple text for text and hybrid search, and other internal services. By leveraging custom chat templates, we can precisely define how the model should format its tool requests and handle the subsequent tool outputs, ensuring seamless integration with Pinterest’s internal services. Furthermore, the support for multi-modal inputs in tool calling means our agents can use visual information to inform their tool use, such as identifying an object in an image and then calling a specific search or recommendation tool to find similar products.</p><h3>Benchmarking Real Multimodal Workloads with AIPerf</h3><p>We use AIPerf, NVIDIA’s distributed benchmarking tool for standardizing our AI inference performance measurement, as the execution layer for our performance benchmarks. AIPerf is designed as a modular benchmarking framework, which makes it a better fit for complex generative AI workloads than tools focused mainly on single request/response patterns.</p><p>This is especially important as both Pinterest and the broader industry move toward agentic AI systems. These workloads are rarely a single model call. They often involve retrieval, routing, multiple model calls, tool use, multimodal inputs, and intermediate reasoning steps before producing a final response. This allows our optimizations to have grounding data and guardrails on whether we are improving or regressing.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*n-DH4cFNbacxqhKmYul1xw.png" /><figcaption>Figure 6. An example of Pinterest Assistant DAG used in AIPerf benchmark</figcaption></figure><p>AIPerf’s DAG support is a big part of why it works well for us. Instead of flattening an agentic workflow into one artificial request, we can model the actual execution graph: nodes represent meaningful stages in the system, and edges capture dependencies between steps. This lets us benchmark workflows that branch, fan out, join, or depend on earlier outputs, patterns that are increasingly common in real AI applications.</p><p>Just as importantly, AIPerf lets us shape the benchmark traffic to look more like real production usage. We can run benchmarks with configurable QPS, realistic Poisson request arrival patterns, multi-turn interactions, multimodal inputs, and configurable prompt characteristics such as system prompt size, prefix length, input token length, and number of visual/embedding items. This makes the benchmark less about testing an isolated model call and more about understanding how the full workload behaves under realistic load.</p><p>Internally, we pair AIPerf’s execution output with Pinterest-specific reporting. We use the results to power dashboards and summaries for latency, throughput, token usage, success rate, per-request details, and aggregate comparisons. That gives teams a practical way to compare runs, catch regressions, and understand whether a model or deployment can meet production SLOs under realistic multimodal and agentic workloads.</p><h3>Product Use Cases Enabled</h3><p>The VLM serving stack described above was built to support Pinterest Assistant, but the same architecture now serves as a reusable foundation for many GenAI and multimodal use cases across Pinterest. By standardizing on Dynamo for orchestration, vLLM for inference, and a common Chat Completions-compatible API, teams can launch new model-backed product experiences on top of this extensible serving platform.</p><h3>Pinterest Assistant: Real-Time Multimodal Interactions</h3><p>Pinterest Assistant is one of the first major product use cases enabled by this stack. As a conversational agent, Pinterest Assistant needs to support natural multi-turn interactions while reasoning over Pinterest’s visual content. A user may ask for help refining an idea, exploring a style, comparing products, or finding inspiration based on a set of Pins or images. Unlike a text-only assistant, this requires the serving system to handle both dialogue history and multimodal context in real time. Pinterest Assistant inference runs on NVIDIA B200 instances, which showed a greater than 2x latency improvement over Hopper during preliminary benchmarking.</p><p>Pinterest Assistant also benefits from custom modality support such as projection embeddings. Instead of always sending raw image pixels through the serving path, Assistant requests can use precomputed visual embeddings for Pinterest entities such as Pins, boards, and products. This allows the model to reason over much larger visual context while avoiding repeated image decoding and vision encoder computation, making richer real-time conversations practical.</p><h3>A Shared Stack for Multimodal Product Patterns</h3><p>As more Pinterest product surfaces adopt GenAI and multimodal models, this shared stack lets us support a growing range of patterns: conversational agents, re-rankers, OCR, safety systems, signal generation, and future VLM-powered experiences. The result is a serving platform that is not tied to a single product launch, but designed as a reusable foundation for multimodal AI at Pinterest.</p><p>Although Pinterest Assistant motivated many of the original requirements, the serving stack has grown to support a much broader set of use cases. Dynamo has become the out-of-the-box default for many GenAI serving workloads at Pinterest because it offers a flexible path for both text-only and multimodal deployment:</p><ul><li>Multimodal reranking uses VLMs to compare candidate content across textual and visual signals</li><li>OCR workloads extract or reason over text in images</li><li>Safety guardrails apply multimodal understanding to check whether responses or retrieved content meet product and policy requirements</li><li>And more across signal generation, embedding-based workflows, and agentic systems</li></ul><p>Dynamo’s LoRA hot loading has also accelerated experimentation under limited GPU capacity. Instead of standing up a separate full deployment per adapter — which increases GPU usage and operational overhead — client teams can load and evaluate multiple sets of LoRA weights dynamically against an existing base model. This shortens experimentation cycles and creates a smoother path from adapter training to production validation.</p><p>With a common serving foundation, teams reuse the same APIs, deployment patterns, routing layer, model management, observability, benchmarking, and GPU infrastructure rather than each building a custom solution. This gives product teams a paved path to focus on model behavior, integration, and evaluation. Dynamo is powering a reusable foundation for multimodal AI at Pinterest.</p><h3>Lessons Learned and What’s Next</h3><p>In building our Gen AI Serving platform, we’ve learned that VLM workloads are fundamentally prefill-heavy and cache-sensitive: encoding large visual contexts and long histories, not just decode, drives both latency and GPU memory utilization, so KV-aware routing, cache offload tiers, and disaggregated serving need to be designed explicitly. Dynamo’s multimodal KV-aware router and E/PD disaggregation and LMCache-based KV offloading turned out to be essential. We also found that payload design and routing are core serving problems, not just interface glue: the way we encode multimodal content arrays, choose image resolutions, and structure prompts directly determines whether Dynamo can reuse prefixes, route efficiently, and keep TTFT within product targets. On the evaluation side, we learned that benchmarks must reflect real multimodal product traffic — including multi-turn conversations, many images per request, and agentic DAGs — so we invested in AIPerf-based DAG benchmarks that mirror production QPS patterns instead of synthetic single-shot prompts. Finally, a shared serving platform built on Dynamo, vLLM, and EKS has significantly accelerated experimentation: once the stack supported multimodal routing, KV offload, and model management, new use cases like Pinterest Assistant and multimodal reranking could launch by reusing the same paved path instead of re-inventing infra per team.</p><p>Looking ahead, we’re investing in several new directions.</p><p><strong>AI Configurator:</strong> Dynamo’s AI Configurator is a performance optimization tool that can simulate 10K+ deployment configurations in seconds, finding optimal prefill/decode worker counts, tensor/expert/data parallelism settings, and deployment parameters. It evaluates both aggregated and disaggregated serving architectures, and uses hardware-specific performance models to predict TTFT, ITL, and throughput across different GPUs. This tooling can help us create optimized deployments with lower lift, increasing performance and developer velocity across teams.</p><p><strong>Dynamo Planner:</strong> As our workloads scale, so will the need to introduce autoscaling in order to maintain a highly available yet cost-efficient compute infrastructure. Dynamo’s Planner will provide a VLM/LLM-optimized autoscaler, which dynamically adjusts prefill and decode replica counts through four optimization targets: throughput (static queue/KV thresholds), latency (aggressive low-latency thresholds), load (user-defined prefill queue and decode KV utilization thresholds), and SLA (regression-based models targeting specific TTFT/ITL values)</p><h3>Conclusion</h3><p>NVIDIA Dynamo has given us a strong foundation for building Pinterest’s VLM serving stack and expanding it across emerging multimodal use cases. Its flexibility has been critical as we move from individual product launches toward a shared platform for production GenAI serving.</p><p>We’re excited to continue partnering with the NVIDIA Dynamo team and the broader community to push the limits of VLM and multimodal serving, and to make real-time multimodal AI systems faster, more efficient, and easier to deploy at scale.</p><h3>Acknowledgements</h3><p>This work would not be possible without the contributions from our partners and collaborators. Our thanks to:</p><h4>Pinterest</h4><p>AI Platform: Neha Upadhyay, Ananya Prabhu Angadi, Nazanin Farahpour, Howard Nguyen<br>Product ML Infra: Li Tang, Yayun Wang, Archer Liu<br>ATG: Yash Upadhyay, David Xue<br>Cloud Runtime Team: Vaibhav Shankar<br>CDP: Khoi Nguyen<br>Traffic: Peter Leng, James Fish, Scott Beardsley<br>Production Engineering: One Marino, Juan Pablo Daniel Borgna<br>Product Management: Colin Leatherbury<br>Leadership: Karthik Anantha Padmanabhan, Bo Liu, Roger Wang, Kartik Paramasivam, Matthias Zenger</p><p>NVIDIA<br>Elijah Soba, Qi Wang, Anthony Casagrande, Guan Luo, Kris Hung, Ryan McCormick, Harry Kim, Akshatha Kamath, Matthew Rawson</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=0dce6e93d0f3" width="1" height="1" alt=""><hr><p><a href="https://medium.com/pinterest-engineering/building-pinterests-vlm-serving-stack-on-nvidia-dynamo-0dce6e93d0f3">Building Pinterest’s VLM Serving Stack on NVIDIA Dynamo</a> was originally published in <a href="https://medium.com/pinterest-engineering">Pinterest Engineering Blog</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
    </channel>
</rss>