Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / Languages / C#

StringShear - A Science Project Adventure in C# and C++

4.53/5 (6 votes)
13 Sep 2021Apache11 min read 20K   134  
A science program is rearranged, ported from C# to C++, measured, and optimized
You would think that C++ would run laps around C# for a science project, that taking the time to do things right in C++ would pay off big time. We shall see...

Introduction

A little background.

A million years ago, I was a stellar math and physics student. Let's say I peaked early...

College math and physics were much harder than expected. I ended up meeting my wife and learning C++. Mission accomplished!

Aside: When I entered college, they taught new students like myself C++. When I left, they were teaching noobs Java. Now they teach them Python. Alas.

So even though physics didn't work out in college, I still dreamt of crazy delusional things like nuclear fusion. I wanted to get on a team doing that sort of thing, to save the world from wars over fossil fuels. Yep, crazy and delusional.

Setting Off To Do Science

So I took a third semester physics class from the local community college. The coolest experiment was "Simple Harmonic Motion On A String." In this experiment, there's a string suspended above a table. At one end, the string is held fixed with a weight applied, creating tension. At the other end of the table, the string is passed over an oscillator that moves that end of the string up and down. The oscillator's motion can be dialled in to a specific frequency, and, then, magic: Patterns called standing waves appear on the vibrating string. With what are called resonant frequencies, the standing waves become pronounced, and large patterns emerge in the vibrating string. This fascinated the hell out of me.

Many great diagrams here

As a software guy, I was inspired to take a diagram from the textbook and try to make a simulation of the standing waves string experiment. At the time, Microsoft was not giving away the crown jewels for free the way they have done with the Visual Studio 2017 and 2019 Community Editions. I wanted to do C++ programming, and the only way to do that back in the days of Visual Studio Express was .NET's C++/CLI, called Managed C++ at the time. I hope you like carrots! It had a form designer, and the .NET library was quite workable. So I set to work.

Weeks of development and tuning later, I had a working simulation. Tuning it took forever, for example my wife's revelation that the tension had to be quite high. There are a couple of these hard-fought magic numbers I had to hone in on to get it to where you can put in a frequency like 20 Hz and see a standing wave.

The User Interface

Image 1

At the left, top to bottom, are a real-time view of the string, and snapshots of the string when it was at its most extreme amplitude (height), velocity (up and down), acceleration, and punch (not fruit, change in acceleration). For each string, in the bottom-left you see the strings height at that moment (in millimetres), in the top right, you see extreme value that was measured ("max = "), and in the bottom right you see the elapsed time ("time = ") in the simulation when the maximum was recorded.

At the right are the simulation controls.

  • Time Slice is how quickly the simulation runs, in parts of a millisecond. Too high and the simulation is not realistic. Too low and the simulation runs too slowly. Best to not go higher that 0.01 ms. You might go lower to validate that something you observed is not some artifact of simulation overload from running too fast.
  • Simulation Speed controls the delay in the simulation's update loop. For any real study, you would run it at All Out for hours on end. You can use lower speeds to observe the string motion even slower.
  • Tension / Root Note is tricky. The number you see there is a magic number arrived at tuning the string to resonate at 10 Hz. You can enter music notes, like C0, and then instead of frequencies you can enter other musical notes, and see how consonance and dissonance play out on the string. Mad science.
  • Oscillators define the vibrations made at the ends of the strings. Not limited to a single physical oscillator with a single frequency, you can enter whatever you'd like for either side or both, each frequency on its own line. Don't get carried away, though. With too many frequencies, especially high ones, you start to see un-string-like behavior. It's a difficult point, that to find anything interesting you have to go to lengths that might mean that what you're seeing isn't realistic. You can try lowering the Time Slice and running the simulation for longer to see if what you found is realistic.
  • Out of Phase sets the offset in the left versus the right oscillators. If you put the same frequency on the left and right, and set this to zero, the string will just move up and down and no standing waves will form. In the real world, I was able to prove this out with a string between two oscillators. Only when I reverse wired them did a standing wave form.
  • Just Pulse and Just Half Pulse let you see how a standing wave forms in the very beginning with just one wave front moving down the string and echoing back.
  • Run / Pause controls the simulation, starting or pausing or restarting it.
  • Reset turns the clock back to zero and resets all the strings.
  • Reset Maximums resets just the maximum strings. This is useful after running a simulation for a little while, to eliminate the weird stuff that happens early in a simulation, to reproduce a legitimate finding of interest, if there were such things.
  • Copy Stats Header puts the stats columns on the clipboard to paste into a spreadsheet program.
  • Copy Stats Values puts the stats values on the clipboard to paste into a spreadsheet program.
  • stringshear.com has an online TypeScript version of this project that you can play with, no need to download or build anything, and some documentation similar to this.

