How can code generate memory garbage without instantiating any memory?

I’ve got the following code snippet, and the Unity profiler is saying that Tile.UpdateWater() is generating about 0.6mb of GC Alloc per frame (it’s called 131,000 times per frame). But it does not instantiate any arrays or classes, and it does not use strings at all.

I’ve tried removing chunks of code, but I couldn’t track it down through that method either because it would allocate fully one run, but allocate nothing the next run on the same code at times. It’s really driving me up a wall.

I’ve also tried pulling this function out into it’s own static class with all local variables predefined as static class variables, but that only seemed to make it worse.

The only local variables being defined are ints and floats, but those shouldn’t trigger allocations, right?

public class Tile
	{
            public enum Modes
            {
                Nothing,
                FloorOnly,
                FloorWall,
                FloorStairs,
                FloorRamp,
                WallOnly,
                StairsOnly,
            }

	    private float WaterAmt;
            private Tile North, South, East, West, Below, Above;
            public Modes Mode;

            public bool HasWall()
            {
                return Mode == Modes.WallOnly || Mode == Modes.FloorWall;
            }

            public void UpdateWater()
            {
                if (WaterAmt > 1)
                    WaterAmt = 1;
                if (WaterAmt <= 0)
                    return;
                if (HasWall())
                {
                    WaterAmt = 0;
                    return;
                }
                float maxFlowAmt = Mathf.Min(0.5f, Time.deltaTime * 5);
                if(Mode == Modes.Nothing && Below != null)
                {
                    if (!Below.HasWall())
                    {
                        float toDrop = Mathf.Min(1 - Below.WaterAmt, WaterAmt);
                        Below.WaterAmt += toDrop;
                        WaterAmt -= toDrop;
                    }
                }
                if (WaterAmt <= 0)
                    return;
                int ct = 1;
                float totalWater = WaterAmt;
                if (West != null && !West.HasWall())
                {
                    ct++;
                    totalWater += West.WaterAmt;
                }
                if (East != null && !East.HasWall())
                {
                    ct++;
                    totalWater += East.WaterAmt;
                }
                if (South != null && !South.HasWall())
                {
                    ct++;
                    totalWater += South.WaterAmt;
                }
                if (North != null && !North.HasWall())
                {
                    ct++;
                    totalWater += North.WaterAmt;
                }
                float avgWater = totalWater / ct;
                if (ct <= 1)
                    return;
                if (avgWater >= 0.9999999f)
                    return;
                if (avgWater < WaterAmt)
                {
                    float needW = 0, needE = 0, needN = 0, needS = 0;
                    if (West != null && !West.HasWall())
                        needW = Mathf.Max(avgWater - West.WaterAmt);
                    if (East != null && !East.HasWall())
                        needE = Mathf.Max(avgWater - East.WaterAmt);
                    if (South != null && !South.HasWall())
                        needS = Mathf.Max(avgWater - South.WaterAmt);
                    if (North != null && !North.HasWall())
                        needN = Mathf.Max(avgWater - North.WaterAmt);
                    float totalNeed = needW + needE + needN + needS;
                    if (totalNeed > 0)
                    {
                        float maxToGive = WaterAmt - avgWater;
                        if (totalNeed > maxToGive)
                        {
                            needW *= (maxToGive / totalNeed);
                            needE *= (maxToGive / totalNeed);
                            needN *= (maxToGive / totalNeed);
                            needS *= (maxToGive / totalNeed);
                        }
                        if (needW > 0)
                        {
                            float amt = needW * maxFlowAmt;
                            West.WaterAmt += amt;
                            WaterAmt -= amt;
                        }
                        if (needE > 0)
                        {
                            float amt = needE * maxFlowAmt;
                            East.WaterAmt += amt;
                            WaterAmt -= amt;
                        }
                        if (needN > 0)
                        {
                            float amt = needN * maxFlowAmt;
                            North.WaterAmt += amt;
                            WaterAmt -= amt;
                        }
                        if (needS > 0)
                        {
                            float amt = needS * maxFlowAmt;
                            South.WaterAmt += amt;
                            WaterAmt -= amt;
                        }
                    }
                }
            }
	}

Having pored over that code for a while and come up with nothing, I wanted to make that time feel a little less wasted by pointing out that you have a few redundant calls to Mathf.Max in there (where you’re passing in a single float parameter, which invokes the Mathf.Max( params float[] values ) version of the function to give you the max out of an arbitrary number of floats, but there’s only one so it always just returns the one you give it).

And then (and this is a bloody long shot) it occurred to me that maybe it’s wrapping that single parameter in a new array under the hood…?

Well. It seems you have a few other local variables: private Tile North, South, East, West, Below, Above;

These are instances of the same class which recursively add instances of the same ints, floats and any other variables you declared in the class (like tiles, which do the same until infinity)

Try making these variables static using (not sure if that is sufficient to make the entire rule static).
private static Tile North, South, East, West, Below, Above;

Can’t tell from code, but you can find out easily by running your code in “deep profiler”

Are you sure you are not creating a lot of Tile objects at runtime that reference to each other? This construction could possibly cause the memory leak.
I would suggest you make the object IDisposable and in the dispose method remove all North, South, East, West, Below and Above references.
Do not forget to call the Dispose method on the Tile object before you lose the last reference to a Tile object.

In the following example the IDisposable pattern is implemented as described on IDisposable Interface (System) | Microsoft Learn.
Combine this with your code above.

public class Tile : IDisposable
    {
        private bool disposed = false;

        ~Tile()
        {
            this.Dispose(false);
        }
        
        public void Dispose()
        {
            this.Dispose(true);
            GC.SuppressFinalize(this);
        }

        protected virtual void Dispose(bool disposing)
        {
            if (this.disposed)
            {
                return;
            }

            if (disposing)
            {
                // Free any managed objects here.
                this.North = null; // Note that you also need to call dispose if you don't have any other references to it's dispose.
                this.South = null; 
                this.East = null;
                this.West = null;
                this.Below = null;
                this.Above = null;
            }

            // Free any unmanaged objects here.
            this.disposed = true;
        }
    }