← Projects

Huawei recomputation scheduler

Decides what to keep in memory and what to recompute, so a model's computation graph runs under a strict memory limit.

40%
lower peak memory than greedy
within 8%
from the optimal schedule
<2s
10K-node graph solved in

Team of four, StormHacks

C++, Beam search, Branch-and-bound, Gurobi

devpost ↗

What it is

Huawei's custom challenge at StormHacks in October 2025 was called "Automatic Decision for Re-computation in AI model optimization." Running a model's computation graph produces intermediate results that take up memory. Under a strict memory limit, you have to decide for each one: keep it, or throw it away and recompute it later when it's needed again.

Keeping everything is fast but blows the memory limit. Recomputing everything fits in memory but is slow. The job was to schedule the graph and choose what to recompute, so peak memory stays under the limit and total runtime stays low.

Recomputation under a memory limit
Recomputation under a memory limitEight operations run left to right. B's output is needed again by F. Instead of keeping it in memory through C, D and E, the scheduler throws it away after C and recomputes it as B prime just before F. Below, memory over time: keeping everything crosses the memory limit, while recomputing stays under it at the cost of one extra operation.execution orderB's output is needed again by Fevicted after C, freed until it's recomputedABCDEB′FGmemory over timememory limitkeep everything: over the limitrecompute B: stays undertime →
An illustration of the idea, not measured data. B's output is thrown away after C and recomputed just before F needs it.

It's the same tradeoff as activation checkpointing in ML training and inference, and the same problem ML compilers face when fitting a model onto an accelerator. We placed 1st of 220+ and presented the solution to Huawei Research.

Why the order matters

A result has to stay in memory until every node that uses it has run. Any valid order runs the same work, but different orders keep different results alive at the same time. In a small graph where A feeds B, C and E, two valid orders give very different peaks:

OrderWorst momentPeak
A, B, E, C, D, F, GRunning C while A, B and E are all still held20 MB
A, C, B, D, E, F, GRunning D, after which B and C are freed before E starts15 MB
Same graph, same work: about 25% less peak memory from the order alone. On real graphs with thousands of nodes the gap is much larger.

Finding the best order is NP-hard in general, so exact methods don't scale to graphs with 10,000 nodes.

How we solved it

We were a team of four: Winston Thov, Dennis Kritchko, James Nguyen and me. We built a scheduler in C++ with several algorithms behind one interface, so we could compare them on the same graphs.

When memory gets tight, the scheduler evicts intermediate results aggressively and recomputes the nodes that produced them later, which cuts the memory peak. To know whether the fast solvers were any good, we wrote an MILP formulation in Gurobi that finds the optimal answer on small graphs, and checked the others against it.

Solver pipeline
Solver pipelineThe scheduler parses the computation graph, works out each node's memory footprint and critical-path priority, then runs one of five solvers: naive topological order, greedy, a critical-path heuristic, beam search, or branch-and-bound. The solver decides what to evict and recompute and produces a run order. On small graphs, an MILP formulation in Gurobi gives the optimal answer to check the others against.Parse graphC++Memory + priorityper nodeSolverone of fiveEvict or recomputeper resultSchedulerun ordernodes=10K+runtime=<2speak_memory=-40%MILP validatorGurobi, small graphs1234compare on small graphs5naive topologicalgreedycritical-path heuristicbeam searchbranch-and-bound
  • service
  • external
  • request
  1. Parse the computation graph.
  2. Work out each node's memory footprint and critical-path priority.
  3. Run one of the five solvers.
  4. Decide, for each intermediate result, whether to keep it or evict it and recompute it later.
  5. Produce the schedule, and on small graphs, compare it against the optimal answer from Gurobi.

Why beam search won

Beam search keeps the best few partial schedules at each step instead of just one. That made it much faster than branch-and-bound and gave it much lower peak memory than greedy.

MethodQualitySpeedVerdict
GreedyGets trapped: a good choice now can force a huge peak laterVery fastBaseline
Branch-and-boundOptimal, given enough timeExponentialToo slow
MILP (Gurobi)Provably optimalMinutes to hours on big graphsUsed to check the others
Beam searchClose to optimal in practiceBounded and predictableChosen
The approaches we compared.

Each candidate schedule is ranked by peak memory, then current memory, then a critical-path tie-breaker that prefers nodes with the longest path still to the output. Without it, the search tends to put off long chains, which keeps their inputs alive and blows up memory late in the schedule. With it, long chains clear early and free their results.

Every state update is incremental, and the state is compact: bitsets and flat arrays instead of node objects, with no allocation per step. That's why it's C++. The inner loop runs millions of times, and that compactness is what fits a 10,000-node graph into under 2 seconds. The beam width is the one knob: wider gives better schedules and takes proportionally longer.

Schedule beam_search(const Dag& g, size_t K, Bytes limit) {
    std::vector<State> beam{ State::initial(g) };
    for (size_t step = 0; step < g.size(); ++step) {
        std::vector<State> next;
        for (const State& s : beam) {
            for (NodeId v : s.ready()) {
                State c = s.extend(v, g);        // incremental alloc/free
                if (c.peak() <= limit) next.push_back(std::move(c));
            }
        }
        dedupe(next);                            // same scheduled set + live set
        std::partial_sort(next.begin(),
                          next.begin() + std::min(K, next.size()),
                          next.end(), by_peak_mem_then_critical_path);
        next.resize(std::min(K, next.size()));
        beam = std::move(next);
    }
    return best_of(beam);
}
The core loop, simplified. K is the beam width.

Across 200 graphs, beam search had 40% lower peak memory than greedy. On the 50 graphs small enough to solve exactly, it came within 8% of Gurobi's optimum.

Solver trade-off
Solver tradeoff: runtime versus peak memoryA qualitative two by two. Greedy is fast but has high peak memory. Branch-and-bound is slow. Beam search sits in the fast, low-memory corner: peak memory 40 percent below the baseline, and graphs of more than ten thousand nodes solved in under two seconds.fasterslowerruntimehigherlowerpeak memorygreedybranch-and-boundbeam searchpeak_memory=-40% vs baselineruntime=under 2s at 10K+ nodesqualitative: only beam search is measured
Qualitative. Beam search's numbers are measured; the other solvers are placed by what we saw, not plotted.

Results

nodes
10K+
runtime
<2s
peak memory
-40% vs greedy (200 graphs)
gap to optimal
within 8% (50 graphs)
team
4

What I'd change

Our own write-up admits the recomputation logic and fallbacks needed work, and on some cases peak memory hit the ceiling because we pushed hard for the best runtime. I'd make smarter choices about what to recompute, and make the fallbacks consistent, before squeezing out more speed.