You can tell you've got a strong standing wave when in the top-left panel, the black circles at either end of the string aren't moving up-and-down much, but the string is. That's the magic, the resonant frequencies, where the string resonates with the vibrations it's getting from its ends.

Build it and plug in the frequencies you've got here, and watch how, in slow motion, the string moves, and maximum strings are recorded. It's mesmerizing.

The science is interesting, but this is not a scientific journal. Let's talk code.

Moving On to the Code

Here are the projects in the attached code ZIP:

  • original - C# port of the truly original C++/CLI WinForms program (from 2007!)
  • app - C# WinForms UI client runs the new client-server show
  • sharpsvr - C# HttpListener server runs the .NET simulation
  • sharplib - C# class library implements the simulation, including inter-process communication
  • seasvr - C# HttpListener server runs the C++ simulation
  • sealib - C++ DLL counterpart to sharplib

Here are the classes of interest, named and coded (mostly) the same in C# and C++ for easy comparison:

  • Simulation - Runs the experiment, and does serialization and event handling with the app
  • Stringy - Well, I couldn't call it String! Manages serialization and updating
  • Particle - Our string effectively consists of 1,000 strung beads... particles sound cooler than beads
  • ScopeTiming - Performance tracking

Code Mechanics

In the original app, all the classes are in the one project. To do a performance comparison between C++ and C# - as I am prone to do! - I needed to run the simulators out of process, in a .NET server and a C++ server. You need to be able to leave the simulation running headless for hours on end at "All Out" speed, without having the app burning up the graphics card and adding lock contention to the simulation.

The general algorithm is...

  1. The simulation is always running, calling the Update function which simulates oscillators and updates strings and particles
  2. The simulation receives settings from the app to apply to itself
  3. This simulation receives requests from the app to return its state (strings, maxes)
  4. The app has UI controls for sending settings to control the simulation
  5. The app has a timer that requests simulation state and updates the display

So my plan of attack was to follow these steps, validating that things still worked at each step:

  1. Pull non-UI bits out of the app into a class library, sharplib.
  2. Introduce serialization of app commands and simulation state between the sharplib classes and the app UI.
  3. Write a HttpListener server, sharpsvr, and use a WebClient in the app, and do out-of-process communication between the UI and the simulation.
  4. Port the server and most of sharplib to C++ and iterate from there.

Let's dive in!

Performance Tracking with ScopeTiming - C#

C#
using...
namespace...
/// <summary>
/// Utility class for timing sections of code
/// To do timing:
/// Call Init first to enable timing
/// Call StartTiming at the beginning of the code to time
/// Call RecordScope at the end of the code to time
/// Call Summary to get a summary of the timings performed
/// Call Clear to remove all timings
/// </summary>
public static class ScopeTiming
{
    /// <summary>
    /// Initialize to enable timing
    /// </summary>
    /// <param name="enable">Specify to enable timing</param>
    public static void Init(bool enable)
    {
        sm_enabled = enable;
    }

    /// <summary>
    /// Get a Stopwatch to start timing
    /// </summary>
    /// <returns>null if not enabled, or else a new Stopwatch</returns>
    public static Stopwatch StartTiming()
    {
        if (!sm_enabled)
            return null;

        return Stopwatch.StartNew();
    }

    /// <summary>
    /// Record the time since StartTiming was called
    /// </summary>
    /// <param name="scope">What area of the code would you call this?</param>
    /// <param name="sw">null if not timing, or Stopwatch started timing</param>
    public static void RecordScope(string scope, Stopwatch sw)
    {
        if (sw == null)
            return;

        var elapsedMs = sw.Elapsed;

        lock (sm_timings)
        {
            Scope scopeObj;
            if (!sm_timings.TryGetValue(scope, out scopeObj))
            {
                scopeObj = new Scope() { ScopeName = scope, Hits = 1, Allotted = elapsedMs };
                sm_timings.Add(scope, scopeObj);
            }
            else
            {
                ++scopeObj.Hits;
                scopeObj.Allotted += elapsedMs;
            }
        }

        sw.Restart();
    }

    /// <summary>
    /// Get a summary of the recorded timings
    /// </summary>
    public static string Summary
    {
        get
        {
            var sb = new List<string>();
            lock (sm_timings)
            {
                foreach (Scope obj in sm_timings.Values)
                {
                    if (obj.Hits == 0)
                        continue;

                    sb.Add
                    (
                        $"{obj.ScopeName} -> {obj.Hits} hits - " +
                        $"{Math.Round(obj.Allotted.TotalMilliseconds)} ms total -> " +
                        $"{Math.Round(obj.Allotted.TotalMilliseconds / obj.Hits, 5)} ms avg"
                    );
                }
            }
            sb.Sort();
            return string.Join("\n", sb);
        }
    }

    /// <summary>
    /// Remove all timings
    /// </summary>
    public static void Clear()
    {
        lock (sm_timings)
            sm_timings.Clear();
    }

    private static bool sm_enabled;

    private class Scope
    {
        public string ScopeName;
        public int Hits;
        public TimeSpan Allotted;
    }
    private static Dictionary<string, Scope> sm_timings = new Dictionary<string, Scope>();
}

The Particle Class - C#

The Stringy class has an array of Particles, so they better be structs for good data locality.

NOTE: ToString turns out to be wicked fast, but FromString does not have to be fast as it's app side, running at human time, so it's meant to be readable, not fast.

C#
// Define a particle as its x position along the string,
// its y position above or below the string,
// its velocity, its acceleration, and its punch (velocity of acceleration)
using System;
using System.Linq;

namespace StringShear
{
    public struct Particle
    {
        public double x;
        public double y;
        public double vel;
        public double acl;
        public double punch;
        public double nextNeighborFactor;

        // NOTE: Default constructor zeroes out all fields which is what we want

        public Particle(double _x, double _y = 0, double _vel = 0, 
                        double _acl = 0, double _punch = 0, double _next = 0)
        {
            x = _x;
            y = _y;
            vel = _vel;
            acl = _acl;
            punch = _punch;
            nextNeighborFactor = _next;
        }

        public override string ToString()
        {
            return $"{x},{y},{vel},{acl},{punch},{nextNeighborFactor}";
        }

        public static Particle FromString(string str)
        {
            double[] vals = 
                str
                .Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries)
                .Select(x => double.Parse(x))
                .ToArray();
            if (vals.Length != 6)
                throw new Exception("Particle parsing fails: " + str);

            return new Particle(vals[0], vals[1], vals[2], vals[3], vals[4], vals[5]);
        }

        public double GetVal(int index)
        {
            switch (index)
            {
                case 0: return x;
                case 1: return y;
                case 2: return vel;
                case 3: return acl;
                case 4: return punch;
                default: throw new Exception($"Invalid index to get: {index}");
            }
        }

        public void Reset()
        {
            // NOTE: Leave x position alone
            y = vel = acl = punch = nextNeighborFactor = 0.0;
        }

        // Update the y value for this, computing vel and acl and punch,
        // and returning the work performed (acl * distance)
        // This is used for endpoints of the string
        public double SetPosY(double newPosY, double elapsedTime, double time)
        {
            double newDisplacement = (newPosY - y);

            double newVel = newDisplacement / elapsedTime;

            double newAcl = (newVel - vel) / elapsedTime;
            if (time <= elapsedTime)
                newAcl = 0.0;

            double newPunch = (newAcl - acl) / elapsedTime;
            if (time <= elapsedTime * 2.0)
                newPunch = 0.0;

            double workDone = newDisplacement * acl;

            y = newPosY;
            vel = newVel;
            acl = newAcl;
            punch = newPunch;

            return Math.Abs(workDone);
        }
    }
}

The Particle Class - C++

Identical code to the C# version, but no ToString / FromString serialization.

The String(y) Class - C#

C#
// Define a string as a list of particles,
// and track the maximum position, velocity, acceleration, and punch of this,
// and the work done to move the end points, 
// and the max work required to move the end points
using ...
namespace...
public class Stringy
{
    Particle[] m_particles;
    double m_length;

    // What were our most extreme values of Particles on this string?
    double m_maxPos;
    double m_maxVel;
    double m_maxAcl;
    double m_maxPunch;

    // Which particle had the most extreme value?
    int m_maxPosIndex;
    int m_maxVelIndex;
    int m_maxAclIndex;
    int m_maxPunchIndex;

    bool m_waveDownAndBackYet;

    Stopwatch m_sw = new Stopwatch();

    public Stringy(Particle[] particles, double length)
    {
        m_length = length;
        m_particles = (Particle[])particles.Clone();
    }

    public Stringy(int particleCount, double length)
    {
        m_length = length;

        // Initialize the particles, spreading them out across the length
        m_particles = new Particle[particleCount];
        m_particles[0] = new Particle();
        for (int i = 1; i < particleCount; ++i)
            m_particles[i] = new Particle(m_length * 1.0 * i / (particleCount - 1));
    }

    public override string ToString()
    {
        Stopwatch sw = ScopeTiming.StartTiming();
        StringBuilder sb = new StringBuilder();

        sb.Append("particles:" + string.Join("|", 
                   m_particles.Select(p => p.ToString())) + ";");
        ScopeTiming.RecordScope("Stringy.ToString.Particles", sw);

        sb.Append("length:" + m_length + ";");

        sb.Append("maxPos:" + m_maxPos + ";");
        sb.Append("maxVel:" + m_maxVel + ";");
        sb.Append("maxAcl:" + m_maxAcl + ";");
        sb.Append("maxPunch:" + m_maxPunch + ";");

        sb.Append("maxPosIndex:" + m_maxPosIndex + ";");
        sb.Append("maxVelIndex:" + m_maxVelIndex + ";");
        sb.Append("maxAclIndex:" + m_maxAclIndex + ";");
        sb.Append("maxPunchIndex:" + m_maxPunchIndex + ";");

        sb.Append("startWork:" + m_startWork + ";");
        sb.Append("endWork:" + m_endWork + ";");

        sb.Append("maxStartWork:" + m_maxStartWork + ";");
        sb.Append("maxEndWork:" + m_maxEndWork + ";");
        ScopeTiming.RecordScope("Stringy.ToString.TheRest", sw);

        string str = sb.ToString();
        ScopeTiming.RecordScope("Stringy.ToString.sb.ToString", sw);
        return str;
    }

    // App side code calls this to compute 
    // what the UI should show from the simulation's state
    public static Stringy FromString(string str)
    {
        var dict = new Dictionary<string, string>();
        foreach (string line in str.Split
                (new[] { ';' }, StringSplitOptions.RemoveEmptyEntries))
        {
            int colon = line.IndexOf(':');
            string name = line.Substring(0, colon);
            string value = line.Substring(colon + 1);
            dict.Add(name, value);
        }

        double length = double.Parse(dict["length"]);

        Particle[] particles = 
            dict["particles"]
            .Split(new[] { '|' }, StringSplitOptions.RemoveEmptyEntries)
            .Select(s => Particle.FromString(s))
            .ToArray();

        Stringy stringy = new Stringy(particles, length);
            
        stringy.m_maxPos = double.Parse(dict["maxPos"]);
        stringy.m_maxVel = double.Parse(dict["maxVel"]);
        stringy.m_maxAcl = double.Parse(dict["maxAcl"]);
        stringy.m_maxPunch = double.Parse(dict["maxPunch"]);
        ...
        return stringy;
    }

    public Stringy Clone()
    {
        Stringy ret = new Stringy(m_particles, m_length);

        ret.m_maxPos = m_maxPos;
        ret.m_maxVel = m_maxVel;
        ret.m_maxAcl = m_maxAcl;
        ret.m_maxPunch = m_maxPunch;
        ...
        return ret;
    }

    // Update the particles of this given new endpoints, an elapsed time, and a tension
    // The tension defines how much acceleration the particles receive due to distance from
    // neighboring particles
    public
        void Update
        (
            double startPosY,
            double endPosY,
            double elapsedTime,
            double time,
            double tension,
            double damping,
            double stringLength,
            double stringConstant
        )
    {
        // Set the endpoints and add to the work we've performed for them
        // and track the max work we've seen for the endpoints.
        double newStartWork = m_particles[0].SetPosY(startPosY, elapsedTime, time);
        m_startWork += newStartWork;

        if (newStartWork > m_maxStartWork)
            m_maxStartWork = newStartWork;

        double newEndWork = m_particles[m_particles.Length - 1].SetPosY
                            (endPosY, elapsedTime, time);
        m_endWork += newEndWork;

        if (newEndWork > m_maxEndWork)
            m_maxEndWork = newEndWork;

        //
        // Here's the magic from the textbook diagram...
        //

        // Compute neighbor factors.
        for (int i = 0; i < m_particles.Length - 1; ++i)
        {
            double xGap = m_particles[i].x - m_particles[i + 1].x;
            double yGap = m_particles[i].y - m_particles[i + 1].y;
            double totalGap = Math.Sqrt(xGap * xGap + yGap * yGap);
            m_particles[i].nextNeighborFactor = Math.Abs((1.0 / totalGap) * yGap);
        }

        // Compute acceleration using neighbors.
        for (int i = 1; i < m_particles.Length - 1; ++i)
        {
            double prevComponent = m_particles[i - 1].nextNeighborFactor;
            if (m_particles[i - 1].y < m_particles[i].y)
                prevComponent = -prevComponent;

            double nextComponent = m_particles[i].nextNeighborFactor;
            if (m_particles[i + 1].y < m_particles[i].y)
                nextComponent = -nextComponent;

            double newAcl = tension * (prevComponent + nextComponent)
                            - damping * m_particles[i].vel;

            m_particles[i].punch = newAcl - m_particles[i].acl;

            m_particles[i].acl = newAcl;
        }

        // Update velocity and position.
        for (int i = 1; i < m_particles.Length - 1; ++i)
        {
            m_particles[i].vel += m_particles[i].acl * elapsedTime;
            m_particles[i].y += m_particles[i].vel * elapsedTime;
        }
        ...
    }
    ...
}

The String(y) Class - C++

The C++ code is line for line identical to the C# code, except for... serialization. Our goal is to turn the string into, well, a std::string, as fast as possible. I got some great advice from Michaelgor: Use the std::to_chars function to build the particles string.

C++
void AppendToString(std::string& str, std::array<char, 1024 * 1024>& particlesBuffer) const
{
    Stopwatch sw;
    str += "particles:";
    {
        char* start = particlesBuffer.data();
        char* end = start + particlesBuffer.size() - 1;
        std::to_chars_result result;
        for (const auto& p : m_particles)
        {
            result = std::to_chars(start, end, p.x);
            if (result.ec != std::errc()) throw std::exception("to_chars error");
            *result.ptr = ',';
            start = result.ptr + 1;
            result = std::to_chars(start, end, p.y);
            if (result.ec != std::errc()) throw std::exception("to_chars error");
            *result.ptr = ',';
            start = result.ptr + 1;
            result = std::to_chars(start, end, p.vel);
            if (result.ec != std::errc()) throw std::exception("to_chars error");
            *result.ptr = ',';
            start = result.ptr + 1;
            result = std::to_chars(start, end, p.acl);
            if (result.ec != std::errc()) throw std::exception("to_chars error");
            *result.ptr = ',';
            start = result.ptr + 1;
            result = std::to_chars(start, end, p.punch);
            if (result.ec != std::errc()) throw std::exception("to_chars error");
            *result.ptr = ',';
            start = result.ptr + 1;
            result = std::to_chars(start, end, p.nextNeighborFactor);
            if (result.ec != std::errc()) throw std::exception("to_chars error");
            *result.ptr = '|';
            start = result.ptr + 1;
        }
        *start = '\0';
        str += particlesBuffer.data();
    }
    str += ";";
    ScopeTiming::GetObj().RecordScope("String.AppendToString.Particles", sw);

    str += "length:" + std::to_string(m_length) + ";";

    str += "maxPos:" + std::to_string(m_maxPos) + ";";
    str += "maxVel:" + std::to_string(m_maxVel) + ";";
    str += "maxAcl:" + std::to_string(m_maxAcl) + ";";
    str += "maxPunch:" + std::to_string(m_maxPunch) + ";";

    str += "maxPosIndex:" + std::to_string(m_maxPosIndex) + ";";
    str += "maxVelIndex:" + std::to_string(m_maxVelIndex) + ";";
    str += "maxAclIndex:" + std::to_string(m_maxAclIndex) + ";";
    str += "maxPunchIndex:" + std::to_string(m_maxPunchIndex) + ";";

    str += "startWork:" + std::to_string(m_startWork) + ";";
    str += "endWork:" + std::to_string(m_endWork) + ";";

    str += "maxStartWork:" + std::to_string(m_maxStartWork) + ";";
    str += "maxEndWork:" + std::to_string(m_maxEndWork) + ";";

    ScopeTiming::GetObj().RecordScope("String.AppendToString.TheRest", sw);
}

The Simulation Class - C#

C#
...
public class Simulation
{
    public const int cParticleCount = 1000;
    public const double cStringConstant = 0.03164;    // magic number
    public const double cStringLength = 1.0;          // meter
    public const double cOscillatorAmplitude = 0.001; // not much, just a mm per frequency
    ...
    public void Update()
    {
        // Bail if we're paused, but sleep to keep the processor cool.
        if (m_bPaused)
        {
            Thread.Sleep(200);
            return;
        }

        // Delay.  Outside the thread safety lock.
        int delayMs = 0;
        {
            lock (this)
            {
                delayMs = m_delayMs;
                if (delayMs > 0)
                {
                    if (m_delayMod > 0)
                    {
                        if ((m_simulationCycle % m_delayMod) != 0)
                            delayMs = 0;
                    }
                }
            }
        }

        // Yield to the main application thread to cool off the CPU...
        // ...unless we're running flat out!
        if (delayMs >= 0)
            Thread.Sleep(delayMs);

        lock (this)
        {
            m_computeStopwatch.Restart();

            ++m_simulationCycle;

            double startPos = 0.0;
            if (m_bLeftEnabled)
            {
                foreach (double frequency in m_leftFrequencies)
                {
                    startPos += GetOscillatorPosition(frequency,
                                                      cOscillatorAmplitude,
                                                      m_bJustPulse,
                                                      m_bJustHalfPulse,
                                                      m_outOfPhase,
                                                      m_time);
                }
            }

            double endPos = 0.0;
            if (m_bRightEnabled)
            {
                foreach (double frequency in m_rightFrequencies)
                {
                    endPos += GetOscillatorPosition(frequency,
                                                    cOscillatorAmplitude,
                                                    m_bJustPulse,
                                                    m_bJustHalfPulse,
                                                    /* outOfPhase = */0.0,
                                                    m_time);
                }
            }

            m_string.Update
            (
                startPos,
                endPos,
                m_timeSlice,
                m_time,
                m_tension,
                m_damping,
                cStringLength,
                cStringConstant
            );

            if (Math.Abs(m_string.GetMaxPos()) > Math.Abs(m_maxPosString.GetMaxPos()))
            {
                m_maxPosString = m_string.Clone();
                m_maxPosTime = m_time;
            }

            if (Math.Abs(m_string.GetMaxVel()) > Math.Abs(m_maxVelString.GetMaxVel()))
            {
                m_maxVelString = m_string.Clone();
                m_maxVelTime = m_time;
            }

            if (Math.Abs(m_string.GetMaxAcl()) > Math.Abs(m_maxAclString.GetMaxAcl()))
            {
                m_maxAclString = m_string.Clone();
                m_maxAclTime = m_time;
            }

            if (Math.Abs(m_string.GetMaxPunch()) > Math.Abs(m_maxPunchString.GetMaxPunch()))
            {
                m_maxPunchString = m_string.Clone();
                m_maxPunchTime = m_time;
            }

            m_time += m_timeSlice;
            ScopeTiming.RecordScope("Update", m_computeStopwatch);
        }
    }

    // Get the position of this oscillator at a particular time
    public static double // static to ensure purity of processing
        GetOscillatorPosition
        (
            double frequency,
            double amplitude,
            bool bJustPulse,
            bool bJustHalfPulse,
            double outOfPhase,
            double time
        )
    {
        double radians = 2.0 * Math.PI * frequency * time;

        radians -= outOfPhase * Math.PI;

        if (radians < 0.0)
            radians = 0.0;

        if (bJustPulse && radians > 2.0 * Math.PI)
            radians = 0.0;
        else if (bJustHalfPulse && radians > 1.0 * Math.PI)
            radians = 0.0;

        double retVal = Math.Sin(radians);

        retVal *= amplitude;

        return retVal;
    }
}

The C++ Update function is line-for-line identical with the C#.

HttpListener and WebClient - Inter-process Communication Made Easy

I chose System.Net.HttpListener and System.Net.WebClient for the inter-process communication. They are simple and easy to use, and I highly recommend them for all your basic .NET client-server needs. Beats the hell out of socket / bind / listen / accept / recv / send...

There was some inclination to go with a binary format. I chose text because it was easy to implement, and the performance of running the simulation was much more important than updating the UI. The thinking is that you use the UI to kick off a simulation, then close the UI and let the server run for hours on end, then check in periodically with the UI. Serialization was fun to optimize, but it's not the high traffic path through the code in the common use case.

Performance Profiles

Here is the output from the ScopeTimings of C# and C++. For each scope ("Output.StreamWriter"), it lists...

  1. How many times called (hits)
  2. How much total time spent in the scope ("ms total")
  3. The average amount of time spent in the scope ("ms avg")
C#
//
// C#
//
Simulation.AppendStrings -> 1984 hits - 21478 ms total -> 10.8256 ms avg  // 3X slower
Simulation.Lock -> 1984 hits - 132564 ms total -> 66.81644 ms avg         // 1.7X slower
Stringy.ToString.Particles -> 9920 hits - 19905 ms total -> 2.0065 ms avg // 3X slower

Output.StreamWriter -> 1984 hits - 16195 ms total -> 8.16298 ms avg       // 1.2X slower
Output.ToString -> 1984 hits - 154508 ms total -> 77.87684 ms avg         // 1.8X slower

Update -> 7768502 hits - 205235 ms total -> 0.02642 ms avg                // 1.7X slower

//
// C++
//
Simulation.AppendStrings -> 3781 hits - 12345.393000 ms total -> 3.265113 ms avg
Simulation.Lock -> 3781 hits - 149573.137000 ms total -> 39.559148 ms
String.ToString.Particles -> 18905 hits - 12056.123000 ms total -> 0.637721 ms avg

Output.StreamWriter -> 3781 hits - 25779 ms total -> 6.81813 ms avg
Output.ToString -> 3781 hits - 165159 ms total -> 43.68125 ms avg

Update -> 26866573 hits - 412475.618075 ms total -> 0.015353 ms avg 

C# runs 1.7X slower than C++ for the crucial Simulation Update function

Making C++ Fast

I already discussed using std::to_chars for the serialization. I have made no effort to optimize the Simulator Update function, I just let the compilers do their thing. It's surprising that identical basic math algorithm code is so much faster than C++. I'll take it!

Things were not always rosy for C++...

Initially, I had a single C++/CLI server project with the HttpListener and all the C++ code burned in. This had pretty poor performance: .NET was 3X faster for building the Stringy particle strings, and the all-important Simulator Update function was pretty much identical performance.

Something had to give.

Michaelgor looked at the assembly, and was not impressed. This made us think that somehow C++/CLI code does not get rigorous C++ optimization.

So what I decided to do was pull the C++ class library out of the C++/CLI server into a Windows DLL, so the compiler can optimize the hell out of the C++. The DLL only had to expose what the server needed, so this was some small surgery":

C++
//
// sealib.h - DLL interface, just what the app needs
//

//
// NOTE: Access to this API must not be multi-threaded
//		 The app program is single-threaded (timer + UI), 
//       so this isn't a problem with that usage
//
extern "C"
{
	__declspec(dllexport)
		void StartSimulation();

	__declspec(dllexport)
		void ApplySimSettings(const char* settings);

	__declspec(dllexport)
		const char* GetSimState();

	__declspec(dllexport)
		const char* GetTimingSummary();
}

//
// sealib.cpp - Global variables are fun and easy!
//
#include...

Simulation* sim = nullptr;
auto particlesBuffer = new std::array<char, 1024 * 1024>();

void StartSimulation()
{
	sim = new Simulation();
	ScopeTiming::GetObj().Init(true);
}

void ApplySimSettings(const char* settings)
{
	sim->ApplySettings(settings);
}

std::string simState;
const char* GetSimState()
{
	sim->ToString(simState, *particlesBuffer);
	return simState.c_str();
}

std::string timingSummary;
const char* GetTimingSummary()
{
	timingSummary = ScopeTiming::GetObj().GetSummary();
	return timingSummary.c_str();
}

I tried sticking with a C++/CLI server, but there was a compiler error that I could not get around, StreamReader/StreamWriter classes being ambiguous symbols. So I chose to create a C# driver program with DLL calls:

C#
using...
namespace...
Program...

[DllImport("sealib.dll", CallingConvention = CallingConvention.Cdecl)]
static extern void StartSimulation();

[DllImport("sealib.dll", CallingConvention = CallingConvention.Cdecl)]
static extern void ApplySimSettings(string settings);

[DllImport("sealib.dll", CallingConvention = CallingConvention.Cdecl)]
static extern IntPtr GetSimState();

[DllImport("sealib.dll", CallingConvention = CallingConvention.Cdecl)]
static extern IntPtr GetTimingSummary();

static string GetString(IntPtr ptr)
{
    return Marshal.PtrToStringAnsi(ptr);
}

static void RunStats()
{
    while (true)
    {
        Thread.Sleep(3 * 1000);

        string seaTiming = GetString(GetTimingSummary());
        string svrTiming = ScopeTiming.Summary;

        string timing = "";
        if (seaTiming != "")
            timing += seaTiming;

        if (svrTiming != "")
        {
            if (timing != "")
                timing += '\n';
            timing += svrTiming;
        }

        if (timing != "")
            timing += '\n';

        Console.WriteLine(timing);
    }
}

static void Main(string[] args)
{
    ScopeTiming.Init(true);

    Console.Write("Setting up simulation...");
    StartSimulation();
    Console.WriteLine("done!");

    Console.Write("Setting up server...");
    HttpListener listener = new HttpListener();
    listener.Prefixes.Add($"http://localhost:9914/");
    listener.Start();
    Console.WriteLine("done!");

    new Thread(RunStats).Start();

    Stopwatch sw = new Stopwatch();
    while (true)
    {
        var ctxt = listener.GetContext();
        if (ctxt.Request.HttpMethod == "GET")
        {
            sw.Restart();
            string state = GetString(GetSimState());
            ScopeTiming.RecordScope("Output.ToString", sw);

            using (StreamWriter writer = new StreamWriter(ctxt.Response.OutputStream))
                writer.Write(state);
            ScopeTiming.RecordScope("Output.StreamWriter", sw);
        }
        else
        {
            sw.Restart();
            string settings;
            using (StreamReader reader = new StreamReader(ctxt.Request.InputStream))
                settings = reader.ReadToEnd();
            ScopeTiming.RecordScope("Settings.StreamReader", sw);

            ApplySimSettings(settings);
            ScopeTiming.RecordScope("Settings.Apply", sw);
        }
        ctxt.Response.OutputStream.Close();
    }
}

Conclusion and Points of Interest

This has been a great game of software development, tuning, measurement, and optimization. With my initial implementation, it seemed there was no benefit to using C++ vs. C# for this application. But with optimization and rearrangement of the C++ code, C++ is clearly the way to go for the simulation, using C# for inter-process communication and user interface.

The software is open source on GitHub. Perhaps a Linux server with React frontend?

Back to mad science...

You may be wondering where the name StringShear came from. If you're game, try picking some frequencies to cause a particle to fly off, or to see something interesting, and share what you find. Such repeatable, noteworthy findings could be of scientific value. Well, at least it's fun to play with.

And have at the code, explore performance with more timing, try speeding things up.

Enjoy!

History

  • 6th September, 2021: Initial version
  • 12th September, 2021: Updated article based on feedback from the community, and significant software improvement

License

This article, along with any associated source code and files, is licensed under The Apache License, Version 2.